Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 |
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
@@ -95,10 +97,65 @@ func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if jobs.NeedsArchiveListing(file) {
|
||||
if listing, ok := a.archiveListingForOG(r, box, file); ok {
|
||||
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
icon := a.ogFileIcon(file)
|
||||
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
||||
}
|
||||
|
||||
type ogArchiveListing struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
FileCount int `json:"fileCount"`
|
||||
FolderCount int `json:"folderCount"`
|
||||
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||
Root *ogArchiveNode `json:"root"`
|
||||
}
|
||||
|
||||
type ogArchiveNode struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size,omitempty"`
|
||||
Dir bool `json:"dir"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Items []*ogArchiveNode `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
|
||||
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 {
|
||||
return ogArchiveListing{}, false
|
||||
}
|
||||
defer object.Body.Close()
|
||||
|
||||
var listing ogArchiveListing
|
||||
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
|
||||
return ogArchiveListing{}, false
|
||||
}
|
||||
if listing.Root == nil {
|
||||
return ogArchiveListing{}, false
|
||||
}
|
||||
return listing, true
|
||||
}
|
||||
|
||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
@@ -213,6 +270,174 @@ func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
|
||||
return canvas
|
||||
}
|
||||
|
||||
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
|
||||
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||
|
||||
titleFace := a.ogFont(36, true)
|
||||
bodyFace := a.ogFont(22, false)
|
||||
treeFace := a.ogFont(19, false)
|
||||
labelFace := a.ogFont(17, true)
|
||||
|
||||
icon := a.ogFileIcon(file)
|
||||
if icon != nil {
|
||||
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
|
||||
} else {
|
||||
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
title := listing.Name
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = file.Name
|
||||
}
|
||||
titleLines := wrapOGText(title, titleFace, 820)
|
||||
if len(titleLines) > 2 {
|
||||
titleLines = titleLines[:2]
|
||||
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
|
||||
}
|
||||
y := 106
|
||||
for _, line := range titleLines {
|
||||
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||
y += 42
|
||||
}
|
||||
|
||||
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
|
||||
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
|
||||
treePanel := image.Rect(104, 214, 1096, 548)
|
||||
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||
|
||||
rows := archiveOGRows(listing.Root, 13)
|
||||
rowY := treePanel.Min.Y + 64
|
||||
for _, row := range rows {
|
||||
if row.Ellipsis {
|
||||
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
break
|
||||
}
|
||||
x := treePanel.Min.X + 20 + row.Depth*28
|
||||
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
|
||||
name := row.Name
|
||||
if row.Dir {
|
||||
name += "/"
|
||||
}
|
||||
maxNameWidth := treePanel.Max.X - x - 170
|
||||
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
|
||||
if !row.Dir {
|
||||
size := formatOGArchiveBytes(row.Size)
|
||||
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||
}
|
||||
rowY += 23
|
||||
}
|
||||
|
||||
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
return canvas
|
||||
}
|
||||
|
||||
type archiveOGRow struct {
|
||||
Name string
|
||||
Icon string
|
||||
Size uint64
|
||||
Dir bool
|
||||
Depth int
|
||||
Ellipsis bool
|
||||
}
|
||||
|
||||
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
|
||||
rows := make([]archiveOGRow, 0, limit+1)
|
||||
truncated := false
|
||||
var walk func(items []*ogArchiveNode, depth int)
|
||||
walk = func(items []*ogArchiveNode, depth int) {
|
||||
for _, item := range items {
|
||||
if len(rows) >= limit {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
icon := item.Icon
|
||||
if item.Dir {
|
||||
icon = "folder"
|
||||
}
|
||||
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
|
||||
if item.Dir {
|
||||
walk(item.Items, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if root != nil {
|
||||
walk(root.Items, 0)
|
||||
}
|
||||
if truncated {
|
||||
rows = append(rows, archiveOGRow{Ellipsis: true})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
|
||||
c := archiveOGIconColor(icon)
|
||||
rect := image.Rect(x, y, x+20, y+20)
|
||||
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
if icon == "folder" {
|
||||
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
}
|
||||
}
|
||||
|
||||
func archiveOGIconColor(icon string) color.RGBA {
|
||||
switch icon {
|
||||
case "folder":
|
||||
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
|
||||
case "img":
|
||||
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
|
||||
case "vid":
|
||||
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
|
||||
case "aud":
|
||||
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
|
||||
case "code":
|
||||
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
|
||||
case "arc":
|
||||
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
|
||||
default:
|
||||
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
|
||||
}
|
||||
}
|
||||
|
||||
func archiveOGTextColor(row archiveOGRow) color.RGBA {
|
||||
if row.Dir {
|
||||
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
|
||||
}
|
||||
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
|
||||
}
|
||||
|
||||
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
|
||||
if strings.TrimSpace(listing.Type) != "" {
|
||||
return listing.Type
|
||||
}
|
||||
if strings.TrimSpace(file.ContentType) != "" {
|
||||
return file.ContentType
|
||||
}
|
||||
return "Archive"
|
||||
}
|
||||
|
||||
func formatOGArchiveBytes(size uint64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
value := float64(size) / unit
|
||||
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
value /= unit
|
||||
}
|
||||
return fmt.Sprintf("%.1f PiB", value)
|
||||
}
|
||||
|
||||
func fileCardInfo(file services.File) string {
|
||||
switch {
|
||||
case strings.HasPrefix(file.ContentType, "audio/"):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -54,6 +54,20 @@
|
||||
margin: 0.45rem 0 0;
|
||||
}
|
||||
|
||||
.preview-header > .button {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 1rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.preview-header > .button svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .preview-header > .button-primary:active {
|
||||
padding-right: calc(1rem - 1px);
|
||||
}
|
||||
|
||||
.preview-window {
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
|
||||
@@ -271,6 +285,270 @@
|
||||
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-svg-icon,
|
||||
.archive-retro-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.archive-retro-icon {
|
||||
display: none;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-preview {
|
||||
border: 1px solid #000000;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #808080,
|
||||
inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-header {
|
||||
border-bottom: 1px solid #808080;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #808080,
|
||||
inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-header span {
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-tree {
|
||||
background: #ffffff;
|
||||
font-family: "PixelOperatorMono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row {
|
||||
min-height: 1.8rem;
|
||||
border-radius: 0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row:hover {
|
||||
background: #000078;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-size {
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row:hover .archive-node-size {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-chevron {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-folder[open] > summary .archive-chevron {
|
||||
color: #000078;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row:hover .archive-chevron,
|
||||
[data-theme="retro"] .archive-node-row:hover .archive-file-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-chevron svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-file-icon {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-svg-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-retro-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-empty,
|
||||
[data-theme="retro"] .archive-browser-legacy {
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
font-family: "PixelOperatorMono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.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 +561,7 @@
|
||||
.preview-placeholder[hidden],
|
||||
.default-preview[hidden],
|
||||
.native-preview[hidden],
|
||||
.archive-browser-preview[hidden],
|
||||
.large-preview-gate[hidden],
|
||||
.code-preview[hidden],
|
||||
.render-preview[hidden] {
|
||||
|
||||
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 B |
@@ -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,252 @@
|
||||
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 = '<span class="archive-svg-icon">' + archiveIconSVG(icon) + '</span>';
|
||||
var retroURL = archiveRetroIconURL(icon);
|
||||
if (retroURL) {
|
||||
var retro = document.createElement("img");
|
||||
retro.className = "archive-retro-icon";
|
||||
retro.src = retroURL;
|
||||
retro.alt = "";
|
||||
retro.decoding = "async";
|
||||
retro.loading = "lazy";
|
||||
element.appendChild(retro);
|
||||
}
|
||||
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 archiveRetroIconURL(icon) {
|
||||
var base = "/static/file-icons/retro/";
|
||||
var icons = {
|
||||
folder: "directory_open_file_mydocs-4.png",
|
||||
img: "shimgvw.dll_14_1-2.png",
|
||||
vid: "wmploc.dll_14_504-2.png",
|
||||
aud: "wmploc.dll_14_610-2.png",
|
||||
txt: "shell32.dll_14_151-2.png",
|
||||
code: "mshtml.dll_14_2660-2.png",
|
||||
arc: "zipfldr.dll_14_101-2.png",
|
||||
file: "shell32.dll_14_152-2.png"
|
||||
};
|
||||
return base + (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 +930,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user