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:
2026-06-08 03:43:43 +03:00
parent f9755fa98f
commit cba416b238
9 changed files with 852 additions and 2 deletions

View File

@@ -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)

View File

@@ -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),

View File

@@ -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/

View File

@@ -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 {

View File

@@ -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++ {

View File

@@ -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 {

View File

@@ -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] {

View File

@@ -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 = "<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() {
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",

View File

@@ -23,7 +23,7 @@
</a>
</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>
<strong data-preview-mode-label>Preview</strong>
@@ -57,6 +57,10 @@
<div class="code-preview prism-code-preview" data-code-preview hidden>
<pre class="line-numbers"><code data-code-output></code></pre>
</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>
<div class="large-preview-gate" data-large-preview-gate hidden>
<strong>Large preview</strong>