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}/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/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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