diff --git a/server/handlers/console/buckets/objects.go b/server/handlers/console/buckets/objects.go index 729d115..7d8ac59 100644 --- a/server/handlers/console/buckets/objects.go +++ b/server/handlers/console/buckets/objects.go @@ -10,6 +10,7 @@ import ( "github.com/mojatter/s2" "github.com/mojatter/s2/internal/numconv" "github.com/mojatter/s2/server" + "github.com/mojatter/s2/server/handlers/console" "github.com/mojatter/s2/server/middleware" ) @@ -63,8 +64,14 @@ func handleObjects(s *server.Server, w http.ResponseWriter, r *http.Request) { parentPrefix = "" } + // Non-HTMX requests get redirected to the index page where the + // sidebar navigation triggers the htmx load. + if r.Header.Get("HX-Request") != "true" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + data := struct { - Buckets []string BucketName string Objects []s2.Object Prefixes []string @@ -82,27 +89,8 @@ func handleObjects(s *server.Server, w http.ResponseWriter, r *http.Request) { HasParent: prefix != "" && prefix != "/", } - // Use a partial template for HTMX requests - if r.Header.Get("HX-Request") == "true" { - var buf bytes.Buffer - if err := s.Template.ExecuteTemplate(&buf, "buckets/objects.html", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _, _ = buf.WriteTo(w) - return - } - - // Fallback to full page if accessed directly - bucketNames, err := s.Buckets.Names() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - data.Buckets = bucketNames - var buf bytes.Buffer - if err := s.Template.ExecuteTemplate(&buf, "index.html", data); err != nil { + if err := s.Template.ExecuteTemplate(&buf, "buckets/objects.html", data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -220,6 +208,6 @@ func init() { server.RegisterConsoleHandleFunc("POST /buckets/{name}/folders", middleware.BasicAuth(handleCreateFolder)) server.RegisterConsoleHandleFunc("POST /buckets/{name}/upload", middleware.BasicAuth(handleUploadFile)) server.RegisterConsoleHandleFunc("DELETE /buckets/{name}/objects", middleware.BasicAuth(handleDeleteObject)) - server.RegisterTemplate("buckets/objects.html") + console.RegisterTemplateWithScripts("buckets/objects.html") } diff --git a/server/handlers/console/buckets/objects/view.go b/server/handlers/console/buckets/objects/view.go index a7659a5..c8b874e 100644 --- a/server/handlers/console/buckets/objects/view.go +++ b/server/handlers/console/buckets/objects/view.go @@ -1,6 +1,7 @@ package objects import ( + "bytes" "encoding/json" "fmt" "io" @@ -106,7 +107,78 @@ func handleMeta(s *server.Server, w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +func handlePreview(s *server.Server, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + bucketName := r.PathValue("name") + objectName := r.PathValue("object") + + strg, err := s.Buckets.Get(ctx, bucketName) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + obj, err := strg.Get(ctx, objectName) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + ext := strings.ToLower(path.Ext(objectName)) + viewURL := fmt.Sprintf("/buckets/%s/view/%s", bucketName, objectName) + previewType := server.PreviewType(ext) + + var textContent string + if previewType == "text" { + rc, err := obj.Open() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = rc.Close() }() + + b, err := io.ReadAll(rc) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + textContent = string(b) + } + + data := struct { + Filename string + ViewURL string + ContentType string + Size uint64 + LastModified string + Metadata map[string]string + PreviewType string + TextContent string + }{ + Filename: path.Base(objectName), + ViewURL: viewURL, + ContentType: contentTypeByExt(ext), + Size: obj.Length(), + LastModified: obj.LastModified().Format("2006-01-02 15:04:05"), + PreviewType: previewType, + TextContent: textContent, + } + if md := obj.Metadata(); len(md) > 0 { + data.Metadata = md + } + + var buf bytes.Buffer + if err := s.Template.ExecuteTemplate(&buf, "buckets/preview.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} + func init() { server.RegisterConsoleHandleFunc("GET /buckets/{name}/view/{object...}", middleware.BasicAuth(handleView)) server.RegisterConsoleHandleFunc("GET /buckets/{name}/meta/{object...}", middleware.BasicAuth(handleMeta)) + server.RegisterConsoleHandleFunc("GET /buckets/{name}/preview/{object...}", middleware.BasicAuth(handlePreview)) } diff --git a/server/handlers/console/buckets/objects_test.go b/server/handlers/console/buckets/objects_test.go index 86aeb25..34e0978 100644 --- a/server/handlers/console/buckets/objects_test.go +++ b/server/handlers/console/buckets/objects_test.go @@ -101,7 +101,7 @@ func (s *ObjectsTestSuite) TestHandleObjects() { s.Equal(http.StatusNotFound, w.Code) }) - s.Run("full page without HX-Request", func() { + s.Run("full page without HX-Request redirects to index", func() { s.createBucket("full") req := httptest.NewRequest("GET", "/buckets/full", nil) @@ -109,8 +109,8 @@ func (s *ObjectsTestSuite) TestHandleObjects() { w := httptest.NewRecorder() handleObjects(s.server, w, req) - s.Equal(http.StatusOK, w.Code) - s.Contains(w.Body.String(), "") + s.Equal(http.StatusFound, w.Code) + s.Equal("/", w.Header().Get("Location")) }) } diff --git a/server/handlers/console/console_test.go b/server/handlers/console/console_test.go index 7aef0de..ff18793 100644 --- a/server/handlers/console/console_test.go +++ b/server/handlers/console/console_test.go @@ -73,7 +73,9 @@ func (s *IndexTestSuite) TestHandleCreateBucket() { w := httptest.NewRecorder() handleCreateBucket(s.server, w, req) - s.Equal(http.StatusFound, w.Code) + s.Equal(http.StatusOK, w.Code) + s.Contains(w.Body.String(), "new-bucket") + exists, err := s.server.Buckets.Exists("new-bucket") s.Require().NoError(err) s.True(exists) @@ -102,7 +104,8 @@ func (s *IndexTestSuite) TestHandleDeleteBucket() { handleDeleteBucket(s.server, w, req) s.Equal(http.StatusOK, w.Code) - s.Equal("/", w.Header().Get("HX-Redirect")) + s.Equal("/", w.Header().Get("HX-Push-Url")) + s.Contains(w.Body.String(), "bucket-list") exists, err := s.server.Buckets.Exists("to-delete") s.Require().NoError(err) diff --git a/server/handlers/console/index.go b/server/handlers/console/index.go index c22a196..b69c1e0 100644 --- a/server/handlers/console/index.go +++ b/server/handlers/console/index.go @@ -42,8 +42,7 @@ func handleCreateBucket(s *server.Server, w http.ResponseWriter, r *http.Request return } - // For HTMX, just redirect or re-render (usually redirect to index is fine) - http.Redirect(w, r, "/", http.StatusFound) + renderBucketList(s, w) } func handleDeleteBucket(s *server.Server, w http.ResponseWriter, r *http.Request) { @@ -57,11 +56,52 @@ func handleDeleteBucket(s *server.Server, w http.ResponseWriter, r *http.Request return } - // For HTMX DELETE request, redirect to index - w.Header().Set("HX-Redirect", "/") - w.WriteHeader(http.StatusOK) + names, err := s.Buckets.Names() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + data := struct{ Buckets []string }{Buckets: names} + + var buf bytes.Buffer + if err := s.Template.ExecuteTemplate(&buf, "buckets/list.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // OOB swap to reset main content to empty state + buf.WriteString(`
`) + + if err := s.Template.ExecuteTemplate(&buf, "empty.html", nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + buf.WriteString(`
`) + + w.Header().Set("HX-Push-Url", "/") + _, _ = buf.WriteTo(w) } +// renderBucketList renders the sidebar bucket list fragment. +func renderBucketList(s *server.Server, w http.ResponseWriter) { + names, err := s.Buckets.Names() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + data := struct{ Buckets []string }{Buckets: names} + + var buf bytes.Buffer + if err := s.Template.ExecuteTemplate(&buf, "buckets/list.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, _ = buf.WriteTo(w) +} func init() { server.RegisterConsoleHandleFunc("GET /{$}", middleware.BasicAuth(handleIndex)) diff --git a/server/handlers/console/scripts.go b/server/handlers/console/scripts.go new file mode 100644 index 0000000..4bb264a --- /dev/null +++ b/server/handlers/console/scripts.go @@ -0,0 +1,39 @@ +package console + +import ( + "bytes" + "net/http" + + "github.com/mojatter/s2/server" + "github.com/mojatter/s2/server/middleware" +) + +// scriptTemplates lists the {{define "scripts:..."}} blocks to render. +// Populated automatically by RegisterTemplateWithScripts. +var scriptTemplates []string + +// RegisterTemplateWithScripts registers a template that contains a +// {{define "scripts:"}} block. It registers both the template +// itself (via server.RegisterTemplate) and the scripts block for the +// GET /scripts endpoint. +func RegisterTemplateWithScripts(name string) { + server.RegisterTemplate(name) + scriptTemplates = append(scriptTemplates, "scripts:"+name) +} + +func handleScripts(s *server.Server, w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + for _, name := range scriptTemplates { + if err := s.Template.ExecuteTemplate(&buf, name, nil); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} + +func init() { + server.RegisterConsoleHandleFunc("GET /scripts", middleware.BasicAuth(handleScripts)) +} diff --git a/server/templates.go b/server/templates.go index 946f8b9..2d3c267 100644 --- a/server/templates.go +++ b/server/templates.go @@ -20,8 +20,11 @@ var templatesFS embed.FS var ( templatesMux sync.Mutex templateNames = []string{ - "index.html", + "empty.html", + "buckets/list.html", "buckets/objects.html", + "buckets/preview.html", + "index.html", } ) @@ -61,6 +64,36 @@ var imageExts = map[string]bool{ ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, ".bmp": true, ".ico": true, } +// videoExts is the set of file extensions recognized as video for preview. +var videoExts = map[string]bool{ + ".mp4": true, ".webm": true, ".ogg": true, +} + +// audioExts is the set of file extensions recognized as audio for preview. +var audioExts = map[string]bool{ + ".mp3": true, ".wav": true, ".aac": true, ".flac": true, +} + +// PreviewType returns the preview category for the given file extension: +// "image", "video", "audio", "pdf", "text", or "" (unsupported). +func PreviewType(ext string) string { + ext = strings.ToLower(ext) + switch { + case imageExts[ext]: + return "image" + case videoExts[ext]: + return "video" + case audioExts[ext]: + return "audio" + case ext == ".pdf": + return "pdf" + case textPreviewExts[ext]: + return "text" + default: + return "" + } +} + // previewableExts is the set of file extensions that can be previewed in the Web Console. var previewableExts = map[string]bool{ // Images diff --git a/server/templates/buckets/list.html b/server/templates/buckets/list.html new file mode 100644 index 0000000..a69a652 --- /dev/null +++ b/server/templates/buckets/list.html @@ -0,0 +1,30 @@ + diff --git a/server/templates/buckets/objects.html b/server/templates/buckets/objects.html index 3af1ca3..cead397 100644 --- a/server/templates/buckets/objects.html +++ b/server/templates/buckets/objects.html @@ -129,7 +129,7 @@

{{.BucketName}}

{{if isPreviewable .Name .Length}} - +
+ + + + + + diff --git a/server/templates/empty.html b/server/templates/empty.html new file mode 100644 index 0000000..9753291 --- /dev/null +++ b/server/templates/empty.html @@ -0,0 +1,21 @@ +
+
+

Storage Overview

+

Select a bucket from the sidebar to view its objects.

+
+
+ +
+
+
+ + + + + +
+

No Bucket Selected

+

Navigate through your cloud storage buckets using the sidebar on the left.

+
+
diff --git a/server/templates/index.html b/server/templates/index.html index 7386741..cbbe138 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -38,62 +38,19 @@

Buckets

-
+
- + {{template "buckets/list.html" .}}
-
-
-

Storage Overview

-

Select a bucket from the sidebar to view its objects.

-
-
- -
-
-
- - - - - -
-

No Bucket Selected

-

Navigate through your cloud storage buckets using the sidebar on the left.

-
-
+ {{template "empty.html" .}}
@@ -103,168 +60,9 @@

No Bucket Selected

- +
\ No newline at end of file