From cba416b2380505a017d135f9dc2645095216d2ec Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 8 Jun 2026 03:43:43 +0300 Subject: [PATCH] feat(preview): add archive listing and browser support Introduces the ability to browse and preview the contents of archive files directly within the web interface. Changes include: - Added a new API endpoint `GET /d/{boxID}/archive/{fileID}` to fetch archive listings. - Implemented on-demand archive listing generation in the backend. - Updated the frontend preview component to support rendering and navigating archive contents. --- backend/libs/handlers/app.go | 1 + backend/libs/handlers/download.go | 74 ++++++++ backend/libs/handlers/meta.go | 1 + backend/libs/jobs/thumbnails.go | 261 ++++++++++++++++++++++++- backend/libs/jobs/thumbnails_test.go | 47 +++++ backend/libs/services/upload.go | 27 +++ backend/static/css/30-download.css | 165 ++++++++++++++++ backend/static/js/45-preview.js | 272 +++++++++++++++++++++++++++ backend/templates/pages/preview.html | 6 +- 9 files changed, 852 insertions(+), 2 deletions(-) diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 598cad0..96c0422 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -135,6 +135,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview) + mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /robots.txt", a.RobotsTxt) mux.HandleFunc("GET /sitemap.xml", a.SitemapXML) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 8d5db7f..3ed5b01 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -48,8 +48,10 @@ type fileView struct { DownloadURL string ThumbnailURL string SceneURL string + ArchiveURL string HasThumbnail bool HasScene bool + HasArchive bool IconURL string IconRetroURL string ReactURL string @@ -384,6 +386,51 @@ func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body)) } +func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + box, file, ok := a.loadFileForRequest(w, r) + if !ok { + return + } + if !jobs.NeedsArchiveListing(file) { + http.NotFound(w, r) + return + } + if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) { + http.Error(w, "password required", http.StatusUnauthorized) + return + } + + if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" { + if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" { + file.ArchiveListing = listing + file.ArchiveListingObjectKey = "" + } + } + + object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file) + if err != nil { + if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" { + file.ArchiveListing = listing + file.ArchiveListingObjectKey = "" + object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file) + if err == nil { + defer object.Body.Close() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body)) + return + } + } + http.Error(w, "archive preview unavailable", http.StatusInternalServerError) + return + } + defer object.Body.Close() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body)) +} + func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string { if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) { return "" @@ -432,6 +479,31 @@ func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services return scene } +func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string { + if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) { + return "" + } + listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file) + if err != nil || listing == "" { + if err != nil { + a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...) + } + return "" + } + for i := range box.Files { + if box.Files[i].ID == file.ID { + box.Files[i].ArchiveListing = listing + box.Files[i].ArchiveListingObjectKey = "" + break + } + } + if err := a.uploadService.SaveBox(box); err != nil { + a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...) + return "" + } + return listing +} + // servePlaceholderThumbnail serves the fallback image with no-store so the // browser re-requests on the next load and picks up the real thumbnail as soon // as it has been generated. @@ -577,8 +649,10 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID), + ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID), HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file), HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file), + HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file), IconURL: fileIconURL("standard", icon.Standard), IconRetroURL: fileIconURL("retro", icon.Retro), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), diff --git a/backend/libs/handlers/meta.go b/backend/libs/handlers/meta.go index f78139c..c437b48 100644 --- a/backend/libs/handlers/meta.go +++ b/backend/libs/handlers/meta.go @@ -24,6 +24,7 @@ Disallow: /d/*/f/*/download Disallow: /d/*/zip Disallow: /d/*/thumb/ Disallow: /d/*/scene/ +Disallow: /d/*/archive/ Disallow: /d/*/og-image.jpg Disallow: /d/*/unlock Disallow: /d/*/manage/ diff --git a/backend/libs/jobs/thumbnails.go b/backend/libs/jobs/thumbnails.go index a875947..6d53229 100644 --- a/backend/libs/jobs/thumbnails.go +++ b/backend/libs/jobs/thumbnails.go @@ -1,8 +1,10 @@ package jobs import ( + "archive/zip" "bytes" "context" + "encoding/json" "fmt" "html" "image" @@ -17,6 +19,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" @@ -112,7 +115,8 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg file := &box.Files[i] needsPrimary := file.Thumbnail == "" && needsThumbnail(*file) needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file) - if !needsPrimary && !needsScenes { + needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file) + if !needsPrimary && !needsScenes && !needsArchive { continue } result.Scanned++ @@ -144,6 +148,21 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg result.Generated++ } } + + if needsArchive { + archiveListing, err := generateArchiveListing(uploadService, box, *file) + if err != nil { + logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error()) + result.Failed++ + } else if archiveListing == "" { + result.Failed++ + } else { + file.ArchiveListing = archiveListing + file.ArchiveListingObjectKey = "" + changed = true + result.Generated++ + } + } } if changed { @@ -170,6 +189,10 @@ func NeedsVideoScenes(file services.File) bool { return needsVideoScenes(file) } +func NeedsArchiveListing(file services.File) bool { + return needsArchiveListing(file) +} + func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { return generateThumbnail(uploadService, box, file) } @@ -178,6 +201,10 @@ func GenerateVideoScenesForFile(uploadService *services.UploadService, box servi return generateVideoScenesThumbnail(uploadService, box, file) } +func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { + return generateArchiveListing(uploadService, box, file) +} + func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { thumbnailName := "@thumb@" + file.ID + ".jpg" object, err := uploadService.OpenFileObject(context.Background(), box, file) @@ -232,6 +259,25 @@ func generateVideoScenesThumbnail(uploadService *services.UploadService, box ser return sceneName, err } +func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { + if !needsArchiveListing(file) { + return "", nil + } + listingName := "@archive@" + file.ID + ".json" + object, err := uploadService.OpenFileObject(context.Background(), box, file) + if err != nil { + return "", err + } + defer object.Body.Close() + + data, err := createArchiveListing(file, object.Body) + if err != nil { + return "", err + } + _, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json") + return listingName, err +} + func isTextThumbnailCandidate(file services.File) bool { contentType := strings.ToLower(strings.TrimSpace(file.ContentType)) if i := strings.IndexByte(contentType, ';'); i >= 0 { @@ -253,6 +299,219 @@ func isTextThumbnailCandidate(file services.File) bool { } } +func needsArchiveListing(file services.File) bool { + contentType := strings.ToLower(strings.TrimSpace(file.ContentType)) + if i := strings.IndexByte(contentType, ';'); i >= 0 { + contentType = strings.TrimSpace(contentType[:i]) + } + switch contentType { + case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip": + return true + } + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".") + switch ext { + case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx": + return true + default: + return false + } +} + +func archiveListingCurrent(file services.File) bool { + return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" +} + +type archiveTreeNode struct { + Name string `json:"name"` + Size uint64 `json:"size,omitempty"` + Dir bool `json:"dir"` + Icon string `json:"icon,omitempty"` + Children map[string]*archiveTreeNode `json:"-"` + Items []*archiveTreeNode `json:"items,omitempty"` +} + +type archiveListingData struct { + Name string `json:"name"` + Type string `json:"type"` + FileCount int `json:"fileCount"` + FolderCount int `json:"folderCount"` + UncompressedSize uint64 `json:"uncompressedSize"` + Root *archiveTreeNode `json:"root"` +} + +func createArchiveListing(file services.File, source io.Reader) ([]byte, error) { + sourceFile, err := os.CreateTemp("", "warpbox-archive-*") + if err != nil { + return nil, err + } + defer os.Remove(sourceFile.Name()) + if _, err := io.Copy(sourceFile, source); err != nil { + sourceFile.Close() + return nil, err + } + if err := sourceFile.Close(); err != nil { + return nil, err + } + + archive, err := zip.OpenReader(sourceFile.Name()) + if err != nil { + return nil, err + } + defer archive.Close() + + root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}} + var totalSize uint64 + var fileCount int + var dirCount int + for _, entry := range archive.File { + name := strings.Trim(entry.Name, "/") + if name == "" || strings.HasPrefix(name, "__MACOSX/") { + continue + } + parts := strings.Split(name, "/") + node := root + for i, part := range parts { + if part == "" { + continue + } + if node.Children == nil { + node.Children = map[string]*archiveTreeNode{} + } + child, ok := node.Children[part] + if !ok { + child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}} + node.Children[part] = child + if child.Dir { + dirCount++ + } + } + node = child + } + if !entry.FileInfo().IsDir() { + node.Dir = false + node.Size = entry.UncompressedSize64 + totalSize += entry.UncompressedSize64 + fileCount++ + } + } + + finalizeArchiveTree(root) + data := archiveListingData{ + Name: file.Name, + Type: archiveLabel(file), + FileCount: fileCount, + FolderCount: dirCount, + UncompressedSize: totalSize, + Root: root, + } + return json.MarshalIndent(data, "", " ") +} + +func finalizeArchiveTree(node *archiveTreeNode) { + node.Items = sortedArchiveChildren(node) + for _, child := range node.Items { + if child.Dir { + child.Icon = "folder" + finalizeArchiveTree(child) + } else { + child.Icon = archiveFileIconName(child.Name) + } + } +} + +func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) { + children := sortedArchiveChildren(node) + for i, child := range children { + last := i == len(children)-1 + branch := "|-- " + nextPrefix := prefix + "| " + if last { + branch = "`-- " + nextPrefix = prefix + " " + } + + out.WriteString(prefix) + out.WriteString(branch) + out.WriteString(archiveNodeLabel(child)) + out.WriteString("\n") + if child.Dir { + writeArchiveTree(out, child, nextPrefix) + } + } +} + +func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode { + children := make([]*archiveTreeNode, 0, len(node.Children)) + for _, child := range node.Children { + children = append(children, child) + } + sort.Slice(children, func(i, j int) bool { + if children[i].Dir != children[j].Dir { + return children[i].Dir + } + return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name) + }) + return children +} + +func archiveNodeLabel(node *archiveTreeNode) string { + if node.Dir { + return "[DIR] " + node.Name + "/" + } + return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")" +} + +func archiveFileIcon(name string) string { + return "[" + strings.ToUpper(archiveFileIconName(name)) + "]" +} + +func archiveFileIconName(name string) string { + switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") { + case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg": + return "img" + case "mp4", "mov", "webm", "mkv", "avi": + return "vid" + case "mp3", "wav", "flac", "ogg", "m4a": + return "aud" + case "md", "txt", "log", "csv": + return "txt" + case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml": + return "code" + case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx": + return "arc" + default: + return "file" + } +} + +func archiveLabel(file services.File) string { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".") + if ext != "" { + return strings.ToUpper(ext) + " archive" + } + if file.ContentType != "" { + return file.ContentType + } + return "ZIP-compatible archive" +} + +func formatArchiveBytes(size uint64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div := float64(unit) + value := float64(size) / div + units := []string{"KiB", "MiB", "GiB", "TiB"} + for _, suffix := range units { + if value < unit { + return fmt.Sprintf("%.1f %s", value, suffix) + } + value /= div + } + return fmt.Sprintf("%.1f PiB", value) +} + func createImageThumbnail(source io.Reader) ([]byte, error) { img, _, err := image.Decode(source) if err != nil { diff --git a/backend/libs/jobs/thumbnails_test.go b/backend/libs/jobs/thumbnails_test.go index 7aaac46..07e8056 100644 --- a/backend/libs/jobs/thumbnails_test.go +++ b/backend/libs/jobs/thumbnails_test.go @@ -1,7 +1,9 @@ package jobs import ( + "archive/zip" "bytes" + "encoding/json" "image" "image/color" "image/jpeg" @@ -108,6 +110,51 @@ func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) { } } +func TestCreateArchiveListingRendersZipTree(t *testing.T) { + var archive bytes.Buffer + writer := zip.NewWriter(&archive) + addZipTestFile(t, writer, "docs/readme.md", "hello") + addZipTestFile(t, writer, "src/main.go", "package main\n") + if err := writer.Close(); err != nil { + t.Fatalf("zip.Close returned error: %v", err) + } + + data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes())) + if err != nil { + t.Fatalf("createArchiveListing returned error: %v", err) + } + var listing archiveListingData + if err := json.Unmarshal(data, &listing); err != nil { + t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data)) + } + if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 { + t.Fatalf("archive listing metadata = %+v", listing) + } + if listing.Root == nil || len(listing.Root.Items) != 2 { + t.Fatalf("archive listing root = %+v", listing.Root) + } + if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" { + t.Fatalf("first archive folder = %+v", listing.Root.Items[0]) + } + if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" { + t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0]) + } + if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" { + t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0]) + } +} + +func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) { + t.Helper() + file, err := writer.Create(name) + if err != nil { + t.Fatalf("zip.Create returned error: %v", err) + } + if _, err := file.Write([]byte(body)); err != nil { + t.Fatalf("zip file write returned error: %v", err) + } +} + func solidTestImage(c color.Color) image.Image { img := image.NewRGBA(image.Rect(0, 0, 32, 24)) for y := 0; y < img.Bounds().Dy(); y++ { diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 2f15a1a..26ff838 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -129,9 +129,11 @@ type File struct { PreviewKind string `json:"previewKind"` Thumbnail string `json:"thumbnail,omitempty"` SceneThumbnail string `json:"sceneThumbnail,omitempty"` + ArchiveListing string `json:"archiveListing,omitempty"` ObjectKey string `json:"objectKey,omitempty"` ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"` + ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"` Processing bool `json:"processing,omitempty"` ProcessingError string `json:"processingError,omitempty"` UploadedAt time.Time `json:"uploadedAt"` @@ -736,6 +738,9 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) { if key := s.SceneThumbnailObjectKey(box, file); key != "" { _ = backend.Delete(context.Background(), key) } + if key := s.ArchiveListingObjectKey(box, file); key != "" { + _ = backend.Delete(context.Background(), key) + } } box.Files = append(box.Files[:index], box.Files[index+1:]...) @@ -833,6 +838,16 @@ func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string { return boxObjectKey(box.ID, file.SceneThumbnail) } +func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string { + if file.ArchiveListingObjectKey != "" { + return file.ArchiveListingObjectKey + } + if file.ArchiveListing == "" { + return "" + } + return boxObjectKey(box.ID, file.ArchiveListing) +} + func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) { if file.Processing { return StorageObject{}, fmt.Errorf("file is still processing") @@ -868,6 +883,18 @@ func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, f return backend.Get(ctx, key) } +func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) { + key := s.ArchiveListingObjectKey(box, file) + if key == "" { + return StorageObject{}, os.ErrNotExist + } + backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) + if err != nil { + return StorageObject{}, err + } + return backend.Get(ctx, key) +} + func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) { backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) if err != nil { diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index ffd91d1..76c9339 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -271,6 +271,170 @@ height: auto; } +.archive-browser-preview { + width: 100%; + height: clamp(18rem, 64vh, 38rem); + overflow: auto; + background: color-mix(in srgb, var(--card) 86%, black 14%); + color: var(--foreground); +} + +.archive-browser-header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1rem; + padding: 0.9rem 1rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--card) 92%, black 8%); +} + +.archive-browser-header strong { + min-width: 0; + overflow-wrap: anywhere; + font-size: 0.98rem; +} + +.archive-browser-header span { + color: var(--muted-foreground); + font-size: 0.82rem; +} + +.archive-tree { + padding: 0.6rem 0.8rem 1rem; + font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; + font-size: 0.88rem; + line-height: 1.45; +} + +.archive-node { + min-width: max-content; +} + +.archive-node-row { + display: grid; + grid-template-columns: 1.25rem 1.45rem minmax(12rem, 1fr) auto; + align-items: center; + gap: 0.45rem; + min-height: 2.1rem; + padding: 0.18rem 0.45rem; + border-radius: 6px; + color: var(--foreground); +} + +.archive-node-row:hover { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.archive-folder > summary { + cursor: pointer; + list-style: none; +} + +.archive-folder > summary::-webkit-details-marker { + display: none; +} + +.archive-chevron, +.archive-chevron-spacer, +.archive-file-icon { + display: inline-grid; + place-items: center; + width: 1.25rem; + height: 1.25rem; +} + +.archive-chevron { + color: var(--muted-foreground); + transition: transform 140ms ease, color 140ms ease; +} + +.archive-folder[open] > summary .archive-chevron { + transform: rotate(90deg); + color: var(--accent); +} + +.archive-chevron svg { + width: 1.18rem; + height: 1.18rem; +} + +.archive-file-icon { + color: var(--muted-foreground); +} + +.archive-file-icon svg, +.archive-chevron svg { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.archive-file-icon-folder { + color: var(--accent); +} + +.archive-file-icon-folder svg { + fill: color-mix(in srgb, var(--accent) 18%, transparent); +} + +.archive-file-icon-img { + color: #67e8f9; +} + +.archive-file-icon-vid { + color: #f9a8d4; +} + +.archive-file-icon-aud { + color: #86efac; +} + +.archive-file-icon-code { + color: #c4b5fd; +} + +.archive-file-icon-arc { + color: #fcd34d; +} + +.archive-file-icon-txt { + color: #f8fafc; +} + +.archive-node-name { + min-width: 0; + overflow-wrap: anywhere; +} + +.archive-node-size { + color: var(--muted-foreground); + font-size: 0.78rem; +} + +.archive-browser-empty { + margin: 0; + padding: 1rem; + color: var(--muted-foreground); +} + +.archive-browser-legacy { + min-width: max-content; + margin: 0; + padding: 1rem; + color: var(--foreground); + font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace; + font-size: 0.88rem; + line-height: 1.55; + white-space: pre; +} + .preview-placeholder { display: grid; place-items: center; @@ -283,6 +447,7 @@ .preview-placeholder[hidden], .default-preview[hidden], .native-preview[hidden], +.archive-browser-preview[hidden], .large-preview-gate[hidden], .code-preview[hidden], .render-preview[hidden] { diff --git a/backend/static/js/45-preview.js b/backend/static/js/45-preview.js index 7938a16..d6aa1bd 100644 --- a/backend/static/js/45-preview.js +++ b/backend/static/js/45-preview.js @@ -17,6 +17,7 @@ downloadURL: preview.dataset.downloadUrl || "", iconURL: preview.dataset.iconUrl || "", sceneURL: preview.dataset.sceneUrl || "", + archiveURL: preview.dataset.archiveUrl || "", activeMode: "", defaultMode: "default", pendingMode: "", @@ -26,6 +27,10 @@ prismLoaded: false, renderLoaded: false, sceneLoaded: false, + archiveLoaded: false, + archiveUIRendered: false, + archiveData: null, + archiveText: "", renderFullscreenFallback: false, confirmedLargeModes: {}, tabs: [] @@ -43,6 +48,9 @@ rawOutput: preview.querySelector("[data-raw-output]"), codePane: preview.querySelector("[data-code-preview]"), codeOutput: preview.querySelector("[data-code-output]"), + archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"), + archivePane: preview.querySelector("[data-archive-preview]"), + archiveOutput: preview.querySelector("[data-archive-output]"), renderPane: preview.querySelector("[data-render-preview]"), fullscreenButton: preview.querySelector("[data-render-fullscreen]"), gatePane: preview.querySelector("[data-large-preview-gate]"), @@ -68,6 +76,7 @@ var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml"; var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0; var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0; + var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType); return { extension: extension, @@ -79,6 +88,7 @@ isImage: isImage, isVideo: isVideo, isAudio: isAudio, + isArchive: isArchive, isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches }; } @@ -104,6 +114,12 @@ return tabs; } + if (type.isArchive) { + tabs.push({ mode: "archive-ui", label: "Archive Preview" }); + tabs.push({ mode: "archive", label: "Text Tree" }); + return tabs; + } + if (type.isTextLike) { if (type.isHTML || type.isMarkdown) { tabs.push({ mode: "render", label: "Render Preview" }); @@ -122,6 +138,9 @@ if (type.isVideo) { return "video"; } + if (type.isArchive) { + return "archive-ui"; + } if (state.sizeBytes > LARGE_PREVIEW_BYTES) { if (type.isAudio && hasMode(tabs, "browser-audio")) { return "browser-audio"; @@ -198,6 +217,12 @@ } else if (mode === "code") { show(els.codePane); ensurePrismPreview(); + } else if (mode === "archive-ui") { + show(els.archiveBrowserPane); + ensureArchiveBrowserPreview(); + } else if (mode === "archive") { + show(els.archivePane); + ensureArchivePreview(); } else if (mode === "render") { show(els.renderPane); if (fileType.isMarkdown) { @@ -416,6 +441,8 @@ hide(els.browserAudioPane); hide(els.rawPane); hide(els.codePane); + hide(els.archiveBrowserPane); + hide(els.archivePane); hide(els.renderPane); hide(els.gatePane); hide(els.placeholder); @@ -512,6 +539,8 @@ "browser-audio": "Browser preview", "raw": "Raw preview", "code": "Code preview", + "archive-ui": "Archive preview", + "archive": "Archive preview", "render": "Render preview" }; return labels[mode] || "Preview"; @@ -529,6 +558,227 @@ state.sceneLoaded = true; } + function ensureArchivePreview() { + if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) { + return; + } + ensureArchiveData() + .then(function () { + var text = state.archiveText || archiveDataToText(state.archiveData); + els.archiveOutput.textContent = text; + state.archiveLoaded = true; + hide(els.placeholder); + show(els.archivePane); + }) + .catch(function () { + showError("Archive preview could not be loaded."); + }); + } + + function ensureArchiveBrowserPreview() { + if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) { + return; + } + ensureArchiveData() + .then(function () { + renderArchiveBrowser(state.archiveData); + state.archiveUIRendered = true; + hide(els.placeholder); + show(els.archiveBrowserPane); + }) + .catch(function () { + showError("Archive preview could not be loaded."); + }); + } + + function ensureArchiveData() { + if (state.archiveData || state.archiveText) { + return Promise.resolve(); + } + showLoading("Loading archive contents..."); + return fetch(state.archiveURL, { credentials: "same-origin" }) + .then(function (response) { + if (!response.ok) { + throw new Error("Archive preview could not be loaded."); + } + return response.text(); + }) + .then(function (text) { + try { + state.archiveData = JSON.parse(text); + } catch (error) { + state.archiveText = text; + } + }); + } + + function renderArchiveBrowser(data) { + if (!els.archiveBrowserPane) { + return; + } + els.archiveBrowserPane.innerHTML = ""; + if (!data || !data.root) { + var fallback = document.createElement("pre"); + fallback.className = "archive-browser-legacy"; + fallback.textContent = state.archiveText || "Archive preview is unavailable."; + els.archiveBrowserPane.appendChild(fallback); + return; + } + + var header = document.createElement("div"); + header.className = "archive-browser-header"; + header.innerHTML = ""; + header.querySelector("strong").textContent = data.name || state.fileName || "Archive"; + header.querySelector("span").textContent = [ + data.type || "Archive", + formatArchiveCount(data.fileCount, "file"), + formatArchiveCount(data.folderCount, "folder"), + formatBytes(data.uncompressedSize || 0) + ].filter(Boolean).join(" ยท "); + els.archiveBrowserPane.appendChild(header); + + var tree = document.createElement("div"); + tree.className = "archive-tree"; + var items = data.root.items || []; + if (items.length === 0) { + var emptyTree = document.createElement("p"); + emptyTree.className = "archive-browser-empty"; + emptyTree.textContent = "This archive is empty."; + tree.appendChild(emptyTree); + } else { + items.forEach(function (item) { + tree.appendChild(renderArchiveNode(item, 0)); + }); + } + els.archiveBrowserPane.appendChild(tree); + } + + function renderArchiveNode(node, depth) { + var row = document.createElement(node.dir ? "details" : "div"); + row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file"; + if (node.dir && depth < 1) { + row.open = true; + } + + var summary = document.createElement(node.dir ? "summary" : "div"); + summary.className = "archive-node-row"; + summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem"; + + if (node.dir) { + summary.appendChild(createArchiveChevron()); + } else { + var spacer = document.createElement("span"); + spacer.className = "archive-chevron-spacer"; + summary.appendChild(spacer); + } + + summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file"))); + + var name = document.createElement("span"); + name.className = "archive-node-name"; + name.textContent = node.name + (node.dir ? "/" : ""); + summary.appendChild(name); + + if (!node.dir) { + var size = document.createElement("span"); + size.className = "archive-node-size"; + size.textContent = formatBytes(node.size || 0); + summary.appendChild(size); + } + + row.appendChild(summary); + if (node.dir) { + (node.items || []).forEach(function (child) { + row.appendChild(renderArchiveNode(child, depth + 1)); + }); + } + return row; + } + + function createArchiveChevron() { + var chevron = document.createElement("span"); + chevron.className = "archive-chevron"; + chevron.setAttribute("aria-hidden", "true"); + chevron.innerHTML = ''; + return chevron; + } + + function createArchiveIcon(icon) { + var element = document.createElement("span"); + element.className = "archive-file-icon archive-file-icon-" + icon; + element.setAttribute("aria-hidden", "true"); + element.innerHTML = archiveIconSVG(icon); + return element; + } + + function archiveDataToText(data) { + if (!data || !data.root) { + return state.archiveText || ""; + } + var lines = [ + "Archive preview", + "Name: " + (data.name || state.fileName || "Archive"), + "Type: " + (data.type || "Archive"), + "Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders", + "Uncompressed size: " + formatBytes(data.uncompressedSize || 0), + "", + "." + ]; + appendArchiveTextLines(lines, data.root.items || [], ""); + if (!(data.root.items || []).length) { + lines.push("(empty archive)"); + } + return lines.join("\n"); + } + + function appendArchiveTextLines(lines, items, prefix) { + items.forEach(function (item, index) { + var last = index === items.length - 1; + var branch = last ? "`-- " : "|-- "; + var nextPrefix = prefix + (last ? " " : "| "); + var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")"; + lines.push(prefix + branch + label); + if (item.dir) { + appendArchiveTextLines(lines, item.items || [], nextPrefix); + } + }); + } + + function archiveIconSVG(icon) { + var icons = { + folder: '', + img: '', + vid: '', + aud: '', + txt: '', + code: '', + arc: '', + file: '' + }; + return icons[icon] || icons.file; + } + + function formatArchiveCount(value, label) { + value = Number(value || 0); + return value + " " + label + (value === 1 ? "" : "s"); + } + + function formatBytes(value) { + value = Number(value || 0); + if (value < 1024) { + return value + " B"; + } + var units = ["KiB", "MiB", "GiB", "TiB"]; + var size = value / 1024; + for (var i = 0; i < units.length; i++) { + if (size < 1024 || i === units.length - 1) { + return size.toFixed(1) + " " + units[i]; + } + size /= 1024; + } + return value + " B"; + } + function loadPrism() { if (window.Prism) { return Promise.resolve(); @@ -655,6 +905,28 @@ return parts.length > 1 ? parts.pop() : ""; } + function isArchiveFile(extension, baseType) { + var archiveExtensions = { + "apk": true, + "docx": true, + "ear": true, + "epub": true, + "jar": true, + "pptx": true, + "war": true, + "xlsx": true, + "zip": true + }; + var archiveTypes = { + "application/epub+zip": true, + "application/java-archive": true, + "application/vnd.android.package-archive": true, + "application/x-zip-compressed": true, + "application/zip": true + }; + return Boolean(archiveExtensions[extension] || archiveTypes[baseType]); + } + function languageFor(extension, baseType) { var extensionMap = { "c": "c", diff --git a/backend/templates/pages/preview.html b/backend/templates/pages/preview.html index d94cdd1..45dc262 100644 --- a/backend/templates/pages/preview.html +++ b/backend/templates/pages/preview.html @@ -23,7 +23,7 @@ -
+
Preview @@ -57,6 +57,10 @@ + {{if .Data.File.HasArchive}} + {{end}}