Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a7590493c | |||
| 5d77b36634 | |||
| 0b8d4a3ab9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,4 +16,5 @@ backend/static/uploads/*
|
|||||||
scripts/env/dev.env
|
scripts/env/dev.env
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
.claude
|
.claude
|
||||||
|
docs/possible_new_features
|
||||||
@@ -141,7 +141,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST")
|
||||||
title := "Shared files on Warpbox"
|
title := "Shared files on Warpbox"
|
||||||
description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel)
|
description := fmt.Sprintf("%d file%s shared via Warpbox | Expires %s.", len(box.Files), plural(len(box.Files)), expiresLabel)
|
||||||
ogImage := absoluteURL(r, fmt.Sprintf("/d/%s/og-image.jpg", box.ID))
|
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)))
|
imageAlt := fmt.Sprintf("%d shared file%s on Warp Box", len(box.Files), plural(len(box.Files)))
|
||||||
imageType := "image/jpeg"
|
imageType := "image/jpeg"
|
||||||
@@ -203,7 +203,7 @@ func fileShareDescription(size, contentType string, expiresAt time.Time) string
|
|||||||
if strings.TrimSpace(contentType) == "" {
|
if strings.TrimSpace(contentType) == "" {
|
||||||
contentType = "file"
|
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 {
|
func socialImageURL(r *http.Request, box services.Box, file services.File, view fileView) string {
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
expiryOptions, defaultExpiry := a.homeExpiryOptions(settings, user, loggedIn, isAdmin)
|
||||||
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
|
||||||
Title: "Upload your files",
|
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, "/"),
|
CanonicalURL: absoluteURL(r, "/"),
|
||||||
ImageURL: absoluteURL(r, "/static/og-default.png"),
|
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,
|
CurrentUser: currentUser,
|
||||||
Data: homeData{
|
Data: homeData{
|
||||||
MaxUploadSize: maxUploadSize,
|
MaxUploadSize: maxUploadSize,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -62,7 +63,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
files := uploadFiles(r)
|
files := uploadIncomingFiles(r)
|
||||||
totalBytes := totalUploadBytes(files)
|
totalBytes := totalUploadBytes(files)
|
||||||
var ownerID string
|
var ownerID string
|
||||||
var collectionID string
|
var collectionID string
|
||||||
@@ -164,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
|
// 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
|
// 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.
|
// 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))
|
batch := strings.TrimSpace(r.Header.Get(uploadBatchHeader))
|
||||||
if batch == "" {
|
if batch == "" {
|
||||||
if enforceBoxLimits {
|
if enforceBoxLimits {
|
||||||
@@ -172,7 +173,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
|||||||
return services.UploadResult{}, 0, status, message, nil
|
return services.UploadResult{}, 0, status, message, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, opts)
|
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services.UploadResult{}, 0, 0, "", err
|
return services.UploadResult{}, 0, 0, "", err
|
||||||
}
|
}
|
||||||
@@ -193,7 +194,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
|||||||
|
|
||||||
if entry.boxID != "" && time.Since(entry.at) < uploadGroupWindow {
|
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 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
|
// Re-attach the manage/delete URLs from the box's creation so every
|
||||||
// upload in the batch returns a working deletion URL.
|
// upload in the batch returns a working deletion URL.
|
||||||
result.ManageURL = entry.manageURL
|
result.ManageURL = entry.manageURL
|
||||||
@@ -209,7 +210,7 @@ func (a *App) createOrAppendBox(r *http.Request, user services.User, loggedIn bo
|
|||||||
return services.UploadResult{}, 0, status, message, nil
|
return services.UploadResult{}, 0, status, message, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result, err := a.uploadService.CreateBox(files, opts)
|
result, err := a.uploadService.CreateBoxFromIncoming(files, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return services.UploadResult{}, 0, 0, "", err
|
return services.UploadResult{}, 0, 0, "", err
|
||||||
}
|
}
|
||||||
@@ -229,13 +230,13 @@ func (a *App) batchBoxMatches(box services.Box, user services.User, loggedIn boo
|
|||||||
return box.OwnerID == "" && box.CreatorIP == uploadClientIP(r)
|
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 {
|
if len(files) == 0 {
|
||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
sizes := make([]int64, 0, len(files))
|
sizes := make([]int64, 0, len(files))
|
||||||
for _, file := range 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)
|
return a.checkUploadPolicyForSizes(r, user, loggedIn, settings, policy, sizes, totalBytes)
|
||||||
}
|
}
|
||||||
@@ -383,10 +384,10 @@ func uploadRateKey(r *http.Request, user services.User, loggedIn bool) string {
|
|||||||
return "ip:" + uploadClientIP(r)
|
return "ip:" + uploadClientIP(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func totalUploadBytes(files []*multipart.FileHeader) int64 {
|
func totalUploadBytes(files []services.IncomingFile) int64 {
|
||||||
var total int64
|
var total int64
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
total += file.Size
|
total += file.Size()
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
@@ -409,13 +410,48 @@ func statusForDownloadError(err error) int {
|
|||||||
return http.StatusForbidden
|
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 {
|
if r.MultipartForm == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
files := make([]*multipart.FileHeader, 0)
|
fileHeaders := r.MultipartForm.File["file"]
|
||||||
files = append(files, r.MultipartForm.File["file"]...)
|
shareXHeaders := r.MultipartForm.File["sharex"]
|
||||||
files = append(files, 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
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"warpbox.dev/backend/libs/config"
|
"warpbox.dev/backend/libs/config"
|
||||||
|
"warpbox.dev/backend/libs/jobs"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"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+`"`) {
|
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)
|
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)
|
t.Fatalf("social preview body missing preview/download description: %s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,6 +818,7 @@ func newTestApp(t *testing.T) (*App, func()) {
|
|||||||
t.Fatalf("NewBanService returned error: %v", err)
|
t.Fatalf("NewBanService returned error: %v", err)
|
||||||
}
|
}
|
||||||
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
|
return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
|
||||||
|
jobs.WaitForThumbnailJobs()
|
||||||
if err := service.Close(); err != nil {
|
if err := service.Close(); err != nil {
|
||||||
t.Fatalf("Close returned error: %v", err)
|
t.Fatalf("Close returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/image/font"
|
"golang.org/x/image/font"
|
||||||
@@ -38,8 +39,12 @@ type ThumbnailJobResult struct {
|
|||||||
Failed int
|
Failed int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var thumbnailJobs sync.WaitGroup
|
||||||
|
|
||||||
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
|
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
|
||||||
|
thumbnailJobs.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer thumbnailJobs.Done()
|
||||||
box, err := uploadService.GetBox(boxID)
|
box, err := uploadService.GetBox(boxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
|
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
|
||||||
@@ -61,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 {
|
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
|
||||||
return job{
|
return job{
|
||||||
name: "thumbnail",
|
name: "thumbnail",
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ func (s *UploadService) CreateProcessingBoxFromResumable(sessionID string) (Uplo
|
|||||||
}
|
}
|
||||||
box.Files = append(box.Files, File{
|
box.Files = append(box.Files, File{
|
||||||
ID: fileID,
|
ID: fileID,
|
||||||
Name: filepath.Base(incoming.Name),
|
Name: cleanUploadDisplayName(incoming.Name),
|
||||||
StoredName: storedName,
|
StoredName: storedName,
|
||||||
Size: incoming.Size,
|
Size: incoming.Size,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
@@ -557,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) {
|
func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts UploadOptions, chunkSize int64, existing map[string]bool) ([]ResumableFile, error) {
|
||||||
sessionFiles := make([]ResumableFile, 0, len(files))
|
sessionFiles := make([]ResumableFile, 0, len(files))
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
file.Name = filepath.Base(strings.TrimSpace(file.Name))
|
file.Name = cleanUploadDisplayName(file.Name)
|
||||||
if file.Name == "." || file.Name == "" {
|
if file.Name == "." || file.Name == "" {
|
||||||
return nil, fmt.Errorf("file name is required")
|
return nil, fmt.Errorf("file name is required")
|
||||||
}
|
}
|
||||||
@@ -594,7 +594,7 @@ func (s *UploadService) resumableFilesFromInput(files []ResumableFileInput, opts
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resumableFileKey(name string, size int64, fingerprint string) string {
|
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 {
|
type resumableIncomingFile struct {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -452,7 +453,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
|||||||
|
|
||||||
box.Files = append(box.Files, File{
|
box.Files = append(box.Files, File{
|
||||||
ID: fileID,
|
ID: fileID,
|
||||||
Name: filepath.Base(incoming.Name()),
|
Name: cleanUploadDisplayName(incoming.Name()),
|
||||||
StoredName: storedName,
|
StoredName: storedName,
|
||||||
Size: incoming.Size(),
|
Size: incoming.Size(),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
@@ -464,6 +465,36 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
|
|||||||
return nil
|
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) {
|
func (s *UploadService) GetBox(id string) (Box, error) {
|
||||||
var box Box
|
var box Box
|
||||||
err := s.db.View(func(tx *bbolt.Tx) error {
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
const openBox = document.querySelector("#open-box");
|
const openBox = document.querySelector("#open-box");
|
||||||
const manageLink = document.querySelector("#manage-link");
|
const manageLink = document.querySelector("#manage-link");
|
||||||
const newUpload = document.querySelector("#new-upload");
|
const newUpload = document.querySelector("#new-upload");
|
||||||
|
const folderPicker = document.querySelector("[data-folder-picker]");
|
||||||
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
const RESUMABLE_SESSIONS_KEY = "warpbox-resumable-sessions";
|
||||||
const SHARE_CACHE = "warpbox-share-target-v1";
|
const SHARE_CACHE = "warpbox-share-target-v1";
|
||||||
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
const SHARE_LATEST_KEY = "/__warpbox_share_target__/latest";
|
||||||
@@ -75,18 +76,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("drop", (event) => {
|
document.addEventListener("drop", (event) => {
|
||||||
if (!event.dataTransfer || !event.dataTransfer.files.length) {
|
if (!hasTransferFiles(event.dataTransfer)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!dropZone.contains(event.target)) {
|
if (!dropZone.contains(event.target)) {
|
||||||
addSelectedFiles(event.dataTransfer.files);
|
addDroppedFiles(event.dataTransfer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
dropZone.addEventListener("drop", (event) => {
|
dropZone.addEventListener("drop", (event) => {
|
||||||
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
if (hasTransferFiles(event.dataTransfer)) {
|
||||||
addSelectedFiles(event.dataTransfer.files);
|
addDroppedFiles(event.dataTransfer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,6 +96,36 @@
|
|||||||
fileInput.value = "";
|
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) => {
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedFiles.length === 0) {
|
if (selectedFiles.length === 0) {
|
||||||
@@ -116,6 +147,7 @@
|
|||||||
|
|
||||||
const submit = form.querySelector("button[type='submit']");
|
const submit = form.querySelector("button[type='submit']");
|
||||||
const formData = uploadFormData();
|
const formData = uploadFormData();
|
||||||
|
await maybeRequestUploadNotificationPermission(selectedFiles);
|
||||||
if (resumeMode && recoveredDraft) {
|
if (resumeMode && recoveredDraft) {
|
||||||
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
renderResumeQueue(recoveredDraft.session, selectedFiles);
|
||||||
} else {
|
} else {
|
||||||
@@ -126,6 +158,7 @@
|
|||||||
try {
|
try {
|
||||||
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
const payload = await uploadResumable(form.action, formData, selectedFiles);
|
||||||
renderResult(payload);
|
renderResult(payload);
|
||||||
|
showUploadNotification("Warpbox upload complete", `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} uploaded.`, payload.boxUrl);
|
||||||
await clearSharedTargetPayload();
|
await clearSharedTargetPayload();
|
||||||
form.reset();
|
form.reset();
|
||||||
selectedFiles = [];
|
selectedFiles = [];
|
||||||
@@ -144,6 +177,7 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateStatus(error.message || "Upload failed");
|
updateStatus(error.message || "Upload failed");
|
||||||
notifyUploadError(error);
|
notifyUploadError(error);
|
||||||
|
showUploadNotification("Warpbox upload failed", error.message || "Upload failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false, submit);
|
setLoading(false, submit);
|
||||||
}
|
}
|
||||||
@@ -173,7 +207,7 @@
|
|||||||
recoverResumableSessions();
|
recoverResumableSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSelectedFiles(files) {
|
function addSelectedFiles(files, options) {
|
||||||
if (uploadLocked) {
|
if (uploadLocked) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,9 +224,132 @@
|
|||||||
if (rejected.length > 0) {
|
if (rejected.length > 0) {
|
||||||
notifyRejectedFiles(rejected);
|
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();
|
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) {
|
function fileExceedsUploadLimit(file) {
|
||||||
return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes;
|
return Number.isFinite(maxUploadBytes) && maxUploadBytes > 0 && file && file.size > maxUploadBytes;
|
||||||
}
|
}
|
||||||
@@ -229,6 +386,53 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function notify(variant, message, options) {
|
||||||
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
|
if (window.Warpbox && typeof window.Warpbox.notify === "function") {
|
||||||
window.Warpbox.notify({ ...(options || {}), variant, message });
|
window.Warpbox.notify({ ...(options || {}), variant, message });
|
||||||
@@ -555,7 +759,7 @@
|
|||||||
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
|
const fingerprints = await Promise.all(files.map((file) => fileFingerprint(file)));
|
||||||
const createPayload = {
|
const createPayload = {
|
||||||
files: files.map((file, index) => ({
|
files: files.map((file, index) => ({
|
||||||
name: file.name,
|
name: uploadName(file),
|
||||||
size: file.size,
|
size: file.size,
|
||||||
contentType: file.type || "application/octet-stream",
|
contentType: file.type || "application/octet-stream",
|
||||||
fingerprint: fingerprints[index],
|
fingerprint: fingerprints[index],
|
||||||
@@ -1082,7 +1286,7 @@
|
|||||||
const rows = [];
|
const rows = [];
|
||||||
const localByNameSize = new Map();
|
const localByNameSize = new Map();
|
||||||
(localFiles || []).forEach((file, index) => {
|
(localFiles || []).forEach((file, index) => {
|
||||||
localByNameSize.set(`${file.name}:${file.size}`, { file, index });
|
localByNameSize.set(`${uploadName(file)}:${file.size}`, { file, index });
|
||||||
});
|
});
|
||||||
const usedLocalIndexes = new Set();
|
const usedLocalIndexes = new Set();
|
||||||
(session.files || []).forEach((file) => {
|
(session.files || []).forEach((file) => {
|
||||||
@@ -1093,7 +1297,7 @@
|
|||||||
usedLocalIndexes.add(localMatch.index);
|
usedLocalIndexes.add(localMatch.index);
|
||||||
}
|
}
|
||||||
rows.push({
|
rows.push({
|
||||||
name: file.name,
|
name: uploadName(file),
|
||||||
size: file.size,
|
size: file.size,
|
||||||
uploadedBytes,
|
uploadedBytes,
|
||||||
meta: complete
|
meta: complete
|
||||||
@@ -1113,7 +1317,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rows.push({
|
rows.push({
|
||||||
name: file.name,
|
name: uploadName(file),
|
||||||
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
|
meta: `${window.Warpbox.formatBytes(file.size)} · new file`,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
status: "queued",
|
status: "queued",
|
||||||
@@ -1142,7 +1346,7 @@
|
|||||||
uploadQueue.replaceChildren();
|
uploadQueue.replaceChildren();
|
||||||
files.forEach((file, index) => {
|
files.forEach((file, index) => {
|
||||||
uploadQueue.append(createFileRow({
|
uploadQueue.append(createFileRow({
|
||||||
name: file.name,
|
name: uploadName(file),
|
||||||
meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size),
|
meta: shared ? `${window.Warpbox.formatBytes(file.size)} · Shared from device` : window.Warpbox.formatBytes(file.size),
|
||||||
progress: status === "queued" ? 0 : 100,
|
progress: status === "queued" ? 0 : 100,
|
||||||
status,
|
status,
|
||||||
@@ -1210,14 +1414,16 @@
|
|||||||
function uploadFormData() {
|
function uploadFormData() {
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
formData.delete("file");
|
formData.delete("file");
|
||||||
|
formData.delete("file_path");
|
||||||
selectedFiles.forEach((file) => {
|
selectedFiles.forEach((file) => {
|
||||||
formData.append("file", file, file.name);
|
formData.append("file", file, uploadName(file));
|
||||||
|
formData.append("file_path", uploadName(file));
|
||||||
});
|
});
|
||||||
return formData;
|
return formData;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileIdentity(file) {
|
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) {
|
async function fileFingerprint(file) {
|
||||||
@@ -1226,7 +1432,7 @@
|
|||||||
}
|
}
|
||||||
const sampleSize = Math.min(file.size, 1024 * 1024);
|
const sampleSize = Math.min(file.size, 1024 * 1024);
|
||||||
const sample = await file.slice(0, sampleSize).arrayBuffer();
|
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);
|
const combined = new Uint8Array(metadata.byteLength + sample.byteLength);
|
||||||
combined.set(metadata, 0);
|
combined.set(metadata, 0);
|
||||||
combined.set(new Uint8Array(sample), metadata.byteLength);
|
combined.set(new Uint8Array(sample), metadata.byteLength);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
bindLargeGate();
|
bindLargeGate();
|
||||||
bindThemeChanges();
|
bindThemeChanges();
|
||||||
bindRenderFullscreen();
|
bindRenderFullscreen();
|
||||||
|
configureMediaSession();
|
||||||
renderTabs();
|
renderTabs();
|
||||||
selectMode(state.defaultMode);
|
selectMode(state.defaultMode);
|
||||||
|
|
||||||
@@ -301,6 +302,32 @@
|
|||||||
document.addEventListener("fullscreenchange", updateRenderFullscreenButton);
|
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() {
|
function ensureTextLoaded() {
|
||||||
if (state.textLoaded) {
|
if (state.textLoaded) {
|
||||||
return Promise.resolve(state.textSource);
|
return Promise.resolve(state.textSource);
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ self.addEventListener("fetch", (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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_CACHE = "warpbox-share-target-v1";
|
||||||
const SHARE_PREFIX = "/__warpbox_share_target__/";
|
const SHARE_PREFIX = "/__warpbox_share_target__/";
|
||||||
const LATEST_KEY = SHARE_PREFIX + "latest";
|
const LATEST_KEY = SHARE_PREFIX + "latest";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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}}">
|
<meta name="description" content="{{.Description}}">
|
||||||
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
{{if .CanonicalURL}}<link rel="canonical" href="{{.CanonicalURL}}">{{end}}
|
||||||
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
|
<meta name="robots" content="{{if .Robots}}{{.Robots}}{{else}}index,follow{{end}}">
|
||||||
|
|||||||
@@ -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>
|
<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>
|
||||||
<span class="drop-title">Drop files to upload</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>
|
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
|
||||||
<input id="file-input" name="file" type="file" multiple>
|
<input id="file-input" name="file" type="file" multiple>
|
||||||
</label>
|
</label>
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p id="file-summary">Choose one or more files to begin.</p>
|
<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 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-primary" type="submit">Upload files</button>
|
||||||
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
|
<button class="button button-danger upload-new-button" type="button" id="new-upload" hidden>New upload</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user