From b2f39bd1f6cb8f86f73673e7b1a8ab7d3775d3a5 Mon Sep 17 00:00:00 2001 From: arnef Date: Fri, 21 Aug 2026 09:15:40 +0200 Subject: [PATCH] Add delete, inline preview, and mobile list layout to the file browser - DELETE /files/{path} recursively removes a file/folder (root itself can't be deleted, 404 on missing paths); wired to a new "Delete" action in the web UI with a confirmation prompt. - Files now open inline (Content-Disposition: inline) so browsers can play/preview natively-supported types (video, audio, images, PDF) directly instead of always forcing a download. A separate "Download" link (?download=1) still forces a save-as. - Narrow screens get a stacked card list (name, size, modified date, download/delete actions) instead of a squeezed table, so nothing is hidden or requires horizontal scrolling; the table layout is kept unchanged for sm+ screens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/web/files.go | 42 ++- internal/web/templates/files.templ | 92 ++++++- internal/web/templates/files_templ.go | 377 ++++++++++++++++++++------ web/static/files.js | 22 ++ web/ts/files.ts | 22 ++ 5 files changed, 459 insertions(+), 96 deletions(-) diff --git a/internal/web/files.go b/internal/web/files.go index 1587f75..c4b4a35 100644 --- a/internal/web/files.go +++ b/internal/web/files.go @@ -96,12 +96,42 @@ func (s *Server) handleFiles(w http.ResponseWriter, r *http.Request) { return } s.handleFilesUpload(w, r, root, fullPath) + case http.MethodDelete: + s.handleFilesDelete(w, r, root, relPath, fullPath) default: - w.Header().Set("Allow", "GET, POST") + w.Header().Set("Allow", "GET, POST, DELETE") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } +// handleFilesDelete removes the file or folder (recursively) at fullPath, +// mounted at DELETE /files/{path}. The root itself (relPath == "") can +// never be deleted this way. +func (s *Server) handleFilesDelete(w http.ResponseWriter, r *http.Request, root, relPath, fullPath string) { + if relPath == "" { + http.Error(w, "cannot delete the root folder", http.StatusBadRequest) + return + } + if _, err := os.Lstat(fullPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + http.NotFound(w, r) + return + } + s.logger.Error("stat path to delete", "path", fullPath, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if err := os.RemoveAll(fullPath); err != nil { + s.logger.Error("deleting path", "path", fullPath, "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "deleted %q", filepath.Base(fullPath)) +} + // handleFilesMkdir creates a new subdirectory named by the "name" form // field directly inside fullPath (the current directory), mounted at // POST /files/{path}?mkdir=1. @@ -161,7 +191,15 @@ func (s *Server) handleFilesGet(w http.ResponseWriter, r *http.Request, username return } defer f.Close() - w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(fullPath)+`"`) + // Force a download only when explicitly requested (the "Download" + // link/button); otherwise serve "inline" so the browser can render + // natively-supported types (video, audio, images, PDF, text) right + // in the tab instead of always saving to disk. + disposition := "inline" + if r.URL.Query().Has("download") { + disposition = "attachment" + } + w.Header().Set("Content-Disposition", disposition+`; filename="`+filepath.Base(fullPath)+`"`) http.ServeContent(w, r, info.Name(), info.ModTime(), f) return } diff --git a/internal/web/templates/files.templ b/internal/web/templates/files.templ index 42d235d..ec0c99e 100644 --- a/internal/web/templates/files.templ +++ b/internal/web/templates/files.templ @@ -20,7 +20,7 @@ templ FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, @Layout("Files", username) {

Files

-
+

} } +templ fileEntryLink(e FileEntry) { + if e.IsDir { + + { e.Name } + + } else { + + { e.Name } + + } +} + +// fileActions renders the per-entry action buttons: a Download link +// (forces a save-as via ?download=1, since the name link above now opens +// files inline so the browser can play/preview natively-supported types +// like video, audio, images, or PDFs) plus the Delete button. Folders have +// no download link since they aren't served as a single file. +templ fileActions(e FileEntry) { + if !e.IsDir { + + Download + + } + @fileDeleteButton(e) +} + +templ fileDeleteButton(e FileEntry) { + +} + diff --git a/internal/web/templates/files_templ.go b/internal/web/templates/files_templ.go index 5ab70ca..bb4eaaf 100644 --- a/internal/web/templates/files_templ.go +++ b/internal/web/templates/files_templ.go @@ -57,7 +57,7 @@ func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, c }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Files

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -242,4 +259,200 @@ func FilesPage(username string, breadcrumbs []Breadcrumb, entries []FileEntry, c }) } +func fileEntryLink(e FileEntry) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if e.IsDir { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "📁") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 130, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "📄") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/files.templ`, Line: 134, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// fileActions renders the per-entry action buttons: a Download link +// (forces a save-as via ?download=1, since the name link above now opens +// files inline so the browser can play/preview natively-supported types +// like video, audio, images, or PDFs) plus the Delete button. Folders have +// no download link since they aren't served as a single file. +func fileActions(e FileEntry) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if !e.IsDir { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Download") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = fileDeleteButton(e).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func fileDeleteButton(e FileEntry) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/web/static/files.js b/web/static/files.js index 71891e0..47a779b 100644 --- a/web/static/files.js +++ b/web/static/files.js @@ -84,6 +84,28 @@ setStatus(`Could not create folder: ${String(err)}`); } }); + document.querySelectorAll(".delete-entry-button").forEach((button) => { + button.addEventListener("click", async () => { + const relPath = button.dataset.path || ""; + const name = button.dataset.name || relPath; + if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) { + return; + } + setStatus(`Deleting "${name}"…`); + try { + const resp = await fetch(`/web/files/${relPath}`, { method: "DELETE" }); + if (!resp.ok) { + const text = await resp.text(); + setStatus(`Could not delete "${name}": ${text}`); + return; + } + window.location.reload(); + } + catch (err) { + setStatus(`Could not delete "${name}": ${String(err)}`); + } + }); + }); // Recursively walk a dropped DataTransferItem (file or directory) into // a flat list of File objects, using the browser's non-standard but // widely-supported webkitGetAsEntry/FileSystemEntry APIs to support diff --git a/web/ts/files.ts b/web/ts/files.ts index 8d8067c..dd98de0 100644 --- a/web/ts/files.ts +++ b/web/ts/files.ts @@ -90,6 +90,28 @@ } }); + document.querySelectorAll(".delete-entry-button").forEach((button) => { + button.addEventListener("click", async () => { + const relPath = button.dataset.path || ""; + const name = button.dataset.name || relPath; + if (!window.confirm(`Delete "${name}"? This cannot be undone.`)) { + return; + } + setStatus(`Deleting "${name}"…`); + try { + const resp = await fetch(`/web/files/${relPath}`, { method: "DELETE" }); + if (!resp.ok) { + const text = await resp.text(); + setStatus(`Could not delete "${name}": ${text}`); + return; + } + window.location.reload(); + } catch (err) { + setStatus(`Could not delete "${name}": ${String(err)}`); + } + }); + }); + // Recursively walk a dropped DataTransferItem (file or directory) into // a flat list of File objects, using the browser's non-standard but // widely-supported webkitGetAsEntry/FileSystemEntry APIs to support