a0c8483c0e
- 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
511 lines
16 KiB
Markdown
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">×</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';
|
|
}
|
|
```
|