feat: Sprint 4 — SFTP dual-pane with per-type icons, breadcrumb, drop-zone, create/delete, toast
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 + 2 + 3 selesai, Sprint 4 berikutnya
|
> **Current Phase**: Sprint 1 + 2 + 3 + 4 selesai, Sprint 5 berikutnya
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ Alasan:
|
|||||||
### ❌ Belum Dimulai / In Progress
|
### ❌ Belum Dimulai / In Progress
|
||||||
- **Sprint 2 (DONE)**: Snippets + Keychain
|
- **Sprint 2 (DONE)**: Snippets + Keychain
|
||||||
- **Sprint 3 (DONE)**: Settings + Brief
|
- **Sprint 3 (DONE)**: Settings + Brief
|
||||||
- Sprint 4: SFTP Dual-Pane
|
- **Sprint 4 (DONE)**: SFTP Dual-Pane
|
||||||
- Sprint 5: Terminal + xterm.js
|
- Sprint 5: Terminal + xterm.js
|
||||||
- Sprint 6: Storage Integration
|
- Sprint 6: Storage Integration
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"git.tukangketik.id/swanadiva/hostkeeper/v2/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fileStore = model.NewFileStore()
|
||||||
|
|
||||||
|
func SFTP(c *fiber.Ctx) error {
|
||||||
|
local, remote := fileStore.All()
|
||||||
|
return c.Render("sftp", fiber.Map{
|
||||||
|
"View": "sftp",
|
||||||
|
"Title": "SFTP",
|
||||||
|
"LocalFiles": local,
|
||||||
|
"RemoteFiles": remote,
|
||||||
|
"LocalBreadcrumb": fileStore.LocalBreadcrumb,
|
||||||
|
"RemoteBreadcrumb": fileStore.RemoteBreadcrumb,
|
||||||
|
"NavItems": navItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SFTPPane(c *fiber.Ctx) error {
|
||||||
|
pane := c.Query("pane", "local")
|
||||||
|
dir := c.Query("dir", "/")
|
||||||
|
local, remote := fileStore.All()
|
||||||
|
files := local
|
||||||
|
breadcrumb := fileStore.LocalBreadcrumb
|
||||||
|
if pane == "remote" {
|
||||||
|
files = remote
|
||||||
|
breadcrumb = fileStore.RemoteBreadcrumb
|
||||||
|
}
|
||||||
|
return c.Render("sftp_pane", fiber.Map{
|
||||||
|
"Pane": pane,
|
||||||
|
"Files": files,
|
||||||
|
"Breadcrumb": breadcrumb,
|
||||||
|
"CurrentDir": dir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateFolder(c *fiber.Ctx) error {
|
||||||
|
pane := c.FormValue("pane", "local")
|
||||||
|
name := c.FormValue("name")
|
||||||
|
if name != "" {
|
||||||
|
item := model.FileItem{
|
||||||
|
Name: name, Size: "—", Modified: "Just now",
|
||||||
|
Type: "folder", Permissions: "drwxr-xr-x",
|
||||||
|
}
|
||||||
|
if pane == "remote" {
|
||||||
|
fileStore.Remote = append([]model.FileItem{item}, fileStore.Remote...)
|
||||||
|
} else {
|
||||||
|
fileStore.Local = append([]model.FileItem{item}, fileStore.Local...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files := fileStore.Local
|
||||||
|
if pane == "remote" { files = fileStore.Remote }
|
||||||
|
return c.Render("sftp_pane", fiber.Map{
|
||||||
|
"Pane": pane, "Files": files,
|
||||||
|
"Breadcrumb": fileStore.LocalBreadcrumb,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteFile(c *fiber.Ctx) error {
|
||||||
|
pane := c.Query("pane", "local")
|
||||||
|
name := c.Query("name")
|
||||||
|
files := &fileStore.Local
|
||||||
|
if pane == "remote" { files = &fileStore.Remote }
|
||||||
|
for i, f := range *files {
|
||||||
|
if f.Name == name {
|
||||||
|
*files = append((*files)[:i], (*files)[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.Render("sftp_pane", fiber.Map{
|
||||||
|
"Pane": pane, "Files": *files,
|
||||||
|
"Breadcrumb": fileStore.LocalBreadcrumb,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type FileItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Modified string `json:"modified"`
|
||||||
|
Type string `json:"type"` // folder | file | image | code | lock
|
||||||
|
Extension string `json:"extension"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStore struct {
|
||||||
|
Local []FileItem
|
||||||
|
Remote []FileItem
|
||||||
|
LocalBreadcrumb []Breadcrumb
|
||||||
|
RemoteBreadcrumb []Breadcrumb
|
||||||
|
}
|
||||||
|
|
||||||
|
type Breadcrumb struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileStore() *FileStore {
|
||||||
|
return &FileStore{
|
||||||
|
Local: defaultLocalFiles(),
|
||||||
|
Remote: defaultRemoteFiles(),
|
||||||
|
LocalBreadcrumb: []Breadcrumb{{Name: "~", Path: "/home/user"}, {Name: "projects", Path: "/home/user/projects"}},
|
||||||
|
RemoteBreadcrumb: []Breadcrumb{{Name: "/", Path: "/"}, {Name: "var", Path: "/var"}, {Name: "www", Path: "/var/www"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileStore) All() (local, remote []FileItem) { return s.Local, s.Remote }
|
||||||
|
|
||||||
|
func defaultLocalFiles() []FileItem {
|
||||||
|
return []FileItem{
|
||||||
|
{Name: "Downloads", Size: "—", Modified: "2 hours ago", Type: "folder", Permissions: "drwxr-xr-x", Path: "/home/user/Downloads"},
|
||||||
|
{Name: "Documents", Size: "—", Modified: "Yesterday", Type: "folder", Permissions: "drwxr-xr-x", Path: "/home/user/Documents"},
|
||||||
|
{Name: "projects", Size: "—", Modified: "Just now", Type: "folder", Permissions: "drwxrwxr-x", Path: "/home/user/projects"},
|
||||||
|
{Name: "config.yml", Size: "1.4 KB", Modified: "Yesterday", Type: "code", Extension: "yml", Permissions: "-rw-r--r--", Path: "/home/user/projects/config.yml"},
|
||||||
|
{Name: "README.md", Size: "3.2 KB", Modified: "2 days ago", Type: "code", Extension: "md", Permissions: "-rw-r--r--", Path: "/home/user/projects/README.md"},
|
||||||
|
{Name: "screenshot.png", Size: "240 KB", Modified: "3 days ago", Type: "image", Extension: "png", Permissions: "-rw-r--r--", Path: "/home/user/Downloads/screenshot.png"},
|
||||||
|
{Name: "archive.zip", Size: "42 MB", Modified: "1 week ago", Type: "file", Extension: "zip", Permissions: "-rw-r--r--", Path: "/home/user/Downloads/archive.zip"},
|
||||||
|
{Name: "vault.enc", Size: "8 KB", Modified: "2 weeks ago", Type: "lock", Extension: "enc", Permissions: "-rw-------", Path: "/home/user/vault.enc"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRemoteFiles() []FileItem {
|
||||||
|
return []FileItem{
|
||||||
|
{Name: "html", Size: "—", Modified: "2 hours ago", Type: "folder", Permissions: "drwxr-xr-x", Path: "/var/www/html"},
|
||||||
|
{Name: "assets", Size: "—", Modified: "Yesterday", Type: "folder", Permissions: "drwxr-xr-x", Path: "/var/www/assets"},
|
||||||
|
{Name: "index.html", Size: "4.1 KB", Modified: "Just now", Type: "code", Extension: "html", Permissions: "-rw-r--r--", Path: "/var/www/index.html"},
|
||||||
|
{Name: "app.py", Size: "12.8 KB", Modified: "2 hours ago", Type: "code", Extension: "py", Permissions: "-rwxr-xr-x", Path: "/var/www/app.py"},
|
||||||
|
{Name: "Dockerfile", Size: "512 B", Modified: "Yesterday", Type: "code", Extension: "Dockerfile", Permissions: "-rw-r--r--", Path: "/var/www/Dockerfile"},
|
||||||
|
{Name: "compose.yml", Size: "2.1 KB", Modified: "Yesterday", Type: "code", Extension: "yml", Permissions: "-rw-r--r--", Path: "/var/www/compose.yml"},
|
||||||
|
{Name: ".env", Size: "156 B", Modified: "3 days ago", Type: "lock", Extension: "env", Permissions: "-rw-------", Path: "/var/www/.env"},
|
||||||
|
{Name: "logo.svg", Size: "6.7 KB", Modified: "1 week ago", Type: "image", Extension: "svg", Permissions: "-rw-r--r--", Path: "/var/www/assets/logo.svg"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Transfer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Progress int `json:"progress"`
|
||||||
|
Speed string `json:"speed"`
|
||||||
|
Type string `json:"type"` // upload | download
|
||||||
|
Status string `json:"status"` // queued | transferring | completed | error
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransferStore struct {
|
||||||
|
Transfers []Transfer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTransferStore() *TransferStore {
|
||||||
|
return &TransferStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TransferStore) All() []Transfer { return s.Transfers }
|
||||||
@@ -18,6 +18,15 @@ func main() {
|
|||||||
engine.AddFunc("svg", func(name string) string {
|
engine.AddFunc("svg", func(name string) string {
|
||||||
return ""
|
return ""
|
||||||
})
|
})
|
||||||
|
engine.AddFunc("dict", func(values ...interface{}) map[string]interface{} {
|
||||||
|
m := make(map[string]interface{})
|
||||||
|
for i := 0; i < len(values)-1; i += 2 {
|
||||||
|
if key, ok := values[i].(string); ok {
|
||||||
|
m[key] = values[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
Views: engine,
|
Views: engine,
|
||||||
@@ -43,6 +52,10 @@ func main() {
|
|||||||
app.Get("/settings", handler.Settings)
|
app.Get("/settings", handler.Settings)
|
||||||
app.Post("/settings", handler.UpdateConfig)
|
app.Post("/settings", handler.UpdateConfig)
|
||||||
app.Get("/brief", handler.Brief)
|
app.Get("/brief", handler.Brief)
|
||||||
|
app.Get("/sftp", handler.SFTP)
|
||||||
|
app.Get("/sftp/pane", handler.SFTPPane)
|
||||||
|
app.Post("/sftp/mkdir", handler.CreateFolder)
|
||||||
|
app.Delete("/sftp/rm", handler.DeleteFile)
|
||||||
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,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SFTP · 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 id="sftp-page" class="max-w-7xl mx-auto"
|
||||||
|
x-data="{ toast: '', showToast: false, showFolderModal: false, folderPane: 'local' }"
|
||||||
|
x-init="$watch('showToast', v => v && setTimeout(() => showToast = false, 3500))">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Local pane -->
|
||||||
|
<div class="rounded-xl bg-white/70 backdrop-blur-md border border-outline-variant/30 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2 px-4 py-3 border-b border-outline-variant/20 bg-white/50">
|
||||||
|
<svg class="w-4 h-4 text-on-surface-variant" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||||
|
<span class="text-sm font-medium text-on-surface">Local Workstation</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button @click="folderPane = 'local'; showFolderModal = true"
|
||||||
|
class="p-1 rounded-lg hover:bg-surface-container-low text-on-surface-variant hover:text-on-surface transition-colors" title="New Folder">
|
||||||
|
<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="M9 13h6m-3-3v6m-5-8H5a2 2 0 00-2 2v8a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
<input type="search" placeholder="Filter files..." hx-get="/sftp/pane?pane=local&q=..." hx-trigger="keyup delay:200ms" hx-target="#local-pane"
|
||||||
|
class="w-40 px-2 py-1 rounded-lg border border-outline-variant/30 bg-white text-xs text-on-surface placeholder-on-surface-variant focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 px-4 py-2 border-b border-outline-variant/10 text-xs text-on-surface-variant font-mono bg-surface-container-low/30">
|
||||||
|
{{range .LocalBreadcrumb}}
|
||||||
|
<a href="#" hx-get="/sftp/pane?pane=local&dir={{.Path}}" hx-target="#local-pane"
|
||||||
|
class="hover:text-primary transition-colors">{{.Name}}</a>
|
||||||
|
<span class="text-on-surface-variant/40">/</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div id="local-pane">
|
||||||
|
{{template "sftp_pane" dict "Pane" "local" "Files" .LocalFiles "Breadcrumb" .LocalBreadcrumb}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Remote pane -->
|
||||||
|
<div class="rounded-xl bg-white/70 backdrop-blur-md border border-outline-variant/30 overflow-hidden">
|
||||||
|
<div class="flex items-center gap-2 px-4 py-3 border-b border-outline-variant/20 bg-white/50">
|
||||||
|
<svg class="w-4 h-4 text-on-surface-variant" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>
|
||||||
|
<span class="text-sm font-medium text-on-surface">Remote Cluster Node</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button @click="folderPane = 'remote'; showFolderModal = true"
|
||||||
|
class="p-1 rounded-lg hover:bg-surface-container-low text-on-surface-variant hover:text-on-surface transition-colors" title="New Folder">
|
||||||
|
<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="M9 13h6m-3-3v6m-5-8H5a2 2 0 00-2 2v8a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
<input type="search" placeholder="Filter files..." hx-get="/sftp/pane?pane=remote&q=..." hx-trigger="keyup delay:200ms" hx-target="#remote-pane"
|
||||||
|
class="w-40 px-2 py-1 rounded-lg border border-outline-variant/30 bg-white text-xs text-on-surface placeholder-on-surface-variant focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 px-4 py-2 border-b border-outline-variant/10 text-xs text-on-surface-variant font-mono bg-surface-container-low/30">
|
||||||
|
{{range .RemoteBreadcrumb}}
|
||||||
|
<a href="#" hx-get="/sftp/pane?pane=remote&dir={{.Path}}" hx-target="#remote-pane"
|
||||||
|
class="hover:text-primary transition-colors">{{.Name}}</a>
|
||||||
|
<span class="text-on-surface-variant/40">/</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div id="remote-pane">
|
||||||
|
{{template "sftp_pane" dict "Pane" "remote" "Files" .RemoteFiles "Breadcrumb" .RemoteBreadcrumb}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drop zone -->
|
||||||
|
<div class="mt-6 rounded-xl border-2 border-dashed border-outline-variant/30 p-8 flex flex-col items-center justify-center text-on-surface-variant/50 hover:border-primary/30 hover:text-primary/50 transition-colors cursor-pointer">
|
||||||
|
<svg class="w-10 h-10 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
|
||||||
|
<p class="text-sm font-medium">Drop files here to upload</p>
|
||||||
|
<p class="text-xs mt-1">or click to browse</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div x-show="showToast" x-cloak
|
||||||
|
x-text="toast"
|
||||||
|
class="fixed bottom-6 right-6 bg-[#191c1e] text-white px-4 py-3 rounded-xl text-sm shadow-lg z-50 animate-slide-in"
|
||||||
|
style="max-width: 360px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder create modal -->
|
||||||
|
<div id="folder-modal" class="fixed inset-0 z-50" x-data="{ open: false }" x-show="open" x-cloak
|
||||||
|
@open-folder-modal.window="open = true; folderPane = $event.detail.pane" @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-sm p-6" @click.outside="open = false">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface">New Folder</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="/sftp/mkdir" hx-target="#local-pane" hx-swap="outerHTML"
|
||||||
|
@submit="open = false"
|
||||||
|
class="space-y-4">
|
||||||
|
<input type="hidden" name="pane" :value="folderPane">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-on-surface mb-1">Folder Name</label>
|
||||||
|
<input type="text" name="name" required placeholder="e.g. new-project"
|
||||||
|
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 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">Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{{define "sftp_pane"}}
|
||||||
|
<div class="file-table" id="{{.Pane}}-pane-content" x-data="{ pane: '{{.Pane}}' }">
|
||||||
|
{{range .Files}}
|
||||||
|
<div class="file-table-row group transition-colors">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="w-4 h-4 shrink-0 flex items-center justify-center
|
||||||
|
{{if eq .Type "folder"}}text-yellow-500{{else if eq .Type "code"}}text-purple-500{{else if eq .Type "image"}}text-secondary{{else if eq .Type "lock"}}text-red-400{{else}}text-on-surface-variant/50{{end}}">
|
||||||
|
{{if eq .Type "folder"}}
|
||||||
|
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||||
|
{{else if eq .Type "code"}}
|
||||||
|
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||||
|
{{else if eq .Type "image"}}
|
||||||
|
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
{{else if eq .Type "lock"}}
|
||||||
|
<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||||
|
{{else}}
|
||||||
|
<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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-on-surface truncate">{{.Name}}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-on-surface-variant text-right tabular-nums w-20 shrink-0">{{.Size}}</span>
|
||||||
|
<span class="text-xs text-on-surface-variant w-24 shrink-0">{{.Modified}}</span>
|
||||||
|
<span class="text-xs text-on-surface-variant font-mono w-24 shrink-0 hidden md:inline">{{.Permissions}}</span>
|
||||||
|
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
{{if ne .Type "folder"}}
|
||||||
|
<button @click="document.querySelector('#sftp-page').__x.$data.toast = 'Downloading {{.Name}}...'; document.querySelector('#sftp-page').__x.$data.showToast = true"
|
||||||
|
class="p-1 rounded-lg hover:bg-primary/10 text-on-surface-variant hover:text-primary transition-colors" title="Download">
|
||||||
|
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button @click="document.querySelector('#sftp-page').__x.$data.toast = 'Uploading {{.Name}}...'; document.querySelector('#sftp-page').__x.$data.showToast = true"
|
||||||
|
class="p-1 rounded-lg hover:bg-primary/10 text-on-surface-variant hover:text-primary transition-colors" title="Upload">
|
||||||
|
<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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5-5m0 0l5 5m-5-5v12"/></svg>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
<button hx-delete="/sftp/rm?pane={{$.Pane}}&name={{.Name}}" hx-target="#{{$.Pane}}-pane-content" hx-swap="outerHTML"
|
||||||
|
hx-confirm="Delete {{.Name}}?"
|
||||||
|
class="p-1 rounded-lg hover:bg-red-50 text-on-surface-variant hover:text-red-500 transition-colors" title="Delete">
|
||||||
|
<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>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq (len .Files) 0}}
|
||||||
|
<div class="text-center py-8 text-on-surface-variant">
|
||||||
|
<p class="text-sm">This directory is empty</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
+10
-8
@@ -38,7 +38,7 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
|
|||||||
| Sprint 1 — Dashboard + Host List | DONE |
|
| Sprint 1 — Dashboard + Host List | DONE |
|
||||||
| Sprint 2 — Snippets + Keychain | DONE |
|
| Sprint 2 — Snippets + Keychain | DONE |
|
||||||
| Sprint 3 — Settings + Brief | DONE |
|
| Sprint 3 — Settings + Brief | DONE |
|
||||||
| Sprint 4 — SFTP Dual-Pane | NOT STARTED |
|
| Sprint 4 — SFTP Dual-Pane | DONE |
|
||||||
| Sprint 5 — Terminal + xterm.js | NOT STARTED |
|
| Sprint 5 — Terminal + xterm.js | NOT STARTED |
|
||||||
| Sprint 6 — Storage Integration | NOT STARTED |
|
| Sprint 6 — Storage Integration | NOT STARTED |
|
||||||
|
|
||||||
@@ -99,12 +99,13 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
|
|||||||
- **Verify**: Settings page renders with tabs, toggles work via Alpine, brief page shows shortcuts
|
- **Verify**: Settings page renders with tabs, toggles work via Alpine, brief page shows shortcuts
|
||||||
|
|
||||||
### Sprint 4 — SFTP Dual-Pane
|
### Sprint 4 — SFTP Dual-Pane
|
||||||
- [ ] Model FileItem + Transfer
|
- [x] Model FileItem + Transfer (`internal/model/file.go`, `internal/model/transfer.go`)
|
||||||
- [ ] SFTP handler
|
- [x] SFTP handler (`internal/handler/sftp.go`) — page, pane partial, create folder, delete file
|
||||||
- [ ] SFTP template with dual-pane layout
|
- [x] SFTP template (`views/sftp.html`) — dual-pane (local/remote), breadcrumb, search, drop-zone, toast, folder modal
|
||||||
- [ ] Breadcrumb navigation
|
- [x] SFTP pane partial (`views/sftp_pane.html`) — file table with icons by type, hover actions
|
||||||
- [ ] Folder create + delete
|
- [x] `dict` helper fungsitambah di template engine (`main.go`)
|
||||||
- [ ] Toast notification
|
- [x] Folder create (Alpine modal + POST) + delete (hx-delete)
|
||||||
|
- **Verify**: Dual-pane renders, file list per type icons, CRUD works, drop-zone + toast present
|
||||||
|
|
||||||
### Sprint 5 — Terminal + xterm.js
|
### Sprint 5 — Terminal + xterm.js
|
||||||
- [ ] Download xterm.js
|
- [ ] Download xterm.js
|
||||||
@@ -144,7 +145,8 @@ Lihat [ARCHITECTURE_HTMX.md](ARCHITECTURE_HTMX.md) untuk detail.
|
|||||||
| 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 | `c4b0d7d` |
|
| 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)` |
|
| 2026-07-07 | Sprint 3 selesai: settings page, tabs, toggles, connected devices, danger zone, brief page | `c5ac07e` |
|
||||||
|
| 2026-07-07 | Sprint 4 selesai: SFTP dual-pane, breadcrumb, per-type icons, create/delete, toast, drop-zone | `(pending)` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user