feat(web): add vanilla JS/CSS SPA with embed.FS
Stream view with date grouping, card view sorted by usage, capture bar with client-side grammar parsing, tag rail filter, detail pane with card affordances (template slot fill, checklist toggle, link open), promote modal with auto-detect, keyboard shortcuts (j/k/n/p/ Enter/dd/1/2). Dark theme, responsive layout. Embedded in Go binary.
This commit is contained in:
+7
-1
@@ -3,6 +3,7 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -14,6 +15,8 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var WebFS fs.FS
|
||||||
|
|
||||||
var (
|
var (
|
||||||
servePort int
|
servePort int
|
||||||
serveDev bool
|
serveDev bool
|
||||||
@@ -51,7 +54,10 @@ func runServe(_ *cobra.Command, _ []string) error {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
router := api.NewRouter(store, serveDev)
|
var router = api.NewRouter(store, serveDev)
|
||||||
|
if WebFS != nil {
|
||||||
|
router = api.NewRouter(store, serveDev, WebFS)
|
||||||
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf(":%d", port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
|
|||||||
+21
-1
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -8,7 +9,7 @@ import (
|
|||||||
"github.com/lerko/nib/internal/db"
|
"github.com/lerko/nib/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(store *db.Store, devMode bool) chi.Router {
|
func NewRouter(store *db.Store, devMode bool, webFS ...fs.FS) chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@@ -32,9 +33,28 @@ func NewRouter(store *db.Store, devMode bool) chi.Router {
|
|||||||
r.Get("/tags", listTags(store))
|
r.Get("/tags", listTags(store))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if len(webFS) > 0 && webFS[0] != nil {
|
||||||
|
r.Get("/*", spaHandler(webFS[0]))
|
||||||
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func spaHandler(fsys fs.FS) http.HandlerFunc {
|
||||||
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
|
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/" || path == "/cards" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(indexHTML)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func jsonContentType(next http.Handler) http.Handler {
|
func jsonContentType(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/lerko/nib/cmd"
|
"github.com/lerko/nib/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
webContent, err := fs.Sub(WebFS, "web")
|
||||||
|
if err == nil {
|
||||||
|
cmd.WebFS = webContent
|
||||||
|
}
|
||||||
|
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+597
@@ -0,0 +1,597 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const GLYPHS = {
|
||||||
|
note: '◦', todo: '▸', event: '◇',
|
||||||
|
snippet: '◆', template: '◈', checklist: '☐',
|
||||||
|
decision: '⚖', link: '🔗',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLYPH_CLASSES = {
|
||||||
|
note: 'glyph-note', todo: 'glyph-todo', event: 'glyph-event',
|
||||||
|
snippet: 'glyph-snippet', template: 'glyph-template',
|
||||||
|
checklist: 'glyph-checklist', decision: 'glyph-decision',
|
||||||
|
link: 'glyph-link',
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
view: 'stream',
|
||||||
|
entities: [],
|
||||||
|
tags: [],
|
||||||
|
selectedIndex: -1,
|
||||||
|
activeTag: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const $$ = (sel) => document.querySelectorAll(sel);
|
||||||
|
|
||||||
|
// ========== API ==========
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async listEntities(params = {}) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.tag) q.set('tag', params.tag);
|
||||||
|
if (params.date) q.set('date', params.date);
|
||||||
|
if (params.cards_only) q.set('cards_only', 'true');
|
||||||
|
if (params.sort) q.set('sort', params.sort);
|
||||||
|
if (params.order) q.set('order', params.order);
|
||||||
|
const resp = await fetch('/api/entities?' + q);
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async createEntity(data) {
|
||||||
|
const resp = await fetch('/api/entities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async getEntity(id) {
|
||||||
|
const resp = await fetch('/api/entities/' + id);
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async updateEntity(id, data) {
|
||||||
|
const resp = await fetch('/api/entities/' + id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async deleteEntity(id) {
|
||||||
|
return fetch('/api/entities/' + id, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
async promoteEntity(id, cardType, cardData) {
|
||||||
|
const resp = await fetch('/api/entities/' + id + '/promote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ card_type: cardType, card_data: cardData }),
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async demoteEntity(id) {
|
||||||
|
const resp = await fetch('/api/entities/' + id + '/demote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async useEntity(id) {
|
||||||
|
const resp = await fetch('/api/entities/' + id + '/use', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
async listTags() {
|
||||||
|
const resp = await fetch('/api/tags');
|
||||||
|
return resp.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Grammar parser (mirrors Go parser) ==========
|
||||||
|
|
||||||
|
const VALID_CARDS = { card: 'snippet', c: 'snippet', snippet: 'snippet', template: 'template', checklist: 'checklist', decision: 'decision', link: 'link' };
|
||||||
|
|
||||||
|
function parseInput(input) {
|
||||||
|
input = input.trim();
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
const tokens = input.split(/\s+/);
|
||||||
|
let glyph = 'note';
|
||||||
|
|
||||||
|
const first = tokens[0];
|
||||||
|
if (first === '-' || first === '▸') { glyph = 'todo'; tokens.shift(); }
|
||||||
|
else if (first === '*' || first === '◇') { glyph = 'event'; tokens.shift(); }
|
||||||
|
|
||||||
|
const bodyParts = [];
|
||||||
|
let timeAnchor = null;
|
||||||
|
const tags = [];
|
||||||
|
const seenTags = {};
|
||||||
|
let cardSuffix = null;
|
||||||
|
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (tok.startsWith('@') && tok.length > 1) {
|
||||||
|
timeAnchor = tok.slice(1);
|
||||||
|
} else if (tok.startsWith('#') && tok.length > 1) {
|
||||||
|
const tag = tok.slice(1);
|
||||||
|
if (!seenTags[tag]) { tags.push(tag); seenTags[tag] = true; }
|
||||||
|
} else if (tok.startsWith('^') && tok.length > 1) {
|
||||||
|
const suffix = tok.slice(1);
|
||||||
|
if (VALID_CARDS[suffix]) cardSuffix = VALID_CARDS[suffix];
|
||||||
|
} else {
|
||||||
|
bodyParts.push(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = bodyParts.join(' ');
|
||||||
|
if (!body) return null;
|
||||||
|
|
||||||
|
return { body, glyph, timeAnchor, tags, cardSuffix };
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectCardType(body) {
|
||||||
|
if (/\$\{.+\}/.test(body)) return 'template';
|
||||||
|
if (/\bchose:|why:/.test(body)) return 'decision';
|
||||||
|
if (/\[ \]|\d+\./.test(body)) return 'checklist';
|
||||||
|
if (/https?:\/\//.test(body)) return 'link';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Rendering ==========
|
||||||
|
|
||||||
|
function displayGlyph(entity) {
|
||||||
|
return entity.card_type ? GLYPHS[entity.card_type] : GLYPHS[entity.glyph];
|
||||||
|
}
|
||||||
|
|
||||||
|
function glyphClass(entity) {
|
||||||
|
return entity.card_type ? GLYPH_CLASSES[entity.card_type] : GLYPH_CLASSES[entity.glyph];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
||||||
|
return months[d.getMonth()] + ' ' + d.getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTagRail() {
|
||||||
|
const rail = $('#tag-rail');
|
||||||
|
const allItem = `<div class="tag-item ${!state.activeTag ? 'active' : ''}" data-tag="">
|
||||||
|
<span class="tag-name" style="font-style: italic">all</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
rail.innerHTML = allItem + state.tags.map(t =>
|
||||||
|
`<div class="tag-item ${state.activeTag === t.tag ? 'active' : ''}" data-tag="${t.tag}">
|
||||||
|
<span class="tag-name">${t.tag}</span>
|
||||||
|
<span class="tag-count">${t.count}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
rail.querySelectorAll('.tag-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
state.activeTag = el.dataset.tag || null;
|
||||||
|
loadEntities();
|
||||||
|
renderTagRail();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(entities) {
|
||||||
|
const groups = [];
|
||||||
|
let current = null;
|
||||||
|
for (const e of entities) {
|
||||||
|
const label = formatDate(e.created_at);
|
||||||
|
if (!current || current.label !== label) {
|
||||||
|
if (current) groups.push(current);
|
||||||
|
current = { label, entities: [] };
|
||||||
|
}
|
||||||
|
current.entities.push(e);
|
||||||
|
}
|
||||||
|
if (current) groups.push(current);
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntityList() {
|
||||||
|
const list = $('#entity-list');
|
||||||
|
|
||||||
|
if (state.entities.length === 0) {
|
||||||
|
list.innerHTML = '<div class="detail-empty" style="margin-top:40px">no entities yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (state.view === 'stream') {
|
||||||
|
const groups = groupByDate(state.entities);
|
||||||
|
let idx = 0;
|
||||||
|
for (const g of groups) {
|
||||||
|
html += `<div class="date-header">── ${g.label} ──</div>`;
|
||||||
|
for (const e of g.entities) {
|
||||||
|
html += renderEntityItem(e, idx);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.entities.forEach((e, idx) => {
|
||||||
|
html += renderEntityItem(e, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = html;
|
||||||
|
|
||||||
|
list.querySelectorAll('.entity-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selectEntity(parseInt(el.dataset.index));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntityItem(e, idx) {
|
||||||
|
const glyph = displayGlyph(e);
|
||||||
|
const gc = glyphClass(e);
|
||||||
|
const selected = idx === state.selectedIndex ? 'selected' : '';
|
||||||
|
const tags = (e.tags || []).map(t => `<span class="entity-tag">${t}</span>`).join('');
|
||||||
|
const time = e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : '';
|
||||||
|
const useBadge = e.use_count > 0 ? `<span class="use-badge">${e.use_count}×</span>` : '';
|
||||||
|
|
||||||
|
return `<div class="entity-item ${selected}" data-index="${idx}" data-id="${e.id}">
|
||||||
|
<span class="entity-glyph ${gc}">${glyph}</span>
|
||||||
|
<span class="entity-body">${escHtml(e.body)}</span>
|
||||||
|
${time}
|
||||||
|
<span class="entity-tags">${tags}</span>
|
||||||
|
<span class="entity-meta">${useBadge}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailPane() {
|
||||||
|
const pane = $('#detail-pane');
|
||||||
|
const e = state.entities[state.selectedIndex];
|
||||||
|
|
||||||
|
if (!e) {
|
||||||
|
pane.innerHTML = '<div class="detail-empty">select an entity</div>';
|
||||||
|
pane.classList.remove('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pane.classList.add('visible');
|
||||||
|
const glyph = displayGlyph(e);
|
||||||
|
const gc = glyphClass(e);
|
||||||
|
const tags = (e.tags || []).map(t => `<span class="detail-tag">#${t}</span>`).join('');
|
||||||
|
const shortId = e.id.slice(0, 12);
|
||||||
|
|
||||||
|
let cardContent = '';
|
||||||
|
let actions = '';
|
||||||
|
|
||||||
|
if (e.card_type) {
|
||||||
|
cardContent = renderCardContent(e);
|
||||||
|
actions += `<button class="action-btn" onclick="nibApp.copyEntity('${e.id}')">copy</button>`;
|
||||||
|
actions += `<button class="action-btn" onclick="nibApp.demoteEntity('${e.id}')">demote</button>`;
|
||||||
|
} else {
|
||||||
|
actions += `<button class="action-btn primary" onclick="nibApp.showPromote('${e.id}')">promote</button>`;
|
||||||
|
}
|
||||||
|
actions += `<button class="action-btn danger" onclick="nibApp.deleteEntity('${e.id}')">delete</button>`;
|
||||||
|
|
||||||
|
pane.innerHTML = `
|
||||||
|
<div class="detail-header">
|
||||||
|
<span class="detail-glyph ${gc}">${glyph}</span>
|
||||||
|
<span class="detail-id">${shortId}</span>
|
||||||
|
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="detail-body">${escHtml(e.body)}</div>
|
||||||
|
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||||||
|
${cardContent}
|
||||||
|
<div class="detail-actions">${actions}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCardContent(e) {
|
||||||
|
if (!e.card_data) return '';
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(e.card_data); } catch { return ''; }
|
||||||
|
|
||||||
|
switch (e.card_type) {
|
||||||
|
case 'template':
|
||||||
|
if (!data.slots || !data.slots.length) return '';
|
||||||
|
return `<div class="slot-form">
|
||||||
|
${data.slots.map(s => `
|
||||||
|
<div class="slot-field">
|
||||||
|
<span class="slot-label">\${${s.name}}</span>
|
||||||
|
<input class="slot-input" data-slot="${s.name}" placeholder="${s.default || s.name}" value="${s.default || ''}">
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
<button class="action-btn primary" onclick="nibApp.resolveTemplate('${e.id}')">resolve & copy</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
case 'checklist':
|
||||||
|
if (!data.steps || !data.steps.length) return '';
|
||||||
|
return `<div class="checklist">
|
||||||
|
${data.steps.map((s, i) => `
|
||||||
|
<div class="checklist-step ${s.done ? 'done' : ''}">
|
||||||
|
<input type="checkbox" ${s.done ? 'checked' : ''} onchange="nibApp.toggleStep('${e.id}', ${i})">
|
||||||
|
<span>${escHtml(s.text)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
case 'decision':
|
||||||
|
return `<div>
|
||||||
|
<div class="decision-field"><div class="decision-label">chose</div><div class="decision-value">${escHtml(data.chose || '—')}</div></div>
|
||||||
|
<div class="decision-field"><div class="decision-label">why</div><div class="decision-value">${escHtml(data.why || '—')}</div></div>
|
||||||
|
${data.rejected && data.rejected.length ? `<div class="decision-field"><div class="decision-label">rejected</div><div class="decision-value">${data.rejected.map(escHtml).join(', ') || '—'}</div></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
case 'link':
|
||||||
|
if (data.url) {
|
||||||
|
return `<div style="margin-bottom:12px">
|
||||||
|
<button class="action-btn" onclick="window.open('${escAttr(data.url)}', '_blank')">open link</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Actions ==========
|
||||||
|
|
||||||
|
function selectEntity(idx) {
|
||||||
|
state.selectedIndex = idx;
|
||||||
|
renderEntityList();
|
||||||
|
renderDetailPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntities() {
|
||||||
|
const params = {};
|
||||||
|
if (state.activeTag) params.tag = state.activeTag;
|
||||||
|
if (state.view === 'cards') {
|
||||||
|
params.cards_only = true;
|
||||||
|
params.sort = 'use_count';
|
||||||
|
params.order = 'desc';
|
||||||
|
} else {
|
||||||
|
params.sort = 'created';
|
||||||
|
params.order = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
state.entities = await api.listEntities(params);
|
||||||
|
state.selectedIndex = -1;
|
||||||
|
renderEntityList();
|
||||||
|
renderDetailPane();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTags() {
|
||||||
|
state.tags = await api.listTags();
|
||||||
|
renderTagRail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
state.view = view;
|
||||||
|
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||||||
|
window.location.hash = view === 'cards' ? '/cards' : '/';
|
||||||
|
loadEntities();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Public API (for inline handlers) ==========
|
||||||
|
|
||||||
|
window.nibApp = {
|
||||||
|
async copyEntity(id) {
|
||||||
|
const e = state.entities.find(x => x.id === id);
|
||||||
|
if (!e) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(e.body);
|
||||||
|
await api.useEntity(id);
|
||||||
|
await loadEntities();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('clipboard:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showPromote(id) {
|
||||||
|
const e = state.entities.find(x => x.id === id);
|
||||||
|
if (!e || e.card_type) return;
|
||||||
|
|
||||||
|
const modal = $('#promote-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('visible');
|
||||||
|
modal.dataset.entityId = id;
|
||||||
|
|
||||||
|
const suggested = detectCardType(e.body);
|
||||||
|
$$('.type-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('suggested', btn.dataset.type === suggested);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async demoteEntity(id) {
|
||||||
|
await api.demoteEntity(id);
|
||||||
|
await loadEntities();
|
||||||
|
await loadTags();
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteEntity(id) {
|
||||||
|
await api.deleteEntity(id);
|
||||||
|
await loadEntities();
|
||||||
|
await loadTags();
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveTemplate(id) {
|
||||||
|
const e = state.entities.find(x => x.id === id);
|
||||||
|
if (!e) return;
|
||||||
|
let resolved = e.body;
|
||||||
|
$$('.slot-input').forEach(input => {
|
||||||
|
const name = input.dataset.slot;
|
||||||
|
const val = input.value || input.placeholder;
|
||||||
|
resolved = resolved.replace(new RegExp('\\$\\{' + name + '\\}', 'g'), val);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(resolved);
|
||||||
|
await api.useEntity(id);
|
||||||
|
await loadEntities();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('clipboard:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleStep(id, stepIdx) {
|
||||||
|
const e = state.entities.find(x => x.id === id);
|
||||||
|
if (!e || !e.card_data) return;
|
||||||
|
const data = JSON.parse(e.card_data);
|
||||||
|
data.steps[stepIdx].done = !data.steps[stepIdx].done;
|
||||||
|
await api.updateEntity(id, { card_data: JSON.stringify(data) });
|
||||||
|
await loadEntities();
|
||||||
|
selectEntity(state.entities.findIndex(x => x.id === id));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Capture bar ==========
|
||||||
|
|
||||||
|
$('#capture-bar').addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const input = $('#capture-input');
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
|
||||||
|
const parsed = parseInput(val);
|
||||||
|
if (!parsed) return;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
body: parsed.body,
|
||||||
|
glyph: parsed.glyph,
|
||||||
|
tags: parsed.tags,
|
||||||
|
};
|
||||||
|
if (parsed.timeAnchor) data.time_anchor = parsed.timeAnchor;
|
||||||
|
if (parsed.cardSuffix) data.card_type = parsed.cardSuffix;
|
||||||
|
|
||||||
|
await api.createEntity(data);
|
||||||
|
input.value = '';
|
||||||
|
await loadEntities();
|
||||||
|
await loadTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Promote modal ==========
|
||||||
|
|
||||||
|
$$('.type-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const modal = $('#promote-modal');
|
||||||
|
const id = modal.dataset.entityId;
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
|
||||||
|
await api.promoteEntity(id, btn.dataset.type);
|
||||||
|
await loadEntities();
|
||||||
|
await loadTags();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.modal-backdrop').addEventListener('click', closeModal);
|
||||||
|
$('.modal-close').addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
const modal = $('#promote-modal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Keyboard shortcuts ==========
|
||||||
|
|
||||||
|
let lastDTime = 0;
|
||||||
|
const captureInput = $('#capture-input');
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (ev) => {
|
||||||
|
if (document.activeElement === captureInput) {
|
||||||
|
if (ev.key === 'Escape') captureInput.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($('#promote-modal').classList.contains('visible')) {
|
||||||
|
if (ev.key === 'Escape') closeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ev.key) {
|
||||||
|
case 'j':
|
||||||
|
ev.preventDefault();
|
||||||
|
selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1));
|
||||||
|
scrollSelectedIntoView();
|
||||||
|
break;
|
||||||
|
case 'k':
|
||||||
|
ev.preventDefault();
|
||||||
|
selectEntity(Math.max(state.selectedIndex - 1, 0));
|
||||||
|
scrollSelectedIntoView();
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
ev.preventDefault();
|
||||||
|
captureInput.focus();
|
||||||
|
break;
|
||||||
|
case 'p': {
|
||||||
|
const e = state.entities[state.selectedIndex];
|
||||||
|
if (e && !e.card_type) nibApp.showPromote(e.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
const e = state.entities[state.selectedIndex];
|
||||||
|
if (e) nibApp.copyEntity(e.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'd': {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastDTime < 400) {
|
||||||
|
const e = state.entities[state.selectedIndex];
|
||||||
|
if (e) nibApp.deleteEntity(e.id);
|
||||||
|
lastDTime = 0;
|
||||||
|
} else {
|
||||||
|
lastDTime = now;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case '1': switchView('stream'); break;
|
||||||
|
case '2': switchView('cards'); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function scrollSelectedIntoView() {
|
||||||
|
const el = $(`.entity-item[data-index="${state.selectedIndex}"]`);
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== View nav buttons ==========
|
||||||
|
|
||||||
|
$$('.nav-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Hash routing ==========
|
||||||
|
|
||||||
|
function handleHash() {
|
||||||
|
const hash = window.location.hash;
|
||||||
|
if (hash === '#/cards') {
|
||||||
|
state.view = 'cards';
|
||||||
|
} else {
|
||||||
|
state.view = 'stream';
|
||||||
|
}
|
||||||
|
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
|
||||||
|
loadEntities();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', handleHash);
|
||||||
|
|
||||||
|
// ========== Utils ==========
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escAttr(s) {
|
||||||
|
return escHtml(s).replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Init ==========
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await Promise.all([loadEntities(), loadTags()]);
|
||||||
|
handleHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>nib</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="logo">nib</h1>
|
||||||
|
<nav>
|
||||||
|
<button data-view="stream" class="nav-btn active">stream</button>
|
||||||
|
<button data-view="cards" class="nav-btn">cards</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<form id="capture-bar" autocomplete="off">
|
||||||
|
<input type="text" id="capture-input" placeholder="capture... (n to focus)" spellcheck="false">
|
||||||
|
</form>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<aside id="tag-rail"></aside>
|
||||||
|
<section id="entity-list"></section>
|
||||||
|
<aside id="detail-pane">
|
||||||
|
<div class="detail-empty">select an entity</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="promote-modal" class="modal hidden">
|
||||||
|
<div class="modal-backdrop"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>promote to card</h3>
|
||||||
|
<div class="type-picker">
|
||||||
|
<button data-type="snippet" class="type-btn">
|
||||||
|
<span class="type-glyph">◆</span>
|
||||||
|
<span>snippet</span>
|
||||||
|
</button>
|
||||||
|
<button data-type="template" class="type-btn">
|
||||||
|
<span class="type-glyph">◈</span>
|
||||||
|
<span>template</span>
|
||||||
|
</button>
|
||||||
|
<button data-type="checklist" class="type-btn">
|
||||||
|
<span class="type-glyph">☐</span>
|
||||||
|
<span>checklist</span>
|
||||||
|
</button>
|
||||||
|
<button data-type="decision" class="type-btn">
|
||||||
|
<span class="type-glyph">⚖</span>
|
||||||
|
<span>decision</span>
|
||||||
|
</button>
|
||||||
|
<button data-type="link" class="type-btn">
|
||||||
|
<span class="type-glyph">🔗</span>
|
||||||
|
<span>link</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close">esc to cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+469
@@ -0,0 +1,469 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #1a1b26;
|
||||||
|
--bg-surface: #24283b;
|
||||||
|
--bg-hover: #292e42;
|
||||||
|
--bg-selected: #33394d;
|
||||||
|
--text: #c0caf5;
|
||||||
|
--text-dim: #565f89;
|
||||||
|
--text-muted: #3b4261;
|
||||||
|
--accent: #7aa2f7;
|
||||||
|
--accent-dim: #3d59a1;
|
||||||
|
--green: #9ece6a;
|
||||||
|
--red: #f7768e;
|
||||||
|
--yellow: #e0af68;
|
||||||
|
--orange: #ff9e64;
|
||||||
|
--purple: #bb9af7;
|
||||||
|
--cyan: #7dcfff;
|
||||||
|
--border: #292e42;
|
||||||
|
--radius: 6px;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
|
.nav-btn.active { color: var(--accent); border-color: var(--accent-dim); background: var(--bg); }
|
||||||
|
|
||||||
|
#capture-bar {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#capture-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#capture-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#capture-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main layout */
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr 320px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag rail */
|
||||||
|
#tag-rail {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 12px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.tag-item.active { color: var(--accent); background: var(--bg-selected); }
|
||||||
|
|
||||||
|
.tag-name { font-family: var(--font-mono); }
|
||||||
|
.tag-name::before { content: '#'; color: var(--text-muted); }
|
||||||
|
.tag-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entity list */
|
||||||
|
#entity-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-header {
|
||||||
|
padding: 8px 20px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-item:hover { background: var(--bg-hover); }
|
||||||
|
.entity-item.selected {
|
||||||
|
background: var(--bg-selected);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-glyph {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glyph-note { color: var(--text-dim); }
|
||||||
|
.glyph-todo { color: var(--green); }
|
||||||
|
.glyph-event { color: var(--yellow); }
|
||||||
|
.glyph-snippet { color: var(--accent); }
|
||||||
|
.glyph-template { color: var(--purple); }
|
||||||
|
.glyph-checklist { color: var(--orange); }
|
||||||
|
.glyph-decision { color: var(--cyan); }
|
||||||
|
.glyph-link { color: var(--red); }
|
||||||
|
|
||||||
|
.entity-body {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-dim);
|
||||||
|
background: rgba(122, 162, 247, 0.1);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-meta {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-badge {
|
||||||
|
color: var(--yellow);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail pane */
|
||||||
|
#detail-pane {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-glyph { font-size: 20px; }
|
||||||
|
|
||||||
|
.detail-id {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-tag {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(122, 162, 247, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.action-btn.primary { background: var(--accent-dim); border-color: var(--accent); color: white; }
|
||||||
|
.action-btn.danger { border-color: var(--red); color: var(--red); }
|
||||||
|
.action-btn.danger:hover { background: rgba(247, 118, 142, 0.1); }
|
||||||
|
|
||||||
|
/* Template slot form */
|
||||||
|
.slot-form { margin: 16px 0; }
|
||||||
|
|
||||||
|
.slot-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--purple);
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-input:focus { border-color: var(--purple); }
|
||||||
|
|
||||||
|
/* Checklist */
|
||||||
|
.checklist-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-step input[type="checkbox"] {
|
||||||
|
accent-color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-step.done span {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Decision card */
|
||||||
|
.decision-field {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cyan);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decision-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal { display: none; }
|
||||||
|
.modal.visible { display: flex; }
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 101;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn:hover { border-color: var(--accent); background: var(--bg-hover); }
|
||||||
|
.type-btn.suggested { border-color: var(--accent-dim); background: rgba(122, 162, 247, 0.05); }
|
||||||
|
|
||||||
|
.type-glyph { font-size: 16px; width: 24px; text-align: center; }
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
main { grid-template-columns: 1fr; }
|
||||||
|
#tag-rail { display: none; }
|
||||||
|
#detail-pane {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
top: auto;
|
||||||
|
height: 50vh;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-left: none;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
#detail-pane.visible { transform: translateY(0); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user