Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b278642dc | |||
| 3a0dd04e61 |
@@ -132,6 +132,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile)
|
||||||
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent)
|
||||||
|
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage)
|
||||||
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail)
|
||||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||||
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type fileView struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Size string
|
Size string
|
||||||
|
SizeBytes int64
|
||||||
ContentType string
|
ContentType string
|
||||||
PreviewKind string
|
PreviewKind string
|
||||||
URL string
|
URL string
|
||||||
@@ -103,13 +104,16 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box)
|
||||||
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
if isSocialPreviewBot(r) && !locked && len(box.Files) == 1 {
|
||||||
if box.Files[0].Processing {
|
file := box.Files[0]
|
||||||
|
if file.Processing {
|
||||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.serveFileContent(w, r, box, box.Files[0], false)
|
if shouldServeRawSocialMedia(file) {
|
||||||
a.logger.Info("single-file box served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", box.Files[0].ID)...)
|
a.serveFileContent(w, r, box, file, false)
|
||||||
return
|
a.logger.Info("single-file media served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2008, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
visitorID := a.reactionVisitorID(w, r)
|
visitorID := a.reactionVisitorID(w, r)
|
||||||
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID)
|
||||||
@@ -131,13 +135,25 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
||||||
title := "Shared files on Warpbox"
|
title := "Shared files on Warpbox"
|
||||||
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
||||||
|
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
||||||
|
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
|
||||||
|
imageType := "image/jpeg"
|
||||||
|
if !locked && len(box.Files) == 1 && !box.Files[0].Processing {
|
||||||
|
file := box.Files[0]
|
||||||
|
view := a.fileView(box, file)
|
||||||
|
fileSize := helpers.FormatBytes(file.Size)
|
||||||
|
title = file.Name
|
||||||
|
description = fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||||
|
ogImage = socialImageURL(r, box, file, view)
|
||||||
|
imageAlt = fmt.Sprintf("Download card for %s", file.Name)
|
||||||
|
imageType = socialImageType(file)
|
||||||
|
}
|
||||||
if locked && box.Obfuscate {
|
if locked && box.Obfuscate {
|
||||||
title = "Protected Warpbox link"
|
title = "Protected Warpbox link"
|
||||||
description = "This shared box is password protected."
|
description = "This shared box is password protected."
|
||||||
}
|
}
|
||||||
|
|
||||||
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
|
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s", box.ID))
|
||||||
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
|
||||||
|
|
||||||
// All user uploads are private/temporary — noindex by default.
|
// All user uploads are private/temporary — noindex by default.
|
||||||
robots := web.RobotsNone
|
robots := web.RobotsNone
|
||||||
@@ -148,7 +164,8 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
CanonicalURL: pageURL,
|
CanonicalURL: pageURL,
|
||||||
Robots: robots,
|
Robots: robots,
|
||||||
ImageURL: ogImage,
|
ImageURL: ogImage,
|
||||||
ImageAlt: fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files))),
|
ImageAlt: imageAlt,
|
||||||
|
ImageType: imageType,
|
||||||
Data: downloadPageData{
|
Data: downloadPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
Files: files,
|
Files: files,
|
||||||
@@ -171,6 +188,43 @@ func plural(n int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldServeRawSocialMedia(file services.File) bool {
|
||||||
|
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileShareDescription(size, contentType string, expiresAt time.Time) string {
|
||||||
|
if strings.TrimSpace(contentType) == "" {
|
||||||
|
contentType = "file"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s · %s · click to preview or download · expires %s", size, contentType, boxExpiryLabel(expiresAt, "Jan 2, 2006"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
|
if file.PreviewKind == "video" && view.HasThumbnail {
|
||||||
|
return absoluteURL(r, view.ThumbnailURL)
|
||||||
|
}
|
||||||
|
return absoluteURL(r, fmt.Sprintf("/d/%s/f/%s/og-image.jpg", box.ID, file.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialImageType(file services.File) string {
|
||||||
|
if file.PreviewKind == "image" {
|
||||||
|
return file.ContentType
|
||||||
|
}
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
func socialOGType(file services.File) string {
|
||||||
|
switch file.PreviewKind {
|
||||||
|
case "video":
|
||||||
|
return "video.other"
|
||||||
|
default:
|
||||||
|
return "website"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||||
box, file, ok := a.loadFileForRequest(w, r)
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -183,21 +237,30 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.serveFileContent(w, r, box, file, false)
|
if shouldServeRawSocialMedia(file) {
|
||||||
a.logger.Info("file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
|
a.serveFileContent(w, r, box, file, false)
|
||||||
return
|
a.logger.Info("media file served inline for social preview", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2009, "box_id", box.ID, "file_id", file.ID)...)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
view := a.fileView(box, file)
|
view := a.fileView(box, file)
|
||||||
fileSize := helpers.FormatBytes(file.Size)
|
fileSize := helpers.FormatBytes(file.Size)
|
||||||
title := file.Name
|
title := file.Name
|
||||||
description := fmt.Sprintf("%s · %s file shared via Warp Box", fileSize, file.ContentType)
|
description := fileShareDescription(fileSize, file.ContentType, box.ExpiresAt)
|
||||||
imageURL := absoluteURL(r, view.ThumbnailURL)
|
imageURL := socialImageURL(r, box, file, view)
|
||||||
imageAlt := fmt.Sprintf("Preview of %s", file.Name)
|
imageAlt := fmt.Sprintf("Download card for %s", file.Name)
|
||||||
|
ogType := socialOGType(file)
|
||||||
|
mediaURL := ""
|
||||||
|
if file.PreviewKind == "video" {
|
||||||
|
mediaURL = absoluteURL(r, view.DownloadURL+"?inline=1")
|
||||||
|
}
|
||||||
if locked && box.Obfuscate {
|
if locked && box.Obfuscate {
|
||||||
title = "Protected Warpbox file"
|
title = "Protected Warpbox file"
|
||||||
description = "This shared file is password protected."
|
description = "This shared file is password protected."
|
||||||
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
|
||||||
imageAlt = "Password protected file on Warp Box"
|
imageAlt = "Password protected file on Warp Box"
|
||||||
|
ogType = "website"
|
||||||
|
mediaURL = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID))
|
pageURL := absoluteURL(r, fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID))
|
||||||
@@ -207,8 +270,12 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
Description: description,
|
Description: description,
|
||||||
CanonicalURL: pageURL,
|
CanonicalURL: pageURL,
|
||||||
Robots: web.RobotsNone,
|
Robots: web.RobotsNone,
|
||||||
|
OGType: ogType,
|
||||||
ImageURL: imageURL,
|
ImageURL: imageURL,
|
||||||
ImageAlt: imageAlt,
|
ImageAlt: imageAlt,
|
||||||
|
ImageType: socialImageType(file),
|
||||||
|
MediaURL: mediaURL,
|
||||||
|
MediaType: file.ContentType,
|
||||||
Data: previewPageData{
|
Data: previewPageData{
|
||||||
Box: boxView{ID: box.ID},
|
Box: boxView{ID: box.ID},
|
||||||
File: view,
|
File: view,
|
||||||
@@ -404,6 +471,7 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
|
|||||||
ID: file.ID,
|
ID: file.ID,
|
||||||
Name: file.Name,
|
Name: file.Name,
|
||||||
Size: helpers.FormatBytes(file.Size),
|
Size: helpers.FormatBytes(file.Size),
|
||||||
|
SizeBytes: file.Size,
|
||||||
ContentType: file.ContentType,
|
ContentType: file.ContentType,
|
||||||
PreviewKind: file.PreviewKind,
|
PreviewKind: file.PreviewKind,
|
||||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
@@ -11,10 +12,18 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/font/opentype"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
|
"warpbox.dev/backend/libs/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Open Graph image dimensions recommended for large summary cards
|
// Open Graph image dimensions recommended for large summary cards
|
||||||
@@ -74,6 +83,22 @@ func (a *App) BoxOGImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.serveOGImage(w, r, renderCollage(thumbs))
|
a.serveOGImage(w, r, renderCollage(thumbs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileOGImage renders a branded card for files that should not be served as raw
|
||||||
|
// media to social preview bots: text, Markdown, HTML, PDF, audio, archives, etc.
|
||||||
|
func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
box, file, ok := a.loadFileForRequest(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||||
|
a.serveOGImage(w, r, a.ogPlaceholder())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
icon := a.ogFileIcon(file)
|
||||||
|
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||||
@@ -115,6 +140,158 @@ func (a *App) ogPlaceholder() image.Image {
|
|||||||
return canvas
|
return canvas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) ogFileIcon(file services.File) image.Image {
|
||||||
|
if a.fileIcons == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
icon := a.fileIcons.lookup(file.Name, file.ContentType)
|
||||||
|
if icon.Retro == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
path := filepath.Join(a.cfg.StaticDir, "file-icons", "retro", icon.Retro)
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
img, _, err := image.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderFileCard(file services.File, icon image.Image) 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, 72, ogImageWidth-70, ogImageHeight-72)
|
||||||
|
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(44, true)
|
||||||
|
bodyFace := a.ogFont(28, false)
|
||||||
|
metaFace := a.ogFont(24, false)
|
||||||
|
buttonFace := a.ogFont(26, true)
|
||||||
|
|
||||||
|
if icon != nil {
|
||||||
|
xdraw.NearestNeighbor.Scale(canvas, image.Rect(110, 142, 230, 262), icon, icon.Bounds(), xdraw.Over, nil)
|
||||||
|
} else {
|
||||||
|
draw.Draw(canvas, image.Rect(110, 142, 230, 262), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLines := wrapOGText(file.Name, titleFace, 850)
|
||||||
|
if len(titleLines) > 2 {
|
||||||
|
titleLines = titleLines[:2]
|
||||||
|
titleLines[1] = trimOGText(titleLines[1], titleFace, 850)
|
||||||
|
}
|
||||||
|
y := 156
|
||||||
|
for _, line := range titleLines {
|
||||||
|
drawOGText(canvas, titleFace, line, 265, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
y += 52
|
||||||
|
}
|
||||||
|
|
||||||
|
size := helpers.FormatBytes(file.Size)
|
||||||
|
typeLabel := strings.TrimSpace(file.ContentType)
|
||||||
|
if typeLabel == "" {
|
||||||
|
typeLabel = "application/octet-stream"
|
||||||
|
}
|
||||||
|
drawOGText(canvas, bodyFace, fmt.Sprintf("%s · %s", size, typeLabel), 265, y+12, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
|
||||||
|
info := fileCardInfo(file)
|
||||||
|
for i, line := range wrapOGText(info, metaFace, 900) {
|
||||||
|
if i >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
drawOGText(canvas, metaFace, line, 110, 355+i*34, color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
button := image.Rect(110, 474, 430, 548)
|
||||||
|
draw.Draw(canvas, button, &image.Uniform{color.RGBA{R: 0x8b, G: 0x5c, B: 0xf6, A: 0xff}}, image.Point{}, draw.Src)
|
||||||
|
drawOGText(canvas, buttonFace, "Click to download", 142, 520, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||||
|
drawOGText(canvas, metaFace, "warpbox.dev", 910, 520, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||||
|
return canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileCardInfo(file services.File) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(file.ContentType, "audio/"):
|
||||||
|
return "Audio file shared through Warpbox. Open the link to preview in your browser or download the original."
|
||||||
|
case file.ContentType == "text/markdown":
|
||||||
|
return "Markdown file shared through Warpbox. Open the link to view the rendered preview, source, or download."
|
||||||
|
case strings.Contains(file.ContentType, "html"):
|
||||||
|
return "HTML file shared through Warpbox. Open the link to preview rendered HTML, source, or download."
|
||||||
|
case strings.Contains(file.ContentType, "pdf"):
|
||||||
|
return "PDF file shared through Warpbox. Open the link to download the original file."
|
||||||
|
case strings.HasPrefix(file.ContentType, "text/"):
|
||||||
|
return "Text file shared through Warpbox. Open the link to preview the content or download."
|
||||||
|
default:
|
||||||
|
return "File shared through Warpbox. Open the link to preview available details or download the original."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ogFont(size float64, bold bool) font.Face {
|
||||||
|
name := "PixeloidSans.ttf"
|
||||||
|
if bold {
|
||||||
|
name = "PixeloidSans-Bold.ttf"
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(a.cfg.StaticDir, "fonts", "pixeloid_sans", name))
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
parsed, err := opentype.Parse(data)
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
|
||||||
|
if err != nil {
|
||||||
|
return basicfont.Face7x13
|
||||||
|
}
|
||||||
|
return face
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawOGText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapOGText(text string, face font.Face, maxWidth int) []string {
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := words[0]
|
||||||
|
for _, word := range words[1:] {
|
||||||
|
next := current + " " + word
|
||||||
|
if ogTextWidth(face, next) <= maxWidth {
|
||||||
|
current = next
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, current)
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
lines = append(lines, current)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimOGText(text string, face font.Face, maxWidth int) string {
|
||||||
|
for ogTextWidth(face, text+"...") > maxWidth && len(text) > 1 {
|
||||||
|
text = text[:len(text)-1]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func ogTextWidth(face font.Face, text string) int {
|
||||||
|
bounds, _ := font.BoundString(face, text)
|
||||||
|
return (bounds.Max.X - bounds.Min.X).Ceil()
|
||||||
|
}
|
||||||
|
|
||||||
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
// renderCollage tiles up to four thumbnails into the OG canvas with a small gap.
|
||||||
func renderCollage(thumbs []image.Image) image.Image {
|
func renderCollage(thumbs []image.Image) image.Image {
|
||||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
payload := uploadThroughApp(t, app)
|
payload := uploadThroughApp(t, app)
|
||||||
@@ -120,15 +120,16 @@ func TestSocialPreviewBotGetsRawSingleFileBox(t *testing.T) {
|
|||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(response.Body.String(), "Shared files on Warpbox") {
|
body := response.Body.String()
|
||||||
t.Fatalf("social preview bot received HTML download page")
|
if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
}
|
}
|
||||||
if response.Body.String() != "hello" {
|
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||||
t.Fatalf("social preview body = %q", response.Body.String())
|
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
|
||||||
app, cleanup := newTestApp(t)
|
app, cleanup := newTestApp(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
payload := uploadThroughApp(t, app)
|
payload := uploadThroughApp(t, app)
|
||||||
@@ -143,11 +144,74 @@ func TestSocialPreviewBotGetsRawFilePreview(t *testing.T) {
|
|||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(response.Body.String(), "preview-title") {
|
body := response.Body.String()
|
||||||
t.Fatalf("social preview bot received HTML preview page")
|
if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
|
||||||
|
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
|
||||||
}
|
}
|
||||||
if response.Body.String() != "hello" {
|
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
|
||||||
t.Fatalf("social preview body = %q", response.Body.String())
|
t.Fatalf("social preview body missing twitter card metadata: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
|
||||||
|
request.Header.Set("Accept", "application/json")
|
||||||
|
uploadResponse := httptest.NewRecorder()
|
||||||
|
app.Upload(uploadResponse, request)
|
||||||
|
if uploadResponse.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
|
||||||
|
}
|
||||||
|
var payload services.UploadResult
|
||||||
|
if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
|
||||||
|
previewRequest.SetPathValue("boxID", payload.BoxID)
|
||||||
|
previewRequest.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, previewRequest)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(response.Body.String(), "preview-title") {
|
||||||
|
t.Fatalf("image social preview bot received HTML preview page")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
|
||||||
|
t.Fatalf("image social preview body = %q", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
|
||||||
|
app, cleanup := newTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
payload := uploadThroughApp(t, app)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
|
||||||
|
request.SetPathValue("boxID", payload.BoxID)
|
||||||
|
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
app.DownloadFile(response, request)
|
||||||
|
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
body := response.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`data-size-bytes="5"`,
|
||||||
|
`data-source-url="/d/` + payload.BoxID,
|
||||||
|
`data-download-url="/d/` + payload.BoxID,
|
||||||
|
`data-icon-url="/static/file-icons/`,
|
||||||
|
`data-preview-tabs`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Fatalf("preview page missing %q: %s", want, body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ package jobs
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"html"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -131,7 +138,7 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func needsThumbnail(file services.File) bool {
|
func needsThumbnail(file services.File) bool {
|
||||||
return file.PreviewKind == "image" || file.PreviewKind == "video"
|
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||||
@@ -157,11 +164,39 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
|||||||
}
|
}
|
||||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
return thumbnailName, err
|
return thumbnailName, err
|
||||||
|
case isTextThumbnailCandidate(file):
|
||||||
|
data, err := createTextThumbnail(file, object.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||||
|
return thumbnailName, err
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTextThumbnailCandidate(file services.File) bool {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "text/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch contentType {
|
||||||
|
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
switch ext {
|
||||||
|
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||||
img, _, err := image.Decode(source)
|
img, _, err := image.Decode(source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,6 +238,197 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
|||||||
return os.ReadFile(targetPath)
|
return os.ReadFile(targetPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||||
|
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
|
||||||
|
|
||||||
|
mode := textThumbnailMode(file)
|
||||||
|
title := strings.ToUpper(mode)
|
||||||
|
var lines []string
|
||||||
|
if mode == "HTML" {
|
||||||
|
lines = renderedHTMLThumbnailLines(sourceText)
|
||||||
|
} else if mode == "MARKDOWN" {
|
||||||
|
lines = renderedMarkdownThumbnailLines(sourceText)
|
||||||
|
} else {
|
||||||
|
title = "CODE"
|
||||||
|
lines = codeThumbnailLines(sourceText)
|
||||||
|
}
|
||||||
|
return renderTextThumbnail(file.Name, title, lines), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textThumbnailMode(file services.File) string {
|
||||||
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||||
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||||
|
contentType = strings.TrimSpace(contentType[:i])
|
||||||
|
}
|
||||||
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||||
|
if ext == "html" || ext == "htm" || contentType == "text/html" {
|
||||||
|
return "HTML"
|
||||||
|
}
|
||||||
|
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
|
||||||
|
return "MARKDOWN"
|
||||||
|
}
|
||||||
|
return "CODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedHTMLThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
|
||||||
|
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
|
||||||
|
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
|
||||||
|
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
|
||||||
|
text = html.UnescapeString(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderedMarkdownThumbnailLines(source string) []string {
|
||||||
|
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
|
||||||
|
block = strings.Trim(block, "` \n\t")
|
||||||
|
lines := strings.Split(block, "\n")
|
||||||
|
if len(lines) > 1 {
|
||||||
|
lines = lines[1:]
|
||||||
|
}
|
||||||
|
return "\n" + strings.Join(lines, "\n") + "\n"
|
||||||
|
})
|
||||||
|
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
|
||||||
|
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
||||||
|
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
||||||
|
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
|
||||||
|
return documentThumbnailLines(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentThumbnailLines(source string) []string {
|
||||||
|
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 9)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, line := range wrapTextThumbnailLine(raw, 43) {
|
||||||
|
lines = append(lines, line)
|
||||||
|
if len(lines) >= 9 {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"Rendered preview is empty."}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func codeThumbnailLines(source string) []string {
|
||||||
|
rawLines := strings.Split(source, "\n")
|
||||||
|
lines := make([]string, 0, 10)
|
||||||
|
for _, raw := range rawLines {
|
||||||
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
||||||
|
raw = strings.TrimRight(raw, " ")
|
||||||
|
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(raw) > 48 {
|
||||||
|
raw = raw[:45] + "..."
|
||||||
|
}
|
||||||
|
lines = append(lines, raw)
|
||||||
|
if len(lines) >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return []string{"(empty file)"}
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTextThumbnail(name, mode string, lines []string) []byte {
|
||||||
|
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
|
||||||
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
|
||||||
|
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
|
||||||
|
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||||
|
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
|
||||||
|
|
||||||
|
codePane := image.Rect(22, 72, 338, 210)
|
||||||
|
if mode == "CODE" {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
|
||||||
|
} else {
|
||||||
|
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 91
|
||||||
|
for _, line := range lines {
|
||||||
|
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
|
||||||
|
y += 14
|
||||||
|
if y > 202 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var target bytes.Buffer
|
||||||
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
|
||||||
|
return target.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
|
||||||
|
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
||||||
|
d := font.Drawer{
|
||||||
|
Dst: dst,
|
||||||
|
Src: image.NewUniform(c),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapTextThumbnailLine(text string, maxChars int) []string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return []string{text[:maxChars-3] + "..."}
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
current := ""
|
||||||
|
for _, word := range words {
|
||||||
|
if current == "" {
|
||||||
|
current = word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(current)+1+len(word) <= maxChars {
|
||||||
|
current += " " + word
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
current = word
|
||||||
|
}
|
||||||
|
if current != "" {
|
||||||
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimThumbnailText(text string, maxChars int) string {
|
||||||
|
if len(text) <= maxChars {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if maxChars <= 3 {
|
||||||
|
return text[:maxChars]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text[:maxChars-3]) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
||||||
bounds := src.Bounds()
|
bounds := src.Bounds()
|
||||||
width := bounds.Dx()
|
width := bounds.Dx()
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
@@ -46,6 +48,30 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
|
||||||
|
data, err := createTextThumbnail(services.File{
|
||||||
|
Name: "notes.md",
|
||||||
|
ContentType: "text/markdown",
|
||||||
|
}, strings.NewReader("# Meeting notes\n\n```go\nfunc main() {}\n```\n\nA rendered Markdown preview."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createTextThumbnail returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||||
|
}
|
||||||
|
if img.Bounds().Dx() != 360 || img.Bounds().Dy() != 240 {
|
||||||
|
t.Fatalf("thumbnail size = %dx%d, want 360x240", img.Bounds().Dx(), img.Bounds().Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
|
||||||
|
if !needsThumbnail(services.File{Name: "main.go", ContentType: "text/plain"}) {
|
||||||
|
t.Fatalf("Go source file should get a text thumbnail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ func SecurityHeaders(next http.Handler) http.Handler {
|
|||||||
header.Set("X-Frame-Options", "DENY")
|
header.Set("X-Frame-Options", "DENY")
|
||||||
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'")
|
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-src 'self' about:; base-uri 'self'; frame-ancestors 'none'")
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ type PageData struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
CanonicalURL string
|
CanonicalURL string
|
||||||
Robots string
|
Robots string
|
||||||
|
OGType string
|
||||||
Title string
|
Title string
|
||||||
Description string
|
Description string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
ImageAlt string
|
ImageAlt string
|
||||||
|
ImageType string
|
||||||
|
MediaURL string
|
||||||
|
MediaType string
|
||||||
CurrentYear int
|
CurrentYear int
|
||||||
CurrentUser any
|
CurrentUser any
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
|
|||||||
@@ -15,6 +15,374 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-view {
|
||||||
|
width: min(72rem, calc(100% - 2rem));
|
||||||
|
min-height: auto;
|
||||||
|
padding-block: clamp(2rem, 7vh, 4.5rem);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card .card-content {
|
||||||
|
padding: clamp(1rem, 2.4vw, 1.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header .file-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.35rem, 2.4vw, 2rem);
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header .download-subtitle {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in srgb, var(--card) 94%, transparent), color-mix(in srgb, var(--background) 92%, transparent));
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar {
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.72rem 0.9rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--muted) 62%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar > div:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar strong {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar span {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-tools {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fullscreen-button {
|
||||||
|
appearance: none;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 82%, var(--primary));
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: color-mix(in srgb, var(--muted) 74%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fullscreen-button:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 18%, var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fullscreen-button[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window-actions span {
|
||||||
|
width: 0.72rem;
|
||||||
|
height: 0.72rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 75%, var(--foreground));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--card) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tabs[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab {
|
||||||
|
appearance: none;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: calc(var(--radius) - 0.35rem);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-tab:hover,
|
||||||
|
.preview-tab.is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--border) 82%, var(--primary));
|
||||||
|
background: color-mix(in srgb, var(--muted) 78%, transparent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: clamp(18rem, 64vh, 38rem);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, color-mix(in srgb, var(--muted) 18%, transparent) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, color-mix(in srgb, var(--muted) 18%, transparent) 25%, transparent 25%),
|
||||||
|
color-mix(in srgb, var(--background) 88%, #000);
|
||||||
|
background-position: 0 0, 0.5rem 0.5rem;
|
||||||
|
background-size: 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > img,
|
||||||
|
.preview-stage > video {
|
||||||
|
max-height: clamp(18rem, 64vh, 38rem);
|
||||||
|
width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > audio {
|
||||||
|
width: min(42rem, calc(100% - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview,
|
||||||
|
.large-preview-gate {
|
||||||
|
width: min(26rem, calc(100% - 2rem));
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview img {
|
||||||
|
width: 5.5rem;
|
||||||
|
height: 5.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview strong {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-preview span {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 82%, var(--danger));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: color-mix(in srgb, var(--card) 92%, #000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate strong {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-preview-gate div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.native-audio-preview {
|
||||||
|
align-self: center;
|
||||||
|
width: min(42rem, calc(100% - 2rem));
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder[hidden],
|
||||||
|
.default-preview[hidden],
|
||||||
|
.native-preview[hidden],
|
||||||
|
.large-preview-gate[hidden],
|
||||||
|
.code-preview[hidden],
|
||||||
|
.render-preview[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder img {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
overflow: auto;
|
||||||
|
background: #1b1724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview pre[class*="language-"] {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow: visible;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview pre {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
color: #f5f3ff;
|
||||||
|
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview pre[class*="language-"] > code {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview code[class*="language-"] {
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-preview .token.punctuation {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(18rem, 64vh, 38rem);
|
||||||
|
border: 0;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window:fullscreen,
|
||||||
|
.preview-window.is-render-fullscreen {
|
||||||
|
width: 100dvw;
|
||||||
|
height: 100dvh;
|
||||||
|
max-width: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window.is-render-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window:fullscreen .preview-stage,
|
||||||
|
.preview-window.is-render-fullscreen .preview-stage {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
place-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-window:fullscreen .render-preview,
|
||||||
|
.preview-window.is-render-fullscreen .render-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.file-emblem {
|
.file-emblem {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
height: 4rem;
|
height: 4rem;
|
||||||
@@ -801,23 +1169,36 @@ html.reaction-picker-open body {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-stage {
|
@media (max-width: 720px) {
|
||||||
overflow: hidden;
|
.preview-view {
|
||||||
margin-bottom: 1rem;
|
width: min(100%, calc(100% - 1rem));
|
||||||
border: 1px solid var(--border);
|
padding-block: 1rem;
|
||||||
border-radius: var(--radius);
|
}
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stage img,
|
.preview-header {
|
||||||
.preview-stage video {
|
flex-direction: column;
|
||||||
width: 100%;
|
align-items: stretch;
|
||||||
max-height: 55vh;
|
}
|
||||||
display: block;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-stage audio {
|
.preview-header .button {
|
||||||
width: calc(100% - 2rem);
|
justify-content: center;
|
||||||
margin: 1rem;
|
}
|
||||||
|
|
||||||
|
.preview-window-titlebar > div:first-child {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage,
|
||||||
|
.code-preview,
|
||||||
|
.render-preview,
|
||||||
|
.native-preview {
|
||||||
|
min-height: 18rem;
|
||||||
|
height: min(60vh, 32rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-stage > img,
|
||||||
|
.preview-stage > video {
|
||||||
|
max-height: min(60vh, 32rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
299
backend/static/css/80-markdown-preview.css
Normal file
299
backend/static/css/80-markdown-preview.css
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--md-bg: #0b0b16;
|
||||||
|
--md-fg: #f5f3ff;
|
||||||
|
--md-muted: #aaa4d6;
|
||||||
|
--md-panel: #17142d;
|
||||||
|
--md-panel-2: #211b3e;
|
||||||
|
--md-border: rgba(168, 150, 255, 0.24);
|
||||||
|
--md-link: #67e8f9;
|
||||||
|
--md-accent: #a78bfa;
|
||||||
|
--md-code-bg: #1b1724;
|
||||||
|
--md-block-code-bg: #0f111a;
|
||||||
|
--md-block-code-fg: #f8fafc;
|
||||||
|
--md-block-code-border: rgba(248, 250, 252, 0.16);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.28);
|
||||||
|
--md-font: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--md-mono: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="classic"] {
|
||||||
|
--md-bg: #09090b;
|
||||||
|
--md-fg: #fafafa;
|
||||||
|
--md-muted: #a1a1aa;
|
||||||
|
--md-panel: #18181b;
|
||||||
|
--md-panel-2: #27272a;
|
||||||
|
--md-border: rgba(255, 255, 255, 0.13);
|
||||||
|
--md-link: #e4e4e7;
|
||||||
|
--md-accent: #d4d4d8;
|
||||||
|
--md-code-bg: #111113;
|
||||||
|
--md-block-code-bg: #09090b;
|
||||||
|
--md-block-code-fg: #fafafa;
|
||||||
|
--md-block-code-border: rgba(250, 250, 250, 0.15);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="retro"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--md-bg: #c0c0c0;
|
||||||
|
--md-fg: #000000;
|
||||||
|
--md-muted: #404040;
|
||||||
|
--md-panel: #ffffff;
|
||||||
|
--md-panel-2: #dfdfdf;
|
||||||
|
--md-border: #000000;
|
||||||
|
--md-link: #000078;
|
||||||
|
--md-accent: #000078;
|
||||||
|
--md-code-bg: #ffffff;
|
||||||
|
--md-block-code-bg: #000000;
|
||||||
|
--md-block-code-fg: #f5f5f5;
|
||||||
|
--md-block-code-border: #808080;
|
||||||
|
--md-shadow: transparent;
|
||||||
|
--md-font: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||||
|
--md-mono: "PixelOperatorMono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="gruvbox"] {
|
||||||
|
--md-bg: #1d2021;
|
||||||
|
--md-fg: #ebdbb2;
|
||||||
|
--md-muted: #bdae93;
|
||||||
|
--md-panel: #282828;
|
||||||
|
--md-panel-2: #32302f;
|
||||||
|
--md-border: rgba(235, 219, 178, 0.2);
|
||||||
|
--md-link: #fabd2f;
|
||||||
|
--md-accent: #d79921;
|
||||||
|
--md-code-bg: #1b1d1e;
|
||||||
|
--md-block-code-bg: #161819;
|
||||||
|
--md-block-code-fg: #fbf1c7;
|
||||||
|
--md-block-code-border: rgba(251, 241, 199, 0.18);
|
||||||
|
--md-shadow: rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="cyberpunk"] {
|
||||||
|
--md-bg: #08070d;
|
||||||
|
--md-fg: #fff36f;
|
||||||
|
--md-muted: #9bfaff;
|
||||||
|
--md-panel: #16131f;
|
||||||
|
--md-panel-2: #251d34;
|
||||||
|
--md-border: rgba(255, 242, 0, 0.34);
|
||||||
|
--md-link: #00f0ff;
|
||||||
|
--md-accent: #ff2a6d;
|
||||||
|
--md-code-bg: #100d18;
|
||||||
|
--md-block-code-bg: #07060b;
|
||||||
|
--md-block-code-fg: #f8fafc;
|
||||||
|
--md-block-code-border: rgba(0, 240, 255, 0.26);
|
||||||
|
--md-shadow: rgba(255, 42, 109, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixeloidSans";
|
||||||
|
src: url("/static/fonts/pixeloid_sans/PixeloidSans-Bold.ttf") format("truetype");
|
||||||
|
font-weight: bold;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "PixelOperatorMono";
|
||||||
|
src: url("/static/fonts/pixel_operator/PixelOperatorMono.ttf") format("truetype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% -10%, color-mix(in srgb, var(--md-accent) 18%, transparent), transparent 24rem),
|
||||||
|
var(--md-bg);
|
||||||
|
color: var(--md-fg);
|
||||||
|
font-family: var(--md-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] {
|
||||||
|
background-color: #000000;
|
||||||
|
background-image: url("/static/backgrounds/stars1.gif");
|
||||||
|
background-repeat: repeat;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="cyberpunk"] {
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px),
|
||||||
|
var(--md-bg);
|
||||||
|
background-size: 100% 3px, 3rem 100%, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(1rem, 4vw, 2.25rem);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 54rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--md-panel) 90%, transparent);
|
||||||
|
box-shadow: 0 20px 60px var(--md-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] main {
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--md-panel);
|
||||||
|
box-shadow:
|
||||||
|
inset -1px -1px 0 #404040,
|
||||||
|
inset 1px 1px 0 #ffffff,
|
||||||
|
inset -2px -2px 0 #808080,
|
||||||
|
inset 2px 2px 0 #dfdfdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="cyberpunk"] main {
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.5), 0 0 24px rgba(0, 240, 255, 0.12);
|
||||||
|
clip-path: polygon(0 0, calc(100% - 0.9rem) 0, 100% 0.9rem, 100% 100%, 0.9rem 100%, 0 calc(100% - 0.9rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 1.4em 0 0.55em;
|
||||||
|
color: var(--md-fg);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1:first-child,
|
||||||
|
h2:first-child,
|
||||||
|
h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(1.75rem, 5vw, 2.45rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--md-border);
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
pre,
|
||||||
|
table {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--md-link);
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--md-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] img,
|
||||||
|
html[data-theme="retro"] video {
|
||||||
|
border-radius: 0;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 1px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--md-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-left: 4px solid var(--md-accent);
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 58%, transparent);
|
||||||
|
color: var(--md-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--md-block-code-border) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--md-block-code-bg) !important;
|
||||||
|
color: var(--md-block-code-fg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--md-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code,
|
||||||
|
pre > code,
|
||||||
|
pre code[class*="language-"] {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:not(pre) > code {
|
||||||
|
padding: 0.12rem 0.28rem;
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: color-mix(in srgb, var(--md-code-bg) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="retro"] pre,
|
||||||
|
html[data-theme="retro"] :not(pre) > code {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--md-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 70%, transparent);
|
||||||
|
color: var(--md-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) td {
|
||||||
|
background: color-mix(in srgb, var(--md-panel-2) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--md-accent);
|
||||||
|
color: var(--md-bg);
|
||||||
|
}
|
||||||
716
backend/static/js/45-preview.js
Normal file
716
backend/static/js/45-preview.js
Normal file
@@ -0,0 +1,716 @@
|
|||||||
|
(function () {
|
||||||
|
var preview = document.querySelector("[data-source-url][data-file-name][data-content-type]");
|
||||||
|
if (!preview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var SMALL_TEXT_BYTES = 50 * 1024;
|
||||||
|
var LARGE_PREVIEW_BYTES = 500 * 1024;
|
||||||
|
|
||||||
|
var state = {
|
||||||
|
fileName: preview.dataset.fileName || "",
|
||||||
|
contentType: (preview.dataset.contentType || "").toLowerCase(),
|
||||||
|
previewKind: preview.dataset.previewKind || "",
|
||||||
|
sizeBytes: Number(preview.dataset.sizeBytes || 0),
|
||||||
|
sizeLabel: preview.dataset.fileSize || "",
|
||||||
|
sourceURL: preview.dataset.sourceUrl || "",
|
||||||
|
downloadURL: preview.dataset.downloadUrl || "",
|
||||||
|
iconURL: preview.dataset.iconUrl || "",
|
||||||
|
activeMode: "",
|
||||||
|
defaultMode: "default",
|
||||||
|
pendingMode: "",
|
||||||
|
textSource: "",
|
||||||
|
textLoaded: false,
|
||||||
|
rawLoaded: false,
|
||||||
|
prismLoaded: false,
|
||||||
|
renderLoaded: false,
|
||||||
|
renderFullscreenFallback: false,
|
||||||
|
confirmedLargeModes: {},
|
||||||
|
tabs: []
|
||||||
|
};
|
||||||
|
|
||||||
|
var els = {
|
||||||
|
tabs: preview.querySelector("[data-preview-tabs]"),
|
||||||
|
modeLabel: preview.querySelector("[data-preview-mode-label]"),
|
||||||
|
defaultPane: preview.querySelector("[data-default-preview]"),
|
||||||
|
imagePane: preview.querySelector("[data-image-preview]"),
|
||||||
|
videoPane: preview.querySelector("[data-video-preview]"),
|
||||||
|
browserAudioPane: preview.querySelector("[data-browser-audio-preview]"),
|
||||||
|
rawPane: preview.querySelector("[data-raw-preview]"),
|
||||||
|
rawOutput: preview.querySelector("[data-raw-output]"),
|
||||||
|
codePane: preview.querySelector("[data-code-preview]"),
|
||||||
|
codeOutput: preview.querySelector("[data-code-output]"),
|
||||||
|
renderPane: preview.querySelector("[data-render-preview]"),
|
||||||
|
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
|
||||||
|
gatePane: preview.querySelector("[data-large-preview-gate]"),
|
||||||
|
gateConfirm: preview.querySelector("[data-large-preview-confirm]"),
|
||||||
|
gateCancel: preview.querySelector("[data-large-preview-cancel]"),
|
||||||
|
placeholder: preview.querySelector("[data-preview-placeholder]")
|
||||||
|
};
|
||||||
|
|
||||||
|
var fileType = detectFileType();
|
||||||
|
state.tabs = buildTabs(fileType);
|
||||||
|
state.defaultMode = chooseDefaultMode(fileType, state.tabs);
|
||||||
|
|
||||||
|
bindLargeGate();
|
||||||
|
bindThemeChanges();
|
||||||
|
bindRenderFullscreen();
|
||||||
|
renderTabs();
|
||||||
|
selectMode(state.defaultMode);
|
||||||
|
|
||||||
|
function detectFileType() {
|
||||||
|
var extension = extensionFor(state.fileName);
|
||||||
|
var baseType = state.contentType.split(";")[0].trim();
|
||||||
|
var language = languageFor(extension, baseType);
|
||||||
|
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;
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension: extension,
|
||||||
|
baseType: baseType,
|
||||||
|
language: language,
|
||||||
|
isTextLike: Boolean(language),
|
||||||
|
isHTML: language === "html",
|
||||||
|
isMarkdown: language === "markdown",
|
||||||
|
isImage: isImage,
|
||||||
|
isVideo: isVideo,
|
||||||
|
isAudio: isAudio,
|
||||||
|
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTabs(type) {
|
||||||
|
var tabs = [{ mode: "default", label: "Default" }];
|
||||||
|
|
||||||
|
if (type.isImage) {
|
||||||
|
tabs.push({ mode: "image", label: "Image Preview" });
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.isVideo) {
|
||||||
|
tabs.push({ mode: "video", label: "Video Preview" });
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.isAudio) {
|
||||||
|
tabs.push({ mode: "browser-audio", label: "Browser Preview" });
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type.isTextLike) {
|
||||||
|
if (type.isHTML || type.isMarkdown) {
|
||||||
|
tabs.push({ mode: "render", label: "Render Preview" });
|
||||||
|
}
|
||||||
|
tabs.push({ mode: "raw", label: "Raw Preview" });
|
||||||
|
tabs.push({ mode: "code", label: "Code Preview" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDefaultMode(type, tabs) {
|
||||||
|
if (type.isImage) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (type.isVideo) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||||
|
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||||
|
return "browser-audio";
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
if (type.isAudio) {
|
||||||
|
return "browser-audio";
|
||||||
|
}
|
||||||
|
if (type.isTextLike && state.sizeBytes > SMALL_TEXT_BYTES) {
|
||||||
|
return "raw";
|
||||||
|
}
|
||||||
|
if (type.isMarkdown) {
|
||||||
|
return "render";
|
||||||
|
}
|
||||||
|
if (type.isTextLike) {
|
||||||
|
return "code";
|
||||||
|
}
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabs() {
|
||||||
|
if (!els.tabs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.tabs.innerHTML = "";
|
||||||
|
state.tabs.forEach(function (tab) {
|
||||||
|
var button = document.createElement("button");
|
||||||
|
button.className = "preview-tab";
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.previewTab = tab.mode;
|
||||||
|
button.textContent = tab.label;
|
||||||
|
button.addEventListener("click", function () {
|
||||||
|
selectMode(tab.mode);
|
||||||
|
});
|
||||||
|
els.tabs.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMode(mode) {
|
||||||
|
if (!hasMode(state.tabs, mode)) {
|
||||||
|
mode = "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== "render") {
|
||||||
|
exitRenderFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresLargeConfirmation(mode)) {
|
||||||
|
showLargeGate(mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.activeMode = mode;
|
||||||
|
updateTabs(mode);
|
||||||
|
updateRenderFullscreenButton();
|
||||||
|
hideAllPanes();
|
||||||
|
setModeLabel(labelForMode(mode));
|
||||||
|
|
||||||
|
if (mode === "default") {
|
||||||
|
show(els.defaultPane);
|
||||||
|
} else if (mode === "image") {
|
||||||
|
show(els.imagePane);
|
||||||
|
} else if (mode === "video") {
|
||||||
|
show(els.videoPane);
|
||||||
|
} else if (mode === "browser-audio") {
|
||||||
|
show(els.browserAudioPane);
|
||||||
|
} else if (mode === "raw") {
|
||||||
|
show(els.rawPane);
|
||||||
|
ensureRawPreview();
|
||||||
|
} else if (mode === "code") {
|
||||||
|
show(els.codePane);
|
||||||
|
ensurePrismPreview();
|
||||||
|
} else if (mode === "render") {
|
||||||
|
show(els.renderPane);
|
||||||
|
if (fileType.isMarkdown) {
|
||||||
|
ensureMarkdownRenderPreview();
|
||||||
|
} else {
|
||||||
|
ensureHTMLRenderPreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiresLargeConfirmation(mode) {
|
||||||
|
if (state.sizeBytes <= LARGE_PREVIEW_BYTES || state.confirmedLargeModes[mode]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return mode === "raw" || mode === "code" || mode === "render";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLargeGate(mode) {
|
||||||
|
state.pendingMode = mode;
|
||||||
|
updateTabs(state.activeMode || state.defaultMode);
|
||||||
|
updateRenderFullscreenButton(false);
|
||||||
|
hideAllPanes();
|
||||||
|
show(els.gatePane);
|
||||||
|
setModeLabel("Large preview");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindLargeGate() {
|
||||||
|
if (els.gateConfirm) {
|
||||||
|
els.gateConfirm.addEventListener("click", function () {
|
||||||
|
if (!state.pendingMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.confirmedLargeModes[state.pendingMode] = true;
|
||||||
|
selectMode(state.pendingMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (els.gateCancel) {
|
||||||
|
els.gateCancel.addEventListener("click", function () {
|
||||||
|
state.pendingMode = "";
|
||||||
|
selectMode(state.activeMode || state.defaultMode || "default");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindThemeChanges() {
|
||||||
|
var themeSelect = document.querySelector("[data-theme-select]");
|
||||||
|
if (!themeSelect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
themeSelect.addEventListener("change", function () {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
if (!fileType.isMarkdown || !state.renderLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.renderLoaded = false;
|
||||||
|
if (state.activeMode === "render") {
|
||||||
|
ensureMarkdownRenderPreview();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRenderFullscreen() {
|
||||||
|
if (!els.fullscreenButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.fullscreenButton.addEventListener("click", function () {
|
||||||
|
if (isRenderFullscreen()) {
|
||||||
|
exitRenderFullscreen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enterRenderFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("fullscreenchange", updateRenderFullscreenButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTextLoaded() {
|
||||||
|
if (state.textLoaded) {
|
||||||
|
return Promise.resolve(state.textSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading("Loading preview...");
|
||||||
|
return fetch(state.sourceURL, { credentials: "same-origin" })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Preview failed");
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(function (source) {
|
||||||
|
state.textSource = source;
|
||||||
|
state.textLoaded = true;
|
||||||
|
hide(els.placeholder);
|
||||||
|
return source;
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
showError("Preview unavailable");
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRawPreview() {
|
||||||
|
if (state.rawLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTextLoaded().then(function (source) {
|
||||||
|
els.rawOutput.textContent = source;
|
||||||
|
state.rawLoaded = true;
|
||||||
|
if (state.activeMode === "raw") {
|
||||||
|
hide(els.placeholder);
|
||||||
|
show(els.rawPane);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePrismPreview() {
|
||||||
|
if (state.prismLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading("Loading syntax preview...");
|
||||||
|
Promise.all([ensureTextLoaded(), loadPrism()])
|
||||||
|
.then(function (results) {
|
||||||
|
var source = results[0];
|
||||||
|
var language = fileType.language;
|
||||||
|
if (language === "json") {
|
||||||
|
source = formatJSON(source);
|
||||||
|
}
|
||||||
|
els.codeOutput.className = "language-" + language;
|
||||||
|
els.codeOutput.textContent = source;
|
||||||
|
if (window.Prism) {
|
||||||
|
window.Prism.highlightElement(els.codeOutput);
|
||||||
|
}
|
||||||
|
state.prismLoaded = true;
|
||||||
|
if (state.activeMode === "code") {
|
||||||
|
hide(els.placeholder);
|
||||||
|
show(els.codePane);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError("Syntax preview unavailable");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureHTMLRenderPreview() {
|
||||||
|
if (state.renderLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading("Rendering preview...");
|
||||||
|
ensureTextLoaded()
|
||||||
|
.then(function (source) {
|
||||||
|
els.renderPane.srcdoc = withBaseElement(source);
|
||||||
|
state.renderLoaded = true;
|
||||||
|
if (state.activeMode === "render") {
|
||||||
|
hide(els.placeholder);
|
||||||
|
show(els.renderPane);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError("Render preview unavailable");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureMarkdownRenderPreview() {
|
||||||
|
if (state.renderLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading("Rendering Markdown...");
|
||||||
|
Promise.all([ensureTextLoaded(), loadMarkdownLibs()])
|
||||||
|
.then(function (results) {
|
||||||
|
var markdown = results[0];
|
||||||
|
var html = parseMarkdown(markdown);
|
||||||
|
var clean = window.DOMPurify.sanitize(html);
|
||||||
|
els.renderPane.srcdoc = markdownDocument(clean);
|
||||||
|
state.renderLoaded = true;
|
||||||
|
if (state.activeMode === "render") {
|
||||||
|
hide(els.placeholder);
|
||||||
|
show(els.renderPane);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError("Markdown preview unavailable");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(message) {
|
||||||
|
if (!els.placeholder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var text = els.placeholder.querySelector("p");
|
||||||
|
if (text) {
|
||||||
|
text.textContent = message;
|
||||||
|
}
|
||||||
|
show(els.placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
hideAllPanes();
|
||||||
|
var text = els.placeholder && els.placeholder.querySelector("p");
|
||||||
|
if (text) {
|
||||||
|
text.textContent = message;
|
||||||
|
}
|
||||||
|
show(els.placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAllPanes() {
|
||||||
|
hide(els.defaultPane);
|
||||||
|
hide(els.imagePane);
|
||||||
|
hide(els.videoPane);
|
||||||
|
hide(els.browserAudioPane);
|
||||||
|
hide(els.rawPane);
|
||||||
|
hide(els.codePane);
|
||||||
|
hide(els.renderPane);
|
||||||
|
hide(els.gatePane);
|
||||||
|
hide(els.placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterRenderFullscreen() {
|
||||||
|
if (state.activeMode !== "render") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.requestFullscreen) {
|
||||||
|
var request = preview.requestFullscreen();
|
||||||
|
if (request && typeof request.catch === "function") {
|
||||||
|
request.catch(function () {
|
||||||
|
state.renderFullscreenFallback = true;
|
||||||
|
preview.classList.add("is-render-fullscreen");
|
||||||
|
updateRenderFullscreenButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.renderFullscreenFallback = true;
|
||||||
|
preview.classList.add("is-render-fullscreen");
|
||||||
|
updateRenderFullscreenButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitRenderFullscreen() {
|
||||||
|
if (document.fullscreenElement === preview && document.exitFullscreen) {
|
||||||
|
var exit = document.exitFullscreen();
|
||||||
|
if (exit && typeof exit.catch === "function") {
|
||||||
|
exit.catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.renderFullscreenFallback = false;
|
||||||
|
preview.classList.remove("is-render-fullscreen");
|
||||||
|
updateRenderFullscreenButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderFullscreen() {
|
||||||
|
return document.fullscreenElement === preview || state.renderFullscreenFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRenderFullscreenButton(forceVisible) {
|
||||||
|
if (!els.fullscreenButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var visible = typeof forceVisible === "boolean" ? forceVisible : state.activeMode === "render";
|
||||||
|
els.fullscreenButton.hidden = !visible;
|
||||||
|
els.fullscreenButton.textContent = isRenderFullscreen() ? "Exit Full Screen" : "Full Screen";
|
||||||
|
els.fullscreenButton.setAttribute("aria-pressed", isRenderFullscreen() ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTabs(mode) {
|
||||||
|
if (!els.tabs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(els.tabs.querySelectorAll("[data-preview-tab]"), function (button) {
|
||||||
|
button.classList.toggle("is-active", button.dataset.previewTab === mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(element) {
|
||||||
|
if (element) {
|
||||||
|
element.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(element) {
|
||||||
|
if (element) {
|
||||||
|
element.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setModeLabel(label) {
|
||||||
|
if (els.modeLabel) {
|
||||||
|
els.modeLabel.textContent = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMode(tabs, mode) {
|
||||||
|
return tabs.some(function (tab) {
|
||||||
|
return tab.mode === mode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelForMode(mode) {
|
||||||
|
var labels = {
|
||||||
|
"default": "Default",
|
||||||
|
"image": "Image preview",
|
||||||
|
"video": "Video preview",
|
||||||
|
"browser-audio": "Browser preview",
|
||||||
|
"raw": "Raw preview",
|
||||||
|
"code": "Code preview",
|
||||||
|
"render": "Render preview"
|
||||||
|
};
|
||||||
|
return labels[mode] || "Preview";
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrism() {
|
||||||
|
if (window.Prism) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Prism = window.Prism || {};
|
||||||
|
window.Prism.manual = true;
|
||||||
|
return Promise.all([
|
||||||
|
loadStyle("/static/lib/prismjs/prism.css"),
|
||||||
|
loadScript("/static/lib/prismjs/prism.js")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMarkdownLibs() {
|
||||||
|
if (window.marked && window.DOMPurify) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
loadScript("/static/lib/markdown/marked.umd.js"),
|
||||||
|
loadScript("/static/lib/markdown/purify.min.js")
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var existing = document.querySelector('script[src="' + src + '"]');
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === "true") {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.addEventListener("load", resolve, { once: true });
|
||||||
|
existing.addEventListener("error", reject, { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var script = document.createElement("script");
|
||||||
|
script.async = true;
|
||||||
|
script.src = src;
|
||||||
|
script.addEventListener("load", function () {
|
||||||
|
script.dataset.loaded = "true";
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
script.addEventListener("error", reject, { once: true });
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStyle(href) {
|
||||||
|
if (document.querySelector('link[href="' + href + '"]')) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = href;
|
||||||
|
link.addEventListener("load", resolve, { once: true });
|
||||||
|
link.addEventListener("error", reject, { once: true });
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(source) {
|
||||||
|
if (window.marked && typeof window.marked.parse === "function") {
|
||||||
|
return window.marked.parse(source);
|
||||||
|
}
|
||||||
|
if (window.marked && window.marked.marked && typeof window.marked.marked.parse === "function") {
|
||||||
|
return window.marked.marked.parse(source);
|
||||||
|
}
|
||||||
|
throw new Error("Marked unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownDocument(body) {
|
||||||
|
var theme = currentTheme();
|
||||||
|
var base = '<base href="' + escapeAttribute(new URL(state.sourceURL, window.location.href).href) + '">';
|
||||||
|
return '<!doctype html><html data-theme="' + escapeAttribute(theme) + '"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
|
||||||
|
base +
|
||||||
|
'<link rel="stylesheet" href="/static/css/80-markdown-preview.css">' +
|
||||||
|
'<style>' + markdownThemeStyle(theme) + '</style>' +
|
||||||
|
'</head><body><main>' + body + '</main></body></html>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownThemeStyle(theme) {
|
||||||
|
var themes = {
|
||||||
|
revamp: ["dark", "#0b0b16", "#f5f3ff", "#aaa4d6", "#17142d", "#211b3e", "rgba(168,150,255,0.24)", "#67e8f9", "#a78bfa", "#1b1724", "#0f111a", "#f8fafc", "rgba(248,250,252,0.16)", "rgba(0,0,0,0.28)", "Inter,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif", "Consolas,Monaco,\"Andale Mono\",\"Ubuntu Mono\",monospace"],
|
||||||
|
classic: ["dark", "#09090b", "#fafafa", "#a1a1aa", "#18181b", "#27272a", "rgba(255,255,255,0.13)", "#e4e4e7", "#d4d4d8", "#111113", "#09090b", "#fafafa", "rgba(250,250,250,0.15)", "rgba(0,0,0,0.3)", "Inter,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif", "Consolas,Monaco,\"Andale Mono\",\"Ubuntu Mono\",monospace"],
|
||||||
|
retro: ["light", "#c0c0c0", "#000000", "#404040", "#ffffff", "#dfdfdf", "#000000", "#000078", "#000078", "#ffffff", "#000000", "#f5f5f5", "#808080", "transparent", "\"PixeloidSans\",\"PixelOperator\",\"Microsoft Sans Serif\",Tahoma,sans-serif", "\"PixelOperatorMono\",Consolas,monospace"],
|
||||||
|
gruvbox: ["dark", "#1d2021", "#ebdbb2", "#bdae93", "#282828", "#32302f", "rgba(235,219,178,0.2)", "#fabd2f", "#d79921", "#1b1d1e", "#161819", "#fbf1c7", "rgba(251,241,199,0.18)", "rgba(0,0,0,0.26)", "Inter,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif", "Consolas,Monaco,\"Andale Mono\",\"Ubuntu Mono\",monospace"],
|
||||||
|
cyberpunk: ["dark", "#08070d", "#fff36f", "#9bfaff", "#16131f", "#251d34", "rgba(255,242,0,0.34)", "#00f0ff", "#ff2a6d", "#100d18", "#07060b", "#f8fafc", "rgba(0,240,255,0.26)", "rgba(255,42,109,0.14)", "Inter,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif", "Consolas,Monaco,\"Andale Mono\",\"Ubuntu Mono\",monospace"]
|
||||||
|
};
|
||||||
|
var t = themes[theme] || themes.revamp;
|
||||||
|
var vars = "color-scheme:" + t[0] + ";--md-bg:" + t[1] + ";--md-fg:" + t[2] + ";--md-muted:" + t[3] + ";--md-panel:" + t[4] + ";--md-panel-2:" + t[5] + ";--md-border:" + t[6] + ";--md-link:" + t[7] + ";--md-accent:" + t[8] + ";--md-code-bg:" + t[9] + ";--md-block-code-bg:" + t[10] + ";--md-block-code-fg:" + t[11] + ";--md-block-code-border:" + t[12] + ";--md-shadow:" + t[13] + ";--md-font:" + t[14] + ";--md-mono:" + t[15] + ";";
|
||||||
|
return ":root{" + vars + "}*{box-sizing:border-box}html,body{min-height:100%;margin:0;background:var(--md-bg);color:var(--md-fg);font-family:var(--md-font)}body{padding:clamp(1rem,4vw,2.25rem);font-size:16px;line-height:1.65}main{max-width:54rem;margin:0 auto;padding:clamp(1rem,3vw,2rem);border:1px solid var(--md-border);border-radius:10px;background:var(--md-panel);box-shadow:0 20px 60px var(--md-shadow)}a{color:var(--md-link)}h1,h2,h3,h4,h5,h6{color:var(--md-fg);line-height:1.2}code,kbd,pre{font-family:var(--md-mono)}pre{overflow:auto;padding:1rem;border:1px solid var(--md-block-code-border)!important;background:var(--md-block-code-bg)!important;color:var(--md-block-code-fg)!important}code{background:var(--md-code-bg);border-radius:4px;padding:.12rem .3rem}pre code,pre>code,pre code[class*=\"language-\"]{padding:0!important;background:transparent!important;color:inherit!important;border:0!important}blockquote{margin:1rem 0;padding:.2rem 1rem;border-left:3px solid var(--md-accent);color:var(--md-muted);background:var(--md-panel-2)}table{width:100%;border-collapse:collapse;display:block;overflow:auto}th,td{border:1px solid var(--md-border);padding:.5rem .7rem}hr{border:0;border-top:1px solid var(--md-border)}img,video{max-width:100%;height:auto}";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTheme() {
|
||||||
|
var theme = document.documentElement.dataset.theme || "revamp";
|
||||||
|
return /^(revamp|classic|retro|gruvbox|cyberpunk)$/.test(theme) ? theme : "revamp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withBaseElement(source) {
|
||||||
|
var base = '<base href="' + escapeAttribute(new URL(state.sourceURL, window.location.href).href) + '">';
|
||||||
|
if (/<head[\s>]/i.test(source)) {
|
||||||
|
return source.replace(/<head([^>]*)>/i, "<head$1>" + base);
|
||||||
|
}
|
||||||
|
return base + source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttribute(value) {
|
||||||
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJSON(source) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(source), null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionFor(name) {
|
||||||
|
var parts = name.toLowerCase().split(".");
|
||||||
|
return parts.length > 1 ? parts.pop() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageFor(extension, baseType) {
|
||||||
|
var extensionMap = {
|
||||||
|
"c": "c",
|
||||||
|
"cc": "cpp",
|
||||||
|
"conf": "nginx",
|
||||||
|
"cpp": "cpp",
|
||||||
|
"cs": "csharp",
|
||||||
|
"css": "css",
|
||||||
|
"csv": "csv",
|
||||||
|
"diff": "diff",
|
||||||
|
"dockerfile": "docker",
|
||||||
|
"go": "go",
|
||||||
|
"h": "c",
|
||||||
|
"hpp": "cpp",
|
||||||
|
"htm": "html",
|
||||||
|
"html": "html",
|
||||||
|
"ini": "ini",
|
||||||
|
"java": "java",
|
||||||
|
"js": "javascript",
|
||||||
|
"json": "json",
|
||||||
|
"jsx": "jsx",
|
||||||
|
"kt": "kotlin",
|
||||||
|
"log": "log",
|
||||||
|
"lua": "lua",
|
||||||
|
"md": "markdown",
|
||||||
|
"mdown": "markdown",
|
||||||
|
"markdown": "markdown",
|
||||||
|
"php": "php",
|
||||||
|
"pl": "perl",
|
||||||
|
"properties": "properties",
|
||||||
|
"py": "python",
|
||||||
|
"rb": "ruby",
|
||||||
|
"rs": "rust",
|
||||||
|
"sh": "bash",
|
||||||
|
"sql": "sql",
|
||||||
|
"swift": "swift",
|
||||||
|
"toml": "toml",
|
||||||
|
"ts": "typescript",
|
||||||
|
"tsx": "tsx",
|
||||||
|
"txt": "text",
|
||||||
|
"xml": "xml",
|
||||||
|
"yaml": "yaml",
|
||||||
|
"yml": "yaml",
|
||||||
|
"zig": "zig"
|
||||||
|
};
|
||||||
|
var typeMap = {
|
||||||
|
"application/javascript": "javascript",
|
||||||
|
"application/json": "json",
|
||||||
|
"application/ld+json": "json",
|
||||||
|
"application/markdown": "markdown",
|
||||||
|
"application/xml": "xml",
|
||||||
|
"application/x-httpd-php": "php",
|
||||||
|
"application/x-sh": "bash",
|
||||||
|
"image/svg+xml": "xml",
|
||||||
|
"text/css": "css",
|
||||||
|
"text/csv": "csv",
|
||||||
|
"text/html": "html",
|
||||||
|
"text/javascript": "javascript",
|
||||||
|
"text/markdown": "markdown",
|
||||||
|
"text/plain": "text",
|
||||||
|
"text/x-go": "go",
|
||||||
|
"text/xml": "xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
if (extensionMap[extension]) {
|
||||||
|
return extensionMap[extension];
|
||||||
|
}
|
||||||
|
if (typeMap[baseType]) {
|
||||||
|
return typeMap[baseType];
|
||||||
|
}
|
||||||
|
if (baseType.indexOf("+json") !== -1) {
|
||||||
|
return "json";
|
||||||
|
}
|
||||||
|
if (baseType.indexOf("+xml") !== -1) {
|
||||||
|
return "xml";
|
||||||
|
}
|
||||||
|
if (baseType.indexOf("text/") === 0) {
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
79
backend/static/lib/markdown/marked.umd.js
Normal file
79
backend/static/lib/markdown/marked.umd.js
Normal file
File diff suppressed because one or more lines are too long
3
backend/static/lib/markdown/purify.min.js
vendored
Normal file
3
backend/static/lib/markdown/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/static/lib/prismjs/prism.css
Normal file
4
backend/static/lib/prismjs/prism.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/* PrismJS 1.30.0
|
||||||
|
https://prismjs.com/download#themes=prism-dark&languages=markup+css+clike+javascript+abap+abnf+actionscript+ada+agda+al+antlr4+apacheconf+apex+apl+applescript+aql+arduino+arff+armasm+arturo+asciidoc+aspnet+asm6502+asmatmel+autohotkey+autoit+avisynth+avro-idl+awk+bash+basic+batch+bbcode+bbj+bicep+birb+bison+bnf+bqn+brainfuck+brightscript+bro+bsl+c+csharp+cpp+cfscript+chaiscript+cil+cilkc+cilkcpp+clojure+cmake+cobol+coffeescript+concurnas+csp+cooklang+coq+crystal+css-extras+csv+cue+cypher+d+dart+dataweave+dax+dhall+diff+django+dns-zone-file+docker+dot+ebnf+editorconfig+eiffel+ejs+elixir+elm+etlua+erb+erlang+excel-formula+fsharp+factor+false+firestore-security-rules+flow+fortran+ftl+gml+gap+gcode+gdscript+gedcom+gettext+gherkin+git+glsl+gn+linker-script+go+go-module+gradle+graphql+groovy+haml+handlebars+haskell+haxe+hcl+hlsl+hoon+http+hpkp+hsts+ichigojam+icon+icu-message-format+idris+ignore+inform7+ini+io+j+java+javadoc+javadoclike+javastacktrace+jexl+jolie+jq+jsdoc+js-extras+json+json5+jsonp+jsstacktrace+js-templates+julia+keepalived+keyman+kotlin+kumir+kusto+latex+latte+less+lilypond+liquid+lisp+livescript+llvm+log+lolcode+lua+magma+makefile+markdown+markup-templating+mata+matlab+maxscript+mel+mermaid+metafont+mizar+mongodb+monkey+moonscript+n1ql+n4js+nand2tetris-hdl+naniscript+nasm+neon+nevod+nginx+nim+nix+nsis+objectivec+ocaml+odin+opencl+openqasm+oz+parigp+parser+pascal+pascaligo+psl+pcaxis+peoplecode+perl+php+phpdoc+php-extras+plant-uml+plsql+powerquery+powershell+processing+prolog+promql+properties+protobuf+pug+puppet+pure+purebasic+purescript+python+qsharp+q+qml+qore+r+racket+cshtml+jsx+tsx+reason+regex+rego+renpy+rescript+rest+rip+roboconf+robotframework+ruby+rust+sas+sass+scss+scala+scheme+shell-session+smali+smalltalk+smarty+sml+solidity+solution-file+soy+sparql+splunk-spl+sqf+sql+squirrel+stan+stata+iecst+stylus+supercollider+swift+systemd+t4-templating+t4-cs+t4-vb+tap+tcl+tt2+textile+toml+tremor+turtle+twig+typescript+typoscript+unrealscript+uorazor+uri+v+vala+vbnet+velocity+verilog+vhdl+vim+visual-basic+warpscript+wasm+web-idl+wgsl+wiki+wolfram+wren+xeora+xml-doc+xojo+xquery+yaml+yang+zig&plugins=line-numbers */
|
||||||
|
code[class*=language-],pre[class*=language-]{color:#fff;background:0 0;text-shadow:0 -.1em .2em #000;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}:not(pre)>code[class*=language-],pre[class*=language-]{background:#4c3f33}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border:.3em solid #7a6651;border-radius:.5em;box-shadow:1px 1px .5em #000 inset}:not(pre)>code[class*=language-]{padding:.15em .2em .05em;border-radius:.3em;border:.13em solid #7a6651;box-shadow:1px 1px .3em -.1em #000 inset;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#997f66}.token.punctuation{opacity:.7}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.number,.token.property,.token.symbol,.token.tag{color:#d1939e}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#bce051}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f4b73d}.token.atrule,.token.attr-value,.token.keyword{color:#d1939e}.token.important,.token.regex{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.deleted{color:red}
|
||||||
|
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}
|
||||||
301
backend/static/lib/prismjs/prism.js
Normal file
301
backend/static/lib/prismjs/prism.js
Normal file
File diff suppressed because one or more lines are too long
@@ -11,16 +11,30 @@
|
|||||||
<meta name="generator" content="Warp Box {{.AppVersion}}">
|
<meta name="generator" content="Warp Box {{.AppVersion}}">
|
||||||
|
|
||||||
<meta property="og:site_name" content="{{.AppName}}">
|
<meta property="og:site_name" content="{{.AppName}}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
|
||||||
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
<meta property="og:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||||
<meta property="og:description" content="{{.Description}}">
|
<meta property="og:description" content="{{.Description}}">
|
||||||
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
|
<meta property="og:url" content="{{if .CanonicalURL}}{{.CanonicalURL}}{{else}}{{.BaseURL}}{{end}}">
|
||||||
{{if .ImageURL}}
|
{{if .ImageURL}}
|
||||||
<meta property="og:image" content="{{.ImageURL}}">
|
<meta property="og:image" content="{{.ImageURL}}">
|
||||||
|
<meta property="og:image:secure_url" content="{{.ImageURL}}">
|
||||||
|
{{if .ImageType}}<meta property="og:image:type" content="{{.ImageType}}">{{end}}
|
||||||
<meta property="og:image:width" content="1200">
|
<meta property="og:image:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
|
{{if .ImageAlt}}<meta property="og:image:alt" content="{{.ImageAlt}}">{{else}}<meta property="og:image:alt" content="{{.AppName}} preview">{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .MediaURL}}
|
||||||
|
{{if eq .OGType "video.other"}}
|
||||||
|
<meta property="og:video" content="{{.MediaURL}}">
|
||||||
|
<meta property="og:video:secure_url" content="{{.MediaURL}}">
|
||||||
|
{{if .MediaType}}<meta property="og:video:type" content="{{.MediaType}}">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq .OGType "music.song"}}
|
||||||
|
<meta property="og:audio" content="{{.MediaURL}}">
|
||||||
|
<meta property="og:audio:secure_url" content="{{.MediaURL}}">
|
||||||
|
{{if .MediaType}}<meta property="og:audio:type" content="{{.MediaType}}">{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.AppName}}{{end}}">
|
||||||
@@ -60,6 +74,7 @@
|
|||||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/45-preview.js?version={{.AppVersion}}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="dark">
|
<body class="dark">
|
||||||
<a class="skip-link" href="#main">Skip to content</a>
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{{define "preview.html"}}{{template "base" .}}{{end}}
|
{{define "preview.html"}}{{template "base" .}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<section class="download-view" aria-labelledby="preview-title">
|
<section class="download-view preview-view" aria-labelledby="preview-title">
|
||||||
<div class="card download-card">
|
<div class="card download-card preview-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
{{if .Data.Locked}}
|
{{if .Data.Locked}}
|
||||||
<div class="file-emblem" aria-hidden="true">
|
<div class="file-emblem" aria-hidden="true">
|
||||||
@@ -12,23 +12,65 @@
|
|||||||
<p class="download-subtitle">Unlock the box before viewing this file.</p>
|
<p class="download-subtitle">Unlock the box before viewing this file.</p>
|
||||||
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
|
<a class="button button-primary button-wide" href="/d/{{.Data.Box.ID}}">Unlock box</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="preview-stage">
|
<header class="preview-header">
|
||||||
{{if eq .Data.File.PreviewKind "image"}}
|
<div class="preview-title-group">
|
||||||
<img src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}">
|
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
|
||||||
{{else if eq .Data.File.PreviewKind "video"}}
|
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
|
||||||
<video src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata"></video>
|
</div>
|
||||||
{{else if eq .Data.File.PreviewKind "audio"}}
|
<a class="button button-primary" href="{{.Data.DownloadURL}}">
|
||||||
<audio src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata"></audio>
|
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
||||||
{{else}}
|
Download
|
||||||
<img src="{{.Data.File.ThumbnailURL}}" alt="">
|
</a>
|
||||||
{{end}}
|
</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}}">
|
||||||
|
<div class="preview-window-titlebar">
|
||||||
|
<div>
|
||||||
|
<strong data-preview-mode-label>Preview</strong>
|
||||||
|
<span>{{.Data.File.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-window-tools">
|
||||||
|
<button class="preview-fullscreen-button" type="button" data-render-fullscreen hidden>Full Screen</button>
|
||||||
|
<div class="preview-window-actions" aria-hidden="true"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-tabs" data-preview-tabs></div>
|
||||||
|
<div class="preview-stage">
|
||||||
|
<div class="default-preview" data-default-preview hidden>
|
||||||
|
<img src="{{.Data.File.IconURL}}" alt="" loading="lazy">
|
||||||
|
<div>
|
||||||
|
<strong title="{{.Data.File.Name}}">{{.Data.File.Name}}</strong>
|
||||||
|
<span>{{.Data.File.Size}} · {{.Data.File.ContentType}}</span>
|
||||||
|
</div>
|
||||||
|
<a class="button button-primary" href="{{.Data.DownloadURL}}">
|
||||||
|
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden>
|
||||||
|
<video class="native-preview native-video-preview" data-video-preview src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata" hidden></video>
|
||||||
|
<audio class="native-preview native-audio-preview" data-browser-audio-preview src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata" hidden></audio>
|
||||||
|
<div class="code-preview raw-code-preview" data-raw-preview hidden>
|
||||||
|
<pre><code data-raw-output></code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="code-preview prism-code-preview" data-code-preview hidden>
|
||||||
|
<pre class="line-numbers"><code data-code-output></code></pre>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<p>This file is larger than 500 KB. Loading this preview may be slow on some devices.</p>
|
||||||
|
<div>
|
||||||
|
<button class="button button-primary" type="button" data-large-preview-confirm>Load anyway</button>
|
||||||
|
<button class="button button-outline" type="button" data-large-preview-cancel>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-placeholder" data-preview-placeholder hidden>
|
||||||
|
<img src="{{.Data.File.IconURL}}" alt="">
|
||||||
|
<p>Preparing preview...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 id="preview-title" class="file-name" title="{{.Data.File.Name}}">{{.Data.File.Name}}</h1>
|
|
||||||
<p class="download-subtitle">{{.Data.File.Size}} · {{.Data.File.ContentType}}</p>
|
|
||||||
<a class="button button-primary button-wide" href="{{.Data.DownloadURL}}">
|
|
||||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" /></svg>
|
|
||||||
Download file
|
|
||||||
</a>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user