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.
This commit is contained in:
@@ -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}/f/{fileID}/og-image.jpg", a.FileOGImage)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||||
mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview)
|
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 /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||||
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||||
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ type fileView struct {
|
|||||||
DownloadURL string
|
DownloadURL string
|
||||||
ThumbnailURL string
|
ThumbnailURL string
|
||||||
SceneURL string
|
SceneURL string
|
||||||
|
ArchiveURL string
|
||||||
HasThumbnail bool
|
HasThumbnail bool
|
||||||
HasScene bool
|
HasScene bool
|
||||||
|
HasArchive bool
|
||||||
IconURL string
|
IconURL string
|
||||||
IconRetroURL string
|
IconRetroURL string
|
||||||
ReactURL 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))
|
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 {
|
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||||
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) {
|
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) {
|
||||||
return ""
|
return ""
|
||||||
@@ -432,6 +479,31 @@ func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services
|
|||||||
return scene
|
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
|
// 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
|
// browser re-requests on the next load and picks up the real thumbnail as soon
|
||||||
// as it has been generated.
|
// 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),
|
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
||||||
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", 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),
|
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),
|
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
|
||||||
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
|
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
|
||||||
|
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file),
|
||||||
IconURL: fileIconURL("standard", icon.Standard),
|
IconURL: fileIconURL("standard", icon.Standard),
|
||||||
IconRetroURL: fileIconURL("retro", icon.Retro),
|
IconRetroURL: fileIconURL("retro", icon.Retro),
|
||||||
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Disallow: /d/*/f/*/download
|
|||||||
Disallow: /d/*/zip
|
Disallow: /d/*/zip
|
||||||
Disallow: /d/*/thumb/
|
Disallow: /d/*/thumb/
|
||||||
Disallow: /d/*/scene/
|
Disallow: /d/*/scene/
|
||||||
|
Disallow: /d/*/archive/
|
||||||
Disallow: /d/*/og-image.jpg
|
Disallow: /d/*/og-image.jpg
|
||||||
Disallow: /d/*/unlock
|
Disallow: /d/*/unlock
|
||||||
Disallow: /d/*/manage/
|
Disallow: /d/*/manage/
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"image"
|
"image"
|
||||||
@@ -17,6 +19,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -112,7 +115,8 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
file := &box.Files[i]
|
file := &box.Files[i]
|
||||||
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
||||||
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
||||||
if !needsPrimary && !needsScenes {
|
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
||||||
|
if !needsPrimary && !needsScenes && !needsArchive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Scanned++
|
result.Scanned++
|
||||||
@@ -144,6 +148,21 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
result.Generated++
|
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 {
|
if changed {
|
||||||
@@ -170,6 +189,10 @@ func NeedsVideoScenes(file services.File) bool {
|
|||||||
return needsVideoScenes(file)
|
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) {
|
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
return generateThumbnail(uploadService, box, file)
|
return generateThumbnail(uploadService, box, file)
|
||||||
}
|
}
|
||||||
@@ -178,6 +201,10 @@ func GenerateVideoScenesForFile(uploadService *services.UploadService, box servi
|
|||||||
return generateVideoScenesThumbnail(uploadService, box, file)
|
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) {
|
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
||||||
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||||
@@ -232,6 +259,25 @@ func generateVideoScenesThumbnail(uploadService *services.UploadService, box ser
|
|||||||
return sceneName, err
|
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 {
|
func isTextThumbnailCandidate(file services.File) bool {
|
||||||
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
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) {
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||||
img, _, err := image.Decode(source)
|
img, _, err := image.Decode(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package jobs
|
package jobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"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 {
|
func solidTestImage(c color.Color) image.Image {
|
||||||
img := image.NewRGBA(image.Rect(0, 0, 32, 24))
|
img := image.NewRGBA(image.Rect(0, 0, 32, 24))
|
||||||
for y := 0; y < img.Bounds().Dy(); y++ {
|
for y := 0; y < img.Bounds().Dy(); y++ {
|
||||||
|
|||||||
@@ -129,9 +129,11 @@ type File struct {
|
|||||||
PreviewKind string `json:"previewKind"`
|
PreviewKind string `json:"previewKind"`
|
||||||
Thumbnail string `json:"thumbnail,omitempty"`
|
Thumbnail string `json:"thumbnail,omitempty"`
|
||||||
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||||
|
ArchiveListing string `json:"archiveListing,omitempty"`
|
||||||
ObjectKey string `json:"objectKey,omitempty"`
|
ObjectKey string `json:"objectKey,omitempty"`
|
||||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||||
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
||||||
|
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
|
||||||
Processing bool `json:"processing,omitempty"`
|
Processing bool `json:"processing,omitempty"`
|
||||||
ProcessingError string `json:"processingError,omitempty"`
|
ProcessingError string `json:"processingError,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploadedAt"`
|
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 != "" {
|
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
|
||||||
_ = backend.Delete(context.Background(), 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:]...)
|
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)
|
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) {
|
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||||
if file.Processing {
|
if file.Processing {
|
||||||
return StorageObject{}, fmt.Errorf("file is still 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)
|
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) {
|
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))
|
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -271,6 +271,170 @@
|
|||||||
height: auto;
|
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 {
|
.preview-placeholder {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -283,6 +447,7 @@
|
|||||||
.preview-placeholder[hidden],
|
.preview-placeholder[hidden],
|
||||||
.default-preview[hidden],
|
.default-preview[hidden],
|
||||||
.native-preview[hidden],
|
.native-preview[hidden],
|
||||||
|
.archive-browser-preview[hidden],
|
||||||
.large-preview-gate[hidden],
|
.large-preview-gate[hidden],
|
||||||
.code-preview[hidden],
|
.code-preview[hidden],
|
||||||
.render-preview[hidden] {
|
.render-preview[hidden] {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
downloadURL: preview.dataset.downloadUrl || "",
|
downloadURL: preview.dataset.downloadUrl || "",
|
||||||
iconURL: preview.dataset.iconUrl || "",
|
iconURL: preview.dataset.iconUrl || "",
|
||||||
sceneURL: preview.dataset.sceneUrl || "",
|
sceneURL: preview.dataset.sceneUrl || "",
|
||||||
|
archiveURL: preview.dataset.archiveUrl || "",
|
||||||
activeMode: "",
|
activeMode: "",
|
||||||
defaultMode: "default",
|
defaultMode: "default",
|
||||||
pendingMode: "",
|
pendingMode: "",
|
||||||
@@ -26,6 +27,10 @@
|
|||||||
prismLoaded: false,
|
prismLoaded: false,
|
||||||
renderLoaded: false,
|
renderLoaded: false,
|
||||||
sceneLoaded: false,
|
sceneLoaded: false,
|
||||||
|
archiveLoaded: false,
|
||||||
|
archiveUIRendered: false,
|
||||||
|
archiveData: null,
|
||||||
|
archiveText: "",
|
||||||
renderFullscreenFallback: false,
|
renderFullscreenFallback: false,
|
||||||
confirmedLargeModes: {},
|
confirmedLargeModes: {},
|
||||||
tabs: []
|
tabs: []
|
||||||
@@ -43,6 +48,9 @@
|
|||||||
rawOutput: preview.querySelector("[data-raw-output]"),
|
rawOutput: preview.querySelector("[data-raw-output]"),
|
||||||
codePane: preview.querySelector("[data-code-preview]"),
|
codePane: preview.querySelector("[data-code-preview]"),
|
||||||
codeOutput: preview.querySelector("[data-code-output]"),
|
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]"),
|
renderPane: preview.querySelector("[data-render-preview]"),
|
||||||
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
|
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
|
||||||
gatePane: preview.querySelector("[data-large-preview-gate]"),
|
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 isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
|
||||||
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
|
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
|
||||||
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
|
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
|
||||||
|
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
extension: extension,
|
extension: extension,
|
||||||
@@ -79,6 +88,7 @@
|
|||||||
isImage: isImage,
|
isImage: isImage,
|
||||||
isVideo: isVideo,
|
isVideo: isVideo,
|
||||||
isAudio: isAudio,
|
isAudio: isAudio,
|
||||||
|
isArchive: isArchive,
|
||||||
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
|
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -104,6 +114,12 @@
|
|||||||
return tabs;
|
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.isTextLike) {
|
||||||
if (type.isHTML || type.isMarkdown) {
|
if (type.isHTML || type.isMarkdown) {
|
||||||
tabs.push({ mode: "render", label: "Render Preview" });
|
tabs.push({ mode: "render", label: "Render Preview" });
|
||||||
@@ -122,6 +138,9 @@
|
|||||||
if (type.isVideo) {
|
if (type.isVideo) {
|
||||||
return "video";
|
return "video";
|
||||||
}
|
}
|
||||||
|
if (type.isArchive) {
|
||||||
|
return "archive-ui";
|
||||||
|
}
|
||||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||||
return "browser-audio";
|
return "browser-audio";
|
||||||
@@ -198,6 +217,12 @@
|
|||||||
} else if (mode === "code") {
|
} else if (mode === "code") {
|
||||||
show(els.codePane);
|
show(els.codePane);
|
||||||
ensurePrismPreview();
|
ensurePrismPreview();
|
||||||
|
} else if (mode === "archive-ui") {
|
||||||
|
show(els.archiveBrowserPane);
|
||||||
|
ensureArchiveBrowserPreview();
|
||||||
|
} else if (mode === "archive") {
|
||||||
|
show(els.archivePane);
|
||||||
|
ensureArchivePreview();
|
||||||
} else if (mode === "render") {
|
} else if (mode === "render") {
|
||||||
show(els.renderPane);
|
show(els.renderPane);
|
||||||
if (fileType.isMarkdown) {
|
if (fileType.isMarkdown) {
|
||||||
@@ -416,6 +441,8 @@
|
|||||||
hide(els.browserAudioPane);
|
hide(els.browserAudioPane);
|
||||||
hide(els.rawPane);
|
hide(els.rawPane);
|
||||||
hide(els.codePane);
|
hide(els.codePane);
|
||||||
|
hide(els.archiveBrowserPane);
|
||||||
|
hide(els.archivePane);
|
||||||
hide(els.renderPane);
|
hide(els.renderPane);
|
||||||
hide(els.gatePane);
|
hide(els.gatePane);
|
||||||
hide(els.placeholder);
|
hide(els.placeholder);
|
||||||
@@ -512,6 +539,8 @@
|
|||||||
"browser-audio": "Browser preview",
|
"browser-audio": "Browser preview",
|
||||||
"raw": "Raw preview",
|
"raw": "Raw preview",
|
||||||
"code": "Code preview",
|
"code": "Code preview",
|
||||||
|
"archive-ui": "Archive preview",
|
||||||
|
"archive": "Archive preview",
|
||||||
"render": "Render preview"
|
"render": "Render preview"
|
||||||
};
|
};
|
||||||
return labels[mode] || "Preview";
|
return labels[mode] || "Preview";
|
||||||
@@ -529,6 +558,227 @@
|
|||||||
state.sceneLoaded = true;
|
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 = "<strong></strong><span></span>";
|
||||||
|
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 = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
|
||||||
|
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: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
|
||||||
|
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
|
||||||
|
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
|
||||||
|
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
|
||||||
|
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
|
||||||
|
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
|
||||||
|
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
|
||||||
|
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
|
||||||
|
};
|
||||||
|
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() {
|
function loadPrism() {
|
||||||
if (window.Prism) {
|
if (window.Prism) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -655,6 +905,28 @@
|
|||||||
return parts.length > 1 ? parts.pop() : "";
|
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) {
|
function languageFor(extension, baseType) {
|
||||||
var extensionMap = {
|
var extensionMap = {
|
||||||
"c": "c",
|
"c": "c",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}">
|
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}" data-archive-url="{{.Data.File.ArchiveURL}}">
|
||||||
<div class="preview-window-titlebar">
|
<div class="preview-window-titlebar">
|
||||||
<div>
|
<div>
|
||||||
<strong data-preview-mode-label>Preview</strong>
|
<strong data-preview-mode-label>Preview</strong>
|
||||||
@@ -57,6 +57,10 @@
|
|||||||
<div class="code-preview prism-code-preview" data-code-preview hidden>
|
<div class="code-preview prism-code-preview" data-code-preview hidden>
|
||||||
<pre class="line-numbers"><code data-code-output></code></pre>
|
<pre class="line-numbers"><code data-code-output></code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
|
||||||
|
<div class="archive-preview code-preview" data-archive-preview hidden>
|
||||||
|
<pre><code data-archive-output></code></pre>
|
||||||
|
</div>{{end}}
|
||||||
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
|
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
|
||||||
<div class="large-preview-gate" data-large-preview-gate hidden>
|
<div class="large-preview-gate" data-large-preview-gate hidden>
|
||||||
<strong>Large preview</strong>
|
<strong>Large preview</strong>
|
||||||
|
|||||||
Reference in New Issue
Block a user