feat: Sprint 3 — settings page with tabs/toggles/devices/danger-zone, brief page

This commit is contained in:
swanadiva
2026-07-07 13:31:13 +07:00
parent c4b0d7d8c0
commit c5ac07e27f
10 changed files with 438 additions and 11 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
> **Purpose**: Memungkinkan AI agent berikutnya melanjutkan development tanpa mengulang atau merusak.
> **Last Updated**: 2026-07-07
> **Current Phase**: Sprint 1 + 2 selesai, Sprint 3 berikutnya
> **Current Phase**: Sprint 1 + 2 + 3 selesai, Sprint 4 berikutnya
---
@@ -115,7 +115,7 @@ Alasan:
### ❌ Belum Dimulai / In Progress
- **Sprint 2 (DONE)**: Snippets + Keychain
- **Sprint 3 (NEXT)**: Settings + Brief
- **Sprint 3 (DONE)**: Settings + Brief
- Sprint 4: SFTP Dual-Pane
- Sprint 5: Terminal + xterm.js
- Sprint 6: Storage Integration
+11
View File
@@ -0,0 +1,11 @@
package handler
import "github.com/gofiber/fiber/v2"
func Brief(c *fiber.Ctx) error {
return c.Render("brief", fiber.Map{
"View": "brief",
"Title": "Quick Brief",
"NavItems": navItems,
})
}
+44
View File
@@ -0,0 +1,44 @@
package handler
import (
"github.com/gofiber/fiber/v2"
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
)
var deviceStore = model.NewDeviceStore()
var configStore = model.NewConfigStore()
func Settings(c *fiber.Ctx) error {
return c.Render("settings", fiber.Map{
"View": "settings",
"Title": "Settings",
"Config": configStore.Get(),
"Devices": deviceStore.All(),
"NavItems": navItems,
})
}
func UpdateConfig(c *fiber.Ctx) error {
cfg := configStore.Get()
if v := c.FormValue("theme"); v != "" { cfg.Theme = v }
if v := c.FormValue("fontSize"); v != "" { cfg.FontSize = parseInt(v, 14) }
if v := c.FormValue("scrollback"); v != "" { cfg.Scrollback = parseInt(v, 5000) }
if v := c.FormValue("autoLock"); v != "" { cfg.AutoLock = parseInt(v, 5) }
cfg.Keepalive = c.FormValue("keepalive") == "on"
cfg.CopyOnSelect = c.FormValue("copyOnSelect") == "on"
cfg.BellEnabled = c.FormValue("bellEnabled") == "on"
cfg.AutoReconnect = c.FormValue("autoReconnect") == "on"
cfg.BlinkCursor = c.FormValue("blinkCursor") == "on"
configStore.Update(cfg)
return c.Redirect("/settings", 303)
}
func parseInt(s string, def int) int {
i := 0
for _, c := range s {
if c < '0' || c > '9' { return def }
i = i*10 + int(c-'0')
}
if i == 0 { return def }
return i
}
+39
View File
@@ -0,0 +1,39 @@
package model
type AppConfig struct {
Theme string `json:"theme"` // light | dark
FontSize int `json:"fontSize"` // 12-18
Scrollback int `json:"scrollback"` // 1000-10000
AutoLock int `json:"autoLock"` // minutes
Keepalive bool `json:"keepalive"`
CopyOnSelect bool `json:"copyOnSelect"`
BellEnabled bool `json:"bellEnabled"`
AutoReconnect bool `json:"autoReconnect"`
BlinkCursor bool `json:"blinkCursor"`
}
type ConfigStore struct {
Config AppConfig
}
func NewConfigStore() *ConfigStore {
return &ConfigStore{Config: defaultConfig()}
}
func (s *ConfigStore) Get() AppConfig { return s.Config }
func (s *ConfigStore) Update(cfg AppConfig) { s.Config = cfg }
func defaultConfig() AppConfig {
return AppConfig{
Theme: "light",
FontSize: 14,
Scrollback: 5000,
AutoLock: 5,
Keepalive: true,
CopyOnSelect: true,
BellEnabled: false,
AutoReconnect: true,
BlinkCursor: false,
}
}
+28
View File
@@ -0,0 +1,28 @@
package model
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // desktop | mobile | tablet
OS string `json:"os"`
LastSeen string `json:"lastSeen"`
Current bool `json:"current"`
}
type DeviceStore struct {
Devices []Device
}
func NewDeviceStore() *DeviceStore {
return &DeviceStore{Devices: defaultDevices()}
}
func (s *DeviceStore) All() []Device { return s.Devices }
func defaultDevices() []Device {
return []Device{
{ID: "d1", Name: "MacBook Pro M4", Type: "desktop", OS: "macOS 15 Sequoia", LastSeen: "Just now", Current: true},
{ID: "d2", Name: "iPhone 17 Pro", Type: "mobile", OS: "iOS 20", LastSeen: "2 hours ago", Current: false},
{ID: "d3", Name: "iPad Air M3", Type: "tablet", OS: "iPadOS 20", LastSeen: "Yesterday", Current: false},
}
}
+3
View File
@@ -40,6 +40,9 @@ func main() {
app.Get("/keys/grid", handler.KeyGrid)
app.Post("/keys", handler.CreateKey)
app.Delete("/keys/:id", handler.DeleteKey)
app.Get("/settings", handler.Settings)
app.Post("/settings", handler.UpdateConfig)
app.Get("/brief", handler.Brief)
app.Get("/api/health", handler.Health)
log.Fatal(app.Listen(":1947"))
+13
View File
@@ -0,0 +1,13 @@
document.addEventListener('alpine:init', () => {
Alpine.data('switchToggle', (initial) => ({
on: initial,
toggle() {
this.on = !this.on;
this.$el.classList.toggle('active', this.on);
this.$nextTick(() => {
const hidden = this.$el.nextElementSibling;
if (hidden) hidden.value = this.on ? 'on' : '';
});
}
}));
});
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quick Brief · 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">
<div class="max-w-3xl mx-auto space-y-6">
<div class="rounded-xl bg-white border border-outline-variant/30 p-6">
<h2 class="text-base font-semibold text-on-surface mb-3">Keyboard Shortcuts</h2>
<div class="space-y-2 text-sm">
<div class="flex items-center justify-between py-1.5 border-b border-outline-variant/10 last:border-0">
<span class="text-on-surface-variant">New Host</span>
<kbd class="px-2 py-0.5 rounded bg-surface-container-low text-xs font-mono text-on-surface">Cmd + N</kbd>
</div>
<div class="flex items-center justify-between py-1.5 border-b border-outline-variant/10 last:border-0">
<span class="text-on-surface-variant">Search</span>
<kbd class="px-2 py-0.5 rounded bg-surface-container-low text-xs font-mono text-on-surface">Cmd + K</kbd>
</div>
<div class="flex items-center justify-between py-1.5 border-b border-outline-variant/10 last:border-0">
<span class="text-on-surface-variant">Toggle Dark Mode</span>
<kbd class="px-2 py-0.5 rounded bg-surface-container-low text-xs font-mono text-on-surface">Cmd + D</kbd>
</div>
<div class="flex items-center justify-between py-1.5 border-b border-outline-variant/10 last:border-0">
<span class="text-on-surface-variant">Quick Brief</span>
<kbd class="px-2 py-0.5 rounded bg-surface-container-low text-xs font-mono text-on-surface">Cmd + B</kbd>
</div>
<div class="flex items-center justify-between py-1.5 border-b border-outline-variant/10 last:border-0">
<span class="text-on-surface-variant">Settings</span>
<kbd class="px-2 py-0.5 rounded bg-surface-container-low text-xs font-mono text-on-surface">Cmd + ,</kbd>
</div>
</div>
</div>
<div class="rounded-xl bg-white border border-outline-variant/30 p-6">
<h2 class="text-base font-semibold text-on-surface mb-3">About Hostkeeper V2</h2>
<div class="space-y-2 text-sm text-on-surface-variant">
<p>Hostkeeper V2 is a free, open-source Termius alternative for managing SSH/SFTP connections.</p>
<p>Built with GoFiber v2 + HTMX + Alpine.js + TailwindCSS v4.</p>
<p>Version: 2.0.0-dev</p>
<p>License: MIT</p>
</div>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
+222
View File
@@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings · 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>
<script src="/static/js/toggles.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 class="max-w-3xl mx-auto space-y-8 pb-16">
<section>
<h2 class="text-lg font-semibold text-on-surface mb-4">Profile</h2>
<div class="bg-white rounded-xl border border-outline-variant/30 p-6 space-y-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-14 h-14 rounded-full bg-tertiary/20 flex items-center justify-center text-tertiary text-xl font-semibold">U</div>
<div>
<p class="text-base font-medium text-on-surface">User</p>
<p class="text-sm text-on-surface-variant">Local administrator</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Display Name</label>
<input type="text" value="User" readonly
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-surface-container-low/30 text-sm text-on-surface">
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Username</label>
<input type="text" value="admin" readonly
class="w-full px-3 py-2 rounded-lg border border-outline-variant/50 bg-surface-container-low/30 text-sm text-on-surface">
</div>
</div>
</div>
</section>
<section x-data="{ tab: 'appearance' }">
<div class="flex gap-1 mb-4 bg-surface-container-low/50 rounded-lg p-1 w-fit">
<button @click="tab = 'appearance'" :class="tab === 'appearance' ? 'bg-white shadow-sm text-on-surface' : 'text-on-surface-variant hover:text-on-surface'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors">Appearance</button>
<button @click="tab = 'terminal'" :class="tab === 'terminal' ? 'bg-white shadow-sm text-on-surface' : 'text-on-surface-variant hover:text-on-surface'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors">Terminal</button>
<button @click="tab = 'connection'" :class="tab === 'connection' ? 'bg-white shadow-sm text-on-surface' : 'text-on-surface-variant hover:text-on-surface'"
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors">Connection</button>
</div>
<form method="post" action="/settings" class="bg-white rounded-xl border border-outline-variant/30 p-6 space-y-5">
<div x-show="tab === 'appearance'" x-cloak class="space-y-5">
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Theme</label>
<select name="theme"
class="w-full max-w-xs 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="light" {{if eq .Config.Theme "light"}}selected{{end}}>Light</option>
<option value="dark" {{if eq .Config.Theme "dark"}}selected{{end}}>Dark (coming soon)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Font Size</label>
<select name="fontSize"
class="w-full max-w-xs 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="12" {{if eq .Config.FontSize 12}}selected{{end}}>12px</option>
<option value="13" {{if eq .Config.FontSize 13}}selected{{end}}>13px</option>
<option value="14" {{if eq .Config.FontSize 14}}selected{{end}}>14px</option>
<option value="15" {{if eq .Config.FontSize 15}}selected{{end}}>15px</option>
<option value="16" {{if eq .Config.FontSize 16}}selected{{end}}>16px</option>
<option value="18" {{if eq .Config.FontSize 18}}selected{{end}}>18px</option>
</select>
</div>
</div>
<div x-show="tab === 'terminal'" x-cloak class="space-y-5">
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Scrollback Lines</label>
<select name="scrollback"
class="w-full max-w-xs 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="1000" {{if eq .Config.Scrollback 1000}}selected{{end}}>1,000</option>
<option value="5000" {{if eq .Config.Scrollback 5000}}selected{{end}}>5,000</option>
<option value="10000" {{if eq .Config.Scrollback 10000}}selected{{end}}>10,000</option>
</select>
</div>
<label class="flex items-center justify-between py-2">
<span class="text-sm text-on-surface">Blinking Cursor</span>
<button type="button" role="switch"
x-data="{ on: {{if .Config.BlinkCursor}}true{{else}}false{{end}} }"
@click="on = !on; $el.classList.toggle('active'); $el.nextElementSibling.value = on ? 'on' : 'off'"
:class="on ? 'active' : ''"
class="toggle-switch" aria-checked="{{if .Config.BlinkCursor}}true{{else}}false{{end}}">
<span class="toggle-knob"></span>
</button>
<input type="hidden" name="blinkCursor" value="{{if .Config.BlinkCursor}}on{{end}}">
</label>
<label class="flex items-center justify-between py-2">
<span class="text-sm text-on-surface">Terminal Bell</span>
<button type="button" role="switch"
x-data="{ on: {{if .Config.BellEnabled}}true{{else}}false{{end}} }"
@click="on = !on; $el.classList.toggle('active'); $el.nextElementSibling.value = on ? 'on' : 'off'"
:class="on ? 'active' : ''"
class="toggle-switch">
<span class="toggle-knob"></span>
</button>
<input type="hidden" name="bellEnabled" value="{{if .Config.BellEnabled}}on{{end}}">
</label>
</div>
<div x-show="tab === 'connection'" x-cloak class="space-y-5">
<div>
<label class="block text-sm font-medium text-on-surface mb-1">Auto-Lock (minutes)</label>
<select name="autoLock"
class="w-full max-w-xs 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="1" {{if eq .Config.AutoLock 1}}selected{{end}}>1 min</option>
<option value="5" {{if eq .Config.AutoLock 5}}selected{{end}}>5 min</option>
<option value="15" {{if eq .Config.AutoLock 15}}selected{{end}}>15 min</option>
<option value="30" {{if eq .Config.AutoLock 30}}selected{{end}}>30 min</option>
<option value="0" {{if eq .Config.AutoLock 0}}selected{{end}}>Never</option>
</select>
</div>
<label class="flex items-center justify-between py-2">
<span class="text-sm text-on-surface">TCP Keepalive</span>
<button type="button" role="switch"
x-data="{ on: {{if .Config.Keepalive}}true{{else}}false{{end}} }"
@click="on = !on; $el.classList.toggle('active'); $el.nextElementSibling.value = on ? 'on' : 'off'"
:class="on ? 'active' : ''"
class="toggle-switch">
<span class="toggle-knob"></span>
</button>
<input type="hidden" name="keepalive" value="{{if .Config.Keepalive}}on{{end}}">
</label>
<label class="flex items-center justify-between py-2">
<span class="text-sm text-on-surface">Copy on Select</span>
<button type="button" role="switch"
x-data="{ on: {{if .Config.CopyOnSelect}}true{{else}}false{{end}} }"
@click="on = !on; $el.classList.toggle('active'); $el.nextElementSibling.value = on ? 'on' : 'off'"
:class="on ? 'active' : ''"
class="toggle-switch">
<span class="toggle-knob"></span>
</button>
<input type="hidden" name="copyOnSelect" value="{{if .Config.CopyOnSelect}}on{{end}}">
</label>
<label class="flex items-center justify-between py-2">
<span class="text-sm text-on-surface">Auto-Reconnect</span>
<button type="button" role="switch"
x-data="{ on: {{if .Config.AutoReconnect}}true{{else}}false{{end}} }"
@click="on = !on; $el.classList.toggle('active'); $el.nextElementSibling.value = on ? 'on' : 'off'"
:class="on ? 'active' : ''"
class="toggle-switch">
<span class="toggle-knob"></span>
</button>
<input type="hidden" name="autoReconnect" value="{{if .Config.AutoReconnect}}on{{end}}">
</label>
</div>
<div class="flex justify-end pt-2">
<button type="submit"
class="px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors">Save Settings</button>
</div>
</form>
</section>
<section>
<h2 class="text-lg font-semibold text-on-surface mb-4">Connected Devices</h2>
<div class="bg-white rounded-xl border border-outline-variant/30 divide-y divide-outline-variant/20">
{{range .Devices}}
<div class="flex items-center gap-4 px-6 py-4">
<div class="w-8 h-8 rounded-lg bg-tertiary/10 text-tertiary flex items-center justify-center shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{{if eq .Type "desktop"}}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>{{end}}
{{if eq .Type "mobile"}}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>{{end}}
{{if eq .Type "tablet"}}<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>{{end}}
</svg>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-on-surface">{{.Name}}</p>
{{if .Current}}<span class="text-xs font-medium px-1.5 py-0.5 rounded-full bg-secondary/10 text-secondary">Current</span>{{end}}
</div>
<p class="text-xs text-on-surface-variant">{{.OS}} · {{.LastSeen}}</p>
</div>
<button class="text-xs font-medium text-red-500 hover:text-red-600 transition-colors">Remove</button>
</div>
{{end}}
</div>
</section>
<section x-data="{ confirm: false }">
<h2 class="text-lg font-semibold text-red-600 mb-4">Danger Zone</h2>
<div class="bg-white rounded-xl border border-red-200 p-6">
<p class="text-sm text-on-surface-variant mb-4">This will permanently delete all your data, including hosts, keys, snippets, and settings. This action cannot be undone.</p>
<div x-show="!confirm">
<button @click="confirm = true"
class="px-4 py-2 rounded-lg text-sm font-medium bg-red-500 text-white hover:bg-red-600 transition-colors">Reset All Data</button>
</div>
<div x-show="confirm" x-cloak class="flex items-center gap-3">
<p class="text-sm font-medium text-red-600">Are you sure?</p>
<button @click="localStorage.clear(); confirm = false; alert('Data cleared')"
class="px-4 py-2 rounded-lg text-sm font-medium bg-red-500 text-white hover:bg-red-600 transition-colors">Yes, Delete Everything</button>
<button @click="confirm = 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>
</div>
</div>
</section>
</div>
</main>
</div>
</div>
</body>
</html>
+11 -9
View File
@@ -37,7 +37,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
| Sprint 0 — Setup GoFiber + HTMX | DONE |
| Sprint 1 — Dashboard + Host List | DONE |
| Sprint 2 — Snippets + Keychain | DONE |
| Sprint 3 — Settings + Brief | NOT STARTED |
| Sprint 3 — Settings + Brief | DONE |
| Sprint 4 — SFTP Dual-Pane | NOT STARTED |
| Sprint 5 — Terminal + xterm.js | NOT STARTED |
| Sprint 6 — Storage Integration | NOT STARTED |
@@ -89,13 +89,14 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
- **Verify**: Both pages render, CRUD works, clipboard copies, strength badges display
### Sprint 3 — Settings + Brief
- [ ] Model Device + Config
- [ ] Settings handler
- [ ] Brief handler
- [ ] Settings template
- [ ] Brief template
- [ ] Toggle switch JS
- [ ] Danger zone
- [x] Model Device + Config (`internal/model/device.go`, `internal/model/config.go`)
- [x] Settings handler + UpdateConfig (`internal/handler/settings.go`)
- [x] Brief handler (`internal/handler/brief.go`)
- [x] Settings template (`views/settings.html`) — profile, tabs, toggles, devices, danger zone
- [x] Brief template (`views/brief.html`) — keyboard shortcuts, about
- [x] Toggle switch JS (`static/js/toggles.js`) — Alpine toggle data
- [x] Danger zone — confirm flow with localStorage.clear()
- **Verify**: Settings page renders with tabs, toggles work via Alpine, brief page shows shortcuts
### Sprint 4 — SFTP Dual-Pane
- [ ] Model FileItem + Transfer
@@ -142,7 +143,8 @@ 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 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 selesai: all CRUD, standalone templates, clipboard.js, utils.js, fix template {{define}} conflict | `(pending)` |
| 2026-07-07 | Sprint 2 selesai: all CRUD, standalone templates, clipboard.js, utils.js, fix template {{define}} conflict | `c4b0d7d` |
| 2026-07-07 | Sprint 3 selesai: settings page, tabs, toggles, connected devices, danger zone, brief page | `(pending)` |
---