Files
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

16 KiB

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)

<!-- 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)

<!-- 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)

<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)

<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)

<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)

<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)

<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)

<!-- 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

// 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

// 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

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

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

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';
}