feat: Sprint 2 — snippets + keychain CRUD, standalone templates, clipboard/utils JS
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('passphraseToggle', () => ({
|
||||||
|
show: false,
|
||||||
|
toggle() { this.show = !this.show; }
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -1,6 +1,26 @@
|
|||||||
{{template "layout" .}}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
{{define "content"}}
|
<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 id="dashboard-page" class="max-w-7xl mx-auto">
|
||||||
<div class="grid grid-cols-3 gap-4 mb-6">
|
<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="rounded-xl bg-white border border-outline-variant/30 p-4">
|
||||||
@@ -31,14 +51,11 @@
|
|||||||
<input type="search" name="q" placeholder="Search hosts..."
|
<input type="search" name="q" placeholder="Search hosts..."
|
||||||
hx-get="/hosts?q=..." hx-trigger="keyup delay:200ms"
|
hx-get="/hosts?q=..." hx-trigger="keyup delay:200ms"
|
||||||
hx-target="#host-list" hx-include="[name='filter']"
|
hx-target="#host-list" hx-include="[name='filter']"
|
||||||
class="w-full pl-9 pr-3 py-2 rounded-lg border border-outline-variant/50
|
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">
|
||||||
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>
|
</div>
|
||||||
<select name="filter"
|
<select name="filter"
|
||||||
hx-get="/hosts?filter={value}" hx-trigger="change" hx-target="#host-list"
|
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
|
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">
|
||||||
focus:outline-none focus:ring-2 focus:ring-primary/30">
|
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="offline">Offline</option>
|
<option value="offline">Offline</option>
|
||||||
@@ -60,4 +77,8 @@
|
|||||||
{{template "host_list" .}}
|
{{template "host_list" .}}
|
||||||
</div>
|
</div>
|
||||||
{{template "host_modal" .}}
|
{{template "host_modal" .}}
|
||||||
{{end}}
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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}}
|
||||||
@@ -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
@@ -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)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user