feat: Sprint 2 — snippets + keychain CRUD, standalone templates, clipboard/utils JS

This commit is contained in:
swanadiva
2026-07-07 13:26:56 +07:00
parent 8cfdec3980
commit c4b0d7d8c0
17 changed files with 691 additions and 112 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
> **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak. > **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak.
> **Last Updated**: 2026-07-07 > **Last Updated**: 2026-07-07
> **Current Phase**: Sprint 1 selesai, Sprint 2 sedang dikerjakan > **Current Phase**: Sprint 1 + 2 selesai, Sprint 3 berikutnya
--- ---
@@ -114,8 +114,8 @@ Alasan:
- **Sprint 1**: Host model (8 mock hosts), dashboard CRUD, search/filter HTMX, grid/list Alpine toggle, add host modal (Alpine events), delete host (hx-delete), transfers panel - **Sprint 1**: Host model (8 mock hosts), dashboard CRUD, search/filter HTMX, grid/list Alpine toggle, add host modal (Alpine events), delete host (hx-delete), transfers panel
### ❌ Belum Dimulai / In Progress ### ❌ Belum Dimulai / In Progress
- **Sprint 2 (IN PROGRESS)**: Snippets + Keychain - **Sprint 2 (DONE)**: Snippets + Keychain
- Sprint 3: Settings + Brief - **Sprint 3 (NEXT)**: Settings + Brief
- Sprint 4: SFTP Dual-Pane - Sprint 4: SFTP Dual-Pane
- Sprint 5: Terminal + xterm.js - Sprint 5: Terminal + xterm.js
- Sprint 6: Storage Integration - Sprint 6: Storage Integration
+10 -8
View File
@@ -7,6 +7,15 @@ import (
var store = model.NewHostStore() var store = model.NewHostStore()
var navItems = []fiber.Map{
{"ID": "hosts", "Label": "Hosts", "Icon": "server"},
{"ID": "terminal", "Label": "Terminal", "Icon": "terminal"},
{"ID": "sftp", "Label": "SFTP", "Icon": "folder-sync"},
{"ID": "snippets", "Label": "Snippets", "Icon": "code2"},
{"ID": "keychain", "Label": "Keychain", "Icon": "key-round"},
{"ID": "settings", "Label": "Settings", "Icon": "settings2"},
}
func Dashboard(c *fiber.Ctx) error { func Dashboard(c *fiber.Ctx) error {
hosts := store.All() hosts := store.All()
return c.Render("index", fiber.Map{ return c.Render("index", fiber.Map{
@@ -15,14 +24,7 @@ func Dashboard(c *fiber.Ctx) error {
"Hosts": hosts, "Hosts": hosts,
"Active": countByStatus(hosts, "active"), "Active": countByStatus(hosts, "active"),
"Offline": countByStatus(hosts, "offline"), "Offline": countByStatus(hosts, "offline"),
"NavItems": []fiber.Map{ "NavItems": navItems,
{"ID": "hosts", "Label": "Hosts", "Icon": "server"},
{"ID": "terminal", "Label": "Terminal", "Icon": "terminal"},
{"ID": "sftp", "Label": "SFTP", "Icon": "folder-sync"},
{"ID": "snippets", "Label": "Snippets", "Icon": "code2"},
{"ID": "keychain", "Label": "Keychain", "Icon": "key-round"},
{"ID": "settings", "Label": "Settings", "Icon": "settings2"},
},
}) })
} }
+41
View File
@@ -0,0 +1,41 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
)
var keyStore = model.NewKeyStore()
func Keychain(c *fiber.Ctx) error {
keys := keyStore.All()
return c.Render("keychain", fiber.Map{
"View": "keychain",
"Title": "Keychain",
"Keys": keys,
"NavItems": navItems,
})
}
func KeyGrid(c *fiber.Ctx) error {
q := c.Query("q", "")
keys := keyStore.Search(q)
return c.Render("credential_grid", fiber.Map{"Keys": keys})
}
func CreateKey(c *fiber.Ctx) error {
name := c.FormValue("name")
username := c.FormValue("username")
url := c.FormValue("url")
passphrase := c.FormValue("passphrase")
keyStore.Add(name, username, url, passphrase)
keys := keyStore.All()
return c.Render("credential_grid", fiber.Map{"Keys": keys})
}
func DeleteKey(c *fiber.Ctx) error {
id := c.Params("id")
keyStore.Delete(id)
keys := keyStore.All()
return c.Render("credential_grid", fiber.Map{"Keys": keys})
}
+40
View File
@@ -0,0 +1,40 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
)
var snippetStore = model.NewSnippetStore()
func Snippets(c *fiber.Ctx) error {
snippets := snippetStore.All()
return c.Render("snippets", fiber.Map{
"View": "snippets",
"Title": "Snippets",
"Snippets": snippets,
"NavItems": navItems,
})
}
func SnippetGrid(c *fiber.Ctx) error {
q := c.Query("q", "")
snippets := snippetStore.Search(q)
return c.Render("snippet_grid", fiber.Map{"Snippets": snippets})
}
func CreateSnippet(c *fiber.Ctx) error {
name := c.FormValue("name")
content := c.FormValue("content")
language := c.FormValue("language")
snippetStore.Add(name, content, language, nil)
snippets := snippetStore.All()
return c.Render("snippet_grid", fiber.Map{"Snippets": snippets})
}
func DeleteSnippet(c *fiber.Ctx) error {
id := c.Params("id")
snippetStore.Delete(id)
snippets := snippetStore.All()
return c.Render("snippet_grid", fiber.Map{"Snippets": snippets})
}
+4
View File
@@ -73,6 +73,10 @@ func (s *HostStore) Add(name, ip, os, provider, hostType string) Host {
return h return h
} }
func contains(s, q string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(q))
}
func randString(n int) string { func randString(n int) string {
const letters = "abcdefghijklmnopqrstuvwxyz0123456789" const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, n) b := make([]byte, n)
+78
View File
@@ -0,0 +1,78 @@
package model
import "time"
type Key struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
URL string `json:"url"`
Passphrase string `json:"passphrase"`
Strength string `json:"strength"` // weak | moderate | secure
CreatedAt string `json:"createdAt"`
}
type KeyStore struct {
Keys []Key
}
func NewKeyStore() *KeyStore {
return &KeyStore{Keys: defaultKeys()}
}
func (s *KeyStore) All() []Key { return s.Keys }
func (s *KeyStore) Search(q string) []Key {
if q == "" { return s.Keys }
var res []Key
for _, k := range s.Keys {
if contains(k.Name, q) || contains(k.Username, q) || contains(k.URL, q) {
res = append(res, k)
}
}
return res
}
func (s *KeyStore) Add(name, username, url, passphrase string) Key {
k := Key{
ID: randString(8),
Name: name,
Username: username,
URL: url,
Passphrase: passphrase,
Strength: calcStrength(passphrase),
CreatedAt: time.Now().Format("Jan 2, 2006"),
}
s.Keys = append(s.Keys, k)
return k
}
func (s *KeyStore) Delete(id string) {
for i, k := range s.Keys {
if k.ID == id { s.Keys = append(s.Keys[:i], s.Keys[i+1:]...); return }
}
}
func calcStrength(p string) string {
l, d, u, s := 0, 0, 0, 0
for _, c := range p {
switch {
case c >= 'a' && c <= 'z': l++
case c >= 'A' && c <= 'Z': u++
case c >= '0' && c <= '9': d++
default: s++
}
}
if len(p) >= 12 && u >= 1 && d >= 1 && s >= 1 { return "secure" }
if len(p) >= 8 && u+d+s >= 2 { return "moderate" }
return "weak"
}
func defaultKeys() []Key {
return []Key{
{ID: "k1", Name: "AWS Prod Root", Username: "ec2-user", URL: "aws-console.amazon.com", Passphrase: "••••••••••", Strength: "secure", CreatedAt: "Mar 15, 2026"},
{ID: "k2", Name: "GitHub Deploy", Username: "git", URL: "github.com", Passphrase: "••••••••", Strength: "secure", CreatedAt: "Apr 2, 2026"},
{ID: "k3", Name: "Dev DB Access", Username: "admin", URL: "dev-db.internal:5432", Passphrase: "••••••", Strength: "moderate", CreatedAt: "May 10, 2026"},
{ID: "k4", Name: "Old Server", Username: "root", URL: "192.168.1.100", Passphrase: "••••", Strength: "weak", CreatedAt: "Jan 5, 2026"},
{ID: "k5", Name: "Staging API Key", Username: "api-user", URL: "staging.api.company.com", Passphrase: "•••••••••••", Strength: "secure", CreatedAt: "Jun 1, 2026"},
}
}
+61
View File
@@ -0,0 +1,61 @@
package model
import "time"
type Snippet struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
Language string `json:"language"`
Tags []string `json:"tags"`
CreatedAt string `json:"createdAt"`
}
type SnippetStore struct {
Snippets []Snippet
}
func NewSnippetStore() *SnippetStore {
return &SnippetStore{Snippets: defaultSnippets()}
}
func (s *SnippetStore) All() []Snippet { return s.Snippets }
func (s *SnippetStore) Search(q string) []Snippet {
if q == "" { return s.Snippets }
var res []Snippet
for _, sn := range s.Snippets {
if contains(sn.Name, q) || contains(sn.Content, q) || contains(sn.Language, q) {
res = append(res, sn)
}
}
return res
}
func (s *SnippetStore) Add(name, content, language string, tags []string) Snippet {
sn := Snippet{
ID: randString(8),
Name: name,
Content: content,
Language: language,
Tags: tags,
CreatedAt: time.Now().Format("Jan 2, 2006"),
}
s.Snippets = append(s.Snippets, sn)
return sn
}
func (s *SnippetStore) Delete(id string) {
for i, sn := range s.Snippets {
if sn.ID == id { s.Snippets = append(s.Snippets[:i], s.Snippets[i+1:]...); return }
}
}
func defaultSnippets() []Snippet {
return []Snippet{
{ID: "s1", Name: "Nginx Reverse Proxy", Content: "server {\n listen 80;\n server_name example.com;\n location / {\n proxy_pass http://localhost:3000;\n proxy_set_header Host $host;\n }\n}", Language: "nginx", Tags: []string{"nginx", "proxy", "web"}, CreatedAt: "Jun 28, 2026"},
{ID: "s2", Name: "Docker Compose Postgres", Content: "version: '3.8'\nservices:\n db:\n image: postgres:16\n environment:\n POSTGRES_DB: app\n POSTGRES_PASSWORD: ${DB_PASS}\n ports:\n - 5432:5432", Language: "yaml", Tags: []string{"docker", "postgres", "db"}, CreatedAt: "Jun 25, 2026"},
{ID: "s3", Name: "Health Check Script", Content: "#!/bin/bash\ncurl -sf http://localhost/health || exit 1\necho \"OK\"", Language: "bash", Tags: []string{"monitoring", "bash"}, CreatedAt: "Jun 20, 2026"},
{ID: "s4", Name: "System Info", Content: "uname -a\ncat /etc/os-release\nfree -h\ndf -h\nuptime", Language: "bash", Tags: []string{"sysadmin", "diagnostics"}, CreatedAt: "Jun 15, 2026"},
{ID: "s5", Name: "ufw Rules", Content: "ufw default deny incoming\nufw default allow outgoing\nufw allow 22/tcp\nufw allow 443/tcp\nufw enable", Language: "bash", Tags: []string{"security", "firewall"}, CreatedAt: "Jun 10, 2026"},
}
}
+8
View File
@@ -32,6 +32,14 @@ func main() {
app.Get("/hosts", handler.DashboardList) app.Get("/hosts", handler.DashboardList)
app.Post("/hosts", handler.CreateHost) app.Post("/hosts", handler.CreateHost)
app.Delete("/hosts/:id", handler.DeleteHost) app.Delete("/hosts/:id", handler.DeleteHost)
app.Get("/snippets", handler.Snippets)
app.Get("/snippets/grid", handler.SnippetGrid)
app.Post("/snippets", handler.CreateSnippet)
app.Delete("/snippets/:id", handler.DeleteSnippet)
app.Get("/keychain", handler.Keychain)
app.Get("/keys/grid", handler.KeyGrid)
app.Post("/keys", handler.CreateKey)
app.Delete("/keys/:id", handler.DeleteKey)
app.Get("/api/health", handler.Health) app.Get("/api/health", handler.Health)
log.Fatal(app.Listen(":1947")) log.Fatal(app.Listen(":1947"))
+18
View File
@@ -0,0 +1,18 @@
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (e) {}
document.body.removeChild(ta);
}
+6
View File
@@ -0,0 +1,6 @@
document.addEventListener('alpine:init', () => {
Alpine.data('passphraseToggle', () => ({
show: false,
toggle() { this.show = !this.show; }
}));
});
+81 -60
View File
@@ -1,63 +1,84 @@
{{template "layout" .}} <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hosts · Hostkeeper V2</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="stylesheet" href="/static/css/custom.css">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
<script src="/static/js/clipboard.js"></script>
<script src="/static/js/utils.js"></script>
</head>
<body class="bg-surface text-on-surface antialiased dot-grid min-h-screen">
<div class="flex min-h-screen">
{{template "nav" .}}
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-14 border-b border-outline-variant/30 flex items-center px-6 bg-white/70 backdrop-blur-md">
<h1 class="text-lg font-semibold text-on-surface">{{.Title}}</h1>
</header>
<main id="main-content" class="flex-1 overflow-auto p-6">
<div id="dashboard-page" class="max-w-7xl mx-auto">
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="rounded-xl bg-white border border-outline-variant/30 p-4">
<div class="flex items-center gap-2 text-primary mb-1">
<span class="w-5 h-5 bg-primary/20 rounded flex items-center justify-center text-xs font-bold"></span>
<span class="text-xs font-medium text-on-surface-variant uppercase tracking-wide">Active Connections</span>
</div>
<span class="text-2xl font-bold text-on-surface">{{.Active}}</span>
</div>
<div class="rounded-xl bg-white border border-outline-variant/30 p-4">
<div class="flex items-center gap-2 text-secondary mb-1">
<span class="w-5 h-5 bg-secondary/20 rounded flex items-center justify-center text-xs font-bold"></span>
<span class="text-xs font-medium text-on-surface-variant uppercase tracking-wide">Transfers Today</span>
</div>
<span class="text-2xl font-bold text-on-surface">0</span>
</div>
<div class="rounded-xl bg-white border border-outline-variant/30 p-4">
<div class="flex items-center gap-2 text-tertiary mb-1">
<span class="w-5 h-5 bg-tertiary/20 rounded flex items-center justify-center text-xs font-bold">#</span>
<span class="text-xs font-medium text-on-surface-variant uppercase tracking-wide">Saved Hosts</span>
</div>
<span class="text-2xl font-bold text-on-surface">{{len .Hosts}}</span>
</div>
</div>
{{define "content"}} <div class="flex items-center gap-3 mb-6" x-data="{ view: 'grid' }">
<div id="dashboard-page" class="max-w-7xl mx-auto"> <div class="relative flex-1 max-w-md">
<div class="grid grid-cols-3 gap-4 mb-6"> <input type="search" name="q" placeholder="Search hosts..."
<div class="rounded-xl bg-white border border-outline-variant/30 p-4"> hx-get="/hosts?q=..." hx-trigger="keyup delay:200ms"
<div class="flex items-center gap-2 text-primary mb-1"> hx-target="#host-list" hx-include="[name='filter']"
<span class="w-5 h-5 bg-primary/20 rounded flex items-center justify-center text-xs font-bold"></span> class="w-full pl-9 pr-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface placeholder-on-surface-variant focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50">
<span class="text-xs font-medium text-on-surface-variant uppercase tracking-wide">Active Connections</span> </div>
</div> <select name="filter"
<span class="text-2xl font-bold text-on-surface">{{.Active}}</span> hx-get="/hosts?filter={value}" hx-trigger="change" hx-target="#host-list"
</div> class="px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30">
<div class="rounded-xl bg-white border border-outline-variant/30 p-4"> <option value="all">All</option>
<div class="flex items-center gap-2 text-secondary mb-1"> <option value="active">Active</option>
<span class="w-5 h-5 bg-secondary/20 rounded flex items-center justify-center text-xs font-bold"></span> <option value="offline">Offline</option>
<span class="text-xs font-medium text-on-surface-variant uppercase tracking-wide">Transfers Today</span> </select>
</div> <div class="flex items-center bg-white rounded-lg border border-outline-variant/50 p-0.5">
<span class="text-2xl font-bold text-on-surface">0</span> <button @click="view = 'grid'"
</div> :class="view === 'grid' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:text-on-surface'"
<div class="rounded-xl bg-white border border-outline-variant/30 p-4"> class="p-1.5 rounded transition-colors">
<div class="flex items-center gap-2 text-tertiary mb-1"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/></svg>
<span class="w-5 h-5 bg-tertiary/20 rounded flex items-center justify-center text-xs font-bold">#</span> </button>
<span class="text-xs font-medium text-on-surface-variant uppercase tracking-wide">Saved Hosts</span> <button @click="view = 'list'"
</div> :class="view === 'list' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:text-on-surface'"
<span class="text-2xl font-bold text-on-surface">{{len .Hosts}}</span> class="p-1.5 rounded transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
</button>
</div>
</div>
{{template "host_list" .}}
</div>
{{template "host_modal" .}}
</main>
</div> </div>
</div> </div>
</body>
<div class="flex items-center gap-3 mb-6" x-data="{ view: 'grid' }"> </html>
<div class="relative flex-1 max-w-md">
<input type="search" name="q" placeholder="Search hosts..."
hx-get="/hosts?q=..." hx-trigger="keyup delay:200ms"
hx-target="#host-list" hx-include="[name='filter']"
class="w-full pl-9 pr-3 py-2 rounded-lg border border-outline-variant/50
bg-white text-sm text-on-surface placeholder-on-surface-variant
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50">
</div>
<select name="filter"
hx-get="/hosts?filter={value}" hx-trigger="change" hx-target="#host-list"
class="px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface
focus:outline-none focus:ring-2 focus:ring-primary/30">
<option value="all">All</option>
<option value="active">Active</option>
<option value="offline">Offline</option>
</select>
<div class="flex items-center bg-white rounded-lg border border-outline-variant/50 p-0.5">
<button @click="view = 'grid'"
:class="view === 'grid' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:text-on-surface'"
class="p-1.5 rounded transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/></svg>
</button>
<button @click="view = 'list'"
:class="view === 'list' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:text-on-surface'"
class="p-1.5 rounded transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
</button>
</div>
</div>
{{template "host_list" .}}
</div>
{{template "host_modal" .}}
{{end}}
+44
View File
@@ -0,0 +1,44 @@
{{define "credential_grid"}}
<div id="key-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .Keys}}
<div class="rounded-xl bg-white border border-outline-variant/30 p-4 hover:shadow-md transition-shadow animate-fade-in">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-tertiary/10 text-tertiary flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
</div>
<div>
<p class="text-sm font-medium text-on-surface">{{.Name}}</p>
<p class="text-xs text-on-surface-variant">{{.Username}} @ {{.URL}}</p>
</div>
</div>
<button class="p-1.5 rounded-lg hover:bg-red-50 text-on-surface-variant hover:text-red-500 transition-colors shrink-0"
hx-delete="/keys/{{.ID}}" hx-target="#key-grid" hx-confirm="Delete {{.Name}}?">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
<div class="flex items-center gap-2 text-xs text-on-surface-variant mb-3">
<span class="font-mono">{{.Passphrase}}</span>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full
{{if eq .Strength "secure"}}bg-secondary/10 text-secondary{{else if eq .Strength "moderate"}}bg-tertiary/10 text-tertiary{{else}}bg-red-50 text-red-500{{end}}">
{{.Strength}}
</span>
</div>
<div class="flex items-center justify-between pt-3 border-t border-outline-variant/20">
<span class="text-xs text-on-surface-variant">{{.CreatedAt}}</span>
<button class="p-1.5 rounded-lg hover:bg-primary/10 text-on-surface-variant hover:text-primary transition-colors"
onclick="copyToClipboard('{{.Passphrase}}')" title="Copy">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
{{end}}
{{if eq (len .Keys) 0}}
<div class="col-span-full text-center py-12 text-on-surface-variant">
<p class="text-lg font-medium">No keys found</p>
<p class="text-sm mt-1">Add your first credential</p>
</div>
{{end}}
</div>
{{end}}
+132
View File
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keychain · Hostkeeper V2</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="stylesheet" href="/static/css/custom.css">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
<script src="/static/js/clipboard.js"></script>
<script src="/static/js/utils.js"></script>
</head>
<body class="bg-surface text-on-surface antialiased dot-grid min-h-screen">
<div class="flex min-h-screen">
{{template "nav" .}}
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-14 border-b border-outline-variant/30 flex items-center px-6 bg-white/70 backdrop-blur-md">
<h1 class="text-lg font-semibold text-on-surface">{{.Title}}</h1>
</header>
<main id="main-content" class="flex-1 overflow-auto p-6">
<div id="keychain-page" class="max-w-7xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<div class="relative flex-1 max-w-md">
<input type="search" placeholder="Search keys..."
hx-get="/keys/grid?q=..." hx-trigger="keyup delay:200ms"
hx-target="#key-grid"
class="w-full pl-9 pr-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface placeholder-on-surface-variant focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50">
</div>
<button @click="$dispatch('open-key-modal')"
class="px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add Key
</button>
</div>
<div id="key-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .Keys}}
<div class="rounded-xl bg-white border border-outline-variant/30 p-4 hover:shadow-md transition-shadow animate-fade-in">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-tertiary/10 text-tertiary flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/></svg>
</div>
<div>
<p class="text-sm font-medium text-on-surface">{{.Name}}</p>
<p class="text-xs text-on-surface-variant">{{.Username}} @ {{.URL}}</p>
</div>
</div>
<button class="p-1.5 rounded-lg hover:bg-red-50 text-on-surface-variant hover:text-red-500 transition-colors shrink-0"
hx-delete="/keys/{{.ID}}" hx-target="#key-grid" hx-confirm="Delete {{.Name}}?">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
<div class="flex items-center gap-2 text-xs text-on-surface-variant mb-3">
<span class="font-mono">{{.Passphrase}}</span>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full
{{if eq .Strength "secure"}}bg-secondary/10 text-secondary{{else if eq .Strength "moderate"}}bg-tertiary/10 text-tertiary{{else}}bg-red-50 text-red-500{{end}}">
{{.Strength}}
</span>
</div>
<div class="flex items-center justify-between pt-3 border-t border-outline-variant/20">
<span class="text-xs text-on-surface-variant">{{.CreatedAt}}</span>
<button class="p-1.5 rounded-lg hover:bg-primary/10 text-on-surface-variant hover:text-primary transition-colors"
onclick="copyToClipboard('{{.Passphrase}}')" title="Copy">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
{{end}}
{{if eq (len .Keys) 0}}
<div class="col-span-full text-center py-12 text-on-surface-variant">
<p class="text-lg font-medium">No keys found</p>
<p class="text-sm mt-1">Add your first credential</p>
</div>
{{end}}
</div>
</div>
<div id="key-modal" class="fixed inset-0 z-50" x-data="{ open: false }" x-show="open" x-cloak
@open-key-modal.window="open = true" @keydown.escape.window="open = false">
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="open = false"></div>
<div class="absolute inset-0 flex items-center justify-center p-4" x-show="open" x-transition>
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6" @click.outside="open = false">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-on-surface">Add New Credential</h2>
<button @click="open = false" class="p-1 rounded-lg hover:bg-surface-container-low text-on-surface-variant">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form hx-post="/keys" hx-target="#key-grid" hx-swap="outerHTML"
@submit="open = false" class="space-y-4">
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Name</label>
<input type="text" name="name" required placeholder="e.g. AWS Prod"
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30">
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Username</label>
<input type="text" name="username" required placeholder="e.g. ec2-user"
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30">
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">URL / Host</label>
<input type="text" name="url" required placeholder="e.g. github.com"
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30">
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Passphrase</label>
<div class="relative">
<input type="password" name="passphrase" required placeholder="Enter passphrase"
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30 pr-10">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-on-surface-variant hover:text-on-surface" title="Toggle visibility">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="open = false"
class="px-4 py-2 rounded-lg text-sm font-medium text-on-surface-variant hover:bg-surface-container-low transition-colors">Cancel</button>
<button type="submit"
class="px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors">Add Key</button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
-27
View File
@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hostkeeper V2</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="stylesheet" href="/static/css/custom.css">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
</head>
<body class="bg-surface text-on-surface antialiased dot-grid min-h-screen">
<div class="flex min-h-screen">
{{template "nav" .}}
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-14 border-b border-outline-variant/30 flex items-center px-6 bg-white/70 backdrop-blur-md">
<h1 class="text-lg font-semibold text-on-surface">{{.Title}}</h1>
</header>
<main id="main-content" class="flex-1 overflow-auto p-6">
{{block "content" .}}{{end}}
</main>
</div>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{{define "snippet_grid"}}
<div id="snippet-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .Snippets}}
<div class="rounded-xl bg-white border border-outline-variant/30 p-4 hover:shadow-md transition-shadow animate-fade-in">
<div class="flex items-start justify-between mb-3">
<div>
<p class="text-sm font-medium text-on-surface">{{.Name}}</p>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-tertiary/10 text-tertiary">{{.Language}}</span>
</div>
<button class="p-1.5 rounded-lg hover:bg-red-50 text-on-surface-variant hover:text-red-500 transition-colors shrink-0"
hx-delete="/snippets/{{.ID}}" hx-target="#snippet-grid" hx-confirm="Delete {{.Name}}?">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
<pre class="text-xs text-on-surface-variant bg-surface-container-low/50 rounded-lg p-3 mb-3 overflow-x-auto font-mono leading-relaxed">{{.Content}}</pre>
<div class="flex items-center justify-between pt-3 border-t border-outline-variant/20">
<span class="text-xs text-on-surface-variant">{{.CreatedAt}}</span>
<button class="p-1.5 rounded-lg hover:bg-primary/10 text-on-surface-variant hover:text-primary transition-colors"
onclick="copyToClipboard(`{{.Content}}`)" title="Copy">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
{{end}}
{{if eq (len .Snippets) 0}}
<div class="col-span-full text-center py-12 text-on-surface-variant">
<p class="text-lg font-medium">No snippets found</p>
<p class="text-sm mt-1">Add your first snippet to get started</p>
</div>
{{end}}
</div>
{{end}}
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snippets · Hostkeeper V2</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/output.css">
<link rel="stylesheet" href="/static/css/custom.css">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
<script src="/static/js/clipboard.js"></script>
<script src="/static/js/utils.js"></script>
</head>
<body class="bg-surface text-on-surface antialiased dot-grid min-h-screen">
<div class="flex min-h-screen">
{{template "nav" .}}
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-14 border-b border-outline-variant/30 flex items-center px-6 bg-white/70 backdrop-blur-md">
<h1 class="text-lg font-semibold text-on-surface">{{.Title}}</h1>
</header>
<main id="main-content" class="flex-1 overflow-auto p-6">
<div id="snippets-page" class="max-w-7xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<div class="relative flex-1 max-w-md">
<input type="search" placeholder="Search snippets..."
hx-get="/snippets/grid?q=..." hx-trigger="keyup delay:200ms"
hx-target="#snippet-grid"
class="w-full pl-9 pr-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface placeholder-on-surface-variant focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50">
</div>
<button @click="$dispatch('open-snippet-modal')"
class="px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Add Snippet
</button>
</div>
<div id="snippet-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{{range .Snippets}}
<div class="rounded-xl bg-white border border-outline-variant/30 p-4 hover:shadow-md transition-shadow animate-fade-in">
<div class="flex items-start justify-between mb-3">
<div>
<p class="text-sm font-medium text-on-surface">{{.Name}}</p>
<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-tertiary/10 text-tertiary">{{.Language}}</span>
</div>
<button class="p-1.5 rounded-lg hover:bg-red-50 text-on-surface-variant hover:text-red-500 transition-colors shrink-0"
hx-delete="/snippets/{{.ID}}" hx-target="#snippet-grid" hx-confirm="Delete {{.Name}}?">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
</button>
</div>
<pre class="text-xs text-on-surface-variant bg-surface-container-low/50 rounded-lg p-3 mb-3 overflow-x-auto font-mono leading-relaxed">{{.Content}}</pre>
<div class="flex items-center justify-between pt-3 border-t border-outline-variant/20">
<span class="text-xs text-on-surface-variant">{{.CreatedAt}}</span>
<button class="p-1.5 rounded-lg hover:bg-primary/10 text-on-surface-variant hover:text-primary transition-colors"
onclick="copyToClipboard(`{{.Content}}`)" title="Copy">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</button>
</div>
</div>
{{end}}
{{if eq (len .Snippets) 0}}
<div class="col-span-full text-center py-12 text-on-surface-variant">
<p class="text-lg font-medium">No snippets found</p>
<p class="text-sm mt-1">Add your first snippet to get started</p>
</div>
{{end}}
</div>
</div>
<div id="snippet-modal" class="fixed inset-0 z-50" x-data="{ open: false }" x-show="open" x-cloak
@open-snippet-modal.window="open = true" @keydown.escape.window="open = false">
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm" @click="open = false"></div>
<div class="absolute inset-0 flex items-center justify-center p-4" x-show="open" x-transition>
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6" @click.outside="open = false">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-on-surface">Add New Snippet</h2>
<button @click="open = false" class="p-1 rounded-lg hover:bg-surface-container-low text-on-surface-variant">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form hx-post="/snippets" hx-target="#snippet-grid" hx-swap="outerHTML"
@submit="open = false" class="space-y-4">
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Name</label>
<input type="text" name="name" required placeholder="e.g. Health Check Script"
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30">
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Content</label>
<textarea name="content" rows="6" required placeholder="Paste your snippet here..."
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface font-mono focus:outline-none focus:ring-2 focus:ring-primary/30"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Language</label>
<select name="language"
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-white text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/30">
<option value="bash">Bash</option>
<option value="nginx">Nginx</option>
<option value="yaml">YAML</option>
<option value="json">JSON</option>
<option value="sql">SQL</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" @click="open = false"
class="px-4 py-2 rounded-lg text-sm font-medium text-on-surface-variant hover:bg-surface-container-low transition-colors">Cancel</button>
<button type="submit"
class="px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors">Add Snippet</button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
+14 -14
View File
@@ -36,7 +36,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
| Planning documents (HTMX) | DONE | | Planning documents (HTMX) | DONE |
| Sprint 0 — Setup GoFiber + HTMX | DONE | | Sprint 0 — Setup GoFiber + HTMX | DONE |
| Sprint 1 — Dashboard + Host List | DONE | | Sprint 1 — Dashboard + Host List | DONE |
| Sprint 2 — Snippets + Keychain | IN PROGRESS | | Sprint 2 — Snippets + Keychain | DONE |
| Sprint 3 — Settings + Brief | NOT STARTED | | Sprint 3 — Settings + Brief | NOT STARTED |
| Sprint 4 — SFTP Dual-Pane | NOT STARTED | | Sprint 4 — SFTP Dual-Pane | NOT STARTED |
| Sprint 5 — Terminal + xterm.js | NOT STARTED | | Sprint 5 — Terminal + xterm.js | NOT STARTED |
@@ -75,19 +75,18 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
- **Verify**: Homepage renders with 8 hosts, search+filter works, create+delete CRUD - **Verify**: Homepage renders with 8 hosts, search+filter works, create+delete CRUD
### Sprint 2 — Snippets + Keychain ### Sprint 2 — Snippets + Keychain
- [ ] Model Snippet + SnippetStore (`internal/model/snippet.go`) - [x] Model Snippet + SnippetStore (`internal/model/snippet.go`)
- [ ] Model Key + KeyStore (`internal/model/keychain.go`) - [x] Model Key + KeyStore (`internal/model/keychain.go`)
- [ ] Snippets handler (`internal/handler/snippets.go`) - [x] Snippets handler (`internal/handler/snippets.go`)
- [ ] Keychain handler (`internal/handler/keychain.go`) - [x] Keychain handler (`internal/handler/keychain.go`)
- [ ] Snippets template (`views/snippets.html`) - [x] Snippets template (`views/snippets.html`) — standalone page (no layout inheritance)
- [ ] Snippet grid partial (`views/snippet_grid.html`) - [x] Snippet grid partial (`views/snippet_grid.html`) — HTMX partial
- [ ] Snippet modal (`views/snippet_modal.html`) - [x] Keychain template (`views/keychain.html`) — standalone page
- [ ] Keychain template (`views/keychain.html`) - [x] Key grid partial (`views/key_grid.html`) — HTMX partial
- [ ] Key grid partial (`views/key_grid.html`) - [x] Clipboard JS (`static/js/clipboard.js`)
- [ ] Key modal (`views/key_modal.html`) - [x] Utils JS (`static/js/utils.js`)
- [ ] Clipboard JS (`static/js/clipboard.js`) - **Note**: Removed `layout.html` — all pages are now standalone (Go template `{{define}}` conflict)
- [ ] Passphrase reveal + strength (`static/js/utils.js`) - **Verify**: Both pages render, CRUD works, clipboard copies, strength badges display
- **Verify**: Both pages render, CRUD works, clipboard copies, passphrase reveal toggles
### Sprint 3 — Settings + Brief ### Sprint 3 — Settings + Brief
- [ ] Model Device + Config - [ ] Model Device + Config
@@ -143,6 +142,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
| 2026-07-07 | Sprint 0: GoFiber+HTMX scaffolding, TailwindCSS, layout, nav, health | `43a19b7` | | 2026-07-07 | Sprint 0: GoFiber+HTMX scaffolding, TailwindCSS, layout, nav, health | `43a19b7` |
| 2026-07-07 | Sprint 1: Host model+store, dashboard CRUD, HTMX partials (grid/list, search, filter, modal, delete), transfers panel | `d771925`, `86620b0` | | 2026-07-07 | Sprint 1: Host model+store, dashboard CRUD, HTMX partials (grid/list, search, filter, modal, delete), transfers panel | `d771925`, `86620b0` |
| 2026-07-07 | Sprint 2 dimulai: Snippet + Keychain models created | — | | 2026-07-07 | Sprint 2 dimulai: Snippet + Keychain models created | — |
| 2026-07-07 | Sprint 2 selesai: all CRUD, standalone templates, clipboard.js, utils.js, fix template {{define}} conflict | `(pending)` |
--- ---