Files
HostKeeper/docs/UI_COMPONENTS.md
T
swanadiva a0c8483c0e docs: migrate all planning docs from React to GoFiber + HTMX
- New ARCHITECTURE_HTMX.md — comprehensive HTMX architecture doc
- ARCHITECTURE.md — updated to point to HTMX version
- UI_COMPONENTS.md — replaced React tree with HTMX partials
- SPRINT_PLAN.md — all sprint tasks updated for HTMX
- BUILD_SYSTEM.md — simplified (no Vite, no npm)
- TIMELINE.md — 3.5 week HTMX timeline
- PROGRESS.md — updated status + HTMX
- AGENTS.md — updated checkpoint with HTMX decision
2026-07-07 12:15:19 +07:00

511 lines
16 KiB
Markdown

# Hostkeeper V2 — HTMX Components & Partials
> **Status**: Menggantikan React Component Tree
> **Last Updated**: 2026-07-07
> **Framework**: Go HTML Template + HTMX + Alpine.js
---
## 1. Template Structure
```
views/
├── layout.html ← Base layout (html, head, body, sidebar, header, scripts)
├── index.html ← Entry: {{template "layout" .}} + {{template "content" .}}
└── partials/ ← HTMX-swappable partial fragments
├── nav.html
├── dashboard.html
├── host_list.html
├── host_card.html
├── host_modal.html
├── terminal.html
├── sftp.html
├── sftp_pane.html
├── snippets.html
├── snippet_grid.html
├── snippet_modal.html
├── keychain.html
├── key_grid.html
├── key_modal.html
├── settings.html
├── brief.html
└── toast.html
```
---
## 2. Komponen per Halaman
### 2.1 Navigasi (layout.html + nav.html)
```html
<!-- layout.html — base shell -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/alpine.min.js" defer></script>
<script src="/static/js/utils.js"></script>
<link rel="stylesheet" href="/static/css/output.css">
<link rel="stylesheet" href="/static/css/custom.css">
<title>Hostkeeper V2</title>
</head>
<body class="bg-surface text-on-surface antialiased dot-grid" hx-boost="true">
<div class="flex h-screen">
{{template "nav" .}}
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-14 border-b border-outline-variant/30 ...">
<h1 class="text-lg font-semibold">{{.Title}}</h1>
</header>
<main id="main-content" class="flex-1 overflow-auto p-6">
{{block "content" .}}{{end}}
</main>
</div>
</div>
</body>
</html>
<!-- nav.html — sidebar -->
<nav x-data="{ collapsed: false }"
class="w-[260px] ... glass-sidebar border-r border-outline-variant/30 flex flex-col">
<div class="p-4 ...">
<span class="text-primary font-bold text-lg">Hostkeeper</span>
</div>
<div class="flex-1 px-3 space-y-1">
{{range .NavItems}}
<a href="/{{.ID}}"
hx-get="/{{.ID}}" hx-target="#main-content" hx-push-url="true"
class="flex items-center gap-3 px-3 py-2 rounded-lg
{{if eq $.View .ID}}bg-primary/10 text-primary font-medium
{{else}}text-on-surface-variant hover:bg-surface-container-low{{end}}">
{{svg .Icon}}
<span>{{.Label}}</span>
</a>
{{end}}
</div>
</nav>
```
### 2.2 Dashboard (partials/dashboard.html)
```html
<!-- Container: halaman utama -->
<div id="dashboard-page" x-data="{ view: 'grid' }">
<!-- Header stats -->
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="stat-card">...</div>
<div class="stat-card">...</div>
<div class="stat-card">...</div>
</div>
<!-- Search + Filter + ViewToggle -->
<div class="flex items-center gap-3 mb-6">
<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="...">
<select name="filter"
hx-get="/hosts?filter={value}" hx-target="#host-list">
<option value="all">All</option>
<option value="active">Active</option>
<option value="offline">Offline</option>
</select>
<button @click="view = 'grid'" :class="view === 'grid' ? 'bg-primary/10' : ''">
{{svg "layout-grid"}}
</button>
<button @click="view = 'list'" :class="view === 'list' ? 'bg-primary/10' : ''">
{{svg "list"}}
</button>
</div>
<!-- Host list (swap target) -->
<div id="host-list"
x-show="view === 'grid'"
class="grid grid-cols-4 gap-4">
{{template "host_list" .Hosts}}
</div>
<div x-show="view === 'list'" class="...">
{{template "host_list_table" .Hosts}}
</div>
</div>
```
### 2.3 Terminal (partials/terminal.html)
```html
<div id="terminal-page" x-data="terminalTabs()">
<!-- Tab bar -->
<div class="flex items-center gap-1 mb-4">
<template x-for="tab in tabs" :key="tab.id">
<button @click="switchTab(tab.id)"
:class="activeTab === tab.id ? 'bg-primary/10 text-primary' : ''"
class="px-3 py-1 rounded text-sm">
<span x-text="tab.title"></span>
<span @click.stop="closeTab(tab.id)" class="ml-2 cursor-pointer">&times;</span>
</button>
</template>
<button @click="addTab()" class="...">+</button>
</div>
<!-- Terminal container -->
<div x-ref="terminalContainer"
class="bg-[#1e1e1e] rounded-xl p-4 font-mono text-sm min-h-[400px]">
<div id="xterm-container" class="..."></div>
</div>
</div>
<script src="/static/xterm/xterm.js"></script>
<script src="/static/js/terminal.js"></script>
```
### 2.4 SFTP (partials/sftp.html)
```html
<div id="sftp-page" x-data="{ toast: '', showToast: false }">
<div class="grid grid-cols-2 gap-6">
<!-- Local pane -->
<div class="pane glass-panel">
<div class="flex items-center gap-2 mb-4">
{{svg "hard-drive"}}
<span class="font-medium">Local Workstation</span>
<input type="search" placeholder="Search files..." class="...">
</div>
<div class="breadcrumb">
{{range .LocalBreadcrumb}}
<a href="#" hx-get="/sftp/ls?path={{.Path}}"
hx-target="#local-pane" class="...">{{.Name}}</a>
{{end}}
</div>
<div id="local-pane" class="file-table">
{{template "sftp_pane" .LocalFiles}}
</div>
</div>
<!-- Remote pane -->
<div class="pane glass-panel">
... sama seperti local pane ...
</div>
</div>
<!-- Drop zone -->
<div class="drop-zone ...">Drop files here</div>
<!-- Toast -->
<div x-show="showToast"
x-text="toast"
x-init="$watch('showToast', v => v && setTimeout(() => showToast = false, 3000))"
class="toast">
</div>
</div>
```
### 2.5 Snippets (partials/snippets.html)
```html
<div id="snippets-page" x-data="{ showForm: false, copiedId: null }">
<div class="flex gap-6">
<!-- Sidebar filters -->
<div class="w-48 space-y-4">
<div class="space-y-1">
<h3 class="text-sm font-medium text-on-surface-variant">Collections</h3>
<a href="#" hx-get="/snippets?col=all" hx-target="#snippet-grid"
class="filter-item {{if eq .Collection "all"}}active{{end}}">All</a>
<a href="#" hx-get="/snippets?col=favorites" hx-target="#snippet-grid"
class="filter-item">Favorites</a>
<a href="#" hx-get="/snippets?col=recent" hx-target="#snippet-grid"
class="filter-item">Recent</a>
</div>
<div class="space-y-1">
<h3 class="text-sm font-medium text-on-surface-variant">Tags</h3>
{{range .AllTags}}
<a href="#" hx-get="/snippets?tag={{.}}" hx-target="#snippet-grid"
class="filter-item">{{.}}</a>
{{end}}
</div>
</div>
<!-- Main grid -->
<div class="flex-1">
<div class="flex items-center gap-3 mb-4">
<input type="search" placeholder="Search snippets..."
hx-get="/snippets?q=..." hx-trigger="keyup delay:200ms"
hx-target="#snippet-grid" class="...">
<button @click="showForm = true" class="btn-primary">
+ Snippet
</button>
</div>
<div id="snippet-grid" class="grid grid-cols-2 gap-4">
{{template "snippet_grid" .Snippets}}
</div>
</div>
</div>
<!-- Add snippet modal -->
<div x-show="showForm" class="modal-backdrop">
<div @click.outside="showForm = false" class="modal-content">
<form hx-post="/snippets" hx-target="#snippet-grid" @submit="showForm = false">
<input name="title" placeholder="Title" required>
<input name="description" placeholder="Description">
<textarea name="code" placeholder="Code" rows="8" required></textarea>
<input name="tags" placeholder="Tags (comma separated)">
<button type="submit">Save</button>
<button type="button" @click="showForm = false">Cancel</button>
</form>
</div>
</div>
</div>
```
### 2.6 Keychain (partials/keychain.html)
```html
<div id="keychain-page" x-data="{ showForm: false, revealPass: null }">
<div class="flex items-center gap-3 mb-6">
<input type="search" placeholder="Search credentials..."
hx-get="/keys?q=..." hx-trigger="keyup delay:200ms"
hx-target="#key-grid" class="...">
<button @click="showForm = true" class="btn-primary">+ Credential</button>
</div>
<div id="key-grid" class="grid grid-cols-2 gap-4">
{{range .Keys}}
<div class="card glass-panel p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
{{svg "key"}}
<span class="font-medium">{{.Name}}</span>
</div>
<span class="strength-badge-{{.Strength}}">{{.Strength}}</span>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-on-surface-variant">User</span>
<span>{{.User}}</span>
</div>
<div class="flex justify-between">
<span class="text-on-surface-variant">Passphrase</span>
<div x-data="{ reveal: false }" class="flex items-center gap-2">
<span x-text="reveal ? '{{.Passphrase}}' : '••••••••'"></span>
<button @click="reveal = !reveal">
{{svg "eye"}}
</button>
<button onclick='copyToClipboard("{{.Passphrase}}")'>
{{svg "copy"}}
</button>
</div>
</div>
<div class="flex justify-between">
<span class="text-on-surface-variant">{{.Fingerprint}}</span>
</div>
</div>
</div>
{{end}}
</div>
</div>
```
### 2.7 Settings (partials/settings.html)
```html
<div id="settings-page">
<div class="max-w-2xl space-y-8">
<!-- Profile form -->
<div class="card glass-panel p-6">
<h2 class="text-lg font-semibold mb-4">Profile</h2>
<form hx-post="/settings" hx-target="#settings-form" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label>Name</label>
<input name="name" value="{{.Profile.Name}}" class="...">
</div>
<div>
<label>Email</label>
<input name="email" value="{{.Profile.Email}}" class="...">
</div>
</div>
<button type="submit" class="btn-primary">Save</button>
</form>
</div>
<!-- Toggles -->
<div class="card glass-panel p-6">
<h2 class="text-lg font-semibold mb-4">Security & Transmission</h2>
{{range .Toggles}}
<div class="flex items-center justify-between py-3">
<div>
<span class="font-medium">{{.Label}}</span>
<p class="text-sm text-on-surface-variant">{{.Desc}}</p>
</div>
<button class="toggle-switch {{if .Enabled}}active{{end}}"
data-key="{{.Key}}"
onclick="toggleSwitch(this)"
aria-checked="{{.Enabled}}">
<span class="toggle-knob"></span>
</button>
</div>
{{end}}
</div>
<!-- Connected devices -->
<div class="card glass-panel p-6">
<h2 class="text-lg font-semibold mb-4">Connected Devices</h2>
{{range .Devices}}
<div class="flex items-center gap-3 py-2">
{{svg .Type}}
<span>{{.Name}}</span>
<span class="ml-auto text-sm {{if eq .Status "online"}}text-secondary{{end}}">
{{.Status}}
</span>
</div>
{{end}}
</div>
<!-- Danger zone -->
<div class="card border border-red-400/30 bg-red-50 p-6">
<h2 class="text-lg font-semibold text-red-600 mb-4">Danger Zone</h2>
<p class="text-sm text-red-600 mb-4">Flush all app state — this cannot be undone.</p>
<button onclick="if(confirm('Yakin?')){localStorage.clear();location.reload()}"
class="btn-danger">
Flush All App State
</button>
</div>
</div>
</div>
```
### 2.8 Brief Overlay (partials/brief.html)
```html
<!-- Brief overlay — static design system reference -->
<div x-data="{ open: false }">
<button @click="open = true" class="...">
{{svg "file-question"}}
</button>
<div x-show="open" class="fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/20" @click="open = false"></div>
<div class="absolute right-0 top-0 h-full w-[400px] glass-sidebar p-6 overflow-auto">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold">System Brief</h2>
<button @click="open = false">{{svg "x"}}</button>
</div>
<!-- Static content dari template BriefOverlay.tsx -->
<div class="space-y-6">
<section>
<h3 class="font-medium text-primary mb-2">Color Palette</h3>
<div class="grid grid-cols-2 gap-2">
<div class="h-12 rounded-lg bg-primary"></div>
<div class="h-12 rounded-lg bg-secondary"></div>
</div>
</section>
<section>
<h3 class="font-medium text-primary mb-2">Typography</h3>
<p class="font-inter text-lg">Inter — Interface UI</p>
<p class="font-mono text-sm">JetBrains Mono — Code & Data</p>
</section>
<section>
<h3 class="font-medium text-primary mb-2">Glassmorphism</h3>
<p>backdrop-blur-md + rgba(255,255,255,0.7)</p>
</section>
</div>
</div>
</div>
</div>
```
---
## 3. Alpine.js Components (State Management)
### 3.1 Terminal Tabs
```javascript
// inline dalam terminal.html
function terminalTabs() {
return {
tabs: [{ id: '1', title: 'localhost' }],
activeTab: '1',
addTab() {
const id = String(Date.now());
this.tabs.push({ id, title: `host-${this.tabs.length + 1}` });
this.activeTab = id;
this.$nextTick(() => initTerminal(id));
},
closeTab(id) {
this.tabs = this.tabs.filter(t => t.id !== id);
if (this.activeTab === id) this.activeTab = this.tabs[0]?.id;
},
switchTab(id) {
this.activeTab = id;
this.$nextTick(() => focusTerminal(id));
}
}
}
```
### 3.2 Toast Notifications
```javascript
// inline dalam sftp.html, dashboard.html
function toastManager() {
return {
message: '',
visible: false,
show(msg) {
this.message = msg;
this.visible = true;
setTimeout(() => this.visible = false, 3000);
}
}
}
```
---
## 4. JavaScript Files (Vanilla)
### static/js/clipboard.js
```javascript
function copyToClipboard(text, triggerId) {
navigator.clipboard.writeText(text).then(() => {
// trigger toast via htmx event
document.body.dispatchEvent(new CustomEvent('showToast', {
detail: { message: 'Copied!' }
}));
});
}
```
### static/js/toggles.js
```javascript
function toggleSwitch(el) {
const checked = el.getAttribute('aria-checked') === 'true';
el.setAttribute('aria-checked', !checked);
el.classList.toggle('active');
fetch(`/settings/toggle?key=${el.dataset.key}&val=${!checked}`, {
method: 'POST'
});
}
```
### static/js/utils.js
```javascript
function revealPassphrase(el) {
const span = el.closest('.pass-row').querySelector('.pass-text');
const isHidden = span.dataset.hidden === 'true';
span.textContent = isHidden ? span.dataset.value : '••••••••';
span.dataset.hidden = isHidden ? 'false' : 'true';
}
function strengthClass(len) {
if (len > 12) return 'secure';
if (len > 7) return 'moderate';
return 'weak';
}
```