Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2cf7115b7 | |||
| a0027fbd18 | |||
| 6a7590493c | |||
| 5d77b36634 | |||
| 0b8d4a3ab9 | |||
| 0b4487ac2e | |||
| ead4cd7492 | |||
| af1fae1a98 | |||
| d11aec96e5 | |||
| dbfdacc396 | |||
| 45507cdcae | |||
| a454e4239f | |||
| cba416b238 | |||
| f9755fa98f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ backend/static/uploads/*
|
||||
scripts/env/dev.env
|
||||
docker-compose.yml
|
||||
|
||||
.claude
|
||||
.claude
|
||||
docs/possible_new_features
|
||||
@@ -357,3 +357,9 @@ bbolt database and JSON logs always remain local under `./data/db` and `./data/l
|
||||
|
||||
The static handler sets long-lived immutable caching for images, video, audio, and fonts, shorter
|
||||
caching for CSS/JS, and gzip compression for compressible responses.
|
||||
|
||||
## AI Usage
|
||||
|
||||
I have used AI to accelerate development, all of the code has been reviewed by humans. I have mostly used self-hosted models as well as big models from big companies for a monthly subscription fee.
|
||||
|
||||
I have nothing against AI as long as you can tell me what every single line of your code does. That's how I personally view things.
|
||||
@@ -54,6 +54,8 @@ func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, pag
|
||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /", a.Home)
|
||||
mux.HandleFunc("GET /api", a.APIDocs)
|
||||
mux.HandleFunc("GET /service-worker.js", a.ServiceWorker)
|
||||
mux.HandleFunc("POST /share-target", a.ShareTargetFallback)
|
||||
mux.HandleFunc("GET /register", a.Register)
|
||||
mux.HandleFunc("POST /register", a.RegisterPost)
|
||||
mux.HandleFunc("GET /login", a.Login)
|
||||
@@ -134,6 +136,8 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
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}/scene/{fileID}", a.VideoScenesPreview)
|
||||
mux.HandleFunc("GET /d/{boxID}/archive/{fileID}", a.ArchiveListing)
|
||||
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
|
||||
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
|
||||
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)
|
||||
|
||||
@@ -47,7 +47,11 @@ type fileView struct {
|
||||
URL string
|
||||
DownloadURL string
|
||||
ThumbnailURL string
|
||||
SceneURL string
|
||||
ArchiveURL string
|
||||
HasThumbnail bool
|
||||
HasScene bool
|
||||
HasArchive bool
|
||||
IconURL string
|
||||
IconRetroURL string
|
||||
ReactURL string
|
||||
@@ -55,6 +59,8 @@ type fileView struct {
|
||||
ReactionMore int
|
||||
Reacted bool
|
||||
Processing bool
|
||||
Failed bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type reactionView struct {
|
||||
@@ -135,7 +141,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
||||
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"
|
||||
@@ -197,7 +203,7 @@ 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"))
|
||||
return fmt.Sprintf("%s %s. Open 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 {
|
||||
@@ -238,12 +244,32 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
if file.ProcessingError != "" {
|
||||
a.logger.Warn("failed file preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4241, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
a.logger.Warn("failed box preview blocked for social bot", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4245, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
||||
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
if shouldServeRawSocialMedia(file) {
|
||||
a.serveFileContent(w, r, box, file, false)
|
||||
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
|
||||
}
|
||||
}
|
||||
if file.ProcessingError != "" && !locked {
|
||||
a.logger.Warn("failed file preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4242, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) && !locked {
|
||||
a.logger.Warn("failed box preview blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4246, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
||||
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
view := a.fileView(box, file)
|
||||
fileSize := helpers.FormatBytes(file.Size)
|
||||
title := file.Name
|
||||
@@ -302,6 +328,16 @@ func (a *App) DownloadFileContent(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
if file.ProcessingError != "" {
|
||||
a.logger.Warn("failed file download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4243, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
a.logger.Warn("failed box download blocked", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4247, "box_id", box.ID, "file_id", file.ID, "error", services.BoxTroubleReason(box))...)
|
||||
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
|
||||
a.serveFileContent(w, r, box, file, r.URL.Query().Get("inline") != "1")
|
||||
a.logger.Info("file content served", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2005, "box_id", box.ID, "file_id", file.ID, "attachment", r.URL.Query().Get("inline") != "1")...)
|
||||
@@ -317,6 +353,11 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||
a.logger.Warn("thumbnail request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4110, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
object, err := a.uploadService.OpenThumbnailObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
@@ -345,8 +386,100 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||
}
|
||||
|
||||
func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !jobs.NeedsVideoScenes(file) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||
a.logger.Warn("video scenes preview request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4111, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" {
|
||||
file.SceneThumbnail = scene
|
||||
object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file)
|
||||
if err == nil {
|
||||
defer object.Body.Close()
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||
return
|
||||
}
|
||||
}
|
||||
a.servePlaceholderThumbnail(w, r)
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body))
|
||||
}
|
||||
|
||||
func (a *App) ArchiveListing(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||
box, file, ok := a.loadFileForRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !jobs.NeedsArchiveListing(file) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) || services.FileHasTrouble(file) {
|
||||
a.logger.Warn("archive listing request skipped trouble file", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4112, "box_id", box.ID, "file_id", file.ID, "error", troubleReasonForLog(box, file))...)
|
||||
http.Error(w, "archive preview unavailable: file processing failed", http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
||||
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||
file.ArchiveListing = listing
|
||||
file.ArchiveListingObjectKey = ""
|
||||
}
|
||||
}
|
||||
|
||||
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||
file.ArchiveListing = listing
|
||||
file.ArchiveListingObjectKey = ""
|
||||
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||
if err == nil {
|
||||
defer object.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
|
||||
}
|
||||
|
||||
func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) {
|
||||
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||
return ""
|
||||
}
|
||||
thumbnail, err := jobs.GenerateThumbnailForFile(a.uploadService, box, file)
|
||||
@@ -369,6 +502,62 @@ func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.B
|
||||
return thumbnail
|
||||
}
|
||||
|
||||
func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||
if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||
return ""
|
||||
}
|
||||
scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file)
|
||||
if err != nil || scene == "" {
|
||||
if err != nil {
|
||||
a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for i := range box.Files {
|
||||
if box.Files[i].ID == file.ID {
|
||||
box.Files[i].SceneThumbnail = scene
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := a.uploadService.SaveBox(box); err != nil {
|
||||
a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||
return ""
|
||||
}
|
||||
return scene
|
||||
}
|
||||
|
||||
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
|
||||
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) || services.BoxHasTrouble(box) || services.FileHasTrouble(file) || file.Processing {
|
||||
return ""
|
||||
}
|
||||
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
|
||||
if err != nil || listing == "" {
|
||||
if err != nil {
|
||||
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for i := range box.Files {
|
||||
if box.Files[i].ID == file.ID {
|
||||
box.Files[i].ArchiveListing = listing
|
||||
box.Files[i].ArchiveListingObjectKey = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := a.uploadService.SaveBox(box); err != nil {
|
||||
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
|
||||
return ""
|
||||
}
|
||||
return listing
|
||||
}
|
||||
|
||||
func troubleReasonForLog(box services.Box, file services.File) string {
|
||||
if services.FileHasTrouble(file) {
|
||||
return file.ProcessingError
|
||||
}
|
||||
return services.BoxTroubleReason(box)
|
||||
}
|
||||
|
||||
// servePlaceholderThumbnail serves the fallback image with no-store so the
|
||||
// browser re-requests on the next load and picks up the real thumbnail as soon
|
||||
// as it has been generated.
|
||||
@@ -437,9 +626,11 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
|
||||
defer object.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", file.ContentType)
|
||||
disposition := "inline"
|
||||
if attachment {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", file.Name))
|
||||
disposition = "attachment"
|
||||
}
|
||||
w.Header().Set("Content-Disposition", contentDisposition(disposition, file.Name))
|
||||
if seeker, ok := object.Body.(io.ReadSeeker); ok {
|
||||
http.ServeContent(w, r, file.Name, object.ModTime, seeker)
|
||||
} else {
|
||||
@@ -455,6 +646,39 @@ func (a *App) serveFileContent(w http.ResponseWriter, r *http.Request, box servi
|
||||
}
|
||||
}
|
||||
|
||||
func contentDisposition(disposition, name string) string {
|
||||
filename := cleanDownloadFilename(name)
|
||||
return fmt.Sprintf(`%s; filename="%s"; filename*=UTF-8''%s`, disposition, asciiFilenameFallback(filename), url.PathEscape(filename))
|
||||
}
|
||||
|
||||
func cleanDownloadFilename(name string) string {
|
||||
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
|
||||
clean = filepath.Base(clean)
|
||||
if clean == "" || clean == "." || clean == "/" {
|
||||
return "download"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func asciiFilenameFallback(name string) string {
|
||||
var fallback strings.Builder
|
||||
for _, char := range name {
|
||||
switch {
|
||||
case char < 0x20 || char == 0x7f || char == '"' || char == '\\' || char == '/' || char == ';':
|
||||
fallback.WriteByte('_')
|
||||
case char <= 0x7e:
|
||||
fallback.WriteRune(char)
|
||||
default:
|
||||
fallback.WriteByte('_')
|
||||
}
|
||||
}
|
||||
clean := strings.TrimSpace(fallback.String())
|
||||
if clean == "" {
|
||||
return "download"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func readSeekCloser(source io.ReadCloser) io.ReadSeeker {
|
||||
data, err := io.ReadAll(source)
|
||||
if err != nil {
|
||||
@@ -481,9 +705,25 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "password required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
for _, file := range box.Files {
|
||||
if file.Processing {
|
||||
http.Error(w, "file is still processing", http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
if file.ProcessingError != "" {
|
||||
a.logger.Warn("zip download blocked by failed file", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4244, "box_id", box.ID, "file_id", file.ID, "error", file.ProcessingError)...)
|
||||
http.Error(w, "file processing failed: "+file.ProcessingError, http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
a.logger.Warn("zip download blocked by failed box", withRequestLogAttrs(r, "source", "download", "severity", "warn", "code", 4248, "box_id", box.ID, "error", services.BoxTroubleReason(box))...)
|
||||
http.Error(w, "box processing failed: "+services.BoxTroubleReason(box), http.StatusFailedDependency)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", "warpbox-"+box.ID+".zip"))
|
||||
w.Header().Set("Content-Disposition", contentDisposition("attachment", "warpbox-"+box.ID+".zip"))
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
|
||||
if err := a.uploadService.WriteZip(w, box); err != nil {
|
||||
@@ -513,7 +753,11 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
|
||||
URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
|
||||
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID),
|
||||
ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID),
|
||||
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
|
||||
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
|
||||
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
|
||||
HasThumbnail: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.Thumbnail != "" || jobs.NeedsThumbnail(file)),
|
||||
HasScene: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file)),
|
||||
HasArchive: !services.BoxHasTrouble(box) && !services.FileHasTrouble(file) && (file.ArchiveListing != "" || jobs.NeedsArchiveListing(file)),
|
||||
IconURL: fileIconURL("standard", icon.Standard),
|
||||
IconRetroURL: fileIconURL("retro", icon.Retro),
|
||||
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),
|
||||
@@ -521,6 +765,8 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti
|
||||
ReactionMore: reactionOverflowCount(reactionViews),
|
||||
Reacted: reacted,
|
||||
Processing: file.Processing,
|
||||
Failed: services.BoxHasTrouble(box) || services.FileHasTrouble(file),
|
||||
Error: troubleReasonForLog(box, file),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ Disallow: /account/
|
||||
Disallow: /d/*/f/*/download
|
||||
Disallow: /d/*/zip
|
||||
Disallow: /d/*/thumb/
|
||||
Disallow: /d/*/scene/
|
||||
Disallow: /d/*/archive/
|
||||
Disallow: /d/*/og-image.jpg
|
||||
Disallow: /d/*/unlock
|
||||
Disallow: /d/*/manage/
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
@@ -95,10 +97,65 @@ func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if jobs.NeedsArchiveListing(file) {
|
||||
if listing, ok := a.archiveListingForOG(r, box, file); ok {
|
||||
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
icon := a.ogFileIcon(file)
|
||||
a.serveOGImage(w, r, a.renderFileCard(file, icon))
|
||||
}
|
||||
|
||||
type ogArchiveListing struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
FileCount int `json:"fileCount"`
|
||||
FolderCount int `json:"folderCount"`
|
||||
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||
Root *ogArchiveNode `json:"root"`
|
||||
}
|
||||
|
||||
type ogArchiveNode struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size,omitempty"`
|
||||
Dir bool `json:"dir"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Items []*ogArchiveNode `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
|
||||
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
|
||||
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||
file.ArchiveListing = listing
|
||||
file.ArchiveListingObjectKey = ""
|
||||
}
|
||||
}
|
||||
|
||||
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||
if err != nil {
|
||||
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
|
||||
file.ArchiveListing = listing
|
||||
file.ArchiveListingObjectKey = ""
|
||||
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return ogArchiveListing{}, false
|
||||
}
|
||||
defer object.Body.Close()
|
||||
|
||||
var listing ogArchiveListing
|
||||
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
|
||||
return ogArchiveListing{}, false
|
||||
}
|
||||
if listing.Root == nil {
|
||||
return ogArchiveListing{}, false
|
||||
}
|
||||
return listing, true
|
||||
}
|
||||
|
||||
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
@@ -213,6 +270,174 @@ func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
|
||||
return canvas
|
||||
}
|
||||
|
||||
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
|
||||
|
||||
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
|
||||
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
|
||||
|
||||
titleFace := a.ogFont(36, true)
|
||||
bodyFace := a.ogFont(22, false)
|
||||
treeFace := a.ogFont(19, false)
|
||||
labelFace := a.ogFont(17, true)
|
||||
|
||||
icon := a.ogFileIcon(file)
|
||||
if icon != nil {
|
||||
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
|
||||
} else {
|
||||
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
title := listing.Name
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = file.Name
|
||||
}
|
||||
titleLines := wrapOGText(title, titleFace, 820)
|
||||
if len(titleLines) > 2 {
|
||||
titleLines = titleLines[:2]
|
||||
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
|
||||
}
|
||||
y := 106
|
||||
for _, line := range titleLines {
|
||||
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
||||
y += 42
|
||||
}
|
||||
|
||||
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
|
||||
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
|
||||
treePanel := image.Rect(104, 214, 1096, 548)
|
||||
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||
|
||||
rows := archiveOGRows(listing.Root, 13)
|
||||
rowY := treePanel.Min.Y + 64
|
||||
for _, row := range rows {
|
||||
if row.Ellipsis {
|
||||
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
break
|
||||
}
|
||||
x := treePanel.Min.X + 20 + row.Depth*28
|
||||
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
|
||||
name := row.Name
|
||||
if row.Dir {
|
||||
name += "/"
|
||||
}
|
||||
maxNameWidth := treePanel.Max.X - x - 170
|
||||
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
|
||||
if !row.Dir {
|
||||
size := formatOGArchiveBytes(row.Size)
|
||||
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||
}
|
||||
rowY += 23
|
||||
}
|
||||
|
||||
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
|
||||
return canvas
|
||||
}
|
||||
|
||||
type archiveOGRow struct {
|
||||
Name string
|
||||
Icon string
|
||||
Size uint64
|
||||
Dir bool
|
||||
Depth int
|
||||
Ellipsis bool
|
||||
}
|
||||
|
||||
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
|
||||
rows := make([]archiveOGRow, 0, limit+1)
|
||||
truncated := false
|
||||
var walk func(items []*ogArchiveNode, depth int)
|
||||
walk = func(items []*ogArchiveNode, depth int) {
|
||||
for _, item := range items {
|
||||
if len(rows) >= limit {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
icon := item.Icon
|
||||
if item.Dir {
|
||||
icon = "folder"
|
||||
}
|
||||
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
|
||||
if item.Dir {
|
||||
walk(item.Items, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if root != nil {
|
||||
walk(root.Items, 0)
|
||||
}
|
||||
if truncated {
|
||||
rows = append(rows, archiveOGRow{Ellipsis: true})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
|
||||
c := archiveOGIconColor(icon)
|
||||
rect := image.Rect(x, y, x+20, y+20)
|
||||
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
|
||||
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
if icon == "folder" {
|
||||
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
}
|
||||
}
|
||||
|
||||
func archiveOGIconColor(icon string) color.RGBA {
|
||||
switch icon {
|
||||
case "folder":
|
||||
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
|
||||
case "img":
|
||||
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
|
||||
case "vid":
|
||||
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
|
||||
case "aud":
|
||||
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
|
||||
case "code":
|
||||
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
|
||||
case "arc":
|
||||
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
|
||||
default:
|
||||
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
|
||||
}
|
||||
}
|
||||
|
||||
func archiveOGTextColor(row archiveOGRow) color.RGBA {
|
||||
if row.Dir {
|
||||
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
|
||||
}
|
||||
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
|
||||
}
|
||||
|
||||
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
|
||||
if strings.TrimSpace(listing.Type) != "" {
|
||||
return listing.Type
|
||||
}
|
||||
if strings.TrimSpace(file.ContentType) != "" {
|
||||
return file.ContentType
|
||||
}
|
||||
return "Archive"
|
||||
}
|
||||
|
||||
func formatOGArchiveBytes(size uint64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
value := float64(size) / unit
|
||||
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
value /= unit
|
||||
}
|
||||
return fmt.Sprintf("%.1f PiB", value)
|
||||
}
|
||||
|
||||
func fileCardInfo(file services.File) string {
|
||||
switch {
|
||||
case strings.HasPrefix(file.ContentType, "audio/"):
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
type homeData struct {
|
||||
MaxUploadSize string
|
||||
MaxUploadBytes int64
|
||||
LimitSummary string
|
||||
Collections []collectionView
|
||||
IsAdmin bool
|
||||
@@ -57,17 +58,18 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
||||
"actor", actor,
|
||||
"user_id", user.ID,
|
||||
)...)
|
||||
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||
maxUploadSize, maxUploadBytes, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
|
||||
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
||||
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||
Title: "Upload your files",
|
||||
Description: "Upload and share files fast. Drop a file, get a link — private, temporary transfers that expire on your terms.",
|
||||
Description: "Upload and share files quickly. Drop a file, get a link.",
|
||||
CanonicalURL: absoluteURL(r, "/"),
|
||||
ImageURL: absoluteURL(r, "/static/og-default.png"),
|
||||
ImageAlt: "Warp Box — simple file sharing and fast downloads",
|
||||
ImageAlt: "Warp Box | simple file sharing and fast downloads",
|
||||
CurrentUser: currentUser,
|
||||
Data: homeData{
|
||||
MaxUploadSize: maxUploadSize,
|
||||
MaxUploadBytes: maxUploadBytes,
|
||||
LimitSummary: limitSummary,
|
||||
Collections: collections,
|
||||
IsAdmin: isAdmin,
|
||||
@@ -155,22 +157,25 @@ func expiryLabel(minutes int) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
|
||||
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, int64, string) {
|
||||
if isAdmin {
|
||||
return "No file size limit", "Admin uploads bypass storage and daily caps."
|
||||
return "No file size limit", -1, "Admin uploads bypass storage and daily caps."
|
||||
}
|
||||
if !loggedIn {
|
||||
if !settings.AnonymousUploadsEnabled {
|
||||
return "Anonymous uploads disabled", "Sign in to upload files."
|
||||
return "Anonymous uploads disabled", 0, "Sign in to upload files."
|
||||
}
|
||||
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
|
||||
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), services.MegabytesToBytes(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP · " + strconv.Itoa(settings.AnonymousMaxDays) + " day max."
|
||||
}
|
||||
policy := a.settingsService.EffectivePolicyForUser(settings, user)
|
||||
maxUpload := a.uploadService.MaxUploadSizeLabel()
|
||||
maxUploadBytes := a.uploadService.MaxUploadSize()
|
||||
if policy.MaxUploadMB < 0 {
|
||||
maxUpload = "unlimited"
|
||||
maxUploadBytes = -1
|
||||
} else if policy.MaxUploadMB > 0 {
|
||||
maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB)
|
||||
maxUploadBytes = services.MegabytesToBytes(policy.MaxUploadMB)
|
||||
}
|
||||
quota := "unlimited"
|
||||
if policy.StorageQuotaSet {
|
||||
@@ -180,5 +185,5 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use
|
||||
if policy.MaxDays < 0 {
|
||||
expiryLimit = "no expiry limit."
|
||||
}
|
||||
return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
|
||||
return maxUpload, maxUploadBytes, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if session.Status == services.ResumableStatusCompleted || session.Status == services.ResumableStatusProcessing {
|
||||
if session.Status == services.ResumableStatusCompleted {
|
||||
result, completed, err := a.uploadService.CompleteResumableSession(r.Context(), session.ID)
|
||||
if err != nil {
|
||||
a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
|
||||
@@ -191,6 +191,17 @@ func (a *App) CompleteResumableUpload(w http.ResponseWriter, r *http.Request) {
|
||||
helpers.WriteJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
if session.Status == services.ResumableStatusProcessing {
|
||||
result, err := a.uploadService.FinalizeProcessingResumableSession(r.Context(), session.ID)
|
||||
if err != nil {
|
||||
a.logger.Warn("resumable upload completion replay failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4004, "session_id", session.ID, "error", err.Error())...)
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
a.logger.Info("resumable upload completion replayed", withRequestLogAttrs(r, "source", "user-upload", "severity", "user_activity", "code", 2004, "session_id", session.ID, "box_id", result.BoxID, "files", len(result.Files))...)
|
||||
helpers.WriteJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
user, loggedIn, _ := a.currentUserWithAuthError(r)
|
||||
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
|
||||
settings, policy, ok := a.loadUploadPolicyForAPI(w, r, user, loggedIn)
|
||||
|
||||
@@ -34,6 +34,17 @@ func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (a *App) ServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("Service-Worker-Allowed", "/")
|
||||
http.ServeFile(w, r, filepath.Join(a.cfg.StaticDir, "js", "service-worker.js"))
|
||||
}
|
||||
|
||||
func (a *App) ShareTargetFallback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/?share-target=unsupported", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -24,3 +28,76 @@ func TestSetStaticCacheHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebManifestIncludesShareTarget(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "static", "site.webmanifest"))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile returned error: %v", err)
|
||||
}
|
||||
var manifest struct {
|
||||
ShareTarget struct {
|
||||
Action string `json:"action"`
|
||||
Method string `json:"method"`
|
||||
EncType string `json:"enctype"`
|
||||
Params struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
URL string `json:"url"`
|
||||
Files []struct {
|
||||
Name string `json:"name"`
|
||||
Accept []string `json:"accept"`
|
||||
} `json:"files"`
|
||||
} `json:"params"`
|
||||
} `json:"share_target"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if manifest.ShareTarget.Action != "/share-target" || manifest.ShareTarget.Method != "POST" || manifest.ShareTarget.EncType != "multipart/form-data" {
|
||||
t.Fatalf("unexpected share_target config: %+v", manifest.ShareTarget)
|
||||
}
|
||||
if manifest.ShareTarget.Params.Title != "title" || manifest.ShareTarget.Params.Text != "text" || manifest.ShareTarget.Params.URL != "url" {
|
||||
t.Fatalf("unexpected share_target params: %+v", manifest.ShareTarget.Params)
|
||||
}
|
||||
if len(manifest.ShareTarget.Params.Files) != 1 || manifest.ShareTarget.Params.Files[0].Name != "files" || len(manifest.ShareTarget.Params.Files[0].Accept) != 1 || manifest.ShareTarget.Params.Files[0].Accept[0] != "*/*" {
|
||||
t.Fatalf("unexpected share_target files: %+v", manifest.ShareTarget.Params.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWorkerServedFromRootScope(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/service-worker.js", nil)
|
||||
response := httptest.NewRecorder()
|
||||
app.ServiceWorker(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if got := response.Header().Get("Service-Worker-Allowed"); got != "/" {
|
||||
t.Fatalf("Service-Worker-Allowed = %q, want /", got)
|
||||
}
|
||||
if got := response.Header().Get("Content-Type"); got != "text/javascript; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
if response.Body.Len() == 0 {
|
||||
t.Fatalf("service worker body missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareTargetFallbackRedirectsHome(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/share-target", nil)
|
||||
response := httptest.NewRecorder()
|
||||
app.ShareTargetFallback(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther)
|
||||
}
|
||||
if got := response.Header().Get("Location"); got != "/?share-target=unsupported" {
|
||||
t.Fatalf("Location = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -53,11 +54,16 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if err := r.ParseMultipartForm(parseLimit); err != nil {
|
||||
a.logger.Warn("upload form parse failed", withRequestLogAttrs(r, "source", "user-upload", "severity", "warn", "code", 4000, "user_id", user.ID, "error", err.Error())...)
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, "upload exceeds the configured upload limit")
|
||||
return
|
||||
}
|
||||
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
files := uploadFiles(r)
|
||||
files := uploadIncomingFiles(r)
|
||||
totalBytes := totalUploadBytes(files)
|
||||
var ownerID string
|
||||
var collectionID string
|
||||
@@ -159,7 +165,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
// uploadGroupWindow are folded into one box. Without the header the behaviour is
|
||||
// identical to creating a fresh box every time. Returns the result and how many
|
||||
// boxes were created (1 for a new box, 0 for an append) for usage accounting.
|
||||
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
|
||||
func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bool, policy services.EffectiveUploadPolicy, files []services.IncomingFile, opts services.UploadOptions, enforceBoxLimits bool) (services.UploadResult, int, int, string, error) {
|
||||
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
|
||||
if batch == "" {
|
||||
if enforceBoxLimits {
|
||||
@@ -167,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
||||
return services.UploadResult{}, 0, status, message, nil
|
||||
}
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, opts)
|
||||
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
|
||||
if err != nil {
|
||||
return services.UploadResult{}, 0, 0, "", err
|
||||
}
|
||||
@@ -188,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
||||
|
||||
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
|
||||
if box, err := a.uploadService.GetBox(entry.boxID); err == nil && a.batchBoxMatches(box, user, loggedIn, r) && a.uploadService.CanDownload(box) == nil {
|
||||
if result, err := a.uploadService.AppendFiles(entry.boxID, files, opts); err == nil {
|
||||
if result, err := a.uploadService.AppendIncomingFiles(entry.boxID, files, opts); err == nil {
|
||||
// Re-attach the manage/delete URLs from the box's creation so every
|
||||
// upload in the batch returns a working deletion URL.
|
||||
result.ManageURL = entry.manageURL
|
||||
@@ -204,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
||||
return services.UploadResult{}, 0, status, message, nil
|
||||
}
|
||||
}
|
||||
result, err := a.uploadService.CreateBox(files, opts)
|
||||
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
|
||||
if err != nil {
|
||||
return services.UploadResult{}, 0, 0, "", err
|
||||
}
|
||||
@@ -224,13 +230,13 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
|
||||
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
|
||||
}
|
||||
|
||||
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []*multipart.FileHeader, totalBytes int64) (int, string) {
|
||||
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, policy services.EffectiveUploadPolicy, files []services.IncomingFile, totalBytes int64) (int, string) {
|
||||
if len(files) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
sizes := make([]int64, 0, len(files))
|
||||
for _, file := range files {
|
||||
sizes = append(sizes, file.Size)
|
||||
sizes = append(sizes, file.Size())
|
||||
}
|
||||
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
|
||||
}
|
||||
@@ -244,7 +250,7 @@ func (a *App) checkUploadPolicyForSizes(r *http.Request, user services.User, log
|
||||
maxBytes := services.MegabytesToBytes(policy.MaxUploadMB)
|
||||
for _, fileSize := range fileSizes {
|
||||
if fileSize > maxBytes {
|
||||
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit"
|
||||
return http.StatusRequestEntityTooLarge, "file exceeds upload size limit of " + services.FormatMegabytesLabel(policy.MaxUploadMB)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
|
||||
return "ip:" + uploadClientIP(r)
|
||||
}
|
||||
|
||||
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
||||
func totalUploadBytes(files []services.IncomingFile) int64 {
|
||||
var total int64
|
||||
for _, file := range files {
|
||||
total += file.Size
|
||||
total += file.Size()
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -404,13 +410,48 @@ func statusForDownloadError(err error) int {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
|
||||
func uploadFiles(r *http.Request) []*multipart.FileHeader {
|
||||
type namedMultipartFile struct {
|
||||
header *multipart.FileHeader
|
||||
name string
|
||||
}
|
||||
|
||||
func (f namedMultipartFile) Name() string {
|
||||
if strings.TrimSpace(f.name) != "" {
|
||||
return f.name
|
||||
}
|
||||
return f.header.Filename
|
||||
}
|
||||
|
||||
func (f namedMultipartFile) Size() int64 {
|
||||
return f.header.Size
|
||||
}
|
||||
|
||||
func (f namedMultipartFile) ContentType() string {
|
||||
return f.header.Header.Get("Content-Type")
|
||||
}
|
||||
|
||||
func (f namedMultipartFile) Open() (io.ReadCloser, error) {
|
||||
return f.header.Open()
|
||||
}
|
||||
|
||||
func uploadIncomingFiles(r *http.Request) []services.IncomingFile {
|
||||
if r.MultipartForm == nil {
|
||||
return nil
|
||||
}
|
||||
files := make([]*multipart.FileHeader, 0)
|
||||
files = append(files, r.MultipartForm.File["file"]...)
|
||||
files = append(files, r.MultipartForm.File["sharex"]...)
|
||||
fileHeaders := r.MultipartForm.File["file"]
|
||||
shareXHeaders := r.MultipartForm.File["sharex"]
|
||||
paths := r.MultipartForm.Value["file_path"]
|
||||
files := make([]services.IncomingFile, 0, len(fileHeaders)+len(shareXHeaders))
|
||||
for index, header := range fileHeaders {
|
||||
name := ""
|
||||
if index < len(paths) {
|
||||
name = paths[index]
|
||||
}
|
||||
files = append(files, namedMultipartFile{header: header, name: name})
|
||||
}
|
||||
for _, header := range shareXHeaders {
|
||||
files = append(files, namedMultipartFile{header: header})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/config"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
@@ -127,7 +128,7 @@ func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
|
||||
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
|
||||
t.Fatalf("download page did not render text thumbnail image: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
|
||||
if !strings.Contains(body, "Open to preview or download") {
|
||||
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -218,6 +219,99 @@ func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadPageShowsProcessingFailure(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
payload := uploadThroughApp(t, app)
|
||||
box, err := app.uploadService.GetBox(payload.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox returned error: %v", err)
|
||||
}
|
||||
box.Files[0].Processing = false
|
||||
box.Files[0].ProcessingError = "Access Denied."
|
||||
if err := app.uploadService.SaveBox(box); err != nil {
|
||||
t.Fatalf("SaveBox returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
|
||||
request.SetPathValue("boxID", payload.BoxID)
|
||||
response := httptest.NewRecorder()
|
||||
app.DownloadPage(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{
|
||||
"Upload processing failed",
|
||||
"Access Denied.",
|
||||
"is-failed",
|
||||
"Failed",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("download page missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) {
|
||||
t.Fatalf("failed file still exposed download context: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadUsesOriginalFilename(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil)
|
||||
request.SetPathValue("boxID", payload.BoxID)
|
||||
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||
response := httptest.NewRecorder()
|
||||
app.DownloadFileContent(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
disposition := response.Header().Get("Content-Disposition")
|
||||
for _, want := range []string{
|
||||
`attachment;`,
|
||||
`filename="report final.txt"`,
|
||||
`filename*=UTF-8''report%20final.txt`,
|
||||
} {
|
||||
if !strings.Contains(disposition, want) {
|
||||
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
|
||||
}
|
||||
}
|
||||
if response.Body.String() != "hello" {
|
||||
t.Fatalf("body = %q", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil)
|
||||
request.SetPathValue("boxID", payload.BoxID)
|
||||
request.SetPathValue("fileID", payload.Files[0].ID)
|
||||
response := httptest.NewRecorder()
|
||||
app.DownloadFileContent(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
disposition := response.Header().Get("Content-Disposition")
|
||||
for _, want := range []string{
|
||||
`inline;`,
|
||||
`filename="r_sum_ 2026.txt"`,
|
||||
`filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`,
|
||||
} {
|
||||
if !strings.Contains(disposition, want) {
|
||||
t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -724,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
t.Fatalf("NewBanService returned error: %v", err)
|
||||
}
|
||||
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
|
||||
jobs.WaitForThumbnailJobs()
|
||||
if err := service.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
@@ -731,8 +826,12 @@ func newTestApp(t *testing.T) (*App, func()) {
|
||||
}
|
||||
|
||||
func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
|
||||
return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
|
||||
}
|
||||
|
||||
func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
|
||||
t.Helper()
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
|
||||
request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
app.Upload(response, request)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
@@ -16,7 +19,10 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
@@ -33,13 +39,21 @@ type ThumbnailJobResult struct {
|
||||
Failed int
|
||||
}
|
||||
|
||||
var thumbnailJobs sync.WaitGroup
|
||||
|
||||
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
|
||||
thumbnailJobs.Add(1)
|
||||
go func() {
|
||||
defer thumbnailJobs.Done()
|
||||
box, err := uploadService.GetBox(boxID)
|
||||
if err != nil {
|
||||
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
|
||||
return
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
logger.Warn("thumbnail one-shot skipped trouble box", "source", "thumbnail", "severity", "warn", "code", 4206, "box_id", boxID, "error", services.BoxTroubleReason(box))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
||||
if err != nil {
|
||||
@@ -52,6 +66,10 @@ func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger
|
||||
}()
|
||||
}
|
||||
|
||||
func WaitForThumbnailJobs() {
|
||||
thumbnailJobs.Wait()
|
||||
}
|
||||
|
||||
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
|
||||
return job{
|
||||
name: "thumbnail",
|
||||
@@ -86,6 +104,9 @@ func generateMissingThumbnails(uploadService *services.UploadService, logger *sl
|
||||
if !box.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
continue
|
||||
}
|
||||
|
||||
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
||||
result.Scanned += boxResult.Scanned
|
||||
@@ -104,29 +125,66 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
|
||||
if !box.ExpiresAt.After(time.Now().UTC()) {
|
||||
return result, nil
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i := range box.Files {
|
||||
file := &box.Files[i]
|
||||
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
||||
if file.Processing || services.FileHasTrouble(*file) {
|
||||
continue
|
||||
}
|
||||
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
||||
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
||||
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
||||
if !needsPrimary && !needsScenes && !needsArchive {
|
||||
continue
|
||||
}
|
||||
result.Scanned++
|
||||
|
||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||
if err != nil {
|
||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||
result.Failed++
|
||||
continue
|
||||
}
|
||||
if thumbnail == "" {
|
||||
result.Failed++
|
||||
continue
|
||||
if needsPrimary {
|
||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||
if err != nil {
|
||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||
result.Failed++
|
||||
} else if thumbnail == "" {
|
||||
result.Failed++
|
||||
} else {
|
||||
file.Thumbnail = thumbnail
|
||||
changed = true
|
||||
result.Generated++
|
||||
}
|
||||
}
|
||||
|
||||
file.Thumbnail = thumbnail
|
||||
changed = true
|
||||
result.Generated++
|
||||
if needsScenes {
|
||||
sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
|
||||
if err != nil {
|
||||
logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error())
|
||||
result.Failed++
|
||||
} else if sceneThumbnail == "" {
|
||||
result.Failed++
|
||||
} else {
|
||||
file.SceneThumbnail = sceneThumbnail
|
||||
changed = true
|
||||
result.Generated++
|
||||
}
|
||||
}
|
||||
|
||||
if needsArchive {
|
||||
archiveListing, err := generateArchiveListing(uploadService, box, *file)
|
||||
if err != nil {
|
||||
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
|
||||
result.Failed++
|
||||
} else if archiveListing == "" {
|
||||
result.Failed++
|
||||
} else {
|
||||
file.ArchiveListing = archiveListing
|
||||
file.ArchiveListingObjectKey = ""
|
||||
changed = true
|
||||
result.Generated++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
@@ -141,15 +199,44 @@ func needsThumbnail(file services.File) bool {
|
||||
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
||||
}
|
||||
|
||||
func needsVideoScenes(file services.File) bool {
|
||||
return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
|
||||
}
|
||||
|
||||
func NeedsThumbnail(file services.File) bool {
|
||||
return needsThumbnail(file)
|
||||
}
|
||||
|
||||
func NeedsVideoScenes(file services.File) bool {
|
||||
return needsVideoScenes(file)
|
||||
}
|
||||
|
||||
func NeedsArchiveListing(file services.File) bool {
|
||||
return needsArchiveListing(file)
|
||||
}
|
||||
|
||||
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
return generateThumbnail(uploadService, box, file)
|
||||
}
|
||||
|
||||
func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
return generateVideoScenesThumbnail(uploadService, box, file)
|
||||
}
|
||||
|
||||
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
return generateArchiveListing(uploadService, box, file)
|
||||
}
|
||||
|
||||
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
if services.BoxHasTrouble(box) {
|
||||
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||
}
|
||||
if file.Processing {
|
||||
return "", fmt.Errorf("file is still processing")
|
||||
}
|
||||
if services.FileHasTrouble(file) {
|
||||
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||
}
|
||||
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
||||
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||
if err != nil {
|
||||
@@ -184,6 +271,62 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box,
|
||||
}
|
||||
}
|
||||
|
||||
func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
if !needsVideoScenes(file) {
|
||||
return "", nil
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||
}
|
||||
if file.Processing {
|
||||
return "", fmt.Errorf("file is still processing")
|
||||
}
|
||||
if services.FileHasTrouble(file) {
|
||||
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||
}
|
||||
sceneName := "@scene@" + file.ID + ".jpg"
|
||||
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer object.Body.Close()
|
||||
|
||||
data, err := createVideoScenesThumbnail(file, object.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
||||
return sceneName, err
|
||||
}
|
||||
|
||||
func generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
||||
if !needsArchiveListing(file) {
|
||||
return "", nil
|
||||
}
|
||||
if services.BoxHasTrouble(box) {
|
||||
return "", fmt.Errorf("box is marked as trouble: %s", services.BoxTroubleReason(box))
|
||||
}
|
||||
if file.Processing {
|
||||
return "", fmt.Errorf("file is still processing")
|
||||
}
|
||||
if services.FileHasTrouble(file) {
|
||||
return "", fmt.Errorf("file processing failed: %s", file.ProcessingError)
|
||||
}
|
||||
listingName := "@archive@" + file.ID + ".json"
|
||||
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer object.Body.Close()
|
||||
|
||||
data, err := createArchiveListing(file, object.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
|
||||
return listingName, err
|
||||
}
|
||||
|
||||
func isTextThumbnailCandidate(file services.File) bool {
|
||||
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
@@ -205,6 +348,219 @@ func isTextThumbnailCandidate(file services.File) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func needsArchiveListing(file services.File) bool {
|
||||
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
||||
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
||||
contentType = strings.TrimSpace(contentType[:i])
|
||||
}
|
||||
switch contentType {
|
||||
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
|
||||
return true
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
switch ext {
|
||||
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func archiveListingCurrent(file services.File) bool {
|
||||
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
|
||||
}
|
||||
|
||||
type archiveTreeNode struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size,omitempty"`
|
||||
Dir bool `json:"dir"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Children map[string]*archiveTreeNode `json:"-"`
|
||||
Items []*archiveTreeNode `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type archiveListingData struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
FileCount int `json:"fileCount"`
|
||||
FolderCount int `json:"folderCount"`
|
||||
UncompressedSize uint64 `json:"uncompressedSize"`
|
||||
Root *archiveTreeNode `json:"root"`
|
||||
}
|
||||
|
||||
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
|
||||
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.Remove(sourceFile.Name())
|
||||
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||
sourceFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := sourceFile.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
archive, err := zip.OpenReader(sourceFile.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer archive.Close()
|
||||
|
||||
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
|
||||
var totalSize uint64
|
||||
var fileCount int
|
||||
var dirCount int
|
||||
for _, entry := range archive.File {
|
||||
name := strings.Trim(entry.Name, "/")
|
||||
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(name, "/")
|
||||
node := root
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if node.Children == nil {
|
||||
node.Children = map[string]*archiveTreeNode{}
|
||||
}
|
||||
child, ok := node.Children[part]
|
||||
if !ok {
|
||||
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
|
||||
node.Children[part] = child
|
||||
if child.Dir {
|
||||
dirCount++
|
||||
}
|
||||
}
|
||||
node = child
|
||||
}
|
||||
if !entry.FileInfo().IsDir() {
|
||||
node.Dir = false
|
||||
node.Size = entry.UncompressedSize64
|
||||
totalSize += entry.UncompressedSize64
|
||||
fileCount++
|
||||
}
|
||||
}
|
||||
|
||||
finalizeArchiveTree(root)
|
||||
data := archiveListingData{
|
||||
Name: file.Name,
|
||||
Type: archiveLabel(file),
|
||||
FileCount: fileCount,
|
||||
FolderCount: dirCount,
|
||||
UncompressedSize: totalSize,
|
||||
Root: root,
|
||||
}
|
||||
return json.MarshalIndent(data, "", " ")
|
||||
}
|
||||
|
||||
func finalizeArchiveTree(node *archiveTreeNode) {
|
||||
node.Items = sortedArchiveChildren(node)
|
||||
for _, child := range node.Items {
|
||||
if child.Dir {
|
||||
child.Icon = "folder"
|
||||
finalizeArchiveTree(child)
|
||||
} else {
|
||||
child.Icon = archiveFileIconName(child.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
|
||||
children := sortedArchiveChildren(node)
|
||||
for i, child := range children {
|
||||
last := i == len(children)-1
|
||||
branch := "|-- "
|
||||
nextPrefix := prefix + "| "
|
||||
if last {
|
||||
branch = "`-- "
|
||||
nextPrefix = prefix + " "
|
||||
}
|
||||
|
||||
out.WriteString(prefix)
|
||||
out.WriteString(branch)
|
||||
out.WriteString(archiveNodeLabel(child))
|
||||
out.WriteString("\n")
|
||||
if child.Dir {
|
||||
writeArchiveTree(out, child, nextPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
|
||||
children := make([]*archiveTreeNode, 0, len(node.Children))
|
||||
for _, child := range node.Children {
|
||||
children = append(children, child)
|
||||
}
|
||||
sort.Slice(children, func(i, j int) bool {
|
||||
if children[i].Dir != children[j].Dir {
|
||||
return children[i].Dir
|
||||
}
|
||||
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
|
||||
})
|
||||
return children
|
||||
}
|
||||
|
||||
func archiveNodeLabel(node *archiveTreeNode) string {
|
||||
if node.Dir {
|
||||
return "[DIR] " + node.Name + "/"
|
||||
}
|
||||
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
|
||||
}
|
||||
|
||||
func archiveFileIcon(name string) string {
|
||||
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
|
||||
}
|
||||
|
||||
func archiveFileIconName(name string) string {
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
|
||||
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
|
||||
return "img"
|
||||
case "mp4", "mov", "webm", "mkv", "avi":
|
||||
return "vid"
|
||||
case "mp3", "wav", "flac", "ogg", "m4a":
|
||||
return "aud"
|
||||
case "md", "txt", "log", "csv":
|
||||
return "txt"
|
||||
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
|
||||
return "code"
|
||||
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
|
||||
return "arc"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func archiveLabel(file services.File) string {
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
if ext != "" {
|
||||
return strings.ToUpper(ext) + " archive"
|
||||
}
|
||||
if file.ContentType != "" {
|
||||
return file.ContentType
|
||||
}
|
||||
return "ZIP-compatible archive"
|
||||
}
|
||||
|
||||
func formatArchiveBytes(size uint64) string {
|
||||
const unit = 1024
|
||||
if size < unit {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
div := float64(unit)
|
||||
value := float64(size) / div
|
||||
units := []string{"KiB", "MiB", "GiB", "TiB"}
|
||||
for _, suffix := range units {
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%.1f %s", value, suffix)
|
||||
}
|
||||
value /= div
|
||||
}
|
||||
return fmt.Sprintf("%.1f PiB", value)
|
||||
}
|
||||
|
||||
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
||||
img, _, err := image.Decode(source)
|
||||
if err != nil {
|
||||
@@ -233,17 +589,320 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
||||
if err := sourceFile.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
||||
sourcePath := sourceFile.Name()
|
||||
candidates := []string{"00:00:01", "00:00:03", "00:00:06"}
|
||||
var fallback []byte
|
||||
for _, timestamp := range candidates {
|
||||
targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPath := targetFile.Name()
|
||||
targetFile.Close()
|
||||
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
|
||||
os.Remove(targetPath)
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(targetPath)
|
||||
os.Remove(targetPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(fallback) == 0 {
|
||||
fallback = data
|
||||
}
|
||||
if usableVideoFrame(data) {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath)
|
||||
if err == nil {
|
||||
img, err := jpeg.Decode(bytes.NewReader(scenes))
|
||||
if err == nil {
|
||||
thumb := resizeNearest(img, 360, 240)
|
||||
var target bytes.Buffer
|
||||
if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil {
|
||||
return target.Bytes(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fallback) > 0 {
|
||||
return fallback, nil
|
||||
}
|
||||
return nil, fmt.Errorf("could not extract a usable video thumbnail")
|
||||
}
|
||||
|
||||
func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPath := targetFile.Name()
|
||||
targetFile.Close()
|
||||
defer os.Remove(targetPath)
|
||||
if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil {
|
||||
defer os.Remove(sourceFile.Name())
|
||||
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||
sourceFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(targetPath)
|
||||
if err := sourceFile.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return createVideoScenesThumbnailFromPath(file, sourceFile.Name())
|
||||
}
|
||||
|
||||
func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) {
|
||||
info := probeVideoInfo(sourcePath, file)
|
||||
timestamps := videoSceneTimestamps(info.Duration)
|
||||
frames := make([]videoSceneFrame, 0, len(timestamps))
|
||||
|
||||
for _, timestamp := range timestamps {
|
||||
targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
targetPath := targetFile.Name()
|
||||
targetFile.Close()
|
||||
if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil {
|
||||
os.Remove(targetPath)
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(targetPath)
|
||||
os.Remove(targetPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img})
|
||||
}
|
||||
|
||||
return renderVideoScenesThumbnail(file, info, frames), nil
|
||||
}
|
||||
|
||||
func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error {
|
||||
return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run()
|
||||
}
|
||||
|
||||
type videoSceneFrame struct {
|
||||
Timestamp string
|
||||
Image image.Image
|
||||
}
|
||||
|
||||
type videoInfo struct {
|
||||
Codec string
|
||||
Width int
|
||||
Height int
|
||||
Duration float64
|
||||
FrameRate string
|
||||
}
|
||||
|
||||
func probeVideoInfo(sourcePath string, file services.File) videoInfo {
|
||||
info := videoInfo{Codec: "unknown", FrameRate: "unknown"}
|
||||
output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output()
|
||||
if err != nil {
|
||||
if file.ContentType != "" {
|
||||
info.Codec = file.ContentType
|
||||
}
|
||||
return info
|
||||
}
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok || value == "" || value == "N/A" {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "codec_name":
|
||||
info.Codec = value
|
||||
case "width":
|
||||
info.Width, _ = strconv.Atoi(value)
|
||||
case "height":
|
||||
info.Height, _ = strconv.Atoi(value)
|
||||
case "duration":
|
||||
info.Duration, _ = strconv.ParseFloat(value, 64)
|
||||
case "avg_frame_rate":
|
||||
info.FrameRate = simplifyFrameRate(value)
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func simplifyFrameRate(value string) string {
|
||||
if value == "0/0" || value == "" {
|
||||
return "unknown"
|
||||
}
|
||||
parts := strings.Split(value, "/")
|
||||
if len(parts) != 2 {
|
||||
return value
|
||||
}
|
||||
n, errN := strconv.ParseFloat(parts[0], 64)
|
||||
d, errD := strconv.ParseFloat(parts[1], 64)
|
||||
if errN != nil || errD != nil || d == 0 {
|
||||
return value
|
||||
}
|
||||
return fmt.Sprintf("%.2f fps", n/d)
|
||||
}
|
||||
|
||||
func videoSceneTimestamps(duration float64) []string {
|
||||
if duration > 4 {
|
||||
points := []float64{0.12, 0.33, 0.58, 0.82}
|
||||
timestamps := make([]string, 0, len(points))
|
||||
for _, point := range points {
|
||||
seconds := duration * point
|
||||
if seconds < 1 {
|
||||
seconds = 1
|
||||
}
|
||||
timestamps = append(timestamps, secondsToTimestamp(seconds))
|
||||
}
|
||||
return timestamps
|
||||
}
|
||||
return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"}
|
||||
}
|
||||
|
||||
func secondsToTimestamp(seconds float64) string {
|
||||
total := int(seconds + 0.5)
|
||||
hours := total / 3600
|
||||
minutes := total % 3600 / 60
|
||||
secs := total % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
func usableVideoFrame(data []byte) bool {
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return averageLuma(img) >= 18
|
||||
}
|
||||
|
||||
func averageLuma(img image.Image) float64 {
|
||||
bounds := img.Bounds()
|
||||
width := bounds.Dx()
|
||||
height := bounds.Dy()
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0
|
||||
}
|
||||
stepX := max(1, width/80)
|
||||
stepY := max(1, height/80)
|
||||
var total float64
|
||||
var samples int
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x += stepX {
|
||||
r, g, b, _ := img.At(x, y).RGBA()
|
||||
total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8)
|
||||
samples++
|
||||
}
|
||||
}
|
||||
if samples == 0 {
|
||||
return 0
|
||||
}
|
||||
return total / float64(samples)
|
||||
}
|
||||
|
||||
func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte {
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630))
|
||||
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff})
|
||||
drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff})
|
||||
drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff})
|
||||
drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff})
|
||||
drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff})
|
||||
|
||||
face := basicfont.Face7x13
|
||||
drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
|
||||
drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff})
|
||||
|
||||
meta := videoMetaLines(file, info)
|
||||
y := 122
|
||||
for _, line := range meta {
|
||||
drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff})
|
||||
y += 20
|
||||
}
|
||||
|
||||
cells := []image.Rectangle{
|
||||
image.Rect(62, 212, 586, 388),
|
||||
image.Rect(614, 212, 1138, 388),
|
||||
image.Rect(62, 414, 586, 566),
|
||||
image.Rect(614, 414, 1138, 566),
|
||||
}
|
||||
for i, rect := range cells {
|
||||
drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff})
|
||||
if i < len(frames) {
|
||||
drawImageCover(canvas, rect, frames[i].Image)
|
||||
drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc})
|
||||
drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff})
|
||||
} else {
|
||||
drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
|
||||
}
|
||||
}
|
||||
|
||||
var target bytes.Buffer
|
||||
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86})
|
||||
return target.Bytes()
|
||||
}
|
||||
|
||||
func videoMetaLines(file services.File, info videoInfo) []string {
|
||||
resolution := "unknown resolution"
|
||||
if info.Width > 0 && info.Height > 0 {
|
||||
resolution = fmt.Sprintf("%dx%d", info.Width, info.Height)
|
||||
}
|
||||
duration := "unknown duration"
|
||||
if info.Duration > 0 {
|
||||
duration = secondsToHumanDuration(info.Duration)
|
||||
}
|
||||
contentType := file.ContentType
|
||||
if contentType == "" {
|
||||
contentType = "video"
|
||||
}
|
||||
return []string{
|
||||
"Duration: " + duration + " Codec: " + info.Codec,
|
||||
"Resolution: " + resolution + " Frame rate: " + info.FrameRate,
|
||||
"Type: " + contentType + " Generated by Warpbox",
|
||||
}
|
||||
}
|
||||
|
||||
func secondsToHumanDuration(seconds float64) string {
|
||||
total := int(seconds + 0.5)
|
||||
hours := total / 3600
|
||||
minutes := total % 3600 / 60
|
||||
secs := total % 60
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
return fmt.Sprintf("%d:%02d", minutes, secs)
|
||||
}
|
||||
|
||||
func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) {
|
||||
bounds := src.Bounds()
|
||||
srcW := bounds.Dx()
|
||||
srcH := bounds.Dy()
|
||||
dstW := rect.Dx()
|
||||
dstH := rect.Dy()
|
||||
if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
srcRatio := float64(srcW) / float64(srcH)
|
||||
dstRatio := float64(dstW) / float64(dstH)
|
||||
crop := bounds
|
||||
if srcRatio > dstRatio {
|
||||
newW := int(float64(srcH) * dstRatio)
|
||||
x0 := bounds.Min.X + (srcW-newW)/2
|
||||
crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y)
|
||||
} else if srcRatio < dstRatio {
|
||||
newH := int(float64(srcW) / dstRatio)
|
||||
y0 := bounds.Min.Y + (srcH-newH)/2
|
||||
crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH)
|
||||
}
|
||||
|
||||
for y := rect.Min.Y; y < rect.Max.Y; y++ {
|
||||
for x := rect.Min.X; x < rect.Max.X; x++ {
|
||||
u := float64(x-rect.Min.X) / float64(dstW)
|
||||
v := float64(y-rect.Min.Y) / float64(dstH)
|
||||
srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx())))
|
||||
srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy())))
|
||||
dst.Set(x, y, src.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
@@ -48,6 +50,36 @@ func TestGenerateMissingThumbnailsForBox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMissingThumbnailsForTroubleBoxSkipsWork(t *testing.T) {
|
||||
service := newThumbnailTestUploadService(t)
|
||||
result := createThumbnailTestBox(t, service)
|
||||
box, err := service.GetBox(result.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox returned error: %v", err)
|
||||
}
|
||||
box.Trouble = true
|
||||
box.TroubleReason = "storage backend failed"
|
||||
if err := service.SaveBox(box); err != nil {
|
||||
t.Fatalf("SaveBox returned error: %v", err)
|
||||
}
|
||||
|
||||
jobResult, err := generateMissingThumbnailsForBox(service, slog.New(slog.NewTextHandler(io.Discard, nil)), box)
|
||||
if err != nil {
|
||||
t.Fatalf("generateMissingThumbnailsForBox returned error: %v", err)
|
||||
}
|
||||
if jobResult != (ThumbnailJobResult{}) {
|
||||
t.Fatalf("job result = %+v, want no work for trouble box", jobResult)
|
||||
}
|
||||
|
||||
updated, err := service.GetBox(result.BoxID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBox after job returned error: %v", err)
|
||||
}
|
||||
if updated.Files[0].Thumbnail != "" {
|
||||
t.Fatalf("thumbnail was generated for trouble box: %+v", updated.Files[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTextThumbnailRendersMarkdownAsJPEG(t *testing.T) {
|
||||
data, err := createTextThumbnail(services.File{
|
||||
Name: "notes.md",
|
||||
@@ -72,6 +104,97 @@ func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsableVideoFrameRejectsBlackFrame(t *testing.T) {
|
||||
var dark bytes.Buffer
|
||||
if err := jpeg.Encode(&dark, solidTestImage(color.RGBA{A: 255}), nil); err != nil {
|
||||
t.Fatalf("jpeg.Encode dark returned error: %v", err)
|
||||
}
|
||||
if usableVideoFrame(dark.Bytes()) {
|
||||
t.Fatalf("black video frame should not be usable")
|
||||
}
|
||||
|
||||
var bright bytes.Buffer
|
||||
if err := jpeg.Encode(&bright, solidTestImage(color.RGBA{R: 180, G: 80, B: 40, A: 255}), nil); err != nil {
|
||||
t.Fatalf("jpeg.Encode bright returned error: %v", err)
|
||||
}
|
||||
if !usableVideoFrame(bright.Bytes()) {
|
||||
t.Fatalf("bright video frame should be usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) {
|
||||
data := renderVideoScenesThumbnail(
|
||||
services.File{Name: "clip.mp4", ContentType: "video/mp4"},
|
||||
videoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 125, FrameRate: "24.00 fps"},
|
||||
[]videoSceneFrame{
|
||||
{Timestamp: "00:00:10", Image: solidTestImage(color.RGBA{R: 140, G: 40, B: 80, A: 255})},
|
||||
{Timestamp: "00:00:35", Image: solidTestImage(color.RGBA{R: 40, G: 120, B: 150, A: 255})},
|
||||
},
|
||||
)
|
||||
img, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("jpeg.Decode returned error: %v", err)
|
||||
}
|
||||
if img.Bounds().Dx() != 1200 || img.Bounds().Dy() != 630 {
|
||||
t.Fatalf("scene preview size = %dx%d, want 1200x630", img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArchiveListingRendersZipTree(t *testing.T) {
|
||||
var archive bytes.Buffer
|
||||
writer := zip.NewWriter(&archive)
|
||||
addZipTestFile(t, writer, "docs/readme.md", "hello")
|
||||
addZipTestFile(t, writer, "src/main.go", "package main\n")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("zip.Close returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("createArchiveListing returned error: %v", err)
|
||||
}
|
||||
var listing archiveListingData
|
||||
if err := json.Unmarshal(data, &listing); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data))
|
||||
}
|
||||
if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 {
|
||||
t.Fatalf("archive listing metadata = %+v", listing)
|
||||
}
|
||||
if listing.Root == nil || len(listing.Root.Items) != 2 {
|
||||
t.Fatalf("archive listing root = %+v", listing.Root)
|
||||
}
|
||||
if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" {
|
||||
t.Fatalf("first archive folder = %+v", listing.Root.Items[0])
|
||||
}
|
||||
if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" {
|
||||
t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0])
|
||||
}
|
||||
if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" {
|
||||
t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) {
|
||||
t.Helper()
|
||||
file, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip.Create returned error: %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("zip file write returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func solidTestImage(c color.Color) image.Image {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 32, 24))
|
||||
for y := 0; y < img.Bounds().Dy(); y++ {
|
||||
for x := 0; x < img.Bounds().Dx(); x++ {
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
|
||||
t.Helper()
|
||||
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
@@ -319,7 +319,7 @@ func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (Uplo
|
||||
}
|
||||
box.Files = append(box.Files, File{
|
||||
ID: fileID,
|
||||
Name: filepath.Base(incoming.Name),
|
||||
Name: cleanUploadDisplayName(incoming.Name),
|
||||
StoredName: storedName,
|
||||
Size: incoming.Size,
|
||||
ContentType: contentType,
|
||||
@@ -369,19 +369,20 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context,
|
||||
}
|
||||
backend, err := s.storage.Backend(box.StorageBackendID)
|
||||
if err != nil {
|
||||
_ = s.markProcessingBoxFailed(box, err)
|
||||
return UploadResult{}, err
|
||||
}
|
||||
for i, incoming := range staged {
|
||||
source, err := incoming.Open()
|
||||
if err != nil {
|
||||
_ = s.markProcessingBoxFailed(box, err)
|
||||
return UploadResult{}, err
|
||||
}
|
||||
file := box.Files[i]
|
||||
if err := s.writeUploadedObject(ctx, backend, file.ObjectKey, source, incoming.Size(), 0, incoming.ContentType()); err != nil {
|
||||
source.Close()
|
||||
_ = backend.Delete(context.Background(), file.ObjectKey)
|
||||
box.Files[i].ProcessingError = err.Error()
|
||||
_ = s.saveBoxRecord(box)
|
||||
_ = s.markProcessingBoxFailed(box, err)
|
||||
return UploadResult{}, err
|
||||
}
|
||||
source.Close()
|
||||
@@ -406,6 +407,35 @@ func (s *UploadService) FinalizeProcessingResumableSession(ctx context.Context,
|
||||
return s.resultForBox(box, ""), nil
|
||||
}
|
||||
|
||||
func (s *UploadService) markProcessingBoxFailed(box Box, cause error) error {
|
||||
message := "upload processing failed"
|
||||
if cause != nil && strings.TrimSpace(cause.Error()) != "" {
|
||||
message = cause.Error()
|
||||
}
|
||||
s.logger.Warn("resumable upload box marked failed", "source", "user-upload", "severity", "warn", "code", 4021, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "files", len(box.Files), "error", message)
|
||||
now := time.Now().UTC()
|
||||
box.Trouble = true
|
||||
box.TroubleReason = message
|
||||
for i := range box.Files {
|
||||
if box.Files[i].Processing || box.Files[i].ProcessingError == "" {
|
||||
box.Files[i].Processing = false
|
||||
box.Files[i].ProcessingError = message
|
||||
if box.Files[i].UploadedAt.IsZero() {
|
||||
box.Files[i].UploadedAt = now
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.saveBoxRecord(box); err != nil {
|
||||
s.logger.Warn("failed to save failed upload box state", "source", "user-upload", "severity", "warn", "code", 4022, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error())
|
||||
return err
|
||||
}
|
||||
if err := s.writeBoxMetadata(box); err != nil {
|
||||
s.logger.Warn("failed to write failed upload box metadata", "source", "user-upload", "severity", "warn", "code", 4023, "box_id", box.ID, "backend_id", s.BoxStorageBackendID(box), "error", err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UploadService) CompleteUploadedResumableSession(ctx context.Context, sessionID string) (UploadResult, ResumableSession, error) {
|
||||
session, err := s.GetResumableSession(sessionID)
|
||||
if err != nil {
|
||||
@@ -527,7 +557,7 @@ func (s *UploadService) saveResumableSession(session ResumableSession) error {
|
||||
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
|
||||
sessionFiles := make([]ResumableFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
file.Name = filepath.Base(strings.TrimSpace(file.Name))
|
||||
file.Name = cleanUploadDisplayName(file.Name)
|
||||
if file.Name == "." || file.Name == "" {
|
||||
return nil, fmt.Errorf("file name is required")
|
||||
}
|
||||
@@ -564,7 +594,7 @@ func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts
|
||||
}
|
||||
|
||||
func resumableFileKey(name string, size int64, fingerprint string) string {
|
||||
return strings.TrimSpace(fingerprint) + "|" + filepath.Base(strings.TrimSpace(name)) + "|" + fmt.Sprintf("%d", size)
|
||||
return strings.TrimSpace(fingerprint) + "|" + cleanUploadDisplayName(name) + "|" + fmt.Sprintf("%d", size)
|
||||
}
|
||||
|
||||
type resumableIncomingFile struct {
|
||||
|
||||
@@ -35,26 +35,35 @@ func (b *s3StorageBackend) ID() string { return b.cfg.ID }
|
||||
func (b *s3StorageBackend) Type() string { return StorageBackendS3 }
|
||||
|
||||
func (b *s3StorageBackend) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||
cleanKey := cleanObjectKey(key)
|
||||
opts := minio.PutObjectOptions{ContentType: contentType}
|
||||
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanObjectKey(key), body, size, opts)
|
||||
return err
|
||||
_, err := b.client.PutObject(ctx, b.cfg.Bucket, cleanKey, body, size, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("s3 put object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Get(ctx context.Context, key string) (StorageObject, error) {
|
||||
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.GetObjectOptions{})
|
||||
cleanKey := cleanObjectKey(key)
|
||||
object, err := b.client.GetObject(ctx, b.cfg.Bucket, cleanKey, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
return StorageObject{}, fmt.Errorf("s3 get object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||
}
|
||||
info, err := object.Stat()
|
||||
if err != nil {
|
||||
object.Close()
|
||||
return StorageObject{}, err
|
||||
return StorageObject{}, fmt.Errorf("s3 stat object %q in bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||
}
|
||||
return StorageObject{Key: key, Size: info.Size, ContentType: info.ContentType, ModTime: info.LastModified, Body: object}, nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) Delete(ctx context.Context, key string) error {
|
||||
return b.client.RemoveObject(ctx, b.cfg.Bucket, cleanObjectKey(key), minio.RemoveObjectOptions{})
|
||||
cleanKey := cleanObjectKey(key)
|
||||
if err := b.client.RemoveObject(ctx, b.cfg.Bucket, cleanKey, minio.RemoveObjectOptions{}); err != nil {
|
||||
return fmt.Errorf("s3 delete object %q from bucket %q failed: %w", cleanKey, b.cfg.Bucket, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) error {
|
||||
@@ -62,7 +71,7 @@ func (b *s3StorageBackend) DeletePrefix(ctx context.Context, prefix string) erro
|
||||
objects := b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true})
|
||||
for object := range objects {
|
||||
if object.Err != nil {
|
||||
return object.Err
|
||||
return fmt.Errorf("s3 list prefix %q in bucket %q failed: %w", prefix, b.cfg.Bucket, object.Err)
|
||||
}
|
||||
if err := b.Delete(ctx, object.Key); err != nil {
|
||||
return err
|
||||
@@ -75,7 +84,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
var total int64
|
||||
for object := range b.client.ListObjects(ctx, b.cfg.Bucket, minio.ListObjectsOptions{Recursive: true}) {
|
||||
if object.Err != nil {
|
||||
return 0, object.Err
|
||||
return 0, fmt.Errorf("s3 usage list bucket %q failed: %w", b.cfg.Bucket, object.Err)
|
||||
}
|
||||
total += object.Size
|
||||
}
|
||||
@@ -85,7 +94,7 @@ func (b *s3StorageBackend) Usage(ctx context.Context) (int64, error) {
|
||||
func (b *s3StorageBackend) Test(ctx context.Context) error {
|
||||
exists, err := b.client.BucketExists(ctx, b.cfg.Bucket)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("s3 bucket check for %q failed: %w", b.cfg.Bucket, err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("bucket %q does not exist", b.cfg.Bucket)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -117,22 +118,59 @@ type Box struct {
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
CreatorIP string `json:"creatorIp,omitempty"`
|
||||
StorageBackendID string `json:"storageBackendId,omitempty"`
|
||||
Trouble bool `json:"trouble,omitempty"`
|
||||
TroubleReason string `json:"troubleReason,omitempty"`
|
||||
Files []File `json:"files"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StoredName string `json:"storedName"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
PreviewKind string `json:"previewKind"`
|
||||
Thumbnail string `json:"thumbnail,omitempty"`
|
||||
ObjectKey string `json:"objectKey,omitempty"`
|
||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||
Processing bool `json:"processing,omitempty"`
|
||||
ProcessingError string `json:"processingError,omitempty"`
|
||||
UploadedAt time.Time `json:"uploadedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StoredName string `json:"storedName"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType"`
|
||||
PreviewKind string `json:"previewKind"`
|
||||
Thumbnail string `json:"thumbnail,omitempty"`
|
||||
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||
ArchiveListing string `json:"archiveListing,omitempty"`
|
||||
ObjectKey string `json:"objectKey,omitempty"`
|
||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
||||
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
|
||||
Processing bool `json:"processing,omitempty"`
|
||||
ProcessingError string `json:"processingError,omitempty"`
|
||||
UploadedAt time.Time `json:"uploadedAt"`
|
||||
}
|
||||
|
||||
func BoxHasTrouble(box Box) bool {
|
||||
if box.Trouble || strings.TrimSpace(box.TroubleReason) != "" {
|
||||
return true
|
||||
}
|
||||
for _, file := range box.Files {
|
||||
if FileHasTrouble(file) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func BoxTroubleReason(box Box) string {
|
||||
if strings.TrimSpace(box.TroubleReason) != "" {
|
||||
return box.TroubleReason
|
||||
}
|
||||
for _, file := range box.Files {
|
||||
if strings.TrimSpace(file.ProcessingError) != "" {
|
||||
return file.ProcessingError
|
||||
}
|
||||
}
|
||||
if box.Trouble {
|
||||
return "box has failed processing"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func FileHasTrouble(file File) bool {
|
||||
return strings.TrimSpace(file.ProcessingError) != ""
|
||||
}
|
||||
|
||||
type UploadResult struct {
|
||||
@@ -415,7 +453,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
||||
|
||||
box.Files = append(box.Files, File{
|
||||
ID: fileID,
|
||||
Name: filepath.Base(incoming.Name()),
|
||||
Name: cleanUploadDisplayName(incoming.Name()),
|
||||
StoredName: storedName,
|
||||
Size: incoming.Size(),
|
||||
ContentType: contentType,
|
||||
@@ -427,6 +465,36 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanUploadDisplayName(name string) string {
|
||||
clean := strings.TrimSpace(strings.ReplaceAll(name, "\\", "/"))
|
||||
clean = strings.TrimLeft(clean, "/")
|
||||
clean = path.Clean(clean)
|
||||
if clean == "." || clean == "/" || clean == "" {
|
||||
return "download"
|
||||
}
|
||||
parts := strings.Split(clean, "/")
|
||||
safeParts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" || part == "." || part == ".." {
|
||||
continue
|
||||
}
|
||||
part = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f || r == '/' || r == '\\' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, part)
|
||||
if part != "" {
|
||||
safeParts = append(safeParts, part)
|
||||
}
|
||||
}
|
||||
if len(safeParts) == 0 {
|
||||
return "download"
|
||||
}
|
||||
return strings.Join(safeParts, "/")
|
||||
}
|
||||
|
||||
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||
var box Box
|
||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||
@@ -731,6 +799,12 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
|
||||
if key := s.ThumbnailObjectKey(box, file); key != "" {
|
||||
_ = backend.Delete(context.Background(), key)
|
||||
}
|
||||
if key := s.SceneThumbnailObjectKey(box, file); key != "" {
|
||||
_ = backend.Delete(context.Background(), key)
|
||||
}
|
||||
if key := s.ArchiveListingObjectKey(box, file); key != "" {
|
||||
_ = backend.Delete(context.Background(), key)
|
||||
}
|
||||
}
|
||||
|
||||
box.Files = append(box.Files[:index], box.Files[index+1:]...)
|
||||
@@ -818,6 +892,26 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string {
|
||||
return boxObjectKey(box.ID, file.Thumbnail)
|
||||
}
|
||||
|
||||
func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string {
|
||||
if file.SceneThumbnailObjectKey != "" {
|
||||
return file.SceneThumbnailObjectKey
|
||||
}
|
||||
if file.SceneThumbnail == "" {
|
||||
return ""
|
||||
}
|
||||
return boxObjectKey(box.ID, file.SceneThumbnail)
|
||||
}
|
||||
|
||||
func (s *UploadService) ArchiveListingObjectKey(box Box, file File) string {
|
||||
if file.ArchiveListingObjectKey != "" {
|
||||
return file.ArchiveListingObjectKey
|
||||
}
|
||||
if file.ArchiveListing == "" {
|
||||
return ""
|
||||
}
|
||||
return boxObjectKey(box.ID, file.ArchiveListing)
|
||||
}
|
||||
|
||||
func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||
if file.Processing {
|
||||
return StorageObject{}, fmt.Errorf("file is still processing")
|
||||
@@ -841,6 +935,30 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F
|
||||
return backend.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||
key := s.SceneThumbnailObjectKey(box, file)
|
||||
if key == "" {
|
||||
return StorageObject{}, os.ErrNotExist
|
||||
}
|
||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return backend.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (s *UploadService) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
|
||||
key := s.ArchiveListingObjectKey(box, file)
|
||||
if key == "" {
|
||||
return StorageObject{}, os.ErrNotExist
|
||||
}
|
||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||
if err != nil {
|
||||
return StorageObject{}, err
|
||||
}
|
||||
return backend.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) {
|
||||
backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
|
||||
if err != nil {
|
||||
|
||||
@@ -230,6 +230,47 @@ func TestResumableCompleteRejectsMissingChunks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessingResumableFailureMarksBoxFailed(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
session, err := service.CreateResumableSession([]ResumableFileInput{{
|
||||
Name: "note.txt",
|
||||
Size: 4,
|
||||
ContentType: "text/plain",
|
||||
}}, UploadOptions{MaxDays: 1, StorageBackendID: "missing"}, 4, time.Hour, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateResumableSession returned error: %v", err)
|
||||
}
|
||||
if _, err := service.PutResumableChunk(testContext(), session.ID, session.Files[0].ID, 0, strings.NewReader("note")); err != nil {
|
||||
t.Fatalf("PutResumableChunk returned error: %v", err)
|
||||
}
|
||||
result, processing, err := service.CreateProcessingBoxFromResumable(session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProcessingBoxFromResumable returned error: %v", err)
|
||||
}
|
||||
if processing.Status != ResumableStatusProcessing {
|
||||
t.Fatalf("session status = %q, want processing", processing.Status)
|
||||
}
|
||||
if _, err := service.FinalizeProcessingResumableSession(testContext(), session.ID); err == nil {
|
||||
t.Fatalf("FinalizeProcessingResumableSession accepted missing backend")
|
||||
}
|
||||
box := getTestBox(t, service, result.BoxID)
|
||||
if len(box.Files) != 1 {
|
||||
t.Fatalf("box files = %+v", box.Files)
|
||||
}
|
||||
if box.Files[0].Processing {
|
||||
t.Fatalf("failed file is still marked processing: %+v", box.Files[0])
|
||||
}
|
||||
if box.Files[0].ProcessingError == "" {
|
||||
t.Fatalf("failed file did not store processing error: %+v", box.Files[0])
|
||||
}
|
||||
if !box.Trouble {
|
||||
t.Fatalf("failed box was not marked as trouble: %+v", box)
|
||||
}
|
||||
if box.TroubleReason == "" {
|
||||
t.Fatalf("failed box did not store trouble reason: %+v", box)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumablePartialCompleteKeepsOnlyFinishedFiles(t *testing.T) {
|
||||
service := newTestUploadService(t)
|
||||
session, err := service.CreateResumableSession([]ResumableFileInput{
|
||||
|
||||
81
backend/static/api/warpbox.ps1
Normal file
81
backend/static/api/warpbox.ps1
Normal file
@@ -0,0 +1,81 @@
|
||||
#requires -version 5
|
||||
<#
|
||||
.SYNOPSIS
|
||||
warpbox: command line uploader for Warpbox
|
||||
.DESCRIPTION
|
||||
Set the server once, then upload anything:
|
||||
setx WARPBOX_HOST "https://your.warpbox.host"
|
||||
warpbox .\report.pdf
|
||||
|
||||
Install (PowerShell):
|
||||
iwr "$env:WARPBOX_HOST/static/api/warpbox.ps1" -OutFile $HOME\warpbox.ps1
|
||||
# add a function to your $PROFILE: function warpbox { & "$HOME\warpbox.ps1" @args }
|
||||
|
||||
Auth: set the token once so it never lands in your command history.
|
||||
setx WARPBOX_TOKEN "wbx_your_token"
|
||||
Create a token under Account, Access tokens.
|
||||
|
||||
.EXAMPLE
|
||||
.\warpbox.ps1 .\report.pdf
|
||||
.EXAMPLE
|
||||
.\warpbox.ps1 -Password 123 -Expiry 2d .\photo.png .\clip.mp4
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Alias('p')][string]$Password,
|
||||
[Alias('e')][string]$Expiry,
|
||||
[Alias('n')][int]$MaxDownloads,
|
||||
[Alias('o')][switch]$Obfuscate,
|
||||
[string]$Server = $env:WARPBOX_HOST,
|
||||
[string]$Auth = $env:WARPBOX_TOKEN,
|
||||
[string]$AuthFile,
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Files
|
||||
)
|
||||
|
||||
if ($Help -or -not $Files) {
|
||||
Write-Host 'warpbox: upload files to Warpbox'
|
||||
Write-Host 'USAGE: warpbox.ps1 [-Password pw] [-Expiry 2d] [-MaxDownloads n] [-Obfuscate] [-Json] <file> [file ...]'
|
||||
Write-Host 'SERVER: set WARPBOX_HOST in your environment (setx WARPBOX_HOST "https://your.host")'
|
||||
Write-Host 'AUTH: set WARPBOX_TOKEN in your environment (setx WARPBOX_TOKEN "wbx_...")'
|
||||
if (-not $Files -and -not $Help) { exit 2 } else { exit 0 }
|
||||
}
|
||||
|
||||
if (-not $Server) {
|
||||
Write-Error 'warpbox: no server set. Use -Server <url> or set WARPBOX_HOST'
|
||||
exit 2
|
||||
}
|
||||
if ($AuthFile) { $Auth = (Get-Content -Raw $AuthFile).Trim() }
|
||||
|
||||
function ConvertTo-Minutes($v) {
|
||||
if ($v -match '^(\d+)([mhdw]?)$') {
|
||||
$n = [int]$Matches[1]
|
||||
switch ($Matches[2]) {
|
||||
'h' { return $n * 60 }
|
||||
'd' { return $n * 1440 }
|
||||
'w' { return $n * 10080 }
|
||||
default { return $n }
|
||||
}
|
||||
}
|
||||
return $v
|
||||
}
|
||||
|
||||
# Expand wildcards (PowerShell does not expand them in arguments).
|
||||
$expanded = @()
|
||||
foreach ($f in $Files) {
|
||||
$hits = Get-ChildItem -Path $f -File -ErrorAction SilentlyContinue
|
||||
if ($hits) { $expanded += $hits.FullName } else { $expanded += $f }
|
||||
}
|
||||
|
||||
$curlArgs = @('-fS')
|
||||
foreach ($f in $expanded) { $curlArgs += @('-F', "file=@$f") }
|
||||
if ($Password) { $curlArgs += @('-F', "password=$Password") }
|
||||
if ($Expiry) { $curlArgs += @('-F', "expires_minutes=$(ConvertTo-Minutes $Expiry)") }
|
||||
if ($MaxDownloads) { $curlArgs += @('-F', "max_downloads=$MaxDownloads") }
|
||||
if ($Obfuscate) { $curlArgs += @('-F', 'obfuscate_metadata=on') }
|
||||
if ($Auth) { $curlArgs += @('-H', "Authorization: Bearer $Auth") }
|
||||
if ($Json) { $curlArgs += @('-H', 'Accept: application/json') }
|
||||
$curlArgs += "$($Server.TrimEnd('/'))/api/v1/upload"
|
||||
|
||||
& curl.exe @curlArgs
|
||||
120
backend/static/api/warpbox.sh
Normal file
120
backend/static/api/warpbox.sh
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# warpbox: command line uploader for Warpbox
|
||||
#
|
||||
# Set the server once, then upload anything:
|
||||
# export WARPBOX_HOST=https://your.warpbox.host
|
||||
# warpbox ./report.pdf
|
||||
#
|
||||
# Install:
|
||||
# curl -fsSL "$WARPBOX_HOST/static/api/warpbox.sh" -o ~/.local/bin/warpbox
|
||||
# chmod +x ~/.local/bin/warpbox
|
||||
# # make sure ~/.local/bin is on your PATH
|
||||
#
|
||||
set -eo pipefail
|
||||
|
||||
WARPBOX_HOST="${WARPBOX_HOST:-}"
|
||||
AUTH="${WARPBOX_TOKEN:-}"
|
||||
PASSWORD=""
|
||||
EXPIRY=""
|
||||
MAX_DOWNLOADS=""
|
||||
OBFUSCATE=""
|
||||
AS_JSON=0
|
||||
FILES=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
warpbox: upload files to Warpbox from the terminal
|
||||
|
||||
USAGE:
|
||||
warpbox [options] <file> [file ...]
|
||||
|
||||
OPTIONS:
|
||||
-p, --password <pw> Require a password to view/download the box
|
||||
-e, --expiry <dur> Lifetime before expiry: 30m, 6h, 2d, 1w (or bare minutes)
|
||||
-n, --max-downloads <n> Expire after N downloads
|
||||
-o, --obfuscate Hide file names/counts until unlocked (needs --password)
|
||||
--host <url> Warpbox server to upload to (or set WARPBOX_HOST)
|
||||
--auth <token> API token (prefer the WARPBOX_TOKEN env var, see AUTH)
|
||||
--auth-file <path> Read the API token from a file (safer than --auth)
|
||||
--json Print the full JSON response instead of just the URL
|
||||
-h, --help Show this help
|
||||
|
||||
AUTH:
|
||||
Uploads are anonymous unless a token is supplied. The most secure option is the
|
||||
WARPBOX_TOKEN environment variable, so the token never lands in your shell
|
||||
history or the process list:
|
||||
|
||||
export WARPBOX_TOKEN=wbx_your_token
|
||||
warpbox ./photo.png
|
||||
|
||||
Create a token under Account, Access tokens. Avoid --auth on shared machines.
|
||||
|
||||
EXAMPLES:
|
||||
warpbox ./report.pdf
|
||||
warpbox --password 123 --expiry 2d ./first_file.zip ./whatever.png ./all_*_photos.jpg
|
||||
warpbox --max-downloads 5 --json ./build.zip
|
||||
EOF
|
||||
}
|
||||
|
||||
expiry_to_minutes() {
|
||||
local v="$1" num unit
|
||||
num="${v%%[mhdw]*}"
|
||||
unit="${v##*[0-9]}"
|
||||
case "$unit" in
|
||||
h) echo $(( num * 60 )) ;;
|
||||
d) echo $(( num * 1440 )) ;;
|
||||
w) echo $(( num * 10080 )) ;;
|
||||
m|"") echo "$num" ;;
|
||||
*) echo "$num" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-p|--password) PASSWORD="$2"; shift 2 ;;
|
||||
-e|--expiry) EXPIRY="$2"; shift 2 ;;
|
||||
-n|--max-downloads) MAX_DOWNLOADS="$2"; shift 2 ;;
|
||||
-o|--obfuscate) OBFUSCATE="on"; shift ;;
|
||||
--host) WARPBOX_HOST="$2"; shift 2 ;;
|
||||
--auth) AUTH="$2"; shift 2 ;;
|
||||
--auth-file) AUTH="$(cat "$2")"; shift 2 ;;
|
||||
--json) AS_JSON=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--) shift; while [ $# -gt 0 ]; do FILES+=("$1"); shift; done ;;
|
||||
-*) echo "warpbox: unknown option $1" >&2; exit 2 ;;
|
||||
*) FILES+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$WARPBOX_HOST" ]; then
|
||||
echo "warpbox: no server set. Use --host <url> or export WARPBOX_HOST=<url>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "warpbox: no files given" >&2
|
||||
echo >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
CURL_ARGS=()
|
||||
for f in "${FILES[@]}"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "warpbox: not a file: $f" >&2
|
||||
exit 2
|
||||
fi
|
||||
CURL_ARGS+=(-F "file=@${f}")
|
||||
done
|
||||
|
||||
[ -n "$PASSWORD" ] && CURL_ARGS+=(-F "password=${PASSWORD}")
|
||||
[ -n "$EXPIRY" ] && CURL_ARGS+=(-F "expires_minutes=$(expiry_to_minutes "$EXPIRY")")
|
||||
[ -n "$MAX_DOWNLOADS" ] && CURL_ARGS+=(-F "max_downloads=${MAX_DOWNLOADS}")
|
||||
[ -n "$OBFUSCATE" ] && CURL_ARGS+=(-F "obfuscate_metadata=on")
|
||||
|
||||
HEADERS=()
|
||||
[ -n "$AUTH" ] && HEADERS+=(-H "Authorization: Bearer ${AUTH}")
|
||||
[ "$AS_JSON" -eq 1 ] && HEADERS+=(-H "Accept: application/json")
|
||||
|
||||
exec curl -fS "${HEADERS[@]}" "${CURL_ARGS[@]}" "${WARPBOX_HOST%/}/api/v1/upload"
|
||||
263
backend/static/css/04-dialogs.css
Normal file
263
backend/static/css/04-dialogs.css
Normal file
@@ -0,0 +1,263 @@
|
||||
.warpbox-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 130;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--background) 60%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
opacity: 0;
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.warpbox-dialog-overlay.is-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.warpbox-dialog {
|
||||
position: relative;
|
||||
width: min(28rem, 100%);
|
||||
max-height: min(34rem, 90vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
transform: translateY(0.6rem) scale(0.98);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.warpbox-dialog:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.warpbox-dialog-overlay.is-visible .warpbox-dialog {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.warpbox-dialog-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
padding: 1.1rem 3.25rem 0 1.1rem;
|
||||
}
|
||||
|
||||
.warpbox-dialog-icon {
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
color: var(--primary);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warpbox-dialog-warning .warpbox-dialog-icon {
|
||||
background: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.warpbox-dialog-error .warpbox-dialog-icon {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.warpbox-dialog-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.warpbox-dialog-close {
|
||||
position: absolute;
|
||||
top: 1.1rem;
|
||||
right: 1.1rem;
|
||||
z-index: 2;
|
||||
min-height: 1.9rem;
|
||||
height: 1.9rem;
|
||||
width: 1.9rem;
|
||||
padding: 0;
|
||||
border-color: var(--border);
|
||||
color: var(--muted-foreground);
|
||||
background: var(--surface-1);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warpbox-dialog-close:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--surface-1-hover);
|
||||
}
|
||||
|
||||
.warpbox-dialog-body {
|
||||
padding: 0.85rem 1.1rem 1.1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.warpbox-dialog-message {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.warpbox-dialog-message:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.warpbox-dialog-field {
|
||||
width: 100%;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: calc(var(--radius) - 0.35rem);
|
||||
background: var(--surface-1);
|
||||
color: var(--foreground);
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.warpbox-dialog-field:focus {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.warpbox-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
padding: 0 1.1rem 1.1rem;
|
||||
}
|
||||
|
||||
html.warpbox-dialog-open,
|
||||
html.warpbox-dialog-open body {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dialog-file-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.dialog-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.35rem);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.dialog-file-icon {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.dialog-file-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.dialog-file-size {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog {
|
||||
border: 1px solid #000000;
|
||||
border-radius: 0;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 4px 4px 0 rgba(0, 0, 0, 0.45);
|
||||
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-head {
|
||||
padding-top: 0.2rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog::before {
|
||||
content: "Warpbox";
|
||||
display: block;
|
||||
margin: 0.18rem 0.18rem 0;
|
||||
padding: 0.22rem 0.35rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-error::before {
|
||||
content: "Warpbox - Error";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-warning::before {
|
||||
content: "Warpbox - Warning";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-info::before {
|
||||
content: "Warpbox - Info";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-icon {
|
||||
border: 1px solid #000000;
|
||||
background: #ffffff;
|
||||
color: #000078;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-warning .warpbox-dialog-icon {
|
||||
color: #9a5b00;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-error .warpbox-dialog-icon {
|
||||
color: #c00000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-message {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-dialog-close {
|
||||
top: 0.36rem;
|
||||
right: 0.3rem;
|
||||
width: 1.1rem;
|
||||
height: 0.95rem;
|
||||
min-height: 0.95rem;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.warpbox-dialog-overlay {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.warpbox-dialog {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -152,16 +152,16 @@
|
||||
|
||||
/* Links: classic blue, underlined, purple when visited. Sidebar links and tabs
|
||||
are styled as their own Win98 controls below, so they're excluded here. */
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link) {
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill) {
|
||||
color: #0000ee;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):visited {
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):visited {
|
||||
color: #551a8b;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):hover {
|
||||
:root[data-theme="retro"] a:not(.button):not(.brand):not(.sidebar-link):not(.tab):not(.sort-link):not(.api-nav-link):not(.shortcut-card):not(.link-pill):hover {
|
||||
color: #ee0000;
|
||||
}
|
||||
|
||||
@@ -655,7 +655,7 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .view-toolbar .icon-button svg {
|
||||
:root[data-theme="retro"] .view-toolbar .icon-button .svg-icon {
|
||||
margin: 0;
|
||||
display: block;
|
||||
}
|
||||
@@ -741,3 +741,283 @@
|
||||
:root[data-theme="retro"] .file-main small {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* API documentation: sidebar + panels as Win98 windows */
|
||||
/* The new .api-docs layout uses dark revamp tokens by default, which are */
|
||||
/* unreadable on the black retro desktop. Re-skin it as Win98 chrome: a */
|
||||
/* raised silver sidebar window, plain light section intros on the desktop, */
|
||||
/* and each card a silver window with a navy title bar from its heading. */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/* Sidebar = raised silver window with a real title bar from its <h1>. */
|
||||
:root[data-theme="retro"] .api-sidebar {
|
||||
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||
background-color: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-sidebar > .kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-sidebar-title {
|
||||
margin: -0.5rem -0.5rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-nav {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
/* Nav entries are flat silver list items; the active one is a navy bar. */
|
||||
:root[data-theme="retro"] .api-nav-link {
|
||||
color: #000000;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-nav-link:hover {
|
||||
background: #d4d0c8;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-nav-link.is-active {
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
border-color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-sidebar-meta {
|
||||
border-top: 1px solid #808080;
|
||||
box-shadow: 0 -1px 0 #ffffff;
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Section intro becomes a real Win98 window: silver body, the <h2> a navy
|
||||
title bar with a fake close button, and the subtitle as black body text.
|
||||
This fixes the default black-on-black inline code in headings/intros. */
|
||||
:root[data-theme="retro"] .panel-head {
|
||||
max-width: none;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||
background-color: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* The kicker is redundant once the title sits in a title bar; hide it so the
|
||||
bar can hug the top edge (the markup puts the kicker before the h2). */
|
||||
:root[data-theme="retro"] .panel-head .kicker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .panel-head h2 {
|
||||
position: relative;
|
||||
margin: -1rem -1rem 1rem;
|
||||
padding: 0.35rem 1.8rem 0.35rem 0.5rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Inline code in the title (e.g. "The warpbox CLI") reads white on the bar
|
||||
instead of the default black. */
|
||||
:root[data-theme="retro"] .panel-head h2 code {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .panel-head h2::after {
|
||||
content: "\2715";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.4rem;
|
||||
transform: translateY(-50%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.15rem;
|
||||
height: 1rem;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .panel-head .lead {
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Inline code in the subtitle: sunken white field, black text. */
|
||||
:root[data-theme="retro"] .panel-head .lead code {
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
border: 1px solid #808080;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
/* The lone "Quick links" label on the home desktop stays light. */
|
||||
:root[data-theme="retro"] .section-label {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ShareX step lists are light-muted by default; black on the silver window. */
|
||||
:root[data-theme="retro"] .docs-steps {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Each card heading becomes a Win98 title bar with a fake close button.
|
||||
Headings bleed to the window edges; only the first hugs the top edge so a
|
||||
multi-step card (e.g. ShareX) reads as stacked group bars, not overlaps. */
|
||||
:root[data-theme="retro"] .api-content .card > .card-content > h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem -1.5rem 1rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-content .card > .card-content > h3:first-child {
|
||||
margin-top: -1.5rem;
|
||||
}
|
||||
|
||||
/* The upload endpoint card leads with a method + path row; make that the bar. */
|
||||
:root[data-theme="retro"] .api-content .endpoint-head {
|
||||
margin: -1.5rem -1.5rem 1rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .endpoint-head .endpoint-path {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .api-content .card > .card-content > h3::after,
|
||||
:root[data-theme="retro"] .api-content .endpoint-head::after {
|
||||
content: "\2715";
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.15rem;
|
||||
height: 1rem;
|
||||
margin-left: auto;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Body text inside windows reads black, not muted purple. */
|
||||
:root[data-theme="retro"] .api-content .card p,
|
||||
:root[data-theme="retro"] .api-content .card h4,
|
||||
:root[data-theme="retro"] .api-content .field-grid span,
|
||||
:root[data-theme="retro"] .endpoint-list div em,
|
||||
:root[data-theme="retro"] .faq-item summary,
|
||||
:root[data-theme="retro"] .faq-item p {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Sub-labels (Request fields, Example, ...) become small black headers. */
|
||||
:root[data-theme="retro"] .api-content .card h4 {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Endpoint rows are sunken white fields. */
|
||||
:root[data-theme="retro"] .endpoint-list div {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
/* Home shortcut tiles and quick links: silver windows / sunken white fields. */
|
||||
:root[data-theme="retro"] .shortcut-card {
|
||||
background: linear-gradient(to bottom, #ffffff, 6%, #c0c0c0 10%);
|
||||
background-color: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .shortcut-card:hover {
|
||||
transform: none;
|
||||
background-color: #d4d0c8;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .shortcut-eyebrow {
|
||||
color: #000078;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .shortcut-title,
|
||||
:root[data-theme="retro"] .shortcut-sub {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .link-pill {
|
||||
background: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .link-pill span {
|
||||
background: #000078;
|
||||
color: #ffffff;
|
||||
border: 1px solid #000000;
|
||||
}
|
||||
|
||||
/* Colour-coded badges in the classic 16-colour VGA palette, with black
|
||||
borders so they read like little Win98 toolbar icons. */
|
||||
:root[data-theme="retro"] .link-pill .tag-get { background: #0000aa; color: #ffffff; }
|
||||
:root[data-theme="retro"] .link-pill .tag-post { background: #008000; color: #ffffff; }
|
||||
:root[data-theme="retro"] .link-pill .tag-json { background: #aa00aa; color: #ffffff; }
|
||||
:root[data-theme="retro"] .link-pill .tag-key { background: #aa5500; color: #ffffff; }
|
||||
:root[data-theme="retro"] .link-pill .tag-help { background: #00aaaa; color: #000000; }
|
||||
|
||||
/* CLI download cards = silver windows. */
|
||||
:root[data-theme="retro"] .download-card {
|
||||
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||
background-color: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .download-card .download-os,
|
||||
:root[data-theme="retro"] .download-card p {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* FAQ entries are silver windows; the +/- marker stays. */
|
||||
:root[data-theme="retro"] .faq-item {
|
||||
background: linear-gradient(to bottom, #ffffff, 4%, #c0c0c0 8%);
|
||||
background-color: #c0c0c0;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .faq-item summary::after {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Copy buttons: stay visible (retro already paints them as silver buttons). */
|
||||
:root[data-theme="retro"] .code-block .copy-btn {
|
||||
background: #c0c0c0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
173
backend/static/css/19-popups.css
Normal file
173
backend/static/css/19-popups.css
Normal file
@@ -0,0 +1,173 @@
|
||||
.warpbox-popups {
|
||||
position: fixed;
|
||||
z-index: 120;
|
||||
inset-block-start: calc(1rem + env(safe-area-inset-top));
|
||||
inset-inline-end: calc(1rem + env(safe-area-inset-right));
|
||||
width: min(26rem, calc(100vw - 2rem));
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.warpbox-popup {
|
||||
pointer-events: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
background: color-mix(in srgb, var(--card) 96%, transparent);
|
||||
color: var(--card-foreground);
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
transform: translateY(-0.55rem);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.warpbox-popup.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.warpbox-popup-chrome {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.85rem;
|
||||
align-items: start;
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.warpbox-popup-icon {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
color: var(--primary);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warpbox-popup-warning .warpbox-popup-icon {
|
||||
background: color-mix(in srgb, var(--primary) 26%, transparent);
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.warpbox-popup-error .warpbox-popup-icon {
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.warpbox-popup-title {
|
||||
display: block;
|
||||
margin: 0 0 0.18rem;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.warpbox-popup-message {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.warpbox-popup-close {
|
||||
min-height: 1.8rem;
|
||||
width: 1.8rem;
|
||||
padding: 0;
|
||||
border-color: var(--border);
|
||||
color: var(--muted-foreground);
|
||||
background: var(--surface-1);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.warpbox-popup-close:hover {
|
||||
color: var(--foreground);
|
||||
background: var(--surface-1-hover);
|
||||
}
|
||||
|
||||
.warpbox-popup-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.55rem;
|
||||
padding: 0 0.95rem 0.95rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popups {
|
||||
inset-block-start: 2.65rem;
|
||||
font-family: "PixeloidSans", "PixelOperator", "Microsoft Sans Serif", Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup {
|
||||
border: 1px solid #000000;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf, 3px 3px 0 rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup::before {
|
||||
content: "Warpbox";
|
||||
display: block;
|
||||
margin: 0.18rem 0.18rem 0;
|
||||
padding: 0.22rem 0.35rem;
|
||||
background: linear-gradient(to right, #000078, 80%, #0f80cd);
|
||||
color: #ffffff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-error::before {
|
||||
content: "Warpbox - Error";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-warning::before {
|
||||
content: "Warpbox - Warning";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-info::before {
|
||||
content: "Warpbox - Info";
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-chrome {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-icon {
|
||||
border: 1px solid #000000;
|
||||
background: #ffffff;
|
||||
color: #000078;
|
||||
box-shadow: inset 1px 1px 0 #808080, inset -1px -1px 0 #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-warning .warpbox-popup-icon {
|
||||
color: #9a5b00;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-error .warpbox-popup-icon {
|
||||
color: #c00000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-message {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
:root[data-theme="retro"] .warpbox-popup-close {
|
||||
width: 1.45rem;
|
||||
height: 1.25rem;
|
||||
min-height: 1.25rem;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: inset -1px -1px 0 #404040, inset 1px 1px 0 #ffffff, inset -2px -2px 0 #808080, inset 2px 2px 0 #dfdfdf;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.warpbox-popups {
|
||||
inset-inline: 1rem;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.install-pwa-button[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -395,6 +399,10 @@ button {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.upload-file-state-shared {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.upload-recovery-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -54,6 +54,20 @@
|
||||
margin: 0.45rem 0 0;
|
||||
}
|
||||
|
||||
.preview-header > .button {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 1rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.preview-header > .button svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .preview-header > .button-primary:active {
|
||||
padding-right: calc(1rem - 1px);
|
||||
}
|
||||
|
||||
.preview-window {
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
|
||||
@@ -260,12 +274,281 @@
|
||||
height: clamp(18rem, 64vh, 38rem);
|
||||
}
|
||||
|
||||
.video-scenes-preview {
|
||||
object-fit: contain;
|
||||
background: color-mix(in srgb, var(--background) 88%, black 12%);
|
||||
}
|
||||
|
||||
.native-audio-preview {
|
||||
align-self: center;
|
||||
width: min(42rem, calc(100% - 2rem));
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.archive-browser-preview {
|
||||
width: 100%;
|
||||
height: clamp(18rem, 64vh, 38rem);
|
||||
overflow: auto;
|
||||
background: color-mix(in srgb, var(--card) 86%, black 14%);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.archive-browser-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--card) 92%, black 8%);
|
||||
}
|
||||
|
||||
.archive-browser-header strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.archive-browser-header span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.archive-tree {
|
||||
padding: 0.6rem 0.8rem 1rem;
|
||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.archive-node {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.archive-node-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem 1.45rem minmax(12rem, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-height: 2.1rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.archive-node-row:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.archive-folder > summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.archive-folder > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.archive-chevron,
|
||||
.archive-chevron-spacer,
|
||||
.archive-file-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.archive-chevron {
|
||||
color: var(--muted-foreground);
|
||||
transition: transform 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.archive-folder[open] > summary .archive-chevron {
|
||||
transform: rotate(90deg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.archive-chevron svg {
|
||||
width: 1.18rem;
|
||||
height: 1.18rem;
|
||||
}
|
||||
|
||||
.archive-file-icon {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.archive-svg-icon,
|
||||
.archive-retro-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.archive-retro-icon {
|
||||
display: none;
|
||||
object-fit: contain;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.archive-file-icon svg,
|
||||
.archive-chevron svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.archive-file-icon-folder {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.archive-file-icon-folder svg {
|
||||
fill: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.archive-file-icon-img {
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.archive-file-icon-vid {
|
||||
color: #f9a8d4;
|
||||
}
|
||||
|
||||
.archive-file-icon-aud {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.archive-file-icon-code {
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.archive-file-icon-arc {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.archive-file-icon-txt {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-preview {
|
||||
border: 1px solid #000000;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #808080,
|
||||
inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-header {
|
||||
border-bottom: 1px solid #808080;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset -1px -1px 0 #808080,
|
||||
inset 1px 1px 0 #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-header span {
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-tree {
|
||||
background: #ffffff;
|
||||
font-family: "PixelOperatorMono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row {
|
||||
min-height: 1.8rem;
|
||||
border-radius: 0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row:hover {
|
||||
background: #000078;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-size {
|
||||
color: #404040;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row:hover .archive-node-size {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-chevron {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-folder[open] > summary .archive-chevron {
|
||||
color: #000078;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-node-row:hover .archive-chevron,
|
||||
[data-theme="retro"] .archive-node-row:hover .archive-file-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-chevron svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-file-icon {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-svg-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-retro-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-theme="retro"] .archive-browser-empty,
|
||||
[data-theme="retro"] .archive-browser-legacy {
|
||||
color: #000000;
|
||||
background: #ffffff;
|
||||
font-family: "PixelOperatorMono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.archive-node-name {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.archive-node-size {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.archive-browser-empty {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.archive-browser-legacy {
|
||||
min-width: max-content;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
color: var(--foreground);
|
||||
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -278,6 +561,7 @@
|
||||
.preview-placeholder[hidden],
|
||||
.default-preview[hidden],
|
||||
.native-preview[hidden],
|
||||
.archive-browser-preview[hidden],
|
||||
.large-preview-gate[hidden],
|
||||
.code-preview[hidden],
|
||||
.render-preview[hidden] {
|
||||
@@ -394,7 +678,54 @@
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.file-emblem svg {
|
||||
.svg-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
background-color: currentColor;
|
||||
vertical-align: -0.125em;
|
||||
mask: var(--svg-icon-url) center / contain no-repeat;
|
||||
-webkit-mask: var(--svg-icon-url) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.svg-icon-document {
|
||||
--svg-icon-url: url("/static/icons/regular/submit-document.svg");
|
||||
}
|
||||
|
||||
.svg-icon-share {
|
||||
--svg-icon-url: url("/static/icons/regular/share-android.svg");
|
||||
}
|
||||
|
||||
.svg-icon-download {
|
||||
--svg-icon-url: url("/static/icons/regular/download.svg");
|
||||
}
|
||||
|
||||
.svg-icon-list {
|
||||
--svg-icon-url: url("/static/icons/regular/list.svg");
|
||||
}
|
||||
|
||||
.svg-icon-grid {
|
||||
--svg-icon-url: url("/static/icons/regular/view-grid.svg");
|
||||
}
|
||||
|
||||
.svg-icon-emoji {
|
||||
--svg-icon-url: url("/static/icons/regular/emoji.svg");
|
||||
}
|
||||
|
||||
.svg-icon-open {
|
||||
--svg-icon-url: url("/static/icons/regular/open-in-browser.svg");
|
||||
}
|
||||
|
||||
.svg-icon-copy {
|
||||
--svg-icon-url: url("/static/icons/regular/copy.svg");
|
||||
}
|
||||
|
||||
.svg-icon-eye {
|
||||
--svg-icon-url: url("/static/icons/regular/eye.svg");
|
||||
}
|
||||
|
||||
.file-emblem .svg-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
@@ -414,6 +745,17 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button.is-disabled {
|
||||
opacity: .62;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.download-share-button {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.upload-processing-alert {
|
||||
margin: 1rem 0;
|
||||
padding: .85rem 1rem;
|
||||
@@ -423,6 +765,11 @@
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.upload-processing-alert-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
flex: 0 0 4.75rem;
|
||||
width: 4.75rem;
|
||||
@@ -528,7 +875,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-toolbar svg {
|
||||
.view-toolbar .svg-icon {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
@@ -586,6 +933,24 @@
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.file-card.is-failed {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--border));
|
||||
background: color-mix(in srgb, var(--danger) 8%, var(--background));
|
||||
}
|
||||
|
||||
.file-card.is-failed .file-open {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-error {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 0.18rem;
|
||||
color: var(--danger);
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-reaction-dock {
|
||||
position: static;
|
||||
z-index: 2;
|
||||
@@ -679,14 +1044,9 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reaction-button svg {
|
||||
.reaction-button .svg-icon {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.file-card:hover .reaction-button,
|
||||
|
||||
@@ -10,6 +10,425 @@
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
API documentation — sidebar layout
|
||||
============================================================ */
|
||||
|
||||
.api-docs {
|
||||
width: min(74rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 2rem 0 3rem;
|
||||
display: grid;
|
||||
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.api-sidebar {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.api-sidebar-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.api-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: 0.3rem;
|
||||
}
|
||||
|
||||
.api-nav-link {
|
||||
display: block;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: calc(var(--radius) - 0.3rem);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
line-height: 1.2;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.api-nav-link:hover {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.api-nav-link.is-active {
|
||||
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.api-sidebar-meta {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.api-sidebar-meta a {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* --- Panels: only one visible at a time --- */
|
||||
.api-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-panel {
|
||||
display: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.doc-panel.is-active {
|
||||
display: block;
|
||||
animation: doc-fade 0.18s ease;
|
||||
}
|
||||
|
||||
@keyframes doc-fade {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
max-width: 46rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-head .lead {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.api-content .card + .card,
|
||||
.api-content .quickstart {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.api-content h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.api-content h4 {
|
||||
margin: 1.4rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.api-content .card p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.api-content code {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.api-content .field-grid p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
margin: 1.75rem 0 0.75rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* --- Home shortcuts --- */
|
||||
.shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.shortcut-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent-c, var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.shortcut-card:hover {
|
||||
border-color: var(--accent-c, var(--ring));
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent-c, var(--ring)) 22%, transparent);
|
||||
}
|
||||
|
||||
/* Per-card accent. Each home shortcut owns a colour, echoed by its eyebrow,
|
||||
left edge, and hover glow. */
|
||||
.accent-blue { --accent-c: #3b82f6; }
|
||||
.accent-green { --accent-c: #22c55e; }
|
||||
.accent-violet { --accent-c: #8b5cf6; }
|
||||
.accent-amber { --accent-c: #f59e0b; }
|
||||
|
||||
.shortcut-eyebrow {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent-c, var(--primary));
|
||||
}
|
||||
|
||||
.shortcut-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.shortcut-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.link-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.link-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.2rem);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
font-size: 0.88rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.link-pill:hover {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
.link-pill span {
|
||||
flex: none;
|
||||
min-width: 2.6rem;
|
||||
text-align: center;
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* Colour-coded tags on the home quick links (and reusable elsewhere). Tinted
|
||||
background plus a saturated label so they read as accents, not loud chips. */
|
||||
.link-pill .tag-get { background: color-mix(in srgb, #3b82f6 22%, transparent); color: #93c5fd; }
|
||||
.link-pill .tag-post { background: color-mix(in srgb, #22c55e 22%, transparent); color: #86efac; }
|
||||
.link-pill .tag-json { background: color-mix(in srgb, #8b5cf6 24%, transparent); color: #c4b5fd; }
|
||||
.link-pill .tag-key { background: color-mix(in srgb, #eab308 24%, transparent); color: #fde047; }
|
||||
.link-pill .tag-help { background: color-mix(in srgb, #06b6d4 24%, transparent); color: #67e8f9; }
|
||||
|
||||
/* --- Code blocks with copy button --- */
|
||||
.code-block {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-block .copy-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.4rem;
|
||||
background: color-mix(in srgb, var(--card) 80%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.code-block:hover .copy-btn,
|
||||
.code-block .copy-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.code-block .copy-btn:hover {
|
||||
color: var(--foreground);
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
/* --- Endpoint blocks --- */
|
||||
.endpoint-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.method {
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 0.35rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.method-get { background: #2563eb; }
|
||||
.method-post { background: #16a34a; }
|
||||
.method-put { background: #d97706; }
|
||||
|
||||
.endpoint-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.endpoint-list div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.3rem);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.endpoint-list div code {
|
||||
font-size: 0.82rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.endpoint-list div em {
|
||||
margin-left: auto;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.8rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* --- CLI download cards --- */
|
||||
.download-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.download-card {
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.download-card .download-os {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.download-card p {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.download-card .button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* --- FAQ --- */
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 0.2rem);
|
||||
background: color-mix(in srgb, var(--card) 94%, transparent);
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
padding: 0.9rem 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
list-style: none;
|
||||
position: relative;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.faq-item summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faq-item summary::after {
|
||||
content: "+";
|
||||
position: absolute;
|
||||
right: 0.1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.faq-item[open] summary::after {
|
||||
content: "\2212";
|
||||
}
|
||||
|
||||
.faq-item p {
|
||||
margin: 0 0 0.95rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
max-width: 44rem;
|
||||
}
|
||||
@@ -63,42 +482,19 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.endpoint-list,
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin: 1rem 0 0;
|
||||
}
|
||||
|
||||
.endpoint-list div,
|
||||
.field-grid {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.endpoint-list dt,
|
||||
.endpoint-list dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.endpoint-list dt,
|
||||
.field-grid span {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.endpoint-list dd code {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docs-steps {
|
||||
margin: 0.85rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
|
||||
@@ -57,6 +57,44 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.api-docs {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.api-sidebar {
|
||||
position: static;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.api-sidebar-title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.api-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.api-nav-link {
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.api-sidebar-meta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.endpoint-list div em {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
|
||||
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
BIN
backend/static/file-icons/retro/directory_open_file_mydocs-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 B |
43
backend/static/js/02-pwa.js
Normal file
43
backend/static/js/02-pwa.js
Normal file
@@ -0,0 +1,43 @@
|
||||
(function () {
|
||||
let installPrompt = null;
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/service-worker.js").catch(() => {
|
||||
/* Service workers are progressive enhancement here. */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (event) => {
|
||||
const button = document.querySelector("[data-install-pwa]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
installPrompt = event;
|
||||
button.hidden = false;
|
||||
button.addEventListener("click", async () => {
|
||||
if (!installPrompt) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await installPrompt.prompt();
|
||||
await installPrompt.userChoice;
|
||||
} finally {
|
||||
installPrompt = null;
|
||||
button.hidden = true;
|
||||
button.disabled = false;
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
const button = document.querySelector("[data-install-pwa]");
|
||||
if (button) {
|
||||
button.hidden = true;
|
||||
}
|
||||
installPrompt = null;
|
||||
});
|
||||
})();
|
||||
174
backend/static/js/03-popups.js
Normal file
174
backend/static/js/03-popups.js
Normal file
@@ -0,0 +1,174 @@
|
||||
(function () {
|
||||
const DEFAULT_DURATION = 6200;
|
||||
const VARIANTS = ["info", "warning", "error"];
|
||||
const GENERIC_ERROR_MESSAGE = "Something went wrong on this page. Please try again in a moment.";
|
||||
|
||||
window.Warpbox = window.Warpbox || {};
|
||||
let lastGlobalErrorAt = 0;
|
||||
|
||||
function ensureRegion() {
|
||||
let region = document.querySelector("[data-warpbox-popups]");
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
region = document.createElement("div");
|
||||
region.className = "warpbox-popups";
|
||||
region.setAttribute("data-warpbox-popups", "");
|
||||
region.setAttribute("aria-live", "polite");
|
||||
region.setAttribute("aria-atomic", "false");
|
||||
document.body.append(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
function normalizeOptions(options, message) {
|
||||
if (typeof options === "string") {
|
||||
options = { message: options };
|
||||
} else {
|
||||
options = options || {};
|
||||
}
|
||||
if (message) {
|
||||
options.message = message;
|
||||
}
|
||||
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
|
||||
return {
|
||||
variant,
|
||||
title: options.title || defaultTitle(variant),
|
||||
message: options.message || "",
|
||||
duration: Number.isFinite(options.duration) ? options.duration : DEFAULT_DURATION,
|
||||
actions: Array.isArray(options.actions) ? options.actions : [],
|
||||
};
|
||||
}
|
||||
|
||||
function defaultTitle(variant) {
|
||||
if (variant === "error") {
|
||||
return "Error";
|
||||
}
|
||||
if (variant === "warning") {
|
||||
return "Warning";
|
||||
}
|
||||
return "Info";
|
||||
}
|
||||
|
||||
function notify(options, message) {
|
||||
const config = normalizeOptions(options, message);
|
||||
const region = ensureRegion();
|
||||
const popup = document.createElement("section");
|
||||
popup.className = "warpbox-popup warpbox-popup-" + config.variant;
|
||||
popup.setAttribute("role", config.variant === "error" ? "alert" : "status");
|
||||
|
||||
const chrome = document.createElement("div");
|
||||
chrome.className = "warpbox-popup-chrome";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "warpbox-popup-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "warpbox-popup-body";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.className = "warpbox-popup-title";
|
||||
title.textContent = config.title;
|
||||
|
||||
const text = document.createElement("p");
|
||||
text.className = "warpbox-popup-message";
|
||||
text.textContent = config.message;
|
||||
|
||||
body.append(title, text);
|
||||
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "warpbox-popup-close";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.textContent = "x";
|
||||
close.addEventListener("click", () => dismiss(popup));
|
||||
|
||||
chrome.append(icon, body, close);
|
||||
popup.append(chrome);
|
||||
|
||||
if (config.actions.length > 0) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "warpbox-popup-actions";
|
||||
config.actions.forEach((action) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button " + (action.kind === "primary" ? "button-primary" : "button-outline");
|
||||
button.textContent = action.label || "Action";
|
||||
button.addEventListener("click", () => {
|
||||
if (typeof action.onClick === "function") {
|
||||
action.onClick();
|
||||
}
|
||||
if (action.dismiss !== false) {
|
||||
dismiss(popup);
|
||||
}
|
||||
});
|
||||
actions.append(button);
|
||||
});
|
||||
popup.append(actions);
|
||||
}
|
||||
|
||||
region.append(popup);
|
||||
window.requestAnimationFrame(() => popup.classList.add("is-visible"));
|
||||
|
||||
let timer = null;
|
||||
if (config.duration > 0) {
|
||||
timer = window.setTimeout(() => dismiss(popup), config.duration);
|
||||
}
|
||||
|
||||
return {
|
||||
element: popup,
|
||||
close: function closePopup() {
|
||||
if (timer) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
dismiss(popup);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dismiss(popup) {
|
||||
if (!popup || popup.dataset.closing === "true") {
|
||||
return;
|
||||
}
|
||||
popup.dataset.closing = "true";
|
||||
popup.classList.remove("is-visible");
|
||||
window.setTimeout(() => popup.remove(), 180);
|
||||
}
|
||||
|
||||
window.Warpbox.notify = notify;
|
||||
window.Warpbox.info = function info(message, options) {
|
||||
return notify({ ...(options || {}), variant: "info", message });
|
||||
};
|
||||
window.Warpbox.warning = function warning(message, options) {
|
||||
return notify({ ...(options || {}), variant: "warning", message });
|
||||
};
|
||||
window.Warpbox.error = function error(message, options) {
|
||||
return notify({ ...(options || {}), variant: "error", message });
|
||||
};
|
||||
|
||||
function showGlobalError() {
|
||||
const now = Date.now();
|
||||
if (now - lastGlobalErrorAt < 2500) {
|
||||
return;
|
||||
}
|
||||
lastGlobalErrorAt = now;
|
||||
notify({
|
||||
variant: "error",
|
||||
title: "Page error",
|
||||
message: GENERIC_ERROR_MESSAGE,
|
||||
duration: 9000,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("error", function (event) {
|
||||
if (event && event.target && event.target !== window) {
|
||||
return;
|
||||
}
|
||||
showGlobalError();
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", function () {
|
||||
showGlobalError();
|
||||
});
|
||||
})();
|
||||
299
backend/static/js/04-dialogs.js
Normal file
299
backend/static/js/04-dialogs.js
Normal file
@@ -0,0 +1,299 @@
|
||||
(function () {
|
||||
const VARIANTS = ["info", "warning", "error"];
|
||||
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
window.Warpbox = window.Warpbox || {};
|
||||
let dialogIdCounter = 0;
|
||||
|
||||
function defaultTitle(variant) {
|
||||
if (variant === "error") {
|
||||
return "Error";
|
||||
}
|
||||
if (variant === "warning") {
|
||||
return "Warning";
|
||||
}
|
||||
return "Info";
|
||||
}
|
||||
|
||||
function normalizeOptions(options, message) {
|
||||
if (typeof options === "string") {
|
||||
options = { message: options };
|
||||
} else {
|
||||
options = options || {};
|
||||
}
|
||||
if (message) {
|
||||
options.message = message;
|
||||
}
|
||||
const variant = VARIANTS.includes(options.variant) ? options.variant : "info";
|
||||
return {
|
||||
variant,
|
||||
title: options.title || defaultTitle(variant),
|
||||
message: options.message || "",
|
||||
body: options.body || null,
|
||||
actions: Array.isArray(options.actions) ? options.actions : [],
|
||||
dismissible: options.dismissible !== false,
|
||||
closable: options.closable !== false,
|
||||
onClose: typeof options.onClose === "function" ? options.onClose : null,
|
||||
};
|
||||
}
|
||||
|
||||
function focusableElements(container) {
|
||||
return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
|
||||
}
|
||||
|
||||
function dialog(options, message) {
|
||||
const config = normalizeOptions(options, message);
|
||||
const previouslyFocused = document.activeElement;
|
||||
dialogIdCounter += 1;
|
||||
const titleId = "warpbox-dialog-title-" + dialogIdCounter;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "warpbox-dialog-overlay";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "warpbox-dialog warpbox-dialog-" + config.variant;
|
||||
card.setAttribute("role", config.variant === "error" ? "alertdialog" : "dialog");
|
||||
card.setAttribute("aria-modal", "true");
|
||||
card.setAttribute("aria-labelledby", titleId);
|
||||
card.setAttribute("tabindex", "-1");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "warpbox-dialog-head";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "warpbox-dialog-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.textContent = config.variant === "error" ? "!" : config.variant === "warning" ? "?" : "i";
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.id = titleId;
|
||||
title.className = "warpbox-dialog-title";
|
||||
title.textContent = config.title;
|
||||
|
||||
head.append(icon, title);
|
||||
|
||||
if (config.closable) {
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "warpbox-dialog-close";
|
||||
close.setAttribute("aria-label", "Close dialog");
|
||||
close.textContent = "x";
|
||||
close.addEventListener("click", () => closeDialog());
|
||||
head.append(close);
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "warpbox-dialog-body";
|
||||
|
||||
if (config.message) {
|
||||
const text = document.createElement("p");
|
||||
text.className = "warpbox-dialog-message";
|
||||
text.textContent = config.message;
|
||||
body.append(text);
|
||||
}
|
||||
|
||||
if (config.body) {
|
||||
const nodes = Array.isArray(config.body) ? config.body : [config.body];
|
||||
nodes.forEach((node) => {
|
||||
if (node instanceof Node) {
|
||||
body.append(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
card.append(head, body);
|
||||
|
||||
let autofocusTarget = null;
|
||||
if (config.actions.length > 0) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "warpbox-dialog-actions";
|
||||
config.actions.forEach((action) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button " + (action.kind === "primary" ? "button-primary" : action.kind === "ghost" ? "button-ghost" : "button-outline");
|
||||
button.textContent = action.label || "OK";
|
||||
button.addEventListener("click", () => {
|
||||
if (typeof action.onClick === "function") {
|
||||
action.onClick();
|
||||
}
|
||||
if (action.dismiss !== false) {
|
||||
closeDialog();
|
||||
}
|
||||
});
|
||||
if (action.autofocus) {
|
||||
autofocusTarget = button;
|
||||
}
|
||||
actions.append(button);
|
||||
});
|
||||
card.append(actions);
|
||||
}
|
||||
|
||||
overlay.append(card);
|
||||
document.body.append(overlay);
|
||||
document.documentElement.classList.add("warpbox-dialog-open");
|
||||
window.requestAnimationFrame(() => {
|
||||
overlay.classList.add("is-visible");
|
||||
(autofocusTarget || card).focus();
|
||||
});
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.key === "Escape") {
|
||||
if (config.dismissible) {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
const focusable = focusableElements(card);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(event) {
|
||||
if (config.dismissible && event.target === overlay) {
|
||||
closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeydown, true);
|
||||
overlay.addEventListener("click", handleOverlayClick);
|
||||
|
||||
let closed = false;
|
||||
function closeDialog() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
document.removeEventListener("keydown", handleKeydown, true);
|
||||
overlay.removeEventListener("click", handleOverlayClick);
|
||||
overlay.classList.remove("is-visible");
|
||||
document.documentElement.classList.remove("warpbox-dialog-open");
|
||||
window.setTimeout(() => overlay.remove(), 180);
|
||||
if (previouslyFocused && typeof previouslyFocused.focus === "function") {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
if (config.onClose) {
|
||||
config.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element: overlay,
|
||||
close: closeDialog,
|
||||
};
|
||||
}
|
||||
|
||||
window.Warpbox.dialog = dialog;
|
||||
|
||||
window.Warpbox.alertDialog = function alertDialog(message, options) {
|
||||
const config = (typeof options === "object" && options) || {};
|
||||
return new Promise((resolve) => {
|
||||
dialog({
|
||||
...config,
|
||||
message: typeof message === "string" ? message : config.message,
|
||||
actions: [{ label: config.okLabel || "OK", kind: "primary", autofocus: true }],
|
||||
onClose: () => {
|
||||
if (typeof config.onClose === "function") {
|
||||
config.onClose();
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.Warpbox.confirmDialog = function confirmDialog(message, options) {
|
||||
const config = (typeof options === "object" && options) || {};
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
function settle(value) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(value);
|
||||
}
|
||||
dialog({
|
||||
...config,
|
||||
message: typeof message === "string" ? message : config.message,
|
||||
actions: [
|
||||
{ label: config.cancelLabel || "Cancel", kind: "outline", autofocus: true, onClick: () => settle(false) },
|
||||
{ label: config.confirmLabel || "Confirm", kind: "primary", onClick: () => settle(true) },
|
||||
],
|
||||
onClose: () => {
|
||||
if (typeof config.onClose === "function") {
|
||||
config.onClose();
|
||||
}
|
||||
settle(false);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
window.Warpbox.promptDialog = function promptDialog(message, options) {
|
||||
const config = (typeof options === "object" && options) || {};
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
function settle(value) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
const field = document.createElement("input");
|
||||
field.type = config.inputType || "text";
|
||||
field.className = "warpbox-dialog-field";
|
||||
if (config.placeholder) {
|
||||
field.placeholder = config.placeholder;
|
||||
}
|
||||
if (typeof config.value === "string") {
|
||||
field.value = config.value;
|
||||
}
|
||||
|
||||
let controller = null;
|
||||
field.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
settle(field.value);
|
||||
if (controller) {
|
||||
controller.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
controller = dialog({
|
||||
...config,
|
||||
message: typeof message === "string" ? message : config.message,
|
||||
body: field,
|
||||
actions: [
|
||||
{ label: config.cancelLabel || "Cancel", kind: "outline", onClick: () => settle(null) },
|
||||
{ label: config.okLabel || "OK", kind: "primary", onClick: () => settle(field.value) },
|
||||
],
|
||||
onClose: () => {
|
||||
if (typeof config.onClose === "function") {
|
||||
config.onClose();
|
||||
}
|
||||
settle(null);
|
||||
},
|
||||
});
|
||||
|
||||
window.requestAnimationFrame(() => field.focus());
|
||||
});
|
||||
};
|
||||
})();
|
||||
50
backend/static/js/13-share.js
Normal file
50
backend/static/js/13-share.js
Normal file
@@ -0,0 +1,50 @@
|
||||
(function () {
|
||||
const shareButtons = document.querySelectorAll("[data-share-box]");
|
||||
if (shareButtons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
shareButtons.forEach((button) => {
|
||||
const label = button.querySelector("[data-share-box-label]") || button;
|
||||
const shareData = {
|
||||
title: button.dataset.shareTitle || document.title,
|
||||
text: button.dataset.shareText || "",
|
||||
url: window.Warpbox.absoluteURL(button.dataset.shareUrl || window.location.href),
|
||||
};
|
||||
const canShare = typeof navigator.share === "function" && (!navigator.canShare || navigator.canShare(shareData));
|
||||
|
||||
label.textContent = canShare ? "Share" : "Copy Link";
|
||||
button.setAttribute("aria-label", canShare ? "Share this box" : "Copy box link");
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
if (canShare) {
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error && error.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await copyShareURL(button, label, shareData.url, canShare);
|
||||
});
|
||||
});
|
||||
|
||||
async function copyShareURL(button, label, url, shareMode) {
|
||||
try {
|
||||
await window.Warpbox.writeClipboard(url);
|
||||
const previous = label.textContent;
|
||||
label.textContent = "Copied";
|
||||
window.setTimeout(() => {
|
||||
label.textContent = shareMode ? "Share" : "Copy Link";
|
||||
}, 1400);
|
||||
} catch (error) {
|
||||
if (window.Warpbox && typeof window.Warpbox.error === "function") {
|
||||
window.Warpbox.error("The share link could not be copied.", {
|
||||
title: "Copy failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -14,7 +14,11 @@
|
||||
const openBox = document.querySelector("#open-box");
|
||||
const manageLink = document.querySelector("#manage-link");
|
||||
const newUpload = document.querySelector("#new-upload");
|
||||
const folderPicker = document.querySelector("[data-folder-picker]");
|
||||
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
||||
const CELLULAR_WARNING_THRESHOLD_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
if (!form || !dropZone || !fileInput) {
|
||||
return;
|
||||
@@ -47,6 +51,9 @@
|
||||
let uploadLocked = false;
|
||||
let recoveredDraft = null;
|
||||
let resumeMode = false;
|
||||
let sharedTargetDraft = null;
|
||||
const maxUploadBytes = parseInt(form.dataset.maxUploadBytes || "-1", 10);
|
||||
const maxUploadLabel = form.dataset.maxUploadLabel || (maxUploadBytes > 0 && window.Warpbox.formatBytes ? window.Warpbox.formatBytes(maxUploadBytes) : "the configured limit");
|
||||
|
||||
["dragenter", "dragover"].forEach((eventName) => {
|
||||
dropZone.addEventListener(eventName, (event) => {
|
||||
@@ -69,18 +76,18 @@
|
||||
});
|
||||
|
||||
document.addEventListener("drop", (event) => {
|
||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
||||
if (!hasTransferFiles(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!dropZone.contains(event.target)) {
|
||||
addSelectedFiles(event.dataTransfer.files);
|
||||
addDroppedFiles(event.dataTransfer);
|
||||
}
|
||||
});
|
||||
|
||||
dropZone.addEventListener("drop", (event) => {
|
||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
||||
addSelectedFiles(event.dataTransfer.files);
|
||||
if (hasTransferFiles(event.dataTransfer)) {
|
||||
addDroppedFiles(event.dataTransfer);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,15 +96,58 @@
|
||||
fileInput.value = "";
|
||||
});
|
||||
|
||||
document.addEventListener("paste", (event) => {
|
||||
if (!event.clipboardData || !event.clipboardData.files || event.clipboardData.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (isTextEditingTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
addSelectedFiles(event.clipboardData.files, { source: "pasted" });
|
||||
});
|
||||
|
||||
if (folderPicker && typeof window.showDirectoryPicker === "function") {
|
||||
folderPicker.hidden = false;
|
||||
folderPicker.addEventListener("click", async () => {
|
||||
if (uploadLocked) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
updateStatus("Reading folder...");
|
||||
const directory = await window.showDirectoryPicker();
|
||||
const files = await filesFromDirectoryHandle(directory, directory.name || "");
|
||||
addSelectedFiles(files, { source: "folder" });
|
||||
} catch (error) {
|
||||
if (!error || error.name !== "AbortError") {
|
||||
updateStatus("Folder could not be read.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (selectedFiles.length === 0) {
|
||||
updateStatus("Choose at least one file first.");
|
||||
notify("warning", "Choose at least one file first.", {
|
||||
title: "No files selected",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!validateSelectedFilesWithinLimit(selectedFiles)) {
|
||||
return;
|
||||
}
|
||||
if (isSlowOrMeteredConnection() && totalSelectedBytes(selectedFiles) >= CELLULAR_WARNING_THRESHOLD_BYTES) {
|
||||
const proceed = await confirmCellularUpload(selectedFiles);
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const submit = form.querySelector("button[type='submit']");
|
||||
const formData = uploadFormData();
|
||||
await maybeRequestUploadNotificationPermission(selectedFiles);
|
||||
if (resumeMode && recoveredDraft) {
|
||||
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||
} else {
|
||||
@@ -108,8 +158,11 @@
|
||||
try {
|
||||
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
||||
renderResult(payload);
|
||||
showUploadNotification("Warpbox upload complete", `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} uploaded.`, payload.boxUrl);
|
||||
await clearSharedTargetPayload();
|
||||
form.reset();
|
||||
selectedFiles = [];
|
||||
sharedTargetDraft = null;
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
fileInput.value = "";
|
||||
@@ -123,6 +176,8 @@
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Upload failed");
|
||||
notifyUploadError(error);
|
||||
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false, submit);
|
||||
}
|
||||
@@ -136,26 +191,388 @@
|
||||
|
||||
if (newUpload) {
|
||||
newUpload.addEventListener("click", () => {
|
||||
if (sharedTargetDraft) {
|
||||
clearSharedTargetPayload().finally(() => resetFreshUploadState());
|
||||
return;
|
||||
}
|
||||
cancelRecoveredDraft().catch((error) => {
|
||||
updateStatus(error.message || "Upload draft could not be deleted");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
recoverResumableSessions();
|
||||
if (isShareTargetLaunch()) {
|
||||
loadSharedTargetFiles();
|
||||
} else {
|
||||
recoverResumableSessions();
|
||||
}
|
||||
|
||||
function addSelectedFiles(files) {
|
||||
function addSelectedFiles(files, options) {
|
||||
if (uploadLocked) {
|
||||
return;
|
||||
}
|
||||
const rejected = [];
|
||||
Array.from(files || []).forEach((file) => {
|
||||
if (fileExceedsUploadLimit(file)) {
|
||||
rejected.push(file);
|
||||
return;
|
||||
}
|
||||
if (!selectedFiles.some((existing) => fileIdentity(existing) === fileIdentity(file))) {
|
||||
selectedFiles.push(file);
|
||||
}
|
||||
});
|
||||
if (rejected.length > 0) {
|
||||
notifyRejectedFiles(rejected);
|
||||
}
|
||||
if (options && options.source === "pasted" && files && files.length > 0) {
|
||||
updateStatus(`${files.length} pasted file${files.length === 1 ? "" : "s"} ready.`);
|
||||
}
|
||||
if (options && options.source === "folder" && files && files.length > 0) {
|
||||
updateStatus(`${files.length} folder file${files.length === 1 ? "" : "s"} ready.`);
|
||||
}
|
||||
updateSelectedState();
|
||||
}
|
||||
|
||||
async function addDroppedFiles(dataTransfer) {
|
||||
if (uploadLocked) {
|
||||
return;
|
||||
}
|
||||
const files = await filesFromDataTransfer(dataTransfer);
|
||||
addSelectedFiles(files, { source: hasDirectoryItems(dataTransfer) ? "folder" : "dropped" });
|
||||
}
|
||||
|
||||
async function filesFromDataTransfer(dataTransfer) {
|
||||
const items = Array.from(dataTransfer.items || []);
|
||||
const entries = items
|
||||
.map((item) => typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null)
|
||||
.filter(Boolean);
|
||||
if (entries.length === 0) {
|
||||
return Array.from(dataTransfer.files || []);
|
||||
}
|
||||
const nested = await Promise.all(entries.map((entry) => filesFromEntry(entry, "")));
|
||||
return nested.flat();
|
||||
}
|
||||
|
||||
function hasDirectoryItems(dataTransfer) {
|
||||
return Array.from(dataTransfer.items || []).some((item) => {
|
||||
const entry = typeof item.webkitGetAsEntry === "function" ? item.webkitGetAsEntry() : null;
|
||||
return entry && entry.isDirectory;
|
||||
});
|
||||
}
|
||||
|
||||
function hasTransferFiles(dataTransfer) {
|
||||
if (!dataTransfer) {
|
||||
return false;
|
||||
}
|
||||
if (dataTransfer.files && dataTransfer.files.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return Array.from(dataTransfer.items || []).some((item) => item.kind === "file");
|
||||
}
|
||||
|
||||
function filesFromEntry(entry, parentPath) {
|
||||
if (!entry) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const relativePath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||
if (entry.isFile) {
|
||||
return new Promise((resolve) => {
|
||||
entry.file((file) => resolve([withRelativePath(file, relativePath)]), () => resolve([]));
|
||||
});
|
||||
}
|
||||
if (!entry.isDirectory) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const reader = entry.createReader();
|
||||
const children = [];
|
||||
return new Promise((resolve) => {
|
||||
const readBatch = () => {
|
||||
reader.readEntries(async (entries) => {
|
||||
if (!entries.length) {
|
||||
const nested = await Promise.all(children.map((child) => filesFromEntry(child, relativePath)));
|
||||
resolve(nested.flat());
|
||||
return;
|
||||
}
|
||||
children.push(...entries);
|
||||
readBatch();
|
||||
}, () => resolve([]));
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
}
|
||||
|
||||
async function filesFromDirectoryHandle(directory, parentPath) {
|
||||
const files = [];
|
||||
for await (const [name, handle] of directory.entries()) {
|
||||
const relativePath = parentPath ? `${parentPath}/${name}` : name;
|
||||
if (handle.kind === "file") {
|
||||
const file = await handle.getFile();
|
||||
files.push(withRelativePath(file, relativePath));
|
||||
} else if (handle.kind === "directory") {
|
||||
files.push(...await filesFromDirectoryHandle(handle, relativePath));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function withRelativePath(file, relativePath) {
|
||||
if (!file || !relativePath) {
|
||||
return file;
|
||||
}
|
||||
try {
|
||||
Object.defineProperty(file, "warpboxRelativePath", {
|
||||
value: normalizeRelativePath(relativePath),
|
||||
configurable: true,
|
||||
});
|
||||
} catch (error) {
|
||||
file.warpboxRelativePath = normalizeRelativePath(relativePath);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(value) {
|
||||
return String(value || "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter((part) => part && part !== "." && part !== "..")
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function uploadName(file) {
|
||||
return normalizeRelativePath(file && (file.warpboxRelativePath || file.webkitRelativePath || file.name)) || (file && file.name) || "file";
|
||||
}
|
||||
|
||||
function isTextEditingTarget(target) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
const tag = (target.tagName || "").toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || target.isContentEditable;
|
||||
}
|
||||
|
||||
function fileExceedsUploadLimit(file) {
|
||||
return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes;
|
||||
}
|
||||
|
||||
function validateSelectedFilesWithinLimit(files) {
|
||||
const rejected = Array.from(files || []).filter(fileExceedsUploadLimit);
|
||||
if (rejected.length === 0) {
|
||||
return true;
|
||||
}
|
||||
selectedFiles = selectedFiles.filter((file) => !fileExceedsUploadLimit(file));
|
||||
notifyRejectedFiles(rejected);
|
||||
updateSelectedState();
|
||||
return false;
|
||||
}
|
||||
|
||||
function notifyRejectedFiles(files) {
|
||||
const names = files.slice(0, 3).map((file) => `"${file.name}" (${window.Warpbox.formatBytes(file.size)})`).join(", ");
|
||||
const extra = files.length > 3 ? `, and ${files.length - 3} more` : "";
|
||||
const message = `${names}${extra} ${files.length === 1 ? "is" : "are"} over the ${maxUploadLabel} upload limit.`;
|
||||
updateStatus(message);
|
||||
notify("error", message, {
|
||||
title: "Upload limit exceeded",
|
||||
duration: 9000,
|
||||
});
|
||||
}
|
||||
|
||||
function notifyUploadError(error) {
|
||||
const message = error && error.message ? error.message : "Upload failed";
|
||||
const lower = message.toLowerCase();
|
||||
const isLimit = lower.includes("limit") || lower.includes("quota") || lower.includes("too large") || lower.includes("exceeds");
|
||||
notify("error", message, {
|
||||
title: isLimit ? "Upload limit reached" : "Upload failed",
|
||||
duration: isLimit ? 9000 : 7200,
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeRequestUploadNotificationPermission(files) {
|
||||
if (!("Notification" in window) || Notification.permission !== "default" || totalSelectedBytes(files) < CELLULAR_WARNING_THRESHOLD_BYTES) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Notification.requestPermission();
|
||||
} catch (error) {
|
||||
/* notification permission is optional */
|
||||
}
|
||||
}
|
||||
|
||||
async function showUploadNotification(title, body, url) {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState === "visible") {
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
body,
|
||||
icon: "/static/android-chrome-192x192.png",
|
||||
badge: "/static/favicon-32x32.png",
|
||||
data: { url: window.Warpbox.absoluteURL(url || "/") },
|
||||
};
|
||||
try {
|
||||
const registration = navigator.serviceWorker ? await navigator.serviceWorker.ready : null;
|
||||
if (registration && registration.showNotification) {
|
||||
await registration.showNotification(title, options);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
/* fall through to page notification */
|
||||
}
|
||||
try {
|
||||
const notification = new Notification(title, options);
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
if (url) {
|
||||
window.location.href = window.Warpbox.absoluteURL(url);
|
||||
}
|
||||
notification.close();
|
||||
};
|
||||
} catch (error) {
|
||||
/* notifications are best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function notify(variant, message, options) {
|
||||
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
|
||||
window.Warpbox.notify({ ...(options || {}), variant, message });
|
||||
}
|
||||
}
|
||||
|
||||
function isSlowOrMeteredConnection() {
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
if (!connection) {
|
||||
return false;
|
||||
}
|
||||
if (connection.saveData === true) {
|
||||
return true;
|
||||
}
|
||||
return ["slow-2g", "2g", "3g"].includes(connection.effectiveType);
|
||||
}
|
||||
|
||||
function totalSelectedBytes(files) {
|
||||
return files.reduce((sum, file) => sum + file.size, 0);
|
||||
}
|
||||
|
||||
function confirmCellularUpload(files) {
|
||||
const list = document.createElement("div");
|
||||
list.className = "dialog-file-list";
|
||||
files.forEach((file) => {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "svg-icon svg-icon-document dialog-file-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "dialog-file-name";
|
||||
name.textContent = file.name;
|
||||
name.title = file.name;
|
||||
|
||||
const size = document.createElement("span");
|
||||
size.className = "dialog-file-size";
|
||||
size.textContent = window.Warpbox.formatBytes(file.size);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "dialog-file-row";
|
||||
row.append(icon, name, size);
|
||||
list.append(row);
|
||||
});
|
||||
|
||||
const totalLabel = window.Warpbox.formatBytes(totalSelectedBytes(files));
|
||||
const message = `You're on a slow or metered connection. You're about to upload ${files.length} file${files.length === 1 ? "" : "s"} (${totalLabel} total) — this could take a while or use up your data plan.`;
|
||||
|
||||
return window.Warpbox.confirmDialog(message, {
|
||||
title: "Slow connection detected",
|
||||
variant: "warning",
|
||||
body: list,
|
||||
confirmLabel: "Upload anyway",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
}
|
||||
|
||||
function isShareTargetLaunch() {
|
||||
const params = new URLSearchParams(window.location.search || "");
|
||||
return params.has("share-target");
|
||||
}
|
||||
|
||||
async function loadSharedTargetFiles() {
|
||||
if (!("caches" in window) || typeof File === "undefined") {
|
||||
updateStatus("Shared files could not be loaded in this browser.");
|
||||
recoverResumableSessions();
|
||||
return;
|
||||
}
|
||||
updateStatus("Loading shared files...");
|
||||
try {
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
const metadataResponse = await cache.match(SHARE_LATEST_KEY);
|
||||
if (!metadataResponse) {
|
||||
updateStatus(new URLSearchParams(window.location.search).get("share-target") === "unsupported"
|
||||
? "Install Warpbox as an app to share files into it from your device."
|
||||
: "No shared files were found.");
|
||||
recoverResumableSessions();
|
||||
return;
|
||||
}
|
||||
const metadata = await metadataResponse.json();
|
||||
if (metadata.error) {
|
||||
updateStatus(metadata.error);
|
||||
recoverResumableSessions();
|
||||
return;
|
||||
}
|
||||
const files = [];
|
||||
for (const item of metadata.files || []) {
|
||||
if (!item.key) {
|
||||
continue;
|
||||
}
|
||||
const response = await cache.match(item.key);
|
||||
if (!response) {
|
||||
continue;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
files.push(new File([blob], item.name || "shared-file", {
|
||||
type: item.type || blob.type || "application/octet-stream",
|
||||
lastModified: item.lastModified || Date.now(),
|
||||
}));
|
||||
}
|
||||
sharedTargetDraft = metadata;
|
||||
selectedFiles = files;
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
validateSelectedFilesWithinLimit(selectedFiles);
|
||||
if (selectedFiles.length > 0) {
|
||||
renderQueue(selectedFiles, "queued", { shared: true });
|
||||
updateStatus("Shared files ready.");
|
||||
} else {
|
||||
updateStatus("No files were included in this share.");
|
||||
}
|
||||
updateSelectedState();
|
||||
} catch (error) {
|
||||
updateStatus(error.message || "Shared files could not be loaded.");
|
||||
recoverResumableSessions();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSharedTargetPayload() {
|
||||
const draft = sharedTargetDraft;
|
||||
sharedTargetDraft = null;
|
||||
if (!draft || !("caches" in window)) {
|
||||
sharedTargetDraft = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
for (const item of draft.files || []) {
|
||||
if (item.key) {
|
||||
await cache.delete(item.key);
|
||||
}
|
||||
}
|
||||
if (draft.id) {
|
||||
await cache.delete("/__warpbox_share_target__/meta/" + encodeURIComponent(draft.id));
|
||||
}
|
||||
await cache.delete(SHARE_LATEST_KEY);
|
||||
} catch (error) {
|
||||
/* ignore cache cleanup failures */
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedFile(index) {
|
||||
if (uploadLocked) {
|
||||
return;
|
||||
@@ -175,12 +592,18 @@
|
||||
fileSummary.textContent = count === 0
|
||||
? "Reselect missing files to resume, or add extra files to this upload."
|
||||
: `${count} local file${count === 1 ? "" : "s"} ready for the recovered upload.`;
|
||||
} else if (sharedTargetDraft) {
|
||||
fileSummary.textContent = count === 0
|
||||
? "No shared files were received."
|
||||
: `${count} shared file${count === 1 ? "" : "s"} ready. Review options, then upload.`;
|
||||
} else {
|
||||
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
||||
}
|
||||
}
|
||||
if (resumeMode && recoveredDraft) {
|
||||
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||
} else if (sharedTargetDraft && count > 0) {
|
||||
renderQueue(selectedFiles, "queued", { shared: true });
|
||||
} else if (count > 0) {
|
||||
renderQueue(selectedFiles, "queued");
|
||||
} else if (uploadQueue) {
|
||||
@@ -194,7 +617,7 @@
|
||||
if (!newUpload) {
|
||||
return;
|
||||
}
|
||||
const visible = Boolean(resumeMode && recoveredDraft);
|
||||
const visible = Boolean((resumeMode && recoveredDraft) || sharedTargetDraft);
|
||||
newUpload.hidden = !visible;
|
||||
newUpload.style.display = visible ? "" : "none";
|
||||
}
|
||||
@@ -336,7 +759,7 @@
|
||||
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
|
||||
const createPayload = {
|
||||
files: files.map((file, index) => ({
|
||||
name: file.name,
|
||||
name: uploadName(file),
|
||||
size: file.size,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
fingerprint: fingerprints[index],
|
||||
@@ -803,6 +1226,7 @@
|
||||
selectedFiles = [];
|
||||
resumeMode = false;
|
||||
recoveredDraft = null;
|
||||
sharedTargetDraft = null;
|
||||
fileInput.value = "";
|
||||
result.hidden = true;
|
||||
if (resultList) {
|
||||
@@ -862,7 +1286,7 @@
|
||||
const rows = [];
|
||||
const localByNameSize = new Map();
|
||||
(localFiles || []).forEach((file, index) => {
|
||||
localByNameSize.set(`${file.name}:${file.size}`, { file, index });
|
||||
localByNameSize.set(`${uploadName(file)}:${file.size}`, { file, index });
|
||||
});
|
||||
const usedLocalIndexes = new Set();
|
||||
(session.files || []).forEach((file) => {
|
||||
@@ -873,7 +1297,7 @@
|
||||
usedLocalIndexes.add(localMatch.index);
|
||||
}
|
||||
rows.push({
|
||||
name: file.name,
|
||||
name: uploadName(file),
|
||||
size: file.size,
|
||||
uploadedBytes,
|
||||
meta: complete
|
||||
@@ -893,7 +1317,7 @@
|
||||
return;
|
||||
}
|
||||
rows.push({
|
||||
name: file.name,
|
||||
name: uploadName(file),
|
||||
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
|
||||
progress: 0,
|
||||
status: "queued",
|
||||
@@ -913,20 +1337,22 @@
|
||||
return Math.max(0, Math.min(100, Math.round((bytes / total) * 100)));
|
||||
}
|
||||
|
||||
function renderQueue(files, status) {
|
||||
function renderQueue(files, status, options) {
|
||||
if (!uploadQueue) {
|
||||
return;
|
||||
}
|
||||
const shared = Boolean(options && options.shared);
|
||||
uploadQueue.hidden = files.length === 0;
|
||||
uploadQueue.replaceChildren();
|
||||
files.forEach((file, index) => {
|
||||
uploadQueue.append(createFileRow({
|
||||
name: file.name,
|
||||
meta: window.Warpbox.formatBytes(file.size),
|
||||
name: uploadName(file),
|
||||
meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size),
|
||||
progress: status === "queued" ? 0 : 100,
|
||||
status,
|
||||
index,
|
||||
removable: status === "queued",
|
||||
shared,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -965,6 +1391,12 @@
|
||||
badge.textContent = "Needs local file";
|
||||
side.append(badge);
|
||||
}
|
||||
if (file.shared) {
|
||||
const badge = document.createElement("small");
|
||||
badge.className = "upload-file-state upload-file-state-shared";
|
||||
badge.textContent = "Shared from device";
|
||||
side.append(badge);
|
||||
}
|
||||
if (file.removable) {
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "upload-file-remove";
|
||||
@@ -982,14 +1414,16 @@
|
||||
function uploadFormData() {
|
||||
const formData = new FormData(form);
|
||||
formData.delete("file");
|
||||
formData.delete("file_path");
|
||||
selectedFiles.forEach((file) => {
|
||||
formData.append("file", file, file.name);
|
||||
formData.append("file", file, uploadName(file));
|
||||
formData.append("file_path", uploadName(file));
|
||||
});
|
||||
return formData;
|
||||
}
|
||||
|
||||
function fileIdentity(file) {
|
||||
return [file.name, file.size, file.lastModified || 0].join(":");
|
||||
return [uploadName(file), file.size, file.lastModified || 0].join(":");
|
||||
}
|
||||
|
||||
async function fileFingerprint(file) {
|
||||
@@ -998,7 +1432,7 @@
|
||||
}
|
||||
const sampleSize = Math.min(file.size, 1024 * 1024);
|
||||
const sample = await file.slice(0, sampleSize).arrayBuffer();
|
||||
const metadata = new TextEncoder().encode([file.name, file.size, file.lastModified || 0, sampleSize].join(":"));
|
||||
const metadata = new TextEncoder().encode([uploadName(file), file.size, file.lastModified || 0, sampleSize].join(":"));
|
||||
const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
|
||||
combined.set(metadata, 0);
|
||||
combined.set(new Uint8Array(sample), metadata.byteLength);
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
sourceURL: preview.dataset.sourceUrl || "",
|
||||
downloadURL: preview.dataset.downloadUrl || "",
|
||||
iconURL: preview.dataset.iconUrl || "",
|
||||
sceneURL: preview.dataset.sceneUrl || "",
|
||||
archiveURL: preview.dataset.archiveUrl || "",
|
||||
activeMode: "",
|
||||
defaultMode: "default",
|
||||
pendingMode: "",
|
||||
@@ -24,6 +26,11 @@
|
||||
rawLoaded: false,
|
||||
prismLoaded: false,
|
||||
renderLoaded: false,
|
||||
sceneLoaded: false,
|
||||
archiveLoaded: false,
|
||||
archiveUIRendered: false,
|
||||
archiveData: null,
|
||||
archiveText: "",
|
||||
renderFullscreenFallback: false,
|
||||
confirmedLargeModes: {},
|
||||
tabs: []
|
||||
@@ -35,11 +42,15 @@
|
||||
defaultPane: preview.querySelector("[data-default-preview]"),
|
||||
imagePane: preview.querySelector("[data-image-preview]"),
|
||||
videoPane: preview.querySelector("[data-video-preview]"),
|
||||
videoScenesPane: preview.querySelector("[data-video-scenes-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]"),
|
||||
archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"),
|
||||
archivePane: preview.querySelector("[data-archive-preview]"),
|
||||
archiveOutput: preview.querySelector("[data-archive-output]"),
|
||||
renderPane: preview.querySelector("[data-render-preview]"),
|
||||
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
|
||||
gatePane: preview.querySelector("[data-large-preview-gate]"),
|
||||
@@ -55,6 +66,7 @@
|
||||
bindLargeGate();
|
||||
bindThemeChanges();
|
||||
bindRenderFullscreen();
|
||||
configureMediaSession();
|
||||
renderTabs();
|
||||
selectMode(state.defaultMode);
|
||||
|
||||
@@ -65,6 +77,7 @@
|
||||
var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
|
||||
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
|
||||
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
|
||||
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
|
||||
|
||||
return {
|
||||
extension: extension,
|
||||
@@ -76,6 +89,7 @@
|
||||
isImage: isImage,
|
||||
isVideo: isVideo,
|
||||
isAudio: isAudio,
|
||||
isArchive: isArchive,
|
||||
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
|
||||
};
|
||||
}
|
||||
@@ -90,6 +104,9 @@
|
||||
|
||||
if (type.isVideo) {
|
||||
tabs.push({ mode: "video", label: "Video Preview" });
|
||||
if (state.sceneURL && els.videoScenesPane) {
|
||||
tabs.push({ mode: "scenes", label: "Scenes Preview" });
|
||||
}
|
||||
return tabs;
|
||||
}
|
||||
|
||||
@@ -98,6 +115,12 @@
|
||||
return tabs;
|
||||
}
|
||||
|
||||
if (type.isArchive) {
|
||||
tabs.push({ mode: "archive-ui", label: "Archive Preview" });
|
||||
tabs.push({ mode: "archive", label: "Text Tree" });
|
||||
return tabs;
|
||||
}
|
||||
|
||||
if (type.isTextLike) {
|
||||
if (type.isHTML || type.isMarkdown) {
|
||||
tabs.push({ mode: "render", label: "Render Preview" });
|
||||
@@ -116,6 +139,9 @@
|
||||
if (type.isVideo) {
|
||||
return "video";
|
||||
}
|
||||
if (type.isArchive) {
|
||||
return "archive-ui";
|
||||
}
|
||||
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
|
||||
if (type.isAudio && hasMode(tabs, "browser-audio")) {
|
||||
return "browser-audio";
|
||||
@@ -181,6 +207,9 @@
|
||||
show(els.imagePane);
|
||||
} else if (mode === "video") {
|
||||
show(els.videoPane);
|
||||
} else if (mode === "scenes") {
|
||||
show(els.videoScenesPane);
|
||||
ensureScenesPreview();
|
||||
} else if (mode === "browser-audio") {
|
||||
show(els.browserAudioPane);
|
||||
} else if (mode === "raw") {
|
||||
@@ -189,6 +218,12 @@
|
||||
} else if (mode === "code") {
|
||||
show(els.codePane);
|
||||
ensurePrismPreview();
|
||||
} else if (mode === "archive-ui") {
|
||||
show(els.archiveBrowserPane);
|
||||
ensureArchiveBrowserPreview();
|
||||
} else if (mode === "archive") {
|
||||
show(els.archivePane);
|
||||
ensureArchivePreview();
|
||||
} else if (mode === "render") {
|
||||
show(els.renderPane);
|
||||
if (fileType.isMarkdown) {
|
||||
@@ -267,6 +302,32 @@
|
||||
document.addEventListener("fullscreenchange", updateRenderFullscreenButton);
|
||||
}
|
||||
|
||||
function configureMediaSession() {
|
||||
if (!("mediaSession" in navigator) || typeof window.MediaMetadata !== "function") {
|
||||
return;
|
||||
}
|
||||
if (!fileType.isAudio && !fileType.isVideo) {
|
||||
return;
|
||||
}
|
||||
var artworkURL = "";
|
||||
if (fileType.isVideo && els.videoPane) {
|
||||
artworkURL = els.videoPane.getAttribute("poster") || state.iconURL || "";
|
||||
} else {
|
||||
artworkURL = state.iconURL || "";
|
||||
}
|
||||
var metadata = {
|
||||
title: state.fileName || "Warpbox media",
|
||||
artist: "Warpbox",
|
||||
album: state.sizeLabel || state.contentType || ""
|
||||
};
|
||||
if (artworkURL) {
|
||||
metadata.artwork = [
|
||||
{ src: window.Warpbox.absoluteURL(artworkURL), sizes: "512x512", type: "image/png" }
|
||||
];
|
||||
}
|
||||
navigator.mediaSession.metadata = new MediaMetadata(metadata);
|
||||
}
|
||||
|
||||
function ensureTextLoaded() {
|
||||
if (state.textLoaded) {
|
||||
return Promise.resolve(state.textSource);
|
||||
@@ -403,9 +464,12 @@
|
||||
hide(els.defaultPane);
|
||||
hide(els.imagePane);
|
||||
hide(els.videoPane);
|
||||
hide(els.videoScenesPane);
|
||||
hide(els.browserAudioPane);
|
||||
hide(els.rawPane);
|
||||
hide(els.codePane);
|
||||
hide(els.archiveBrowserPane);
|
||||
hide(els.archivePane);
|
||||
hide(els.renderPane);
|
||||
hide(els.gatePane);
|
||||
hide(els.placeholder);
|
||||
@@ -498,14 +562,275 @@
|
||||
"default": "Default",
|
||||
"image": "Image preview",
|
||||
"video": "Video preview",
|
||||
"scenes": "Scenes preview",
|
||||
"browser-audio": "Browser preview",
|
||||
"raw": "Raw preview",
|
||||
"code": "Code preview",
|
||||
"archive-ui": "Archive preview",
|
||||
"archive": "Archive preview",
|
||||
"render": "Render preview"
|
||||
};
|
||||
return labels[mode] || "Preview";
|
||||
}
|
||||
|
||||
function ensureScenesPreview() {
|
||||
if (state.sceneLoaded || !els.videoScenesPane) {
|
||||
return;
|
||||
}
|
||||
var src = els.videoScenesPane.dataset.sceneSrc || state.sceneURL;
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
els.videoScenesPane.src = src;
|
||||
state.sceneLoaded = true;
|
||||
}
|
||||
|
||||
function ensureArchivePreview() {
|
||||
if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) {
|
||||
return;
|
||||
}
|
||||
ensureArchiveData()
|
||||
.then(function () {
|
||||
var text = state.archiveText || archiveDataToText(state.archiveData);
|
||||
els.archiveOutput.textContent = text;
|
||||
state.archiveLoaded = true;
|
||||
hide(els.placeholder);
|
||||
show(els.archivePane);
|
||||
})
|
||||
.catch(function () {
|
||||
showError("Archive preview could not be loaded.");
|
||||
});
|
||||
}
|
||||
|
||||
function ensureArchiveBrowserPreview() {
|
||||
if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) {
|
||||
return;
|
||||
}
|
||||
ensureArchiveData()
|
||||
.then(function () {
|
||||
renderArchiveBrowser(state.archiveData);
|
||||
state.archiveUIRendered = true;
|
||||
hide(els.placeholder);
|
||||
show(els.archiveBrowserPane);
|
||||
})
|
||||
.catch(function () {
|
||||
showError("Archive preview could not be loaded.");
|
||||
});
|
||||
}
|
||||
|
||||
function ensureArchiveData() {
|
||||
if (state.archiveData || state.archiveText) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
showLoading("Loading archive contents...");
|
||||
return fetch(state.archiveURL, { credentials: "same-origin" })
|
||||
.then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error("Archive preview could not be loaded.");
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(function (text) {
|
||||
try {
|
||||
state.archiveData = JSON.parse(text);
|
||||
} catch (error) {
|
||||
state.archiveText = text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderArchiveBrowser(data) {
|
||||
if (!els.archiveBrowserPane) {
|
||||
return;
|
||||
}
|
||||
els.archiveBrowserPane.innerHTML = "";
|
||||
if (!data || !data.root) {
|
||||
var fallback = document.createElement("pre");
|
||||
fallback.className = "archive-browser-legacy";
|
||||
fallback.textContent = state.archiveText || "Archive preview is unavailable.";
|
||||
els.archiveBrowserPane.appendChild(fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
var header = document.createElement("div");
|
||||
header.className = "archive-browser-header";
|
||||
header.innerHTML = "<strong></strong><span></span>";
|
||||
header.querySelector("strong").textContent = data.name || state.fileName || "Archive";
|
||||
header.querySelector("span").textContent = [
|
||||
data.type || "Archive",
|
||||
formatArchiveCount(data.fileCount, "file"),
|
||||
formatArchiveCount(data.folderCount, "folder"),
|
||||
formatBytes(data.uncompressedSize || 0)
|
||||
].filter(Boolean).join(" · ");
|
||||
els.archiveBrowserPane.appendChild(header);
|
||||
|
||||
var tree = document.createElement("div");
|
||||
tree.className = "archive-tree";
|
||||
var items = data.root.items || [];
|
||||
if (items.length === 0) {
|
||||
var emptyTree = document.createElement("p");
|
||||
emptyTree.className = "archive-browser-empty";
|
||||
emptyTree.textContent = "This archive is empty.";
|
||||
tree.appendChild(emptyTree);
|
||||
} else {
|
||||
items.forEach(function (item) {
|
||||
tree.appendChild(renderArchiveNode(item, 0));
|
||||
});
|
||||
}
|
||||
els.archiveBrowserPane.appendChild(tree);
|
||||
}
|
||||
|
||||
function renderArchiveNode(node, depth) {
|
||||
var row = document.createElement(node.dir ? "details" : "div");
|
||||
row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file";
|
||||
if (node.dir && depth < 1) {
|
||||
row.open = true;
|
||||
}
|
||||
|
||||
var summary = document.createElement(node.dir ? "summary" : "div");
|
||||
summary.className = "archive-node-row";
|
||||
summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem";
|
||||
|
||||
if (node.dir) {
|
||||
summary.appendChild(createArchiveChevron());
|
||||
} else {
|
||||
var spacer = document.createElement("span");
|
||||
spacer.className = "archive-chevron-spacer";
|
||||
summary.appendChild(spacer);
|
||||
}
|
||||
|
||||
summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file")));
|
||||
|
||||
var name = document.createElement("span");
|
||||
name.className = "archive-node-name";
|
||||
name.textContent = node.name + (node.dir ? "/" : "");
|
||||
summary.appendChild(name);
|
||||
|
||||
if (!node.dir) {
|
||||
var size = document.createElement("span");
|
||||
size.className = "archive-node-size";
|
||||
size.textContent = formatBytes(node.size || 0);
|
||||
summary.appendChild(size);
|
||||
}
|
||||
|
||||
row.appendChild(summary);
|
||||
if (node.dir) {
|
||||
(node.items || []).forEach(function (child) {
|
||||
row.appendChild(renderArchiveNode(child, depth + 1));
|
||||
});
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function createArchiveChevron() {
|
||||
var chevron = document.createElement("span");
|
||||
chevron.className = "archive-chevron";
|
||||
chevron.setAttribute("aria-hidden", "true");
|
||||
chevron.innerHTML = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
|
||||
return chevron;
|
||||
}
|
||||
|
||||
function createArchiveIcon(icon) {
|
||||
var element = document.createElement("span");
|
||||
element.className = "archive-file-icon archive-file-icon-" + icon;
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
element.innerHTML = '<span class="archive-svg-icon">' + archiveIconSVG(icon) + '</span>';
|
||||
var retroURL = archiveRetroIconURL(icon);
|
||||
if (retroURL) {
|
||||
var retro = document.createElement("img");
|
||||
retro.className = "archive-retro-icon";
|
||||
retro.src = retroURL;
|
||||
retro.alt = "";
|
||||
retro.decoding = "async";
|
||||
retro.loading = "lazy";
|
||||
element.appendChild(retro);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function archiveDataToText(data) {
|
||||
if (!data || !data.root) {
|
||||
return state.archiveText || "";
|
||||
}
|
||||
var lines = [
|
||||
"Archive preview",
|
||||
"Name: " + (data.name || state.fileName || "Archive"),
|
||||
"Type: " + (data.type || "Archive"),
|
||||
"Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders",
|
||||
"Uncompressed size: " + formatBytes(data.uncompressedSize || 0),
|
||||
"",
|
||||
"."
|
||||
];
|
||||
appendArchiveTextLines(lines, data.root.items || [], "");
|
||||
if (!(data.root.items || []).length) {
|
||||
lines.push("(empty archive)");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendArchiveTextLines(lines, items, prefix) {
|
||||
items.forEach(function (item, index) {
|
||||
var last = index === items.length - 1;
|
||||
var branch = last ? "`-- " : "|-- ";
|
||||
var nextPrefix = prefix + (last ? " " : "| ");
|
||||
var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")";
|
||||
lines.push(prefix + branch + label);
|
||||
if (item.dir) {
|
||||
appendArchiveTextLines(lines, item.items || [], nextPrefix);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function archiveIconSVG(icon) {
|
||||
var icons = {
|
||||
folder: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
|
||||
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
|
||||
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
|
||||
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
|
||||
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
|
||||
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
|
||||
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
|
||||
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
|
||||
};
|
||||
return icons[icon] || icons.file;
|
||||
}
|
||||
|
||||
function archiveRetroIconURL(icon) {
|
||||
var base = "/static/file-icons/retro/";
|
||||
var icons = {
|
||||
folder: "directory_open_file_mydocs-4.png",
|
||||
img: "shimgvw.dll_14_1-2.png",
|
||||
vid: "wmploc.dll_14_504-2.png",
|
||||
aud: "wmploc.dll_14_610-2.png",
|
||||
txt: "shell32.dll_14_151-2.png",
|
||||
code: "mshtml.dll_14_2660-2.png",
|
||||
arc: "zipfldr.dll_14_101-2.png",
|
||||
file: "shell32.dll_14_152-2.png"
|
||||
};
|
||||
return base + (icons[icon] || icons.file);
|
||||
}
|
||||
|
||||
function formatArchiveCount(value, label) {
|
||||
value = Number(value || 0);
|
||||
return value + " " + label + (value === 1 ? "" : "s");
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
value = Number(value || 0);
|
||||
if (value < 1024) {
|
||||
return value + " B";
|
||||
}
|
||||
var units = ["KiB", "MiB", "GiB", "TiB"];
|
||||
var size = value / 1024;
|
||||
for (var i = 0; i < units.length; i++) {
|
||||
if (size < 1024 || i === units.length - 1) {
|
||||
return size.toFixed(1) + " " + units[i];
|
||||
}
|
||||
size /= 1024;
|
||||
}
|
||||
return value + " B";
|
||||
}
|
||||
|
||||
function loadPrism() {
|
||||
if (window.Prism) {
|
||||
return Promise.resolve();
|
||||
@@ -632,6 +957,28 @@
|
||||
return parts.length > 1 ? parts.pop() : "";
|
||||
}
|
||||
|
||||
function isArchiveFile(extension, baseType) {
|
||||
var archiveExtensions = {
|
||||
"apk": true,
|
||||
"docx": true,
|
||||
"ear": true,
|
||||
"epub": true,
|
||||
"jar": true,
|
||||
"pptx": true,
|
||||
"war": true,
|
||||
"xlsx": true,
|
||||
"zip": true
|
||||
};
|
||||
var archiveTypes = {
|
||||
"application/epub+zip": true,
|
||||
"application/java-archive": true,
|
||||
"application/vnd.android.package-archive": true,
|
||||
"application/x-zip-compressed": true,
|
||||
"application/zip": true
|
||||
};
|
||||
return Boolean(archiveExtensions[extension] || archiveTypes[baseType]);
|
||||
}
|
||||
|
||||
function languageFor(extension, baseType) {
|
||||
var extensionMap = {
|
||||
"c": "c",
|
||||
|
||||
94
backend/static/js/48-api-docs.js
Normal file
94
backend/static/js/48-api-docs.js
Normal file
@@ -0,0 +1,94 @@
|
||||
(function () {
|
||||
const root = document.querySelector("[data-api-docs]");
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panels = Array.from(root.querySelectorAll("[data-doc-panel]"));
|
||||
const navLinks = Array.from(root.querySelectorAll("[data-doc-link]"));
|
||||
const DEFAULT = "home";
|
||||
|
||||
function activate(name, focus) {
|
||||
let matched = false;
|
||||
panels.forEach((panel) => {
|
||||
const on = panel.dataset.docPanel === name;
|
||||
panel.classList.toggle("is-active", on);
|
||||
if (on) {
|
||||
matched = true;
|
||||
}
|
||||
});
|
||||
if (!matched) {
|
||||
return false;
|
||||
}
|
||||
root.querySelectorAll(".api-nav-link").forEach((link) => {
|
||||
link.classList.toggle(
|
||||
"is-active",
|
||||
link.getAttribute("href") === "#" + name
|
||||
);
|
||||
});
|
||||
if (focus) {
|
||||
const panel = root.querySelector('[data-doc-panel="' + name + '"]');
|
||||
if (panel) {
|
||||
panel.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve the current hash to a panel. The hash can point at a panel id
|
||||
// (e.g. #endpoints) or at any element inside a panel (e.g. #ep-upload),
|
||||
// letting FAQ answers deep-link straight into the reference.
|
||||
function resolveHash(focus) {
|
||||
const id = (location.hash || "").slice(1);
|
||||
if (!id) {
|
||||
activate(DEFAULT, focus);
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(id);
|
||||
if (!target) {
|
||||
activate(DEFAULT, focus);
|
||||
return;
|
||||
}
|
||||
const panel = target.closest("[data-doc-panel]");
|
||||
const name = panel ? panel.dataset.docPanel : DEFAULT;
|
||||
activate(name, focus && target === panel);
|
||||
if (panel && target !== panel) {
|
||||
// Scroll the deep-linked element into view once its panel is visible.
|
||||
window.requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
});
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => resolveHash(true));
|
||||
|
||||
navLinks.forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
// hashchange handles activation; this keeps top-level nav clicks snappy.
|
||||
if (link.getAttribute("href") === location.hash) {
|
||||
resolveHash(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add a copy button to every code block.
|
||||
root.querySelectorAll(".code-block").forEach((block) => {
|
||||
const pre = block.querySelector("pre");
|
||||
if (!pre) {
|
||||
return;
|
||||
}
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "copy-btn";
|
||||
button.textContent = "Copy";
|
||||
button.setAttribute("aria-label", "Copy code");
|
||||
button.addEventListener("click", () => {
|
||||
window.Warpbox.copyText(pre.innerText.trim(), button, "Copied");
|
||||
});
|
||||
block.appendChild(button);
|
||||
});
|
||||
|
||||
resolveHash(false);
|
||||
})();
|
||||
130
backend/static/js/service-worker.js
Normal file
130
backend/static/js/service-worker.js
Normal file
@@ -0,0 +1,130 @@
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method === "POST" && url.origin === self.location.origin && url.pathname === "/share-target") {
|
||||
event.respondWith(handleShareTarget(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
|
||||
event.waitUntil((async () => {
|
||||
const windows = await clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
for (const client of windows) {
|
||||
if ("focus" in client) {
|
||||
await client.focus();
|
||||
if ("navigate" in client) {
|
||||
await client.navigate(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
await clients.openWindow(url);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||
const SHARE_PREFIX = "/__warpbox_share_target__/";
|
||||
const LATEST_KEY = SHARE_PREFIX + "latest";
|
||||
|
||||
async function handleShareTarget(request) {
|
||||
const id = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const files = collectSharedFiles(formData);
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
const metadata = {
|
||||
id,
|
||||
title: stringValue(formData.get("title")),
|
||||
text: stringValue(formData.get("text")),
|
||||
url: stringValue(formData.get("url")),
|
||||
createdAt: new Date().toISOString(),
|
||||
files: [],
|
||||
};
|
||||
|
||||
await deletePreviousShare(cache);
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index];
|
||||
const key = SHARE_PREFIX + "file/" + encodeURIComponent(id) + "/" + index;
|
||||
metadata.files.push({
|
||||
key,
|
||||
name: file.name || "shared-file",
|
||||
type: file.type || "application/octet-stream",
|
||||
size: file.size || 0,
|
||||
lastModified: file.lastModified || Date.now(),
|
||||
});
|
||||
await cache.put(key, new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
await cache.put(LATEST_KEY, jsonResponse(metadata));
|
||||
await cache.put(SHARE_PREFIX + "meta/" + encodeURIComponent(id), jsonResponse(metadata));
|
||||
} catch (error) {
|
||||
await storeShareError(id, error);
|
||||
}
|
||||
|
||||
return Response.redirect("/?share-target=1&share-id=" + encodeURIComponent(id), 303);
|
||||
}
|
||||
|
||||
function collectSharedFiles(formData) {
|
||||
const files = [];
|
||||
["files", "file", "sharex"].forEach((name) => {
|
||||
formData.getAll(name).forEach((value) => {
|
||||
if (value instanceof File && value.size > 0) {
|
||||
files.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
return files;
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function jsonResponse(payload) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function storeShareError(id, error) {
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
await cache.put(LATEST_KEY, jsonResponse({
|
||||
id,
|
||||
error: error && error.message ? error.message : "Shared files could not be staged.",
|
||||
createdAt: new Date().toISOString(),
|
||||
files: [],
|
||||
}));
|
||||
}
|
||||
|
||||
async function deletePreviousShare(cache) {
|
||||
const previous = await cache.match(LATEST_KEY);
|
||||
if (!previous) {
|
||||
return;
|
||||
}
|
||||
let metadata = null;
|
||||
try {
|
||||
metadata = await previous.json();
|
||||
} catch (error) {
|
||||
metadata = null;
|
||||
}
|
||||
for (const file of metadata && metadata.files ? metadata.files : []) {
|
||||
if (file.key) {
|
||||
await cache.delete(file.key);
|
||||
}
|
||||
}
|
||||
if (metadata && metadata.id) {
|
||||
await cache.delete(SHARE_PREFIX + "meta/" + encodeURIComponent(metadata.id));
|
||||
}
|
||||
await cache.delete(LATEST_KEY);
|
||||
}
|
||||
@@ -7,6 +7,22 @@
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0b16",
|
||||
"theme_color": "#8b5cf6",
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "files",
|
||||
"accept": ["*/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/android-chrome-192x192.png",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}} — {{end}}{{.AppName}}</title>
|
||||
<title>{{if .Title}}{{.Title}} | {{end}}{{.AppName}}</title>
|
||||
<meta name="description" content="{{.Description}}">
|
||||
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
||||
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
|
||||
@@ -54,11 +54,13 @@
|
||||
|
||||
<script src="/static/js/05-theme.js?version={{.AppVersion}}"></script>
|
||||
<link rel="stylesheet" href="/static/css/00-base.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/04-dialogs.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/10-layout.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/15-revamp.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/16-retro.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/17-gruvbox.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/18-cyberpunk.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/19-popups.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/20-upload.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/30-download.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/40-docs.css?version={{.AppVersion}}">
|
||||
@@ -67,14 +69,19 @@
|
||||
<link rel="stylesheet" href="/static/css/70-tokens.css?version={{.AppVersion}}">
|
||||
<link rel="stylesheet" href="/static/css/90-responsive.css?version={{.AppVersion}}">
|
||||
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/02-pwa.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/03-popups.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/04-dialogs.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/12-reactions.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/13-share.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/25-admin-charts.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/40-upload.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/45-preview.js?version={{.AppVersion}}"></script>
|
||||
<script defer src="/static/js/48-api-docs.js?version={{.AppVersion}}"></script>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
|
||||
@@ -1,69 +1,132 @@
|
||||
{{define "api.html"}}{{template "base" .}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="docs-view" aria-labelledby="api-title">
|
||||
<div class="docs-header">
|
||||
<section class="api-docs" aria-labelledby="api-title" data-api-docs>
|
||||
<aside class="api-sidebar">
|
||||
<p class="kicker">Developer docs</p>
|
||||
<h1 id="api-title">Warpbox API</h1>
|
||||
<p>Anonymous uploads for curl, scripts, and ShareX. The upload endpoint accepts multipart files and returns either plain text or JSON based on the <code>Accept</code> header.</p>
|
||||
</div>
|
||||
<h1 id="api-title" class="api-sidebar-title">Warpbox API</h1>
|
||||
<nav class="api-nav" aria-label="Documentation sections">
|
||||
<a class="api-nav-link" href="#home" data-doc-link>Home</a>
|
||||
<a class="api-nav-link" href="#endpoints" data-doc-link>Endpoints</a>
|
||||
<a class="api-nav-link" href="#cli" data-doc-link>CLI / Binary</a>
|
||||
<a class="api-nav-link" href="#integrations" data-doc-link>Integrations</a>
|
||||
<a class="api-nav-link" href="#examples" data-doc-link>Examples</a>
|
||||
<a class="api-nav-link" href="#faq" data-doc-link>FAQ</a>
|
||||
</nav>
|
||||
<div class="api-sidebar-meta">
|
||||
<a href="{{.Data.RequestSchemaURL}}">Request schema</a>
|
||||
<a href="{{.Data.ResponseSchemaURL}}">Response schema</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="docs-grid">
|
||||
<article class="card docs-card">
|
||||
<div class="card-content">
|
||||
<div class="api-content">
|
||||
|
||||
<!-- ===================== HOME ===================== -->
|
||||
<section id="home" class="doc-panel" data-doc-panel="home" tabindex="-1">
|
||||
<header class="panel-head">
|
||||
<p class="kicker">Get started</p>
|
||||
<h2>Upload files anywhere, from anything</h2>
|
||||
<p class="lead">Warpbox is a one endpoint upload API. Send a multipart file with <code>curl</code>, a script, ShareX, or the <code>warpbox</code> CLI and get back a shareable box link. Request JSON to also receive private manage and delete URLs.</p>
|
||||
</header>
|
||||
|
||||
<div class="shortcut-grid">
|
||||
<a class="shortcut-card accent-blue" href="#examples" data-doc-link>
|
||||
<span class="shortcut-eyebrow">60-second start</span>
|
||||
<span class="shortcut-title">Copy-paste examples</span>
|
||||
<span class="shortcut-sub">curl, wget, HTTPie, Python & more</span>
|
||||
</a>
|
||||
<a class="shortcut-card accent-green" href="#cli" data-doc-link>
|
||||
<span class="shortcut-eyebrow">Terminal</span>
|
||||
<span class="shortcut-title">Install the CLI</span>
|
||||
<span class="shortcut-sub">One command for macOS, Linux & Windows</span>
|
||||
</a>
|
||||
<a class="shortcut-card accent-violet" href="#endpoints" data-doc-link>
|
||||
<span class="shortcut-eyebrow">Reference</span>
|
||||
<span class="shortcut-title">All endpoints</span>
|
||||
<span class="shortcut-sub">Payloads, responses & status codes</span>
|
||||
</a>
|
||||
<a class="shortcut-card accent-amber" href="#integrations" data-doc-link>
|
||||
<span class="shortcut-eyebrow">Screenshots</span>
|
||||
<span class="shortcut-title">ShareX integration</span>
|
||||
<span class="shortcut-sub">Import once, upload as your account</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="quickstart card">
|
||||
<div class="card-content">
|
||||
<h3>Your first upload</h3>
|
||||
<p>No account required. This prints one plain box URL you can share immediately.</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>curl -F file=@./report.pdf {{.Data.UploadURL}}</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">Want file URLs, a manage link, and a delete link back? Add <code>-H 'Accept: application/json'</code>. See <a href="#responses" data-doc-link>the JSON response</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-label">Quick links</h3>
|
||||
<div class="link-grid">
|
||||
<a class="link-pill" href="#ep-upload" data-doc-link><span class="link-tag tag-post">POST</span> Upload endpoint</a>
|
||||
<a class="link-pill" href="/static/api/warpbox.sh" download><span class="link-tag tag-get">GET</span> warpbox.sh (macOS/Linux)</a>
|
||||
<a class="link-pill" href="/static/api/warpbox.ps1" download><span class="link-tag tag-get">GET</span> warpbox.ps1 (Windows)</a>
|
||||
<a class="link-pill" href="{{.Data.ShareXDownloadURL}}" download><span class="link-tag tag-get">GET</span> ShareX .sxcu config</a>
|
||||
<a class="link-pill" href="{{.Data.RequestSchemaURL}}"><span class="link-tag tag-json">JSON</span> Request schema</a>
|
||||
<a class="link-pill" href="{{.Data.ResponseSchemaURL}}"><span class="link-tag tag-json">JSON</span> Response schema</a>
|
||||
<a class="link-pill" href="/account/settings"><span class="link-tag tag-key">KEY</span> Create an API token</a>
|
||||
<a class="link-pill" href="#faq" data-doc-link><span class="link-tag tag-help">?</span> FAQ & troubleshooting</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== ENDPOINTS ===================== -->
|
||||
<section id="endpoints" class="doc-panel" data-doc-panel="endpoints" tabindex="-1">
|
||||
<header class="panel-head">
|
||||
<p class="kicker">Reference</p>
|
||||
<h2>Endpoints</h2>
|
||||
<dl class="endpoint-list">
|
||||
<div><dt>Upload</dt><dd><code>POST /api/v1/upload</code></dd></div>
|
||||
<div><dt>Resumable create</dt><dd><code>POST /api/v1/uploads/resumable</code></dd></div>
|
||||
<div><dt>Resumable status</dt><dd><code>GET /api/v1/uploads/resumable/{sessionID}</code></dd></div>
|
||||
<div><dt>Resumable chunk</dt><dd><code>PUT /api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}</code></dd></div>
|
||||
<div><dt>Resumable complete</dt><dd><code>POST /api/v1/uploads/resumable/{sessionID}/complete</code></dd></div>
|
||||
<div><dt>Health</dt><dd><code>GET /health</code></dd></div>
|
||||
<div><dt>Request schema</dt><dd><a href="/api/v1/schemas/upload-request.json"><code>/api/v1/schemas/upload-request.json</code></a></dd></div>
|
||||
<div><dt>Response schema</dt><dd><a href="/api/v1/schemas/upload-response.json"><code>/api/v1/schemas/upload-response.json</code></a></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
<p class="lead">Base URL <code>{{.Data.BaseURL}}</code>. Authentication is optional: send <code>Authorization: Bearer <token></code> to upload as your account and use your account limits, or omit it to upload anonymously.</p>
|
||||
</header>
|
||||
|
||||
<article class="card docs-card docs-card-wide">
|
||||
<div class="card-content">
|
||||
<h2>Resumable uploads</h2>
|
||||
<p>Browser uploads use the resumable API by default. Custom clients can use the same flow: create a session with file metadata, upload exact-sized chunks, then complete the session. Chunks are temporary and are cleaned if the session expires.</p>
|
||||
<pre><code># 1. Create a session.
|
||||
curl -s {{.Data.BaseURL}}/api/v1/uploads/resumable \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"files":[{"name":"report.pdf","size":1048576,"contentType":"application/pdf"}],"expiresMinutes":1440}'
|
||||
<article id="ep-upload" class="endpoint card">
|
||||
<div class="card-content">
|
||||
<div class="endpoint-head">
|
||||
<span class="method method-post">POST</span>
|
||||
<code class="endpoint-path">/api/v1/upload</code>
|
||||
</div>
|
||||
<p>The core endpoint. Accepts a <code>multipart/form-data</code> body with one or more files. Returns a plain box URL by default, or the full JSON object when you send <code>Accept: application/json</code>.</p>
|
||||
|
||||
# 2. Upload each chunk using the returned sessionId, file id, and chunkSize.
|
||||
dd if=./report.pdf bs=8388608 count=1 skip=0 2>/dev/null | \
|
||||
curl -X PUT --data-binary @- \
|
||||
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
|
||||
<h4>Request fields</h4>
|
||||
<div class="field-grid">
|
||||
<span><code>file</code></span><p>One or more files. Repeat the field for multiple files. Used by curl, browsers, and the CLI.</p>
|
||||
<span><code>sharex</code></span><p>Alternative file field used by ShareX custom uploader configs. Same behaviour as <code>file</code>.</p>
|
||||
<span><code>max_days</code></span><p>Optional. Days before the box expires. Defaults to 7.</p>
|
||||
<span><code>expires_minutes</code></span><p>Optional. Lifetime in minutes. Takes precedence over <code>max_days</code> when > 0. Use it for expiries under a day (e.g. <code>60</code> = one hour).</p>
|
||||
<span><code>max_downloads</code></span><p>Optional. Auto-expire the box after this many downloads.</p>
|
||||
<span><code>password</code></span><p>Optional. Password required before viewing or downloading.</p>
|
||||
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>. Hides file names/counts until unlock (only meaningful with a password).</p>
|
||||
</div>
|
||||
|
||||
# 3. Complete after all chunks are present. The response is the normal upload JSON.
|
||||
curl -X POST -H 'Accept: application/json' \
|
||||
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/complete</code></pre>
|
||||
<p class="muted-copy">For authenticated uploads, send the same <code>Authorization: Bearer <token></code> header on every resumable request. Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
|
||||
</div>
|
||||
</article>
|
||||
<h4>Request headers</h4>
|
||||
<div class="field-grid">
|
||||
<span><code>Accept</code></span><p><code>application/json</code> to receive the JSON body; otherwise a single plain-text URL.</p>
|
||||
<span><code>Authorization</code></span><p>Optional <code>Bearer <token></code>. Attributes the upload to your account.</p>
|
||||
<span><code>X-Warpbox-Batch</code></span><p>Optional grouping key. Uploads sharing a value within {{.Data.ShareXGroupWindow}} land in the same box. See <a href="#integrations" data-doc-link>Integrations</a>.</p>
|
||||
</div>
|
||||
|
||||
<article class="card docs-card">
|
||||
<div class="card-content">
|
||||
<h2>Curl upload</h2>
|
||||
<p>Without a JSON <code>Accept</code> header, Warpbox prints one plain box URL for shell-friendly usage.</p>
|
||||
<pre><code>curl -F file=@./report.pdf {{.Data.UploadURL}}</code></pre>
|
||||
<p>For automation, request JSON to get file URLs and the private manage/delete URLs.</p>
|
||||
<pre><code>curl -F file=@./report.pdf \
|
||||
<h4>Example</h4>
|
||||
<figure class="code-block">
|
||||
<pre><code>curl -F file=@./report.pdf \
|
||||
-F max_downloads=5 \
|
||||
-F expires_minutes=1440 \
|
||||
-H 'Accept: application/json' \
|
||||
{{.Data.UploadURL}}</code></pre>
|
||||
</div>
|
||||
</article>
|
||||
</figure>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card docs-card">
|
||||
<div class="card-content">
|
||||
<h2>JSON response</h2>
|
||||
<p>The raw delete token is returned only once inside <code>manageUrl</code> and <code>deleteUrl</code>. Keep those links private. On error the body is <code>{ "error": "message" }</code> with a non-2xx status (e.g. rate limited or over a limit).</p>
|
||||
<pre><code>{
|
||||
<article id="responses" class="endpoint card">
|
||||
<div class="card-content">
|
||||
<h3>JSON response</h3>
|
||||
<p>Returned when <code>Accept: application/json</code> is sent. The raw delete token appears <strong>only once</strong>, inside <code>manageUrl</code> and <code>deleteUrl</code>, so store them privately. Full schema: <a href="{{.Data.ResponseSchemaURL}}">upload-response.json</a>.</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>{
|
||||
"boxId": "abc123",
|
||||
"boxUrl": "{{.Data.BaseURL}}/d/abc123",
|
||||
"zipUrl": "{{.Data.BaseURL}}/d/abc123/zip",
|
||||
@@ -81,29 +144,177 @@ curl -X POST -H 'Accept: application/json' \
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">On error the body is <code>{ "error": "message" }</code> with a non-2xx status. Common causes: <code>413</code> over the size limit, <code>429</code> rate limited or over your daily quota, <code>401</code> bad token.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ep-resumable" class="endpoint card">
|
||||
<div class="card-content">
|
||||
<h3>Resumable uploads</h3>
|
||||
<p>For large files. Browser uploads use this by default. Create a session with file metadata, <code>PUT</code> exact-sized chunks, then complete. Chunks are temporary and cleaned if the session expires. Send the same <code>Authorization</code> header on every request for authenticated sessions.</p>
|
||||
<div class="endpoint-list">
|
||||
<div><span class="method method-post">POST</span><code>/api/v1/uploads/resumable</code><em>Create a session</em></div>
|
||||
<div><span class="method method-get">GET</span><code>/api/v1/uploads/resumable/{sessionID}</code><em>Session status</em></div>
|
||||
<div><span class="method method-put">PUT</span><code>/api/v1/uploads/resumable/{sessionID}/files/{fileID}/chunks/{index}</code><em>Upload one chunk</em></div>
|
||||
<div><span class="method method-post">POST</span><code>/api/v1/uploads/resumable/{sessionID}/complete</code><em>Finalize (returns the upload JSON)</em></div>
|
||||
</div>
|
||||
<figure class="code-block">
|
||||
<pre><code># 1. Create a session.
|
||||
curl -s {{.Data.BaseURL}}/api/v1/uploads/resumable \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"files":[{"name":"report.pdf","size":1048576,"contentType":"application/pdf"}],"expiresMinutes":1440}'
|
||||
|
||||
# 2. Upload each chunk using the returned sessionId, file id, and chunkSize.
|
||||
dd if=./report.pdf bs=8388608 count=1 skip=0 2>/dev/null | \
|
||||
curl -X PUT --data-binary @- \
|
||||
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/files/FILE_ID/chunks/0
|
||||
|
||||
# 3. Complete after all chunks are present. The response is the normal upload JSON.
|
||||
curl -X POST -H 'Accept: application/json' \
|
||||
{{.Data.BaseURL}}/api/v1/uploads/resumable/SESSION_ID/complete</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">Incomplete chunks are stored under <code>data/tmp/uploads</code> before finalizing into the selected storage backend.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ep-meta" class="endpoint card">
|
||||
<div class="card-content">
|
||||
<h3>Health & schemas</h3>
|
||||
<div class="endpoint-list">
|
||||
<div><span class="method method-get">GET</span><code>/health</code><em>Liveness check</em></div>
|
||||
<div><span class="method method-get">GET</span><code>/api/v1/schemas/upload-request.json</code><em>Request JSON Schema</em></div>
|
||||
<div><span class="method method-get">GET</span><code>/api/v1/schemas/upload-response.json</code><em>Response JSON Schema</em></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ===================== CLI / BINARY ===================== -->
|
||||
<section id="cli" class="doc-panel" data-doc-panel="cli" tabindex="-1">
|
||||
<header class="panel-head">
|
||||
<p class="kicker">Terminal</p>
|
||||
<h2>The <code>warpbox</code> CLI</h2>
|
||||
<p class="lead">A tiny uploader script that wraps the API. It only needs <code>curl</code> (already on macOS, Linux, and Windows 10+). Point it at this instance once by setting <code>WARPBOX_HOST</code> to <code>{{.Data.BaseURL}}</code>, then upload from anywhere.</p>
|
||||
</header>
|
||||
|
||||
<div class="download-row">
|
||||
<div class="download-card">
|
||||
<div class="download-os">macOS & Linux</div>
|
||||
<p>POSIX shell script (<code>warpbox.sh</code>).</p>
|
||||
<a class="button button-primary" href="/static/api/warpbox.sh" download>Download for macOS / Linux</a>
|
||||
</div>
|
||||
<div class="download-card">
|
||||
<div class="download-os">Windows</div>
|
||||
<p>PowerShell script (<code>warpbox.ps1</code>).</p>
|
||||
<a class="button button-primary" href="/static/api/warpbox.ps1" download>Download for Windows</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card docs-card">
|
||||
<div class="card-content">
|
||||
<article id="cli-install" class="card">
|
||||
<div class="card-content">
|
||||
<h3>Install & add to PATH</h3>
|
||||
|
||||
<h4>macOS / Linux</h4>
|
||||
<p>Download into a directory on your <code>PATH</code>, then make it executable. <code>~/.local/bin</code> is the recommended location.</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>mkdir -p ~/.local/bin
|
||||
curl -fsSL {{.Data.BaseURL}}/static/api/warpbox.sh -o ~/.local/bin/warpbox
|
||||
chmod +x ~/.local/bin/warpbox
|
||||
|
||||
# Point it at this instance (add to ~/.profile or ~/.zshrc to keep it set)
|
||||
echo 'export WARPBOX_HOST={{.Data.BaseURL}}' >> ~/.profile
|
||||
|
||||
# If 'warpbox: command not found', add the dir to PATH:
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile
|
||||
# zsh users: use ~/.zshrc, then reload with: source ~/.profile</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">Verify with <code>warpbox --help</code>. Prefer a system wide install? Drop it in <code>/usr/local/bin</code> with <code>sudo</code>.</p>
|
||||
|
||||
<h4>Windows (PowerShell)</h4>
|
||||
<p>Save the script, then add a function to your PowerShell profile so <code>warpbox</code> works anywhere.</p>
|
||||
<figure class="code-block">
|
||||
<pre><code># Save it to your home folder
|
||||
iwr {{.Data.BaseURL}}/static/api/warpbox.ps1 -OutFile $HOME\warpbox.ps1
|
||||
|
||||
# Point it at this instance, and add a 'warpbox' command (run once)
|
||||
setx WARPBOX_HOST "{{.Data.BaseURL}}"
|
||||
Add-Content $PROFILE 'function warpbox { & "$HOME\warpbox.ps1" @args }'
|
||||
. $PROFILE # reload the profile</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">If scripts are blocked, allow local scripts for your user: <code>Set-ExecutionPolicy -Scope CurrentUser RemoteSigned</code>.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="cli-usage" class="card">
|
||||
<div class="card-content">
|
||||
<h3>Usage</h3>
|
||||
<p>A password, an expiry of two days, and a glob the shell expands for you:</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>warpbox --password 123 --expiry 2d ./first_file.zip ./whatever.png ./all_*_photos.jpg</code></pre>
|
||||
</figure>
|
||||
<div class="field-grid">
|
||||
<span><code>-p, --password</code></span><p>Require a password to open the box.</p>
|
||||
<span><code>-e, --expiry</code></span><p>Lifetime: <code>30m</code>, <code>6h</code>, <code>2d</code>, <code>1w</code> (or bare minutes).</p>
|
||||
<span><code>-n, --max-downloads</code></span><p>Expire after N downloads.</p>
|
||||
<span><code>-o, --obfuscate</code></span><p>Hide names/counts until unlock (needs <code>--password</code>).</p>
|
||||
<span><code>--json</code></span><p>Print the full JSON response instead of just the URL.</p>
|
||||
<span><code>--host</code></span><p>Server to upload to. Defaults to your <code>WARPBOX_HOST</code>.</p>
|
||||
</div>
|
||||
<p class="muted-copy">Windows uses PowerShell flags: <code>warpbox -Password 123 -Expiry 2d .\file.zip</code>.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="cli-auth" class="card">
|
||||
<div class="card-content">
|
||||
<h3>Secure authentication</h3>
|
||||
<p>To upload as your account (and use your account's size, daily, and retention limits), the CLI needs an API token. <strong>Set it in your environment</strong> so it never appears in your shell history or in the process list that any user on the machine can read:</p>
|
||||
<figure class="code-block">
|
||||
<pre><code># macOS / Linux (add to ~/.profile or ~/.zshrc to persist)
|
||||
export WARPBOX_TOKEN=wbx_your_token
|
||||
warpbox ./photo.png
|
||||
|
||||
# Windows (persist for your user)
|
||||
setx WARPBOX_TOKEN "wbx_your_token"</code></pre>
|
||||
</figure>
|
||||
<p>For CI or shared machines, keep the token in a file with locked down permissions and point the CLI at it. This avoids putting the secret on the command line at all:</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>printf '%s' "wbx_your_token" > ~/.warpbox-token
|
||||
chmod 600 ~/.warpbox-token
|
||||
warpbox --auth-file ~/.warpbox-token ./photo.png</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy"><code>--auth <token></code> exists for quick tests but is discouraged: it leaks into shell history and <code>ps</code>. Create or revoke tokens under <a href="/account/settings">Account, Access tokens</a>.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ===================== INTEGRATIONS ===================== -->
|
||||
<section id="integrations" class="doc-panel" data-doc-panel="integrations" tabindex="-1">
|
||||
<header class="panel-head">
|
||||
<p class="kicker">Integrations</p>
|
||||
<h2>ShareX setup</h2>
|
||||
<p>Import the uploader, then add your API key to upload as your account — with your account's size, daily, and retention limits — instead of as an anonymous guest.</p>
|
||||
<p class="lead">Import the uploader once, then optionally add your API key to upload as your account instead of as an anonymous guest.</p>
|
||||
</header>
|
||||
|
||||
<h3>1 · Import the uploader</h3>
|
||||
<ol class="docs-steps">
|
||||
<li>Download <a href="/api/v1/sharex/warpbox-anonymous.sxcu"><code>warpbox-anonymous.sxcu</code></a>.</li>
|
||||
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
|
||||
</ol>
|
||||
<article id="sharex" class="card">
|
||||
<div class="card-content">
|
||||
<h3>1. Import the uploader</h3>
|
||||
<ol class="docs-steps">
|
||||
<li>Download <a href="{{.Data.ShareXDownloadURL}}" download><code>warpbox-anonymous.sxcu</code></a>.</li>
|
||||
<li>In ShareX: <code>Destinations → Custom uploader settings → Import → From file</code>, then pick the <code>.sxcu</code>.</li>
|
||||
</ol>
|
||||
|
||||
<h3>2 · Add your API key (upload as your account)</h3>
|
||||
<ol class="docs-steps">
|
||||
<li>Create a personal access token under <a href="/account/settings">Account → Access tokens</a> and copy it.</li>
|
||||
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
|
||||
<li>Add a header — Name <code>Authorization</code>, Value <code>Bearer <your token></code>.</li>
|
||||
</ol>
|
||||
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
|
||||
<h3>2. Add your API key (optional, upload as your account)</h3>
|
||||
<ol class="docs-steps">
|
||||
<li>Create a personal access token under <a href="/account/settings">Account, Access tokens</a> and copy it.</li>
|
||||
<li>In <code>Custom uploader settings</code>, select the Warpbox uploader and open the <code>Headers</code> section.</li>
|
||||
<li>Add a header. Name <code>Authorization</code>, Value <code>Bearer <your token></code>.</li>
|
||||
</ol>
|
||||
<p class="muted-copy">Without that header, uploads stay anonymous. With it, they're attributed to your account and use your account's limits.</p>
|
||||
|
||||
<pre><code>{
|
||||
<figure class="code-block">
|
||||
<pre><code>{
|
||||
"Version": "1.0.0",
|
||||
"Name": "Warpbox (my account)",
|
||||
"DestinationType": "ImageUploader, FileUploader, TextUploader",
|
||||
@@ -121,27 +332,183 @@ curl -X POST -H 'Accept: application/json' \
|
||||
"DeletionURL": "{json:deleteUrl}",
|
||||
"ErrorMessage": "{json:error}"
|
||||
}</code></pre>
|
||||
</figure>
|
||||
|
||||
<h3>Grouping multiple files into one box</h3>
|
||||
<p>Grouping is <strong>opt-in via the <code>X-Warpbox-Batch</code> request header</strong> — without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a multi-file ShareX selection produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
|
||||
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card docs-card docs-card-wide">
|
||||
<div class="card-content">
|
||||
<h2>Multipart fields</h2>
|
||||
<div class="field-grid">
|
||||
<span><code>file</code></span><p>One or more files for curl, browser, and generic multipart clients.</p>
|
||||
<span><code>sharex</code></span><p>One or more files from ShareX custom uploader configs.</p>
|
||||
<span><code>max_days</code></span><p>Optional number of days before expiration. Defaults to 7.</p>
|
||||
<span><code>expires_minutes</code></span><p>Optional lifetime in minutes. Takes precedence over <code>max_days</code> when greater than zero — useful for sub-day expiries (e.g. <code>60</code> for one hour).</p>
|
||||
<span><code>max_downloads</code></span><p>Optional download count limit.</p>
|
||||
<span><code>password</code></span><p>Optional password required before viewing/downloading.</p>
|
||||
<span><code>obfuscate_metadata</code></span><p>Optional <code>on</code>; hides names/counts until unlock when a password is set.</p>
|
||||
<h3>Grouping multiple files into one box</h3>
|
||||
<p>Grouping is <strong>opt in via the <code>X-Warpbox-Batch</code> request header</strong>. Without it, every file becomes its own box (the default). When the header is present, uploads sharing the same value (per account, or per IP for anonymous) within {{.Data.ShareXGroupWindow}} of each other are added to the <strong>same</strong> box, so a ShareX selection of several files produces one shareable link instead of one per file. The shipped config sets <code>X-Warpbox-Batch: sharex</code>; remove that header for one box per file.</p>
|
||||
<p class="muted-copy">The response also exposes <code>{json:thumbnailUrl}</code> for ShareX previews, <code>{json:deleteUrl}</code> for the deletion URL, and <code>{json:error}</code> so ShareX surfaces messages like rate limiting.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ===================== EXAMPLES ===================== -->
|
||||
<section id="examples" class="doc-panel" data-doc-panel="examples" tabindex="-1">
|
||||
<header class="panel-head">
|
||||
<p class="kicker">Cookbook</p>
|
||||
<h2>Examples</h2>
|
||||
<p class="lead">Every snippet hits <code>POST {{.Data.UploadURL}}</code>. Add <code>-H 'Authorization: Bearer <token>'</code> to any of them to upload as your account.</p>
|
||||
</header>
|
||||
|
||||
<article id="ex-curl" class="card">
|
||||
<div class="card-content">
|
||||
<h3>curl</h3>
|
||||
<p>Plain text (one URL) for the shell; JSON for automation.</p>
|
||||
<figure class="code-block">
|
||||
<pre><code># Just the box URL
|
||||
curl -F file=@./report.pdf {{.Data.UploadURL}}
|
||||
|
||||
# Full JSON with manage + delete URLs, password and 1-hour expiry
|
||||
curl -F file=@./report.pdf \
|
||||
-F password=hunter2 \
|
||||
-F expires_minutes=60 \
|
||||
-H 'Accept: application/json' \
|
||||
{{.Data.UploadURL}}</code></pre>
|
||||
</figure>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ex-wget" class="card">
|
||||
<div class="card-content">
|
||||
<h3>wget</h3>
|
||||
<p>The endpoint needs a real <code>multipart/form-data</code> body, which <code>wget</code> can't assemble on its own, so build the body by hand and post it. It also shows the wire format:</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>B=----warpbox$$
|
||||
{ printf -- '--%s\r\nContent-Disposition: form-data; name="file"; filename="report.pdf"\r\nContent-Type: application/octet-stream\r\n\r\n' "$B"
|
||||
cat ./report.pdf
|
||||
printf -- '\r\n--%s--\r\n' "$B"; } > /tmp/wb.body
|
||||
|
||||
wget --quiet --output-document=- \
|
||||
--header="Content-Type: multipart/form-data; boundary=$B" \
|
||||
--header="Accept: application/json" \
|
||||
--post-file=/tmp/wb.body \
|
||||
{{.Data.UploadURL}}</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">Add more form fields (<code>password</code>, <code>expires_minutes</code>, …) by repeating the <code>--%s … Content-Disposition: form-data; name="…"</code> block before the closing boundary. If this feels fiddly, <code>curl</code> or the CLI build the body for you.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ex-httpie" class="card">
|
||||
<div class="card-content">
|
||||
<h3>HTTPie</h3>
|
||||
<p>Multipart with form fields:</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>http --multipart POST {{.Data.UploadURL}} \
|
||||
Accept:application/json \
|
||||
file@./report.pdf \
|
||||
max_downloads=3 \
|
||||
expires_minutes=1440</code></pre>
|
||||
</figure>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ex-python" class="card">
|
||||
<div class="card-content">
|
||||
<h3>Python (requests)</h3>
|
||||
<figure class="code-block">
|
||||
<pre><code>import requests
|
||||
|
||||
with open("report.pdf", "rb") as f:
|
||||
r = requests.post(
|
||||
"{{.Data.UploadURL}}",
|
||||
headers={"Accept": "application/json"}, # add "Authorization": "Bearer <token>"
|
||||
files={"file": f},
|
||||
data={"expires_minutes": 1440, "max_downloads": 5},
|
||||
)
|
||||
r.raise_for_status()
|
||||
print(r.json()["boxUrl"])</code></pre>
|
||||
</figure>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ex-node" class="card">
|
||||
<div class="card-content">
|
||||
<h3>Node.js (fetch)</h3>
|
||||
<figure class="code-block">
|
||||
<pre><code>import { readFile } from "node:fs/promises";
|
||||
|
||||
const form = new FormData();
|
||||
form.set("file", new Blob([await readFile("report.pdf")]), "report.pdf");
|
||||
form.set("expires_minutes", "1440");
|
||||
|
||||
const res = await fetch("{{.Data.UploadURL}}", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" }, // add Authorization: "Bearer <token>"
|
||||
body: form,
|
||||
});
|
||||
const box = await res.json();
|
||||
console.log(box.boxUrl);</code></pre>
|
||||
</figure>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="ex-ps" class="card">
|
||||
<div class="card-content">
|
||||
<h3>PowerShell</h3>
|
||||
<p>PowerShell 7+ has native multipart with <code>-Form</code>:</p>
|
||||
<figure class="code-block">
|
||||
<pre><code>$resp = Invoke-RestMethod -Uri "{{.Data.UploadURL}}" -Method Post -Headers @{ Accept = "application/json" } -Form @{
|
||||
file = Get-Item ".\report.pdf"
|
||||
expires_minutes = 1440
|
||||
}
|
||||
$resp.boxUrl</code></pre>
|
||||
</figure>
|
||||
<p class="muted-copy">On Windows PowerShell 5.1, use the bundled <code>curl.exe</code> (the same approach the <a href="#cli" data-doc-link>CLI</a> takes) or the <code>warpbox.ps1</code> script.</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FAQ ===================== -->
|
||||
<section id="faq" class="doc-panel" data-doc-panel="faq" tabindex="-1">
|
||||
<header class="panel-head">
|
||||
<p class="kicker">Help</p>
|
||||
<h2>FAQ & troubleshooting</h2>
|
||||
<p class="lead">Quick answers, each linking back to the relevant part of the docs.</p>
|
||||
</header>
|
||||
|
||||
<div class="faq-list">
|
||||
<details class="faq-item">
|
||||
<summary>Do I need an account or API key?</summary>
|
||||
<p>No. Anonymous uploads work without one, see the <a href="#home" data-doc-link>quickstart</a>. Add a token only to upload as your account and use your account's limits; set one up under <a href="/account/settings">Account, Access tokens</a> and pass it as described in <a href="#cli-auth" data-doc-link>CLI authentication</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>How do I send a password, expiry, or download limit?</summary>
|
||||
<p>They're multipart form fields on the upload endpoint: <code>password</code>, <code>expires_minutes</code> (or <code>max_days</code>), and <code>max_downloads</code>. See the full list under <a href="#ep-upload" data-doc-link>Endpoints, request fields</a>, or use the CLI flags in <a href="#cli-usage" data-doc-link>CLI usage</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>How do I get file URLs and a delete link back?</summary>
|
||||
<p>Send <code>Accept: application/json</code>. The response includes <code>boxUrl</code>, per-file <code>url</code>s, and the private <code>manageUrl</code>/<code>deleteUrl</code> (shown only once). See <a href="#responses" data-doc-link>the JSON response</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>How do I upload one big file reliably?</summary>
|
||||
<p>Use the <a href="#ep-resumable" data-doc-link>resumable endpoints</a>: create a session, PUT chunks, then complete. Interrupted uploads can resume from the last chunk.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Can I upload several files into one shareable link?</summary>
|
||||
<p>Yes. Send the <code>X-Warpbox-Batch</code> header with a shared value within {{.Data.ShareXGroupWindow}}. Details in <a href="#integrations" data-doc-link>Integrations, grouping</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Where's the keep-it-secret way to store my token?</summary>
|
||||
<p>Use the <code>WARPBOX_TOKEN</code> environment variable or <code>--auth-file</code>, not <code>--auth</code> on the command line. Full guidance in <a href="#cli-auth" data-doc-link>CLI authentication</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>My upload returns an error, what do the codes mean?</summary>
|
||||
<p>Errors come back as <code>{ "error": "message" }</code> with a non-2xx status: <code>413</code> too large, <code>429</code> rate limited / over quota, <code>401</code> invalid token. See <a href="#responses" data-doc-link>error responses</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>How do I use Warpbox from ShareX?</summary>
|
||||
<p>Import the <code>.sxcu</code> and (optionally) add your token header. Step by step with the config in <a href="#integrations" data-doc-link>Integrations, ShareX setup</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary><code>warpbox: command not found</code> after install?</summary>
|
||||
<p>The install directory isn't on your <code>PATH</code>. Fix it per your platform in <a href="#cli-install" data-doc-link>Install & add to PATH</a>.</p>
|
||||
</details>
|
||||
<details class="faq-item">
|
||||
<summary>Is there a machine-readable schema?</summary>
|
||||
<p>Yes: <a href="{{.Data.RequestSchemaURL}}">upload-request.json</a> and <a href="{{.Data.ResponseSchemaURL}}">upload-response.json</a> (JSON Schema 2020-12).</p>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="card download-card">
|
||||
<div class="card-content">
|
||||
<div class="file-emblem" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" /><path d="M14 2v6h6" /></svg>
|
||||
<span class="svg-icon svg-icon-document"></span>
|
||||
</div>
|
||||
<h1 id="download-title">{{if .Data.Locked}}Protected box{{else}}Box: {{.Data.Box.ID}} ({{len .Data.Files}} file{{if ne (len .Data.Files) 1}}s{{end}}){{end}}</h1>
|
||||
{{if .Data.Locked}}<p class="download-subtitle">Bucket id: {{.Data.Box.ID}}</p>{{end}}
|
||||
@@ -25,11 +25,17 @@
|
||||
|
||||
{{if .Data.Files}}
|
||||
{{$processing := false}}{{range .Data.Files}}{{if .Processing}}{{$processing = true}}{{end}}{{end}}
|
||||
{{$failed := false}}{{range .Data.Files}}{{if .Failed}}{{$failed = true}}{{end}}{{end}}
|
||||
{{if $processing}}
|
||||
<div class="upload-processing-alert" role="status">
|
||||
Some files are still processing. You can share this link now, but processing files will become available shortly.
|
||||
</div>
|
||||
{{end}}
|
||||
{{if $failed}}
|
||||
<div class="upload-processing-alert upload-processing-alert-error" role="alert">
|
||||
Upload processing failed for one or more files. The original upload could not be finalized by the storage backend.
|
||||
</div>
|
||||
{{end}}
|
||||
{{$single := eq (len .Data.Files) 1}}
|
||||
<div class="badge-row">
|
||||
<span class="badge badge-expiry">Expires {{.Data.ExpiresLabel}}</span>
|
||||
@@ -37,18 +43,28 @@
|
||||
</div>
|
||||
|
||||
{{if not .Data.Locked}}
|
||||
<button class="button button-outline button-wide download-share-button" type="button" data-share-box data-share-url="/d/{{.Data.Box.ID}}" data-share-title="{{if .Data.Locked}}Protected Warpbox box{{else}}Warpbox box {{.Data.Box.ID}}{{end}}" data-share-text="Shared files on Warpbox">
|
||||
<span class="svg-icon svg-icon-share" aria-hidden="true"></span>
|
||||
<span data-share-box-label>Share</span>
|
||||
</button>
|
||||
{{if or $processing $failed}}
|
||||
<span class="button button-outline button-wide is-disabled" aria-disabled="true">
|
||||
{{if $failed}}Download unavailable{{else}}Files processing{{end}}
|
||||
</span>
|
||||
{{else}}
|
||||
{{if $single}}
|
||||
{{$first := index .Data.Files 0}}
|
||||
<a class="button button-primary button-wide" href="{{$first.DownloadURL}}" download="{{$first.Name}}">
|
||||
<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>
|
||||
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||
Download
|
||||
</a>
|
||||
{{else}}
|
||||
<a class="button button-primary button-wide" href="{{.Data.ZipURL}}">
|
||||
<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>
|
||||
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||
Download zip
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div class="file-browser-window" data-file-browser-window>
|
||||
@@ -64,11 +80,11 @@
|
||||
<div class="file-browser-toolbar" aria-label="File view options">
|
||||
<div class="view-toolbar">
|
||||
<button class="button button-outline icon-button" type="button" data-view-button="list" aria-pressed="false" aria-label="List view" title="List view">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>
|
||||
<span class="svg-icon svg-icon-list" aria-hidden="true"></span>
|
||||
<span class="sr-only">List view</span>
|
||||
</button>
|
||||
<button class="button button-outline icon-button is-active" type="button" data-view-button="thumbs" aria-pressed="true" aria-label="Icon view" title="Icon view">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
|
||||
<span class="svg-icon svg-icon-grid" aria-hidden="true"></span>
|
||||
<span class="sr-only">Icon view</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -80,8 +96,8 @@
|
||||
</div>
|
||||
<div class="download-list file-browser is-thumbs" data-file-browser>
|
||||
{{range .Data.Files}}
|
||||
<article class="download-item file-card {{if .Processing}}is-processing{{end}}" data-kind="{{.PreviewKind}}" {{if not .Processing}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}">
|
||||
{{if .Processing}}<div class="file-open" aria-label="{{.Name}} is processing">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}}
|
||||
<article class="download-item file-card {{if .Processing}}is-processing{{end}} {{if .Failed}}is-failed{{end}}" data-kind="{{.PreviewKind}}" {{if and (not .Processing) (not .Failed)}}data-file-context data-preview-url="{{.URL}}" data-view-url="{{.DownloadURL}}?inline=1" data-download-url="{{.DownloadURL}}"{{end}} data-file-name="{{.Name}}" data-reaction-card data-react-url="{{.ReactURL}}" data-reacted="{{if .Reacted}}true{{else}}false{{end}}">
|
||||
{{if or .Processing .Failed}}<div class="file-open" aria-label="{{.Name}} {{if .Failed}}failed processing{{else}}is processing{{end}}">{{else}}<a class="file-open" href="{{.DownloadURL}}?inline=1"{{if not $single}} target="_blank" rel="noopener noreferrer"{{end}} aria-label="Open {{.Name}}">{{end}}
|
||||
<span class="file-media">
|
||||
{{if .HasThumbnail}}
|
||||
<img class="file-thumb" src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
@@ -92,11 +108,12 @@
|
||||
</span>
|
||||
<span class="file-main">
|
||||
<strong class="file-name" title="{{.Name}}">{{.Name}}</strong>
|
||||
<small>{{.Size}} · {{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small>
|
||||
<small>{{.Size}} · {{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</small>
|
||||
{{if .Failed}}<small class="file-error">{{.Error}}</small>{{end}}
|
||||
</span>
|
||||
<span class="file-type">{{if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span>
|
||||
<span class="file-type">{{if .Failed}}Failed{{else if .Processing}}Processing{{else}}{{.ContentType}}{{end}}</span>
|
||||
<span class="file-size">{{.Size}}</span>
|
||||
{{if .Processing}}</div>{{else}}</a>{{end}}
|
||||
{{if or .Processing .Failed}}</div>{{else}}</a>{{end}}
|
||||
{{if not $.Data.Locked}}
|
||||
<div class="file-reaction-dock" data-reaction-dock>
|
||||
<div class="file-reactions" data-reaction-list>
|
||||
@@ -112,7 +129,7 @@
|
||||
</div>
|
||||
{{if not .Reacted}}
|
||||
<button class="reaction-button" type="button" data-reaction-button data-react-url="{{.ReactURL}}" aria-label="React to {{.Name}}" title="React">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 21a9 9 0 1 0-9-9 9 9 0 0 0 9 9Z" /><path d="M8 14s1.4 2 4 2 4-2 4-2" /><path d="M9 9h.01M15 9h.01" /></svg>
|
||||
<span class="svg-icon svg-icon-emoji" aria-hidden="true"></span>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -160,35 +177,35 @@
|
||||
<small>File actions</small>
|
||||
<div class="context-menu-icons" aria-label="Quick actions">
|
||||
<button type="button" role="menuitem" data-context-action="preview" title="Open preview" aria-label="Open preview">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
||||
<span class="svg-icon svg-icon-open" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" data-context-action="copy-preview" title="Copy preview URL" aria-label="Copy preview URL">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
||||
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||
<span data-context-label class="sr-only">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<button type="button" role="menuitem" data-context-action="preview">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" /><circle cx="12" cy="12" r="3" /></svg>
|
||||
<span class="svg-icon svg-icon-eye" aria-hidden="true"></span>
|
||||
<span data-context-label>Preview</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" data-context-action="view">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
||||
<span class="svg-icon svg-icon-open" aria-hidden="true"></span>
|
||||
<span data-context-label>View raw file</span>
|
||||
</button>
|
||||
<hr>
|
||||
<button type="button" role="menuitem" data-context-action="copy-preview">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
||||
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||
<span data-context-label>Copy Preview</span>
|
||||
</button>
|
||||
<button type="button" role="menuitem" data-context-action="copy-download">
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></svg>
|
||||
<span class="svg-icon svg-icon-copy" aria-hidden="true"></span>
|
||||
<span data-context-label>Copy Download</span>
|
||||
</button>
|
||||
<hr>
|
||||
<button type="button" role="menuitem" data-context-action="download">
|
||||
<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>
|
||||
<span class="svg-icon svg-icon-download" aria-hidden="true"></span>
|
||||
<span data-context-label>Download</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data">
|
||||
<form class="upload-grid" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data" data-max-upload-bytes="{{.Data.MaxUploadBytes}}" data-max-upload-label="{{.Data.MaxUploadSize}}">
|
||||
<div class="card upload-main">
|
||||
<div class="card-content">
|
||||
{{if .CurrentUser}}
|
||||
@@ -25,7 +25,7 @@
|
||||
<svg viewBox="0 0 24 24" role="img" focusable="false" aria-hidden="true"><path d="M12 16V4m0 0 4 4m-4-4-4 4M5 20h14" /></svg>
|
||||
</span>
|
||||
<span class="drop-title">Drop files to upload</span>
|
||||
<span class="drop-copy">or click to browse</span>
|
||||
<span class="drop-copy">or click to browse, paste files, or drop a folder</span>
|
||||
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
|
||||
<input id="file-input" name="file" type="file" multiple>
|
||||
</label>
|
||||
@@ -76,6 +76,8 @@
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="file-summary">Choose one or more files to begin.</p>
|
||||
<button class="button button-outline install-pwa-button" type="button" data-install-pwa hidden>Install Warpbox</button>
|
||||
<button class="button button-outline folder-picker-button" type="button" data-folder-picker hidden>Choose folder</button>
|
||||
<button class="button button-primary" type="submit">Upload files</button>
|
||||
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}">
|
||||
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}" data-archive-url="{{.Data.File.ArchiveURL}}">
|
||||
<div class="preview-window-titlebar">
|
||||
<div>
|
||||
<strong data-preview-mode-label>Preview</strong>
|
||||
@@ -49,6 +49,7 @@
|
||||
</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>
|
||||
{{if .Data.File.HasScene}}<img class="native-preview video-scenes-preview" data-video-scenes-preview data-scene-src="{{.Data.File.SceneURL}}" alt="Scenes preview for {{.Data.File.Name}}" hidden>{{end}}
|
||||
<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>
|
||||
@@ -56,6 +57,10 @@
|
||||
<div class="code-preview prism-code-preview" data-code-preview hidden>
|
||||
<pre class="line-numbers"><code data-code-output></code></pre>
|
||||
</div>
|
||||
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
|
||||
<div class="archive-preview code-preview" data-archive-preview hidden>
|
||||
<pre><code data-archive-output></code></pre>
|
||||
</div>{{end}}
|
||||
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
|
||||
<div class="large-preview-gate" data-large-preview-gate hidden>
|
||||
<strong>Large preview</strong>
|
||||
|
||||
Reference in New Issue
Block a user