diff --git a/backend/libs/handlers/ogimage.go b/backend/libs/handlers/ogimage.go index ac51ff8..bb73992 100644 --- a/backend/libs/handlers/ogimage.go +++ b/backend/libs/handlers/ogimage.go @@ -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/"): diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index 8faa19a..0ac4b91 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -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));