Compare commits

...
23 Commits
Author SHA1 Message Date
d1ab7dda42 Merge main v1.4.3 (#2529)
Co-authored-by: Anatolii Prylutskyi <anpryl@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
2026-06-09 19:07:00 -05:00
Graham SteffaniakandGraham 22174b5575 Improve preview cancellation (#2527)
Co-authored-by: Graham <info@quantumx-apps.com>
2026-06-09 14:48:51 -05:00
Graham Steffaniak f7e09430e6 New folder extraction archive (#2526) 2026-06-08 19:30:20 -05:00
Graham Steffaniak f3f4bbe80c Fix security issue (#2524) 2026-06-08 13:20:36 -05:00
Graham Steffaniak 863f8f64a3 Markdown relative path (#2525) 2026-06-08 12:57:02 -05:00
ec80963d5f Fix arrow keys selection and add shortcuts (#2521)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
2026-06-08 11:12:27 -05:00
Graham Steffaniak 630016a36c Reimplement socket (#2520) 2026-06-08 10:23:01 -05:00
Kevin B. 1b748adc53 fix linting error (#2518) 2026-06-05 22:37:03 -04:00
Graham Steffaniak 6571e3adca updated with deps update (#2519) 2026-06-05 18:46:54 -05:00
Graham Steffaniak 5d88f223e5 updated changelog 2026-06-05 17:57:56 -05:00
Kevin B. 8a9347c9df add fallback to show text in notification if copy fails (#2517) 2026-06-05 17:56:38 -05:00
Kevin B. 6014139f2d fix accidental exit on images while using gestures (#2508) 2026-06-05 17:53:55 -05:00
Graham Steffaniak 4ff600dab1 updated changelog 2026-06-05 17:50:02 -05:00
Kevin B.andNeil 07530de13b feat/pinned files (#2510)
Co-authored-by: Neil <kylelzzz02@icloud.com>
2026-06-05 17:44:46 -05:00
Graham Steffaniak d6e4063f3b Merge branch 'dev/v1.4.3' of github.com:gtsteffaniak/filebrowser into dev/v1.4.3 2026-06-05 17:41:47 -05:00
Kevin B. 2539b893d7 fix lyrics regex parsing (#2505) 2026-06-05 17:30:43 -05:00
Kevin B. b30b1641fc update vue i18n + lazy loading of languages (#2504) 2026-06-05 17:30:03 -05:00
Graham SteffaniakandGraham Steffaniak 010c4728ae changed a few things (#2502)
Co-authored-by: Graham Steffaniak <graham@debian-lake.ghome.net>
2026-05-27 11:22:26 -05:00
Kevin B. 8658b27a97 update eslint and fix linting (#2488) 2026-05-26 10:19:14 -05:00
Graham Steffaniak 36bf16a68b Fix overwrite false quickscan (#2487) 2026-05-25 19:10:25 -05:00
Graham Steffaniak c135b8610d Merge branch 'dev/v1.4.3' of github.com:gtsteffaniak/filebrowser into dev/v1.4.3 2026-05-22 13:02:05 -05:00
Graham Steffaniak 27eb86421d updated nitpicks (#2486) 2026-05-22 12:21:07 -05:00
Graham Steffaniak 7359640dec updated nitpicks 2026-05-22 11:32:55 -05:00
268 changed files with 5877 additions and 7795 deletions

No files matched your search

+28
View File
@@ -2,6 +2,34 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.4.3
**Security**:
- [High] Path traversal in subtitle handler allows any authenticated user to read arbitrary files (GHSA-vvp7-h4fj-m28w)
**New Features**:
- pinned files/folders (#2396) (#2510)
- Markdown Image relative reference support (#2355)
- Add an option to create a new folder for unarchiving (#2338)
**Notes**:
- added fallback to show text in notification if copy fails (#2517)
- added `alt`+`arrow up` shortcut as alias of `backspace` to go into parent directory (#2501) (#2521)
- added `alt`+`arrow down` shortcut as alias of `enter` to open files in Listing View (#2501) (#2521)
- updated help menu with better translations
- migrate i18n to v11 (#2472) (#2504)
- migrate eslint (#2459)
- improved preview cancellation to improve performance when navigating UI.
**BugFixes**:
- fix lyrics regex parsing (#2505)
- fix folder previews issue (#2487) (#2492)
- fix accidental exit on images while using gestures (#2508)
- navigating using the up/down arrow keys in Listing View was "jumping randomly" in some view modes (#2521)
- socket field in config.yaml is ignored (#2497)
- fix keep opened file selected after closing its preview @anpryl (#2515)
## v1.4.2
**Security**:
+7 -64
View File
@@ -5,17 +5,22 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
## Prerequisites
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Node.js 22.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git** especially improtant on windows -- needed for bash support.
### Optional Tools
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- **ffmpeg/ffprobe**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- **exiftool**: For photo metadata processing (RAW/HEIC previews, EXIF orientation)
- Ubuntu/Debian: `sudo apt-get install libimage-exiftool-perl`
- macOS: `brew install exiftool`
- Windows: Download from [exiftool.org](https://exiftool.org/) (install `exiftool.exe` and add it to your PATH, or set `integrations.media.exiftoolPath` in config)
- **mupdf-tools**: For PDF preview generation
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
- macOS: `brew install mupdf-tools`
@@ -90,18 +95,6 @@ make test-playwright # E2E tests in Docker
E2E tests run with three authentication modes: standard auth, no auth, and proxy auth.
### Coverage & Performance
```bash
cd backend
./run_check_coverage.sh # Coverage report with HTML output
./run_benchmark.sh # Benchmarks
```
**Code Coverage:**
- View report: Open `backend/coverage.html` after running coverage script
- CI enforces coverage for critical packages
- Use `go test -cover` for quick package coverage
## Code Standards
### Backend (Go)
@@ -156,53 +149,3 @@ type(scope): description
Types: feat, fix, docs, refactor, test, chore
```
## Roadmap
Check the [Project Roadmap](https://github.com/users/gtsteffaniak/projects/4/views/2) to see issues sorted by priority. This helps you understand what features are planned and where you can contribute most effectively.
## Troubleshooting
### Common Issues
**Build failures:**
```bash
# Frontend
cd frontend && rm -rf node_modules && npm install
# Backend
cd backend && go mod tidy && go clean -modcache
```
**Authentication Issues:**
Always first enable debug logging in your config file if you have issues.
1. **OIDC Login Fails:**
- Check redirect URLs match your config
- Verify OIDC provider settings
- Enable debug logs to see auth flow
- Common issue: mismatched callback URLs
2. **Proxy Auth Not Working:**
- Verify header names match config
- Check nginx/reverse proxy passes headers
- Test with: `curl -H "X-Auth: username" localhost:8080`
- Enable `--log-level debug` to see headers
3. **TOTP Issues:**
- Ensure server time is synchronized
- Check QR code generation in logs
- Test with authenticator app time settings
- Database might have stale TOTP secrets
4. **Session/Cookie Problems:**
- Clear browser cookies and localStorage
- Verify cookie domain settings
- Try incognito/private browsing mode
### Getting Help
- **Wiki**: [Project Wiki](https://github.com/gtsteffaniak/filebrowser/wiki)
- **Issues**: [GitHub Issues](https://github.com/gtsteffaniak/filebrowser/issues)
- **PR Template**: See `.github/PULL_REQUEST_TEMPLATE.md`
+4 -4
View File
@@ -357,7 +357,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
{Name: "Test Object", Type: "directory"},
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
// Delete the old directory item from the database before replacing it with a file
idx.DeleteMetadata("/Test Object", true, false)
@@ -375,7 +375,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
},
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
@@ -467,7 +467,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
// Delete the old file item from the database before replacing it with a directory
idx.DeleteMetadata("/Test Object", false, false)
@@ -483,7 +483,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
Type: "directory",
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
idx.UpdateMetadata(rootInfo, nil, true) // nil scanner for test
// Verify the file was replaced with a directory in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
+3 -2
View File
@@ -27,6 +27,8 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
@@ -381,13 +383,12 @@ func generateOfficeId(realPath string) string {
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
re := regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := re.FindStringSubmatch(line)
matches := reDuration.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
+4 -8
View File
@@ -8,7 +8,6 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
@@ -63,6 +62,9 @@ func Initialize(configFile string) {
}
func setupServer() {
if Config.Server.Socket != "" && (Config.Server.TLSCert != "" || Config.Server.TLSKey != "") {
logger.Fatal("server.socket cannot be used with tlsCert or tlsKey")
}
if Config.Server.ListenAddress == "" {
Config.Server.ListenAddress = "0.0.0.0"
}
@@ -971,12 +973,6 @@ func loadEnvConfig() {
}
func SetDefaults(generate bool) Settings {
// get number of CPUs available
numCpus := 4 // default to 4 CPUs if runtime.NumCPU() fails or is not available
cpus := runtime.NumCPU()
if cpus > 0 && !generate {
numCpus = cpus
}
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
@@ -987,7 +983,7 @@ func SetDefaults(generate bool) Settings {
s := Settings{
Server: Server{
Port: 80,
NumImageProcessors: numCpus,
NumImageProcessors: 4,
BaseURL: "",
Database: database,
SourceMap: map[string]*Source{},
+2 -2
View File
@@ -49,8 +49,8 @@ type Environment struct {
type Server struct {
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is 4.
Socket string `json:"socket"` // socket to listen on - eg. /var/run/filebrowser.sock
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
-2
View File
@@ -6,8 +6,6 @@ server:
sources:
- path: "/srv"
auth:
key: "change-me-to-a-random-string"
totpSecret: "change-me-to-another-random-string"
methods:
password:
enabled: true
+1 -1
View File
@@ -21,7 +21,7 @@ func (s settingsBackend) Save(set *settings.Settings) error {
func (s settingsBackend) GetServer() (*settings.Server, error) {
server := &settings.Server{
Port: 80,
NumImageProcessors: 1,
NumImageProcessors: 4,
}
return server, get(s.db, "server", server)
}
+24
View File
@@ -138,8 +138,17 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
user.SidebarLinks = adjustedLinks
}
if slices.Contains(fields, "PinnedItems") {
adjustedPinnedItems, err := user.GetBackendPinnedItems()
if err != nil {
return err
}
user.PinnedItems = adjustedPinnedItems
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
existingUserFields := reflect.ValueOf(existingUser).Elem()
needsUserSave := false
for _, field := range fields {
// Get the corresponding field using reflection
fieldValue := userFields.FieldByName(field)
@@ -162,12 +171,27 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
// If otpEnabled is true, continue with normal field update
}
existingFieldValue := existingUserFields.FieldByName(field)
if existingFieldValue.IsValid() && existingFieldValue.CanSet() {
existingFieldValue.Set(reflect.ValueOf(val))
}
if field == "PinnedItems" {
existingUser.PinnedItems = user.PinnedItems
needsUserSave = true
continue
}
// Update the database
if err := st.db.UpdateField(existingUser, field, val); err != nil {
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
}
}
if needsUserSave {
if err := st.db.Save(existingUser); err != nil {
return fmt.Errorf("failed to save user with pinned items: %v", err)
}
}
return nil
}
+4 -4
View File
@@ -492,13 +492,13 @@ func TestFilterRestrictedFields(t *testing.T) {
}{
{
name: "all allowed fields",
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode", "PinnedItems"},
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode", "PinnedItems"},
},
{
name: "mixed allowed and restricted",
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled"},
expected: []string{"DarkMode", "OtpEnabled"},
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled", "PinnedItems"},
expected: []string{"DarkMode", "OtpEnabled", "PinnedItems"},
},
{
name: "all restricted fields",
+58
View File
@@ -119,6 +119,64 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
return newLinks, nil
}
// GetBackendPinnedItems converts pinned item source keys from frontend source names to backend source paths.
func (u *User) GetBackendPinnedItems() (PinnedItems, error) {
if len(u.PinnedItems) == 0 {
return PinnedItems{}, nil
}
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newPinnedItems := PinnedItems{}
for sourceKey, directories := range u.PinnedItems {
source, ok := sourceConfig.GetSourceByPath(sourceKey)
if !ok {
source, ok = sourceConfig.GetSourceByName(sourceKey)
}
if !ok {
continue
}
if newPinnedItems[source.Path] == nil {
newPinnedItems[source.Path] = map[string][]string{}
}
for directoryPath, pinnedPaths := range directories {
copiedPaths := append([]string(nil), pinnedPaths...)
newPinnedItems[source.Path][directoryPath] = copiedPaths
}
}
return newPinnedItems, nil
}
// GetFrontendPinnedItems converts pinned item source keys from backend source paths to frontend source names.
func (u *User) GetFrontendPinnedItems() PinnedItems {
if len(u.PinnedItems) == 0 || sourceConfig == nil {
return PinnedItems{}
}
newPinnedItems := PinnedItems{}
for sourceKey, directories := range u.PinnedItems {
source, ok := sourceConfig.GetSourceByPath(sourceKey)
if !ok {
continue
}
if newPinnedItems[source.Name] == nil {
newPinnedItems[source.Name] = map[string][]string{}
}
for directoryPath, pinnedPaths := range directories {
copiedPaths := append([]string(nil), pinnedPaths...)
newPinnedItems[source.Name][directoryPath] = copiedPaths
}
}
return newPinnedItems
}
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
func normalizeScope(scope string) string {
if !strings.HasPrefix(scope, "/") {
+3
View File
@@ -96,6 +96,8 @@ type Preview struct {
Models bool `json:"models"` // show live thumbnails for 3D models files
}
type PinnedItems map[string]map[string][]string
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
// JSON responses use display names only (see http prepForFrontend).
type User struct {
@@ -151,6 +153,7 @@ type NonAdminEditable struct {
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
PinnedItems PinnedItems `json:"pinnedItems,omitempty"` // pinned items organized by source and directory path
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
+15 -15
View File
@@ -10,30 +10,30 @@ require (
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.17.3
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.21
github.com/mattn/go-sqlite3 v1.14.44
github.com/mattn/go-sqlite3 v1.14.45
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0
golang.org/x/mod v0.36.0
golang.org/x/net v0.54.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.44.0
golang.org/x/sys v0.45.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.45.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1
modernc.org/sqlite v1.51.0
)
require (
@@ -60,7 +60,7 @@ require (
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.1 // indirect
github.com/air-verse/air v1.65.3 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
@@ -106,7 +106,7 @@ require (
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -121,8 +121,8 @@ require (
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-openapi/jsonpointer v0.23.1 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.6 // indirect
github.com/go-openapi/spec v0.22.5 // indirect
github.com/go-openapi/swag/conv v0.26.0 // indirect
github.com/go-openapi/swag/jsonname v0.26.0 // indirect
github.com/go-openapi/swag/jsonutils v0.26.0 // indirect
@@ -140,14 +140,14 @@ require (
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.5 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 // indirect
github.com/golang/geo v0.0.0-20260605061815-58f45aa500f1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
@@ -294,7 +294,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/libc v1.72.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+38 -38
View File
@@ -85,8 +85,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.65.1 h1:G4hy4YemKrIcGAvYMor+86PhxdtBEq0n9ebJ4RoYy8w=
github.com/air-verse/air v1.65.1/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.3 h1:7NUAqofZuSf5vZbVoZpdTr/4LuKkOCj1JTiMrOdUs7A=
github.com/air-verse/air v1.65.3/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
@@ -256,8 +256,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -318,10 +318,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo=
github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I=
github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE=
@@ -339,18 +339,18 @@ github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFu
github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE=
github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ=
github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE=
github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4=
github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8=
github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk=
github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo=
github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -377,10 +377,10 @@ github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUN
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
@@ -417,8 +417,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6 h1:maAhA7cpighmEi96eTgDdmlhwnHYEgwhR6t+T7k3Png=
github.com/golang/geo v0.0.0-20260512202753-e3c51de6d1b6/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260605061815-58f45aa500f1 h1:vkAT/N8DPfmQ3ZltANiX95HhM0nv/tqoq5Innoimcwo=
github.com/golang/geo v0.0.0-20260605061815-58f45aa500f1/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -664,8 +664,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
@@ -952,8 +952,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -972,8 +972,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1036,8 +1036,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1107,8 +1107,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -1294,18 +1294,18 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/ccgo/v4 v4.34.2 h1:mxsy2FdrB6+qG3NfXefz1AmWv0ehOSDO4jxgxd7h9yo=
modernc.org/ccgo/v4 v4.34.2/go.mod h1:1L7us56+kAKu04p25EATpmBBvhbcqqZ85ibqWVwVgog=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.72.5 h1:m2OGx9Ser1VvTS4Z9ZJlWs+CBMxutLaTiAWkNz+NB9U=
modernc.org/libc v1.72.5/go.mod h1:np0N7KDJ7eUtMZmOqVZNldrZyG+DHLl2B5pg8Hbar3U=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1314,8 +1314,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+31 -17
View File
@@ -10,6 +10,7 @@ import (
"io/fs"
"net"
"net/http"
"os"
"time"
_ "net/http/pprof"
@@ -127,7 +128,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("POST /resources/archive", withUser(archiveCreateHandler))
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("GET /resources/preview", withTimeout(30*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("POST /resources/pause", withUser(resourcePauseHandler))
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
@@ -137,7 +138,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicApi.HandleFunc("DELETE /resources/bulk", withHashFile(publicBulkDeleteHandler))
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
publicApi.HandleFunc("GET /resources/preview", withTimeout(30*time.Second, withHashFileHelper(publicPreviewHandler)))
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
// Legacy routes (backwards compatibility)
api.HandleFunc("GET /raw", withUser(downloadHandler))
@@ -253,16 +254,28 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
})
}
var scheme string
port := ""
srv := &http.Server{
Addr: fmt.Sprintf("%v:%v", config.Server.ListenAddress, config.Server.Port),
Handler: muxWithMiddleware(router),
}
listenAddress := config.Server.ListenAddress
go func() {
// Determine whether to use HTTPS (TLS) or HTTP
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
var listener net.Listener
if config.Server.Socket != "" {
if err := os.Remove(config.Server.Socket); err != nil && !os.IsNotExist(err) {
logger.Fatalf("Could not remove existing socket: %v", err)
}
var err error
listener, err = net.Listen("unix", config.Server.Socket)
if err != nil {
logger.Fatalf("Could not listen on unix socket: %v", err)
}
logger.Infof("Running at : unix://%s%s", config.Server.Socket, config.Server.BaseURL)
} else if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
var scheme string
port := ""
// Determine whether to use HTTPS (TLS) or HTTP
// Load the TLS certificate and key
cer, err := tls.LoadX509KeyPair(config.Server.TLSCert, config.Server.TLSKey)
if err != nil {
@@ -286,8 +299,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
logger.Infof("Running at : %s", fullURL)
// Attempt to get listener from socket activation with TLS configuration
var listener net.Listener
socketActivationListeners, err := activation.TLSListeners(tlsConfig)
if err == nil && len(socketActivationListeners) > 0 {
listener = socketActivationListeners[0]
@@ -306,10 +317,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
} else {
var scheme string
port := ""
// Set HTTP scheme and the default port for HTTP
scheme = "http"
if config.Server.Port != 80 {
@@ -320,8 +330,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
logger.Infof("Running at : %s", fullURL)
var listener net.Listener
// Attempt to get the listener from socket activation
socketActivationListeners, err := activation.Listeners()
if err == nil && len(socketActivationListeners) > 0 {
@@ -347,10 +355,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}
}
// Start HTTP server
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
}
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
logger.Fatalf("Server error: %v", err)
}
}()
@@ -389,6 +397,12 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
logger.Info("HTTP server shut down gracefully.")
}
if config.Server.Socket != "" {
if err := os.Remove(config.Server.Socket); err != nil && !os.IsNotExist(err) {
logger.Debugf("Could not remove unix socket: %v", err)
}
}
// Signal that shutdown is complete
close(shutdownComplete)
}
+72 -54
View File
@@ -5,14 +5,13 @@ import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
)
// subtitlesHandler handles subtitle requests for both external files and embedded streams
@@ -37,58 +36,49 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
name := r.URL.Query().Get("name")
embedded := r.URL.Query().Get("embedded") == "true"
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
}
userscope, err := d.user.GetScopeForSourceName(source)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
}, store.Access, d.user, store.Share)
if err != nil {
return http.StatusForbidden, err
return errToStatus(err), err
}
if !strings.HasPrefix(fileInfo.Type, "video") {
return http.StatusNotFound, fmt.Errorf("file is not a video")
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
track := findSubtitleTrack(fileInfo.Subtitles, name, embedded)
if track == nil {
return http.StatusNotFound, fmt.Errorf("subtitle track '%s' not found", name)
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
parentDir := filepath.Dir(realPath)
var content string
if !embedded {
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
subtitlePath := filepath.Join(filepath.Dir(fileInfo.RealPath), filepath.Base(track.Name))
content, err = utils.GetSubtitleSidecarContent(subtitlePath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
}
} else {
// For embedded subtitles, we need to find the stream index by name
// Get file modification time for caching
fileInfo, err := os.Stat(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
}
// Detect embedded subtitles
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
// Find the subtitle track by name
var streamIndex *int
for _, sub := range embeddedSubs {
if sub.Name == name {
streamIndex = sub.Index
break
}
}
if streamIndex == nil {
if track.Index == nil {
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
}
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
content, err = ffmpeg.ExtractSubtitleContent(fileInfo.RealPath, *track.Index)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
}
@@ -102,6 +92,16 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
func findSubtitleTrack(subtitles []utils.SubtitleTrack, name string, embedded bool) *utils.SubtitleTrack {
for i := range subtitles {
sub := &subtitles[i]
if sub.Name == name && sub.Embedded == embedded {
return sub
}
}
return nil
}
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
// for client-side patching after a fast listing load.
// @Summary Directory with media metadata
@@ -196,19 +196,26 @@ func lyricsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
userscope, err := d.user.GetScopeForSourceName(source)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.user.ShowHidden,
HideFileExt: d.user.HideFileExt,
SkipExtendedAttrs: false,
}, store.Access, d.user, store.Share)
if err != nil {
return http.StatusForbidden, err
return errToStatus(err), err
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
if !strings.HasPrefix(fileInfo.Type, "audio") {
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
}
realPath, _, err := idx.GetRealPath(userscope, path)
if err != nil {
return http.StatusNotFound, err
}
lyrics, err := files.ExtractLyrics(realPath)
lyrics, err := files.ExtractLyrics(fileInfo.RealPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to extract lyrics: %v", err)
}
@@ -230,15 +237,26 @@ func publicLyricsHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
idx := indexing.GetIndex(sourceCfg.Name)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source not found")
}
realPath, _, err := idx.GetRealPath(d.IndexPath)
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: false,
ShowHidden: d.share.ShowHidden,
HideFileExt: d.user.HideFileExt,
FollowSymlinks: false,
SkipExtendedAttrs: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return http.StatusNotFound, errors.New("could not find real path for file")
return errToStatus(err), err
}
lyrics, err := files.ExtractLyrics(realPath)
if !strings.HasPrefix(fileInfo.Type, "audio") {
return http.StatusNotFound, fmt.Errorf("file is not an audio file")
}
lyrics, err := files.ExtractLyrics(fileInfo.RealPath)
if err != nil {
return http.StatusInternalServerError, errors.New("failed to extract lyrics")
}
+32 -11
View File
@@ -84,6 +84,22 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return status, nil
}
func shouldServeOriginalPreview(ext string, resizable bool, realPath, previewSize string, fileSize int64) bool {
if !resizable {
return false
}
// HEIC/TIFF still need conversion for reliable web display.
switch ext {
case ".heic", ".heif", ".tiff", ".tif":
return false
}
const maxSizeForOriginal = 256 * 1024 // 256KB
if config.Server.DisableResize || fileSize < maxSizeForOriginal {
return true
}
return preview.ShouldServeOriginalImage(realPath, previewSize)
}
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
fd, err := os.Open(file.RealPath)
if err != nil {
@@ -155,6 +171,11 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
ctx := r.Context()
if d.ctx != nil {
ctx = d.ctx
}
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
previewSize = "small"
@@ -191,6 +212,12 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
}
fileInfo, err := getDirectoryPreview(r, d, dirFrameIndex)
if err != nil {
if isClientCancellation(ctx, err) {
return http.StatusOK, nil
}
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out")
}
logger.Errorf("error getting directory preview: %v", err)
return http.StatusInternalServerError, err
}
@@ -203,9 +230,9 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
// For small, displayable images (jpg, png, etc.) serve the original to avoid processing.
const maxSizeForOriginal = 128 * 1024 // 128KB
if resizable && (config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
// For small displayable images (jpg, png, etc.) serve the original to avoid processing.
// Also serve the original when dimensions already fit the requested preview size.
if isImage && shouldServeOriginalPreview(ext, resizable, d.fileInfo.RealPath, previewSize, d.fileInfo.Size) {
return rawFileHandler(w, r, d.fileInfo)
}
@@ -223,12 +250,6 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
officeUrl = scheme + "://" + r.Host + pathUrl
}
}
// Use the context from the request context (which includes timeout)
ctx := r.Context()
if d.ctx != nil {
ctx = d.ctx
}
previewImg, err := preview.GetPreviewForFile(ctx, d.fileInfo, previewSize, officeUrl, seekPercentage)
if err != nil {
// Check if it was a context cancellation (client navigated away)
@@ -239,8 +260,8 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
// Check if it was a context timeout (server-side timeout)
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
logger.Errorf("Preview timeout for file '%s' after 60 seconds", d.fileInfo.Name)
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 60 seconds")
logger.Errorf("Preview timeout for file '%s' after 15 seconds", d.fileInfo.Name)
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 15 seconds")
}
// Log detailed error information for actual server errors
+1 -1
View File
@@ -907,7 +907,7 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
}
if srcIdx.Config.ReadOnly && req.Action == "move" {
item.Message = "source source is read-only"
item.Message = "From source is read-only and cannot be moved"
if d.share != nil {
response.Failed = append(response.Failed, MoveCopyItem{
Message: item.Message,
+17
View File
@@ -99,6 +99,7 @@ func prepForFrontend(u *users.User) {
u.TOTPSecret = ""
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.PinnedItems = u.GetFrontendPinnedItems()
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks, u.ShowToolsInSidebar)
u.Locale = normalizeLocale(u.Locale)
for i := range u.PasskeyCredentials {
@@ -355,6 +356,10 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
return true
}
func userPutOnlyPinnedItems(which []string) bool {
return len(which) == 1 && strings.EqualFold(strings.TrimSpace(which[0]), "PinnedItems")
}
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
// password login. Callers should invoke this only when the update requires re-authentication.
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
@@ -453,6 +458,18 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
}
}
if userPutOnlyPinnedItems(req.Which) {
adjustedPinnedItems, err2 := req.User.GetBackendPinnedItems()
if err2 != nil {
return http.StatusBadRequest, err2
}
oldUser.PinnedItems = adjustedPinnedItems
if err2 := store.Users.Save(oldUser, false, false); err2 != nil {
return http.StatusBadRequest, err2
}
return http.StatusNoContent, nil
}
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
if err != nil {
return http.StatusBadRequest, err
+2
View File
@@ -2,6 +2,7 @@ package icons
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
@@ -205,6 +206,7 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
// Resize image using the preview service's Resize method
err = previewService.Resize(
context.Background(),
bytes.NewReader(sourceData),
outFile,
preview.ResizeOptions{
+1 -1
View File
@@ -624,7 +624,7 @@ func (idx *Index) indexDirectory(indexPath string, opts Options, scanner *Scanne
if err2 != nil {
return 0, false, err2
}
idx.UpdateMetadata(dirFileInfo, scanner)
idx.UpdateMetadata(dirFileInfo, scanner, !opts.Recursive)
// Always store the calculated directory size in the in-memory map
// This ensures parent directories are updated after copy/move operations
+10 -4
View File
@@ -635,7 +635,7 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
}
// Update metadata (will be batched)
s.idx.UpdateMetadata(dirInfo, s)
s.idx.UpdateMetadata(dirInfo, s, true)
// Track this directory as having been updated (for file purge logic)
s.idx.mu.Lock()
@@ -650,11 +650,17 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
// Modtime unchanged - update last_updated to prevent folder from being purged
// Note: we DON'T add this folder to scanUpdatedPaths, so Phase 2 won't check its files
// Use cached aggregate size: BulkInsertItems overwrites DB size;0 would corrupt LoadFolderSizes on restart.
// BulkInsertItems overwrites all columns — preserve size/hasPreview/hidden from cache/DB, not hardcoded defaults.
var sizeForDB int64
if cached, ok := s.idx.GetFolderSize(folderPath); ok {
sizeForDB = int64(cached)
}
hasPreview := false
hidden := IsHidden(realPath)
if existing, ok := s.idx.GetReducedMetadata(folderPath, true); ok && existing != nil {
hasPreview = existing.HasPreview
hidden = existing.Hidden
}
fileInfo := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
@@ -662,8 +668,8 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
Size: sizeForDB,
ModTime: info.ModTime(),
Type: "directory",
Hidden: false,
HasPreview: false,
Hidden: hidden,
HasPreview: hasPreview,
},
}
+80
View File
@@ -3,8 +3,11 @@ package indexing
import (
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestPrepareScanStateInitializesMaps(t *testing.T) {
@@ -33,3 +36,80 @@ func TestIsPathGone(t *testing.T) {
t.Fatal("os.ErrNotExist should be path gone")
}
}
// TestCheckFolderModtime_unchangedPreservesHasPreviewAndHidden verifies quick-scan touch updates
// do not clobber has_preview/hidden when folder modtime is unchanged.
func TestCheckFolderModtime_unchangedPreservesHasPreviewAndHidden(t *testing.T) {
if indexDB == nil {
var err error
indexDB, _, err = dbsql.NewIndexDB("test_scanner_modtime", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
}
root := t.TempDir()
photosDir := root + "/photos"
if err := os.Mkdir(photosDir, 0o755); err != nil {
t.Fatalf("mkdir photos: %v", err)
}
idx := &Index{
ReducedIndex: ReducedIndex{},
Source: settings.Source{
Name: "test_modtime",
Path: root,
},
db: indexDB,
scanUpdatedPaths: make(map[string]bool),
folderSizes: make(map[string]uint64),
folderSizesUnsynced: make(map[string]struct{}),
}
folderPath := "/photos/"
now := time.Now()
stored := &iteminfo.FileInfo{
Path: folderPath,
ItemInfo: iteminfo.ItemInfo{
Name: "photos",
Type: "directory",
Size: 42,
ModTime: now.Add(-time.Hour),
Hidden: true,
HasPreview: true,
},
}
if err := idx.db.InsertItem("test_modtime", folderPath, stored); err != nil {
t.Fatalf("InsertItem: %v", err)
}
idx.SetFolderSize(folderPath, 42)
s := &Scanner{
scanPath: "/",
idx: idx,
}
s.withStatsLock(func() {
s.lastScanned = time.Now()
})
s.batchItems = make([]*iteminfo.FileInfo, 0, idx.db.BatchSize)
changed, err := s.checkFolderModtime(folderPath)
if err != nil {
t.Fatalf("checkFolderModtime: %v", err)
}
if changed {
t.Fatal("expected unchanged folder")
}
s.flushBatch()
got, ok := idx.GetReducedMetadata(folderPath, true)
if !ok || got == nil {
t.Fatal("expected folder metadata after touch update")
}
if !got.HasPreview {
t.Error("HasPreview should be preserved on unchanged modtime touch update")
}
if !got.Hidden {
t.Error("Hidden should be preserved on unchanged modtime touch update")
}
}
+1 -6
View File
@@ -213,11 +213,7 @@ func (idx *Index) runSerialScanPass() {
paths := sortedScannerPaths(idx)
idx.mu.Lock()
idx.schedulerBatch++
batchDepth := idx.schedulerBatch
idx.mu.Unlock()
logger.Debugf("[%s] scheduler: serialPass start paths=%d batchDepth=%d adaptive=%v",
idx.Name, len(paths), batchDepth, idx.useAdaptiveScheduling())
defer func() {
idx.mu.Lock()
idx.schedulerBatch--
@@ -225,7 +221,6 @@ func (idx *Index) runSerialScanPass() {
if err := idx.PostScan(); err != nil {
logger.Errorf("PostScan after serial pass: %v", err)
}
logger.Debugf("[%s] scheduler: serialPass done", idx.Name)
}()
for _, p := range paths {
@@ -334,7 +329,7 @@ func (idx *Index) runIndexScheduler() {
for {
now := time.Now()
next := idx.earliestSlotWake(now)
d := time.Until(next)
d := time.Until(next).Truncate(time.Second)
if d < time.Second {
d = time.Second
}
-13
View File
@@ -411,20 +411,7 @@ func ShouldBubbleUpToFolderPreview(item ItemInfo) bool {
if item.Type == "directory" || !item.HasPreview {
return false
}
// Get the simple type (e.g., "image", "video", "text")
simpleType := strings.Split(item.Type, "/")[0]
// Text files should NOT bubble up
if simpleType == "text" {
return false
}
// Office documents should NOT bubble up
if IsOnlyOffice(item.Name) {
return false
}
// Document convertable files (PDFs, etc.) should NOT bubble up
if HasDocConvertableExtension(item.Name, item.Type) {
return false
}
// Only allow images, videos, and audio with album art to bubble up
if simpleType == "image" || simpleType == "video" || simpleType == "audio" {
return true
+1 -1
View File
@@ -30,7 +30,7 @@ func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
Files: files,
}
idx.UpdateMetadata(dirInfo, nil) // nil scanner for mock
idx.UpdateMetadata(dirInfo, nil, true) // nil scanner for mock
}
}
+13 -35
View File
@@ -13,57 +13,35 @@ import (
)
// UpdateMetadata persists a completed directory listing to the index. The directory row's has_preview
// scanner is optional — if nil (API refresh), inserts run immediately; otherwise items batch until flush.
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo, scanner *Scanner) bool {
// reflects direct previewable children only (see GetDirInfoCore).
func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo, scanner *Scanner, persistListedFolders bool) bool {
items := make([]*iteminfo.FileInfo, 0, len(info.Files)+len(info.Folders)+1)
dirItem := *info
items = append(items, &dirItem)
// Check if we're in a batch scan (scanner provided)
isBatchScan := scanner != nil
includeListedFolders := !isBatchScan || persistListedFolders
if isBatchScan {
// During recursive scans: only insert directory itself and direct files
// Subdirectories will be inserted by their own recursive indexDirectory call
// This avoids redundant inserts and maintains the in-memory size calculation pattern
for i := range info.Files {
f := &info.Files[i]
// Construct index path for the file
filePath := strings.TrimRight(info.Path, "/") + "/" + f.Name
fileItem := &iteminfo.FileInfo{
ItemInfo: f.ItemInfo,
Path: filePath, // Store index path, not absolute path
}
items = append(items, fileItem)
}
} else {
// Not in batch scan (e.g., API-triggered updates): insert everything
// Add folders to the bulk insert with index paths
if includeListedFolders {
for i := range info.Folders {
folder := &info.Folders[i]
// Construct index path for the folder (with trailing slash)
folderPath := strings.TrimRight(info.Path, "/") + "/" + folder.Name + "/"
folderItem := &iteminfo.FileInfo{
ItemInfo: *folder,
Path: folderPath, // Store index path with trailing slash
Path: folderPath,
}
items = append(items, folderItem)
}
}
// Add files to the bulk insert with index paths
for i := range info.Files {
f := &info.Files[i]
// Construct index path for the file
filePath := strings.TrimRight(info.Path, "/") + "/" + f.Name
fileItem := &iteminfo.FileInfo{
ItemInfo: f.ItemInfo,
Path: filePath, // Store index path, not absolute path
}
items = append(items, fileItem)
for i := range info.Files {
f := &info.Files[i]
filePath := strings.TrimRight(info.Path, "/") + "/" + f.Name
fileItem := &iteminfo.FileInfo{
ItemInfo: f.ItemInfo,
Path: filePath,
}
items = append(items, fileItem)
}
// Use scanner-specific batch if available
+66 -2
View File
@@ -242,7 +242,7 @@ func TestUpdateFileMetadata(t *testing.T) {
mock: true,
}
success := index.UpdateMetadata(info, nil) // nil scanner for test
success := index.UpdateMetadata(info, nil, true) // nil scanner for test
if !success {
t.Fatalf("expected UpdateFileMetadata to succeed")
}
@@ -315,9 +315,73 @@ func TestSetDirectoryInfo(t *testing.T) {
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", ModTime: time.Now()}},
},
}
index.UpdateMetadata(dir, nil) // nil scanner for test
index.UpdateMetadata(dir, nil, true) // nil scanner for test
storedDir, exists := index.GetMetadataInfo("/newPath/", true, false)
if !exists || storedDir.Files[0].Name != "testfile.txt" {
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
}
}
func TestUpdateMetadata_batchScanPersistListedFolders(t *testing.T) {
t.Parallel()
if indexDB == nil {
var err error
indexDB, _, err = dbsql.NewIndexDB("test_mutate_batch", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
}
index := &Index{
Source: settings.Source{
Name: "test_batch_folders",
Path: "/mock/path",
},
db: indexDB,
mock: true,
}
scanner := &Scanner{idx: index, batchItems: make([]*iteminfo.FileInfo, 0, 16)}
now := time.Now()
parent := &iteminfo.FileInfo{
Path: "/parent/",
ItemInfo: iteminfo.ItemInfo{
Name: "parent",
Type: "directory",
ModTime: now,
},
Folders: []iteminfo.ItemInfo{
{
Name: "child",
Type: "directory",
Size: 100,
ModTime: now,
HasPreview: true,
Hidden: true,
},
},
}
index.UpdateMetadata(parent, scanner, false)
scanner.flushBatch()
_, exists := index.GetReducedMetadata("/parent/child/", true)
if exists {
t.Fatal("recursive batch scan should not persist listed subfolders when persistListedFolders is false")
}
scanner.batchItems = scanner.batchItems[:0]
index.UpdateMetadata(parent, scanner, true)
scanner.flushBatch()
child, exists := index.GetReducedMetadata("/parent/child/", true)
if !exists || child == nil {
t.Fatal("expected child folder row after persistListedFolders=true batch update")
}
if !child.HasPreview {
t.Error("child HasPreview should be persisted from listing")
}
if !child.Hidden {
t.Error("child Hidden should be persisted from listing")
}
}
+49 -9
View File
@@ -2,6 +2,7 @@ package preview
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
@@ -12,6 +13,22 @@ import (
"github.com/kovidgoyal/imaging"
)
// configureImagingParallelism limits kovidgoyal/imaging to one thread per resize.
// Concurrent preview jobs are capped separately by imageSem (like OG filebrowser).
func configureImagingParallelism() {
imaging.SetMaxProcs(1)
}
// previewDecodeOpts returns decode options tuned for thumbnails: skip ICC/sRGB conversion
// (not needed for previews) and optionally apply EXIF orientation.
func previewDecodeOpts(autoOrient bool) []imaging.DecodeOption {
opts := []imaging.DecodeOption{imaging.ColorSpace(imaging.NO_CHANGE_OF_COLORSPACE)}
if autoOrient {
opts = append(opts, imaging.AutoOrientation(true))
}
return opts
}
// Format is an image file format.
/*
ENUM(
@@ -150,12 +167,26 @@ type ResizeOptions struct {
JpegQuality int // JPEG encoding quality (1-100), 0 means use Quality default
}
func (s *Service) Resize(in io.Reader, out io.Writer, opts ResizeOptions) error {
return s.ResizeWithSize(in, out, 0, opts)
func (s *Service) Resize(ctx context.Context, in io.Reader, out io.Writer, opts ResizeOptions) error {
return s.ResizeWithSize(ctx, in, out, 0, opts)
}
// ResizeWithSize resizes an image with file size information for appropriate semaphore selection
func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
// ResizeWithSize resizes an image with file size information for appropriate semaphore selection.
// Resize runs synchronously so the image semaphore accurately bounds concurrent CPU work.
// A detached goroutine would return on ctx cancellation while still decoding/resizing,
// defeating the semaphore and piling up orphaned work that causes preview timeouts under load.
func (s *Service) ResizeWithSize(ctx context.Context, in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
if ctx.Err() != nil {
return ctx.Err()
}
err := s.resizeWithSize(in, out, fileSize, opts)
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
func (s *Service) resizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
// Set defaults
if opts.ResizeMode == 0 {
opts.ResizeMode = ResizeModeFit
@@ -193,7 +224,7 @@ func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
}
// Decode the image - try imaging library first, fall back to format-specific decoder if it fails
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
img, err := imaging.Decode(wrappedReader, previewDecodeOpts(true)...)
if err != nil {
// Imaging library failed, try format-specific standard decoder as fallback
// Reset reader if possible
@@ -217,7 +248,16 @@ func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, op
}
}
// Resize
// Skip resize in Fit mode when the image is already within target dimensions.
// Fill mode may still need to upscale or crop to reach the target size.
bounds := img.Bounds()
if opts.ResizeMode != ResizeModeFill && bounds.Dx() <= opts.Width && bounds.Dy() <= opts.Height {
if opts.Format == FormatJpeg {
return jpeg.Encode(out, img, &jpeg.Options{Quality: opts.JpegQuality})
}
return imaging.Encode(out, img, opts.Format.toImaging())
}
resampleFilter := opts.Quality.resampleFilter()
switch opts.ResizeMode {
case ResizeModeFill:
@@ -242,7 +282,7 @@ func applyOrientationToPreviewBytes(imageBytes []byte, orientation string) []byt
if len(imageBytes) < 100 {
return imageBytes
}
img, err := imaging.Decode(bytes.NewReader(imageBytes))
img, err := imaging.Decode(bytes.NewReader(imageBytes), previewDecodeOpts(false)...)
if err != nil {
return imageBytes
}
@@ -268,7 +308,7 @@ func applyOrientationToPreviewBytes(imageBytes []byte, orientation string) []byt
return imageBytes
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, out, &jpeg.Options{Quality: 90}); err != nil {
if err := jpeg.Encode(&buf, out, &jpeg.Options{Quality: 80}); err != nil {
return imageBytes
}
return buf.Bytes()
@@ -357,6 +397,6 @@ func CreateThumbnail(rawData io.Reader, width, height int) (image.Image, error)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
thumb := imaging.Fit(img, width, height, imaging.Lanczos)
thumb := imaging.Fit(img, width, height, imaging.CatmullRom)
return thumb, nil
}
+41 -5
View File
@@ -2,6 +2,7 @@ package preview
import (
"bytes"
"context"
"errors"
"image"
"image/gif"
@@ -247,7 +248,7 @@ func TestService_Resize(t *testing.T) {
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(source, buf, test.options)
err := svc.Resize(context.Background(), source, buf, test.options)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
@@ -459,7 +460,7 @@ func TestResize_ConcurrencyLimit(t *testing.T) {
source := newGrayJpeg(t, 100, 100)
defer source.Close()
out := &bytes.Buffer{}
err := svc.Resize(source, out, ResizeOptions{Width: 50, Height: 50})
err := svc.Resize(context.Background(), source, out, ResizeOptions{Width: 50, Height: 50})
require.NoError(t, err)
require.Greater(t, out.Len(), 0)
}
@@ -473,7 +474,7 @@ func TestResize_ConcurrencyLimit_NoFFmpeg(t *testing.T) {
source := newGrayJpeg(t, 100, 100)
defer source.Close()
out := &bytes.Buffer{}
err := svc.Resize(source, out, ResizeOptions{Width: 50, Height: 50})
err := svc.Resize(context.Background(), source, out, ResizeOptions{Width: 50, Height: 50})
require.NoError(t, err)
require.Greater(t, out.Len(), 0)
}
@@ -494,12 +495,47 @@ func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
require.NoError(t, err)
// Create preview from reader
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
out, err := svc.CreatePreview(context.Background(), bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via another call for parity
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
out2, err := svc.CreatePreview(context.Background(), bytes.NewReader(data), int64(len(data)), options)
require.NoError(t, err)
require.Equal(t, out, out2)
}
func TestImageFitsPreviewSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
width int
height int
previewSize string
want bool
}{
{name: "fits xlarge", width: 800, height: 600, previewSize: "xlarge", want: true},
{name: "exceeds xlarge width", width: 1200, height: 600, previewSize: "xlarge", want: false},
{name: "fits large", width: 640, height: 480, previewSize: "large", want: true},
{name: "fits small", width: 256, height: 200, previewSize: "small", want: true},
{name: "exceeds small height", width: 200, height: 300, previewSize: "small", want: false},
{name: "original always fits", width: 5000, height: 5000, previewSize: "original", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, ImageFitsPreviewSize(tt.width, tt.height, tt.previewSize))
})
}
}
func TestReadImageFileDimensions(t *testing.T) {
t.Parallel()
width, height, err := ReadImageFileDimensions("testdata/gray-sample.jpg")
require.NoError(t, err)
require.Greater(t, width, 0)
require.Greater(t, height, 0)
}
+9 -6
View File
@@ -87,14 +87,17 @@ func (s *Service) GenerateOfficePreview(ctx context.Context, filetype, key, titl
return data, fmt.Errorf("error from OnlyOffice: %s", response.Error)
}
// make get request to binary data response.FileURL and return the body as a byte array data
resp, err = http.Get(response.FileURL)
thumbReq, err := http.NewRequestWithContext(ctx, http.MethodGet, response.FileURL, nil)
if err != nil {
return data, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return data, fmt.Errorf("failed to get preview file, status code: %d", resp.StatusCode)
thumbResp, err := client.Do(thumbReq)
if err != nil {
return data, err
}
return io.ReadAll(resp.Body)
defer thumbResp.Body.Close()
if thumbResp.StatusCode != http.StatusOK {
return data, fmt.Errorf("failed to get preview file, status code: %d", thumbResp.StatusCode)
}
return io.ReadAll(thumbResp.Body)
}
+66 -7
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"image"
"io"
"os"
"path/filepath"
@@ -49,6 +50,8 @@ type Service struct {
// 3-6 processors: 2 large, rest small
// 7+ processors: 3 large, rest small
func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
configureImagingParallelism()
if concurrencyLimit < 1 {
concurrencyLimit = 1
}
@@ -342,6 +345,13 @@ func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.E
// generateImagePreview generates preview for regular image files
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
const maxSizeForOriginal = 256 * 1024 // 256KB
if previewSize != "original" && (file.Size < maxSizeForOriginal || ShouldServeOriginalImage(file.RealPath, previewSize)) {
if original, err := os.ReadFile(file.RealPath); err == nil {
return original, nil
}
}
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
f, err := os.Open(file.RealPath)
if err != nil {
@@ -355,8 +365,11 @@ func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.Extend
return nil, err
}
imageBytes, err := s.CreatePreview(f, file.Size, options)
imageBytes, err := s.CreatePreview(ctx, f, file.Size, options)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// For JPEG files, try FFmpeg fallback if initial decode failed
if strings.HasPrefix(file.Type, "image/jpeg") {
return handleJPEGFallback(ctx, s, file, previewSize, err)
@@ -496,8 +509,18 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, err
}
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
if cfg, _, cfgErr := image.DecodeConfig(bytes.NewReader(imageBytes)); cfgErr == nil && ImageFitsPreviewSize(cfg.Width, cfg.Height, previewSize) {
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
return imageBytes, nil
}
resizedBytes, err := service.CreatePreview(ctx, bytes.NewReader(imageBytes), 0, options)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
// For JPEG files, try FFmpeg fallback if resize failed
if strings.HasPrefix(file.Type, "image/jpeg") {
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
@@ -533,6 +556,42 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
}
// ImageFitsPreviewSize reports whether both image dimensions are within the preview bounds.
func ImageFitsPreviewSize(imgWidth, imgHeight int, previewSize string) bool {
if previewSize == "original" {
return true
}
opts, err := getPreviewOptions(previewSize)
if err != nil {
return false
}
return imgWidth <= opts.Width && imgHeight <= opts.Height
}
// ReadImageFileDimensions reads image width and height without decoding the full image.
func ReadImageFileDimensions(path string) (int, int, error) {
f, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0, err
}
return cfg.Width, cfg.Height, nil
}
// ShouldServeOriginalImage reports whether an on-disk image is already within preview bounds.
func ShouldServeOriginalImage(path, previewSize string) bool {
width, height, err := ReadImageFileDimensions(path)
if err != nil {
return false
}
return ImageFitsPreviewSize(width, height, previewSize)
}
// getPreviewOptions returns resize options for the given preview size
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
switch previewSize {
@@ -549,15 +608,15 @@ func getPreviewOptions(previewSize string) (ResizeOptions, error) {
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Quality: QualityMedium,
Format: FormatJpeg,
}, nil
case "small":
return ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
ResizeMode: ResizeModeFill,
Quality: QualityLow,
Format: FormatJpeg,
}, nil
default:
@@ -566,9 +625,9 @@ func getPreviewOptions(previewSize string) (ResizeOptions, error) {
}
// CreatePreview resizes an image from a reader with the given options
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
func (s *Service) CreatePreview(ctx context.Context, reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
output := &bytes.Buffer{}
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
if err := s.ResizeWithSize(ctx, reader, output, fileSize, options); err != nil {
return nil, err
}
return output.Bytes(), nil
+22 -2
View File
@@ -6147,7 +6147,7 @@ const docTemplate = `{
"type": "integer"
},
"numImageProcessors": {
"description": "number of concurrent image processing jobs used to create previews, default is number of cpu cores available.",
"description": "number of concurrent image processing jobs used to create previews, default is 4.",
"type": "integer"
},
"port": {
@@ -6155,7 +6155,7 @@ const docTemplate = `{
"type": "integer"
},
"socket": {
"description": "socket to listen on",
"description": "socket to listen on - eg. /var/run/filebrowser.sock",
"type": "string"
},
"sources": {
@@ -7349,6 +7349,18 @@ const docTemplate = `{
}
}
},
"users.PinnedItems": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"users.Preview": {
"type": "object",
"properties": {
@@ -7577,6 +7589,14 @@ const docTemplate = `{
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"pinnedItems": {
"description": "pinned items organized by source and directory path",
"allOf": [
{
"$ref": "#/definitions/users.PinnedItems"
}
]
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
"type": "boolean"
+22 -2
View File
@@ -6136,7 +6136,7 @@
"type": "integer"
},
"numImageProcessors": {
"description": "number of concurrent image processing jobs used to create previews, default is number of cpu cores available.",
"description": "number of concurrent image processing jobs used to create previews, default is 4.",
"type": "integer"
},
"port": {
@@ -6144,7 +6144,7 @@
"type": "integer"
},
"socket": {
"description": "socket to listen on",
"description": "socket to listen on - eg. /var/run/filebrowser.sock",
"type": "string"
},
"sources": {
@@ -7338,6 +7338,18 @@
}
}
},
"users.PinnedItems": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"users.Preview": {
"type": "object",
"properties": {
@@ -7566,6 +7578,14 @@
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"pinnedItems": {
"description": "pinned items organized by source and directory path",
"allOf": [
{
"$ref": "#/definitions/users.PinnedItems"
}
]
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
"type": "boolean"
+14 -2
View File
@@ -1180,13 +1180,13 @@ definitions:
type: integer
numImageProcessors:
description: number of concurrent image processing jobs used to create previews,
default is number of cpu cores available.
default is 4.
type: integer
port:
description: port to listen on
type: integer
socket:
description: socket to listen on
description: socket to listen on - eg. /var/run/filebrowser.sock
type: string
sources:
items:
@@ -2063,6 +2063,14 @@ definitions:
description: allow sharing files
type: boolean
type: object
users.PinnedItems:
additionalProperties:
additionalProperties:
items:
type: string
type: array
type: object
type: object
users.Preview:
properties:
audio:
@@ -2227,6 +2235,10 @@ definitions:
description: legacy for migration purposes... og filebrowser has perm attribute
permissions:
$ref: '#/definitions/users.Permissions'
pinnedItems:
allOf:
- $ref: '#/definitions/users.PinnedItems'
description: pinned items organized by source and directory path
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer
-54
View File
@@ -1,54 +0,0 @@
{
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended",
"@vue/eslint-config-typescript",
"plugin:@intlify/vue-i18n/recommended"
],
"settings": {
"vue-i18n": {
"localeDir": "./src/i18n/en.json",
"messageSyntaxVersion": "^9.0.0"
}
},
"rules": {
"no-unreachable": "off",
// vue-i18n rules:
// This rule will check if the key used in $t() exists in your locale messages.
// The localeDir setting above points to en.json as the single source of truth.
"@intlify/vue-i18n/no-missing-keys": "error",
// Check for unused keys in en.json (keys that exist but are never used in code)
"@intlify/vue-i18n/no-unused-keys": [
"error",
{
"src": "./src",
"extensions": [".js", ".vue", ".ts"]
}
],
"@intlify/vue-i18n/no-raw-text": [
"error",
{
"ignoreNodes": ["i", "v-icon"]
}
],
// If you have your sync script, you might not need this one for other locales.
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "off",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": [
"error",
{
"shallowOnly": true
}
]
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
}
}
+123
View File
@@ -0,0 +1,123 @@
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginVue from "eslint-plugin-vue";
import vueI18n from '@intlify/eslint-plugin-vue-i18n';
import vueParser from "vue-eslint-parser";
import globals from "globals";
export default defineConfig(
{
ignores: [
"**/dist/**",
"**/node_modules/**",
"**/public/**",
"**/i18n/is.json",
],
},
// Defaults
js.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs["flat/essential"],
...vueI18n.configs.recommended,
// i18n
{
settings: {
"vue-i18n": {
localeDir: "src/i18n/en.json",
messageSyntaxVersion: "^11.0.0",
},
},
rules: {
"@intlify/vue-i18n/no-missing-keys": "error",
"@intlify/vue-i18n/no-unused-keys": ["error", {
src: "./src",
extensions: [".js", ".vue", ".ts"],
ignores: ["/^languages\\./"],
}],
"@intlify/vue-i18n/no-raw-text": ["error", {
ignoreNodes: ["i", "v-icon"],
}],
"@intlify/vue-i18n/no-missing-keys-in-other-locales": "warn",
},
},
// Shared globals + rule overrides
{
files: ["**/*.js", "**/*.ts", "**/*.vue"],
languageOptions: {
globals: {
...globals.node,
...globals.browser,
...globals.es2022,
globalVars: "readonly",
router: "readonly",
$t: "readonly",
next: "readonly",
downloadFiles: "readonly",
},
},
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrors: "none",
}],
"eqeqeq": ["warn", "always"],
"no-var": "error",
"prefer-const": "warn",
"no-unused-expressions": ["error", { allowShortCircuit: true, allowTernary: true }],
"prefer-template": "warn",
"@typescript-eslint/consistent-type-definitions": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
"prefer-object-has-own": "error",
"no-prototype-builtins": "error",
"no-restricted-globals": [
"error",
{ name: "isNaN", message: "Use Number.isNaN instead." },
{ name: "isFinite", message: "Use Number.isFinite instead." }
],
},
},
{
files: ["**/*.ts", "**/*.js"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
ecmaVersion: "latest",
projectService: true,
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.vue'],
},
},
},
// Vue files
{
files: ["**/*.vue"],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
ecmaVersion: "latest",
sourceType: "module",
projectService: true,
extraFileExtensions: ['.vue'],
},
},
rules: {
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": ["error", { shallowOnly: true }],
//"vue/order-in-components": "warn",
"vue/require-v-for-key": "error",
"vue/no-reserved-component-names": "off",
"vue/no-unused-components": "warn",
},
},
);
+1174 -1327
View File
File diff suppressed because it is too large. Load diff
+11 -10
View File
@@ -18,7 +18,7 @@
"i18n:check": "node ./scripts/sync-translations.js --check",
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
"format": "prettier --write .",
"format": "biome format --write .",
"test": "vitest run "
},
"dependencies": {
@@ -38,8 +38,7 @@
"srt-support-for-html5-videos": "^2.6.11",
"three": "^0.182.0",
"vue": "^3.4.21",
"vue-i18n": "^9.10.2",
"vue-lazyload": "^3.0.0",
"vue-i18n": "^11.4.4",
"vue-router": "^4.3.0"
},
"overrides": {
@@ -47,23 +46,25 @@
"@xmldom/xmldom": "^0.8.12"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@intlify/unplugin-vue-i18n": "^4.0.0",
"@eslint/js": "^10.0.1",
"@intlify/eslint-plugin-vue-i18n": "^4.4.0",
"@intlify/unplugin-vue-i18n": "^11.2.3",
"@playwright/test": "^1.54.1",
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0",
"deepl-node": "^1.16.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.24.0",
"eslint": "^10.4.0",
"eslint-plugin-vue": "^10.9.1",
"fs-extra": "^11.3.0",
"globals": "^17.6.0",
"jsdom": "^25.0.1",
"typescript": "^6.0.0",
"typescript-eslint": "^8.59.4",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
"vitest": "^3.0.7",
"vitest": "^4.1.8",
"vue-eslint-parser": "^10.4.0",
"vue-tsc": "^2.0.7"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
server:
minSearchLength: 3 # minimum length of search query to begin searching (default: 3)
disableUpdateCheck: false # disables backend update check service
numImageProcessors: 4 # number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
socket: "" # socket to listen on
numImageProcessors: 4 # number of concurrent image processing jobs used to create previews, default is 4.
socket: "" # socket to listen on - eg. /var/run/filebrowser.sock
tlsKey: "" # path to TLS key
tlsCert: "" # path to TLS cert
disablePreviews: false # disable all previews thumbnails, simple icons will be used
+14 -14
View File
@@ -1,9 +1,9 @@
// scripts/sync-translations.js
import fs from 'fs-extra';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as glob from 'glob';
import * as deepl from 'deepl-node';
import fs from 'fs-extra';
import * as glob from 'glob';
// Parse command line arguments
const args = process.argv.slice(2);
@@ -76,7 +76,7 @@ async function translateText(text, targetLanguage, keyPath = '') {
try {
console.log(`Translating "${text}" from '${masterLanguageCode}' to '${targetLanguage}'...`);
let deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
const deeplTargetLang = deeplLangMap[targetLanguage.toLowerCase()] || targetLanguage.toUpperCase();
const result = await translator.translateText(textToTranslate, masterLanguageCode, deeplTargetLang, options);
@@ -104,8 +104,8 @@ function reorderObject(obj, masterObj) {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
const newObj = {};
for (const key in masterObj) {
if (Object.prototype.hasOwnProperty.call(masterObj, key)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (Object.hasOwn(masterObj, key)) {
if (Object.hasOwn(obj, key)) {
newObj[key] = reorderObject(obj[key], masterObj[key]);
}
}
@@ -120,7 +120,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// First pass: Add/update keys from master to target
for (const key in masterObj) {
if (Object.prototype.hasOwnProperty.call(masterObj, key)) {
if (Object.hasOwn(masterObj, key)) {
const currentPathPartsNext = [...currentPathParts, key];
const currentKeyPath = currentPathPartsNext.join('.');
@@ -153,7 +153,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
if (targetObj[key] && typeof targetObj[key] === 'object') {
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
doEnforceOrder, doCleanupOnly);
if (result == "UNSUPPORTED") {
if (result === "UNSUPPORTED") {
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
@@ -168,7 +168,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// Only handle non‑object values
if (typeof masterValue === 'string') {
const existingTargetValue = targetObj[key];
const isMissing = !Object.prototype.hasOwnProperty.call(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
const isMissing = !Object.hasOwn(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
if (isMissing) {
if (checkOnly) {
@@ -180,7 +180,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
} else {
// Sync translation
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
if (result === "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
@@ -188,7 +188,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
}
}
} else {
if (!Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (!Object.hasOwn(targetObj, key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
@@ -206,8 +206,8 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// Second pass: Remove obsolete keys that exist in target but not in master
const keysToRemove = [];
for (const key in targetObj) {
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (!Object.prototype.hasOwnProperty.call(masterObj, key)) {
if (Object.hasOwn(targetObj, key)) {
if (!Object.hasOwn(masterObj, key)) {
keysToRemove.push(key);
}
}
@@ -259,7 +259,7 @@ async function syncAllTranslations() {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
let originalTargetContent = null;
let fileExisted = await fs.pathExists(targetFile);
const fileExisted = await fs.pathExists(targetFile);
if (fileExisted) {
try {
@@ -349,4 +349,4 @@ syncAllTranslations()
.catch(error => {
console.error("\n❌ An error occurred during translation synchronization:", error);
process.exit(1);
});
});
+1 -6
View File
@@ -5,6 +5,7 @@
<script>
import { onMounted } from "vue";
import { mutations } from "@/store"; // Import your store's mutations
mutations.setLoading("main-app", true);
export default {
name: "app",
@@ -21,9 +22,3 @@ export default {
},
};
</script>
<style>
/* Always load styles.css */
@import "./css/styles.css";
@import "./css/dark.css";
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchJSON } from './utils'
import { getApiPath } from '@/utils/url.js'
import { fetchJSON } from './utils'
/**
* @param {string} source
+2 -2
View File
@@ -1,6 +1,6 @@
import { fetchURL } from "./utils";
import { getApiPath } from "@/utils/url.js";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { fetchURL } from "./utils";
/**
* Create an archive on the server (server-side only).
+12 -10
View File
@@ -1,6 +1,6 @@
import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath } from '@/utils/url.js'
import { fetchJSON, fetchURL } from '@/api/utils'
import { notify } from '@/notify'
import { getApiPath } from '@/utils/url.js'
// POST /api/auth/login
export async function login(username, password, recaptcha, otp) {
@@ -15,7 +15,7 @@ export async function login(username, password, recaptcha, otp) {
}
const params = { username, recaptcha };
let apiPath = getApiPath('auth/login', params);
const apiPath = getApiPath('auth/login', params);
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
@@ -34,7 +34,7 @@ export async function login(username, password, recaptcha, otp) {
body = { message: bodyText };
}
if (res.status != 200) {
if (res.status !== 200) {
const msg = body.message || 'Forbidden';
throw new Error(msg);
}
@@ -54,7 +54,7 @@ export async function logout() {
// POST /api/auth/signup
export async function signup(username, password, otp) {
const params = { username, password, otp }
let apiPath = getApiPath('auth/signup', params)
const apiPath = getApiPath('auth/signup', params)
const res = await fetch(apiPath, {
method: 'POST',
headers: {
@@ -69,7 +69,7 @@ export async function signup(username, password, otp) {
if (errorData.message) {
errorMessage = errorData.message
}
} catch (parseError) {
} catch (_parseError) {
// If parsing fails, keep the status code as error message
}
throw new Error(errorMessage)
@@ -80,7 +80,7 @@ export async function signup(username, password, otp) {
export async function generateOTP(username, password) {
const params = { username }
try {
let apiPath = getApiPath('auth/otp/generate', params)
const apiPath = getApiPath('auth/otp/generate', params)
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
@@ -104,7 +104,7 @@ export async function generateOTP(username, password) {
export async function verifyOTP(username, password, otp) {
const params = { username }
try {
let apiPath = getApiPath('auth/otp/verify', params)
const apiPath = getApiPath('auth/otp/verify', params)
const res = await fetch(apiPath, {
method: 'POST',
credentials: 'same-origin',
@@ -113,7 +113,7 @@ export async function verifyOTP(username, password, otp) {
'X-Secret': otp,
}
})
if (res.status != 200) {
if (res.status !== 200) {
let msg = 'Failed to verify OTP'
try {
const errBody = await res.json()
@@ -186,7 +186,9 @@ export function deleteApiKey(params) {
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
bytes.forEach(b => binary += String.fromCharCode(b))
bytes.forEach(b => {
binary += String.fromCharCode(b);
})
return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
}
+15 -16
View File
@@ -10,25 +10,24 @@
// - /api/media/ -> media.js
// - /public/api/* -> public functions in respective files (e.g., resourcesApi.fetchFilesPublic)
import * as authApi from "./auth";
import * as usersApi from "./users";
import * as resourcesApi from "./resources";
import * as accessApi from "./access";
import * as shareApi from "./share";
import * as settingsApi from "./settings";
import * as toolsApi from "./tools";
import * as officeApi from "./office";
import * as authApi from "./auth";
import * as mediaApi from "./media";
import * as officeApi from "./office";
import * as resourcesApi from "./resources";
import * as settingsApi from "./settings";
import * as shareApi from "./share";
import * as toolsApi from "./tools";
import * as usersApi from "./users";
export {
authApi,
usersApi,
resourcesApi,
export {
accessApi,
shareApi,
settingsApi,
toolsApi,
authApi,
mediaApi,
officeApi,
mediaApi
resourcesApi,
settingsApi,
shareApi,
toolsApi,
usersApi,
};
+4 -5
View File
@@ -1,7 +1,7 @@
import { fetchURL, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { state } from "@/store";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchURL } from "./utils";
// GET /api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
@@ -45,8 +45,7 @@ export async function getLyricsPublic(path, hash, password = "") {
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
data = await response.json();
const data = await response.json();
if (data?.message) {
error.message = data.message;
}
@@ -90,7 +89,7 @@ export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "
let data = null;
try {
data = await response.json();
} catch (e) {
} catch (_e) {
// ignore
}
if (data?.message) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { fetchURL } from './utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { fetchURL } from './utils'
// GET /api/office/config or /public/api/office/config
export async function getConfig(req) {
+45 -36
View File
@@ -1,9 +1,11 @@
import { fetchURL, adjustedData } from './utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { state, mutations } from '@/store'
import { notify } from '@/notify'
import { mutations, state } from '@/store'
import { globalVars } from '@/utils/constants'
import { downloadManager } from '@/utils/downloadManager'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { adjustedData, fetchURL } from './utils'
export { fetchPreviewImage } from '@/utils/previewRequests'
// Notify if errors occur
export async function fetchFiles(source, path, content = false, metadata = false, skipExtendedAttrs = false) {
@@ -58,7 +60,7 @@ export async function signalUploadPause(source, path, shareHash = null) {
path: path,
})
const headers = {}
const sharePassword = localStorage.getItem('sharepass:' + shareHash)
const sharePassword = localStorage.getItem(`sharepass:${shareHash}`)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
@@ -78,7 +80,7 @@ async function resourceAction(source, path, method, content) {
}
try {
const apiPath = getApiPath('resources', { path, source })
let opts = { method }
const opts = { method }
if (content) {
opts.body = content
}
@@ -90,7 +92,7 @@ async function resourceAction(source, path, method, content) {
let data = null;
try {
data = await response.json()
} catch (e) {
} catch (_e) {
// ignore
}
if (data) {
@@ -177,7 +179,7 @@ export async function download(format, files, shareHash = "") {
if (shareHash) {
filePaths = files.map((file) => file.path)
} else {
for (let file of files) {
for (const file of files) {
if (!file.source) {
throw new Error('File source is required for downloads')
}
@@ -264,11 +266,17 @@ async function resolveDownloadContentLength(url) {
headers: { Range: 'bytes=0-0' },
credentials: 'same-origin',
})
const cancelBody = () => {
if (res.body && typeof res.body.cancel === 'function') {
res.body.cancel().catch(() => {})
}
}
try {
token = res.headers.get('X-Archive-Token') || token
if (res.status === 206) {
const total = totalFromContentRange(res.headers.get('Content-Range'))
if (total > 0) {
cancelBody()
return { size: total, token }
}
}
@@ -277,20 +285,17 @@ async function resolveDownloadContentLength(url) {
if (cl) {
const n = parseInt(cl, 10)
if (Number.isFinite(n) && n > 0) {
cancelBody()
return { size: n, token }
}
}
}
} finally {
if (res.body && typeof res.body.cancel === 'function') {
try {
await res.body.cancel()
} catch {
// ignore
}
}
cancelBody()
return { size: 0, token }
} catch (e) {
cancelBody()
throw e
}
return { size: 0, token: null }
}
function archiveDisplayFileName(format, files) {
@@ -299,20 +304,20 @@ function archiveDisplayFileName(format, files) {
if (files.length === 1 && first.isDir) {
const base =
first.name ||
(first.path && first.path.split('/').filter(Boolean).pop()) ||
(first.path?.split('/').filter(Boolean).pop()) ||
'download'
return base + ext
return `${base}${ext}`
}
if (first && first.path) {
if (first?.path) {
const parts = first.path.split('/').filter(Boolean)
if (parts.length >= 2) {
return parts[parts.length - 2] + ext
return `${parts[parts.length - 2]}${ext}`
}
if (parts.length === 1) {
return parts[0] + ext
return `${parts[0]}${ext}`
}
}
return 'download' + ext
return `download${ext}`
}
/**
@@ -337,7 +342,7 @@ async function chunkedRangeDownloadToBlob(
while (offset < totalSize) {
const download = downloadManager.findById(downloadId)
if (download && download.status === 'cancelled') {
if (download?.status === 'cancelled') {
return null
}
@@ -430,7 +435,7 @@ async function chunkedRangeDownloadToBlob(
}
} catch (readError) {
const download = downloadManager.findById(downloadId)
if (readError.name === 'AbortError' || (download && download.status === 'cancelled')) {
if (readError.name === 'AbortError' || (download?.status === 'cancelled')) {
downloadManager.setStatus(downloadId, 'cancelled')
return null
}
@@ -492,7 +497,7 @@ async function downloadChunkedArchive(url, format, files, filePaths, source, sha
downloadManager.setStatus(downloadId, 'downloading')
const hasDownloadPrompt = state.prompts && state.prompts.some((h) => h.name === 'download')
const hasDownloadPrompt = state.prompts?.some((h) => h.name === 'download')
if (!hasDownloadPrompt) {
mutations.showPrompt({ name: 'download' })
}
@@ -532,7 +537,7 @@ async function downloadChunkedArchive(url, format, files, filePaths, source, sha
}, 100)
} catch (error) {
const download = downloadManager.findById(downloadId)
if (error.name === 'AbortError' || (download && download.status === 'cancelled')) {
if (error.name === 'AbortError' || (download?.status === 'cancelled')) {
downloadManager.setStatus(downloadId, 'cancelled')
return
}
@@ -580,7 +585,7 @@ async function downloadChunked(file, shareHash = "") {
downloadManager.setStatus(downloadId, "downloading")
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
const hasDownloadPrompt = state.prompts && state.prompts.some(h => h.name === 'download');
const hasDownloadPrompt = state.prompts?.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showPrompt({ name: 'download' })
@@ -623,7 +628,7 @@ async function downloadChunked(file, shareHash = "") {
} catch (error) {
// Check if download was cancelled by user
const download = downloadManager.findById(downloadId);
if (error.name === 'AbortError' || (download && download.status === "cancelled")) {
if (error.name === 'AbortError' || (download?.status === "cancelled")) {
downloadManager.setStatus(downloadId, "cancelled")
// Don't throw error or show notification for user-initiated cancellation
return;
@@ -686,7 +691,7 @@ export function post(
try {
const errorData = JSON.parse(request.responseText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
} catch (_e) {
errorMessage = request.responseText || errorMessage;
}
const error = new Error(errorMessage);
@@ -831,6 +836,10 @@ export function getDownloadURL(source, path, inline, useExternal) {
}
}
/**
* Build a preview API URL. Use fetchPreviewImage(url, signal) to load it;
* pass an AbortSignal so the caller can cancel on navigation or unmount.
*/
export function getPreviewURL(source, path, modified) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
@@ -940,7 +949,7 @@ export async function fetchFilesPublic(path, hash, password = "", content = fals
let data = null;
try {
data = await response.json()
} catch (e) {
} catch (_e) {
// ignore
}
if (data) {
@@ -949,7 +958,7 @@ export async function fetchFilesPublic(path, hash, password = "", content = fals
(/** @type {any} */ (error)).status = response.status;
throw error;
}
let data = await response.json()
const data = await response.json()
const adjusted = adjustedData(data);
return adjusted
}
@@ -1027,7 +1036,7 @@ export function postPublic(
if (!hash || hash === undefined || hash === null) {
throw new Error('no hash provided')
}
let sharePassword = localStorage.getItem("sharepass:" + hash);
const sharePassword = localStorage.getItem(`sharepass:${hash}`);
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
@@ -1069,7 +1078,7 @@ export function postPublic(
try {
const errorData = JSON.parse(request.responseText);
errorMessage = errorData.message || errorMessage;
} catch (e) {
} catch (_e) {
errorMessage = request.responseText || errorMessage;
}
@@ -1114,8 +1123,8 @@ export function postPublic(
async function resourceActionPublic(hash, path, method, content, token = "") {
try {
let headers = {};
let sharePassword = localStorage.getItem("sharepass:" + hash);
const headers = {};
const sharePassword = localStorage.getItem(`sharepass:${hash}`);
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
@@ -1130,7 +1139,7 @@ async function resourceActionPublic(hash, path, method, content, token = "") {
let data = null;
try {
data = await response.json()
} catch (e) {
} catch (_e) {
// ignore
}
if (data) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { fetchURL } from "./utils";
import { notify } from "@/notify"; // Import notify for error handling
import { getApiPath } from "@/utils/url.js";
import { fetchURL } from "./utils";
export default async function search(base, sources, query, largest = false, extraParams = {}) {
try {
@@ -34,7 +34,7 @@ export default async function search(base, sources, query, largest = false, extr
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
const data = await res.json();
return data
} catch (err) {
+2 -3
View File
@@ -1,7 +1,6 @@
import { fetchURL, fetchJSON } from "./utils";
import { getApiPath } from "@/utils/url.js";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { fetchJSON, fetchURL } from "./utils";
export function get(property="") {
const path = getApiPath("settings", { property });
+2 -2
View File
@@ -1,6 +1,6 @@
import { fetchURL, fetchJSON, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { adjustedData, fetchJSON, fetchURL } from "./utils";
// ============================================================================
@@ -28,7 +28,7 @@ export async function get(path, source) {
try {
const params = { path, source };
const apiPath = getApiPath("share", params);
let data = await fetchJSON(apiPath);
const data = await fetchJSON(apiPath);
return adjustedData(data);
} catch (/** @type {any} */ err) {
notify.showError(err.message || "Error fetching data");
+2 -2
View File
@@ -1,6 +1,6 @@
import { fetchURL } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { fetchURL } from "./utils";
// GET /api/tools/search
// extraParams: optional { olderThan, newerThan, useWildcard, terms, termJoin, perSourceScopes }
@@ -75,7 +75,7 @@ export async function search(base, sources, query, largest = false, extraParams
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
const data = await res.json();
return data;
} catch (err) {
+4 -7
View File
@@ -19,7 +19,7 @@ export async function getAllUsers() {
// GET /api/users or /public/api/users (get single user)
export async function get(id) {
try {
let apiPath = getPublicApiPath('users', { id: id })
const apiPath = getPublicApiPath('users', { id: id })
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || `Failed to fetch user with ID: ${id}`)
@@ -43,8 +43,7 @@ export async function create(user, options = {}) {
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
mergedHeaders['X-Password'] === undefined &&
err &&
err.status === 401 &&
err?.status === 401 &&
typeof err.message === 'string' &&
err.message.includes('X-Password')
@@ -124,8 +123,7 @@ export async function update(user, which = ['all'], options = {}) {
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
mergedHeaders['X-Password'] === undefined &&
err &&
err.status === 401 &&
err?.status === 401 &&
typeof err.message === 'string' &&
err.message.includes('X-Password')
@@ -180,8 +178,7 @@ export async function remove(id, options = {}) {
state.user?.loginMethod === 'password' &&
options.skipActorPasswordConfirm !== true &&
mergedHeaders['X-Password'] === undefined &&
err &&
err.status === 401 &&
err?.status === 401 &&
typeof err.message === 'string' &&
err.message.includes('X-Password')
+6 -6
View File
@@ -1,12 +1,12 @@
import i18n from "@/i18n";
import { state } from "@/store";
import { renew } from "@/utils/auth";
import i18n from "@/i18n";
export async function fetchURL(url, opts, auth = true) {
opts = opts || {};
opts.headers = opts.headers || {};
let { headers, ...rest } = opts;
const { headers, ...rest } = opts;
let res;
try {
@@ -19,8 +19,8 @@ export async function fetchURL(url, opts, auth = true) {
...rest,
});
} catch (e) {
let message = e;
if (e == "TypeError: Failed to fetch") {
let message = e.message;
if (e instanceof TypeError && e.message === "Failed to fetch") {
message = i18n.global.t("errors.failedToConnectToServer");
}
const error = new Error(message);
@@ -33,7 +33,7 @@ export async function fetchURL(url, opts, auth = true) {
}
if (res.status < 200 || res.status > 299) {
let error = new Error(await res.text());
const error = new Error(await res.text());
error.status = res.status;
throw error;
}
@@ -59,7 +59,7 @@ export function adjustedData(data) {
if (item.isShared === undefined) {
item.isShared = false;
}
if (data.path == "/") {
if (data.path === "/") {
if (item.type === "directory") {
item.path = `/${item.name}/`
} else {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/utils/constants', () => {
return {
+21 -9
View File
@@ -1,6 +1,7 @@
<template>
<button
v-if="icon === 'close_back'"
type="button"
:disabled="isDisabled"
@click="actionMultiIcon"
:aria-label="label"
@@ -9,14 +10,15 @@
>
<svg
id="button-toggle-navbar"
class="ham hamRotate180 ham5"
viewBox="0 0 100 100"
width="30"
class="ham hamRotate180 ham5"
:class="{
back: multistate == 'back',
close: multistate == 'close',
back: multistate === 'back',
close: multistate === 'close',
}"
>
<title id="toggle-navbar-title">{{ $t('general.close') }}</title>
<path class="line top" d="M 30,33 H 70" />
<path class="line middle" d="M 30,50 H 70" />
<path class="line bottom" d="M 30,67 H 70" />
@@ -24,22 +26,21 @@
</button>
<button
v-else
type="button"
:disabled="isDisabled"
@click="action"
:aria-label="label"
:title="label"
class="action no-select"
@click="action"
>
<i v-if="icon == 'table_rows_narrow' || icon == 'content_copy' || icon == 'file_copy' || icon == 'copy_all'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-symbols">{{ icon }}</i>
<i :class="iconClass">{{ icon }}</i>
<span>{{ label }}</span>
<span v-if="counter > 0" class="counter">{{ counter }}</span>
</button>
</template>
<script>
import { mutations, state, getters } from "@/store";
import { getters, mutations, state } from "@/store";
export default {
name: "action",
@@ -56,7 +57,18 @@ export default {
},
currentView() {
return getters.currentView();
}
},
iconClass() {
const outlinedIcons = [
'table_rows_narrow',
'content_copy',
'file_copy',
'copy_all'
];
return outlinedIcons.includes(this.icon)
? 'material-symbols-outlined'
: 'material-symbols';
},
},
methods: {
actionMultiIcon() {
+2 -1
View File
@@ -1,11 +1,12 @@
<template>
<div @click="preventDefaults" class="button-group">
<button v-if="isDisabled" disabled>
<button type="button" v-if="isDisabled" disabled>
{{ disableMessage }}
</button>
<template v-else>
<button
v-for="(btn, index) in buttons"
type="button"
:key="index"
class="clickable"
:class="{ active: activeButton === index }"
+56 -31
View File
@@ -9,7 +9,7 @@
id="context-menu"
ref="contextMenu"
v-if="showContext"
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
:style="centered ? {} : { top: `${posY}px`, left: `${posX}px` }"
class="no-select floating-window"
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
:key="showCreate ? 'create-mode' : 'normal-mode'"
@@ -76,7 +76,6 @@
:label="$t('general.info')"
@action="showInfoPrompt"
/>
<action
v-if="showDownload"
icon="file_download"
@@ -95,6 +94,12 @@
:label="$t('general.share')"
@action="showSharePrompt"
/>
<action
v-if="showPinAction"
icon="push_pin"
:label="pinActionLabel"
@action="togglePin"
/>
<action
v-if="showRename"
icon="edit"
@@ -190,15 +195,15 @@
</template>
<script>
import downloadFiles from "@/utils/download";
import { state, getters, mutations } from "@/store";
import Action from "@/components/Action.vue";
import { globalVars } from "@/utils/constants.js";
import buttons from "@/utils/buttons";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import Action from "@/components/Action.vue";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store";
import { url } from "@/utils";
import buttons from "@/utils/buttons";
import { copyToClipboard } from "@/utils/clipboard";
import { globalVars } from "@/utils/constants.js";
import downloadFiles from "@/utils/download";
function isArchivePath(pathOrName) {
if (!pathOrName || typeof pathOrName !== "string") return false;
@@ -263,7 +268,7 @@ export default {
return this.hasDownload && !this.req.isDir && !this.isShare;
},
showGoToItem() {
return this.showLimitedOptions && this.selectedCount == 1;
return this.showLimitedOptions && this.selectedCount === 1;
},
permissions() {
return getters.permissions();
@@ -290,8 +295,8 @@ export default {
return getters.isShare();
},
showInfo() {
if (this.showLimitedOptions) return this.selectedCount == 1;
return !this.showCreate && this.selectedCount == 1;
if (this.showLimitedOptions) return this.selectedCount === 1;
return !this.showCreate && this.selectedCount === 1;
},
showDownload() {
if (this.showLimitedOptions) return false;
@@ -325,7 +330,23 @@ export default {
},
showRename() {
if (this.showLimitedOptions) return false;
return !this.showCreate && this.selectedCount == 1 && this.permissions.modify && !this.isSearchActive;
return !this.showCreate && this.selectedCount === 1 && this.permissions.modify && !this.isSearchActive;
},
showPinAction() {
if (this.showLimitedOptions) return false;
if (getters.isShare() || !getters.isLoggedIn()) return false;
if (this.showCreate || this.isSearchActive) return false;
if (this.selectedCount !== 1) return false;
return getters.currentView() === "listingView";
},
pinActionLabel() {
return this.isPinnedSelection ? this.$t("buttons.unpinItem") : this.$t("buttons.pinItem");
},
isPinnedSelection() {
if (!this.firstSelected) {
return false;
}
return getters.isItemPinned(this.firstSelected);
},
showCopy() {
if (this.showLimitedOptions) return false;
@@ -333,10 +354,10 @@ export default {
},
showCopyPath() {
if (this.showLimitedOptions) return false;
return !this.showCreate && this.selectedCount == 1 && !!state.user?.showCopyPath;
return !this.showCreate && this.selectedCount === 1 && !!state.user?.showCopyPath;
},
showOpenParentFolder() {
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.showLimitedOptions);
return !this.showCreate && this.selectedCount === 1 && (this.isSearchActive || this.showLimitedOptions);
},
showMove() {
if (this.showLimitedOptions) return false;
@@ -384,20 +405,20 @@ export default {
return false;
}
const cv = getters.currentView();
return cv == "preview" || cv == "markdownViewer" || cv == "editor";
return cv === "preview" || cv === "markdownViewer" || cv === "editor";
},
showEdit() {
const cv = getters.currentView();
return cv == "markdownViewer" && this.permissions.modify;
return cv === "markdownViewer" && this.permissions.modify;
},
showDelete() {
if (this.showLimitedOptions) return false;
if (this.selectedCount == 0) {
if (this.selectedCount === 0) {
return false;
}
const cv = getters.currentView();
return (
cv != "settings" &&
cv !== "settings" &&
!this.isSearchActive &&
!!this.permissions.delete
);
@@ -410,10 +431,10 @@ export default {
},
showSave() {
const allowEdit = this.permissions.modify || (getters.isShare() && state.shareInfo.allowEdit);
return getters.currentView() == "editor" && allowEdit;
return getters.currentView() === "editor" && allowEdit;
},
showOverflow() {
return getters.currentPromptName() == "OverflowMenu";
return getters.currentPromptName() === "OverflowMenu";
},
showAccess() {
if (this.showLimitedOptions) return false;
@@ -432,7 +453,7 @@ export default {
return this.permissions.share;
},
showContext() {
return getters.currentPromptName() == "ContextMenu";
return getters.currentPromptName() === "ContextMenu";
},
onlyofficeEnabled() {
return globalVars.onlyOfficeUrl !== "";
@@ -535,13 +556,13 @@ export default {
this.showCreate = !this.showCreate;
},
shouldShowParentFolder() {
return this.isPreview && state.req.path != "/";
return this.isPreview && state.req.path !== "/";
},
showAccessPrompt() {
mutations.closeHovers();
const item = this.firstSelected;
let sourceName = item?.source || state.req.source;
let path = item?.path || state.req.path;
const sourceName = item?.source || state.req.source;
const path = item?.path || state.req.path;
mutations.showPrompt({
name: "access",
props: {
@@ -592,7 +613,7 @@ export default {
el.style.transition = 'height 0.3s, opacity 0.3s';
void el.offsetHeight; // Force reflow
// Animate to full height
el.style.height = fullHeight + 'px';
el.style.height = `${fullHeight}px`;
el.style.opacity = '1';
setTimeout(() => {
this.isAnimating = false;
@@ -603,7 +624,7 @@ export default {
leave(el, done) {
this.isAnimating = true;
el.style.transition = 'height 0.3s, opacity 0.3s';
el.style.height = el.scrollHeight + 'px';
el.style.height = `${el.scrollHeight}px`;
void el.offsetHeight;
el.style.height = '0';
el.style.opacity = '0';
@@ -626,7 +647,7 @@ export default {
mutations.showPrompt({
name: "share",
props: {
item: this.selectedCount == 1 ? this.firstSelected : state.req
item: this.selectedCount === 1 ? this.firstSelected : state.req
},
});
},
@@ -635,7 +656,7 @@ export default {
mutations.showPrompt({
name: "rename",
props: {
item: this.selectedCount == 1 ? this.firstSelected : state.req,
item: this.selectedCount === 1 ? this.firstSelected : state.req,
parentItems: []
},
});
@@ -703,6 +724,10 @@ export default {
},
});
},
togglePin() {
mutations.closeHovers();
mutations.togglePinnedItem(this.firstSelected);
},
async copyPathToClipboard() {
const item = this.firstSelected;
const path = item?.path || "";
@@ -715,7 +740,7 @@ export default {
// If the context menu was triggered on a directory, pass its path as base
const selectedItem = this.firstSelected;
let base = null;
if (selectedItem && selectedItem.isDir) {
if (selectedItem?.isDir) {
// Pass both path and source
base = {
path: selectedItem.path,
@@ -813,7 +838,7 @@ export default {
}
buttons.success(button);
notify.showSuccessToast(this.$t("editor.fileSaved"));
} catch (e) {
} catch (_e) {
// Don't show error notification here - API layer already showed it
buttons.done(button);
}
@@ -824,7 +849,7 @@ export default {
let targetPath = state.req.path;
let targetSource = state.req.source;
const selectedItem = this.firstSelected;
if (selectedItem && selectedItem.isDir) {
if (selectedItem?.isDir) {
targetPath = selectedItem.path;
targetSource = selectedItem.source;
}
+15 -98
View File
@@ -5,18 +5,8 @@
v-for="notification in notifications"
:key="notification.id"
:class="['notification-item', notification.type]"
@mousedown="startDrag($event, notification.id)"
@touchstart="startDrag($event, notification.id)"
@mousemove="handleDrag($event, notification.id)"
@touchmove="handleDrag($event, notification.id)"
@mouseup="endDrag(notification.id)"
@touchend="endDrag(notification.id)"
@mouseenter="pauseAutoClose(notification.id)"
@mouseleave="handleMouseLeave(notification.id)"
:style="{
transform: `translateX(${notification.dragOffset || 0}px)`,
opacity: notification.dragOpacity !== undefined ? notification.dragOpacity : 1
}"
@mouseleave="resumeAutoClose(notification.id)"
>
<!-- Close button - always present on every notification, separate from optional buttons array -->
<i class="material-symbols" @click="closeNotification(notification.id)">close</i>
@@ -29,6 +19,7 @@
</div>
<div v-if="notification.buttons && notification.buttons.length > 0" class="notification-buttons">
<button
type="button"
v-for="(button, btnIndex) in notification.buttons"
:key="btnIndex"
:class="['button', button.className]"
@@ -62,35 +53,15 @@ import { notify } from "@/notify";
export default {
name: "notifications",
data: function () {
return {
notifications: [],
dragState: {
isDragging: false,
startX: 0,
currentX: 0,
notificationId: null
}
};
},
data: () => ({
notifications: []
}),
mounted() {
// Initialize notifications with drag state
this.notifications = notify.getNotifications().map(notification => ({
...notification,
dragOffset: 0,
dragOpacity: 1
}));
// Initialize notifications
this.notifications = notify.getNotifications();
// Register callback to receive notification updates
notify.setUpdateCallback((notifications) => {
this.notifications = notifications.map(notification => {
// Find existing notification to preserve drag state
const existing = this.notifications.find(n => n.id === notification.id);
return {
...notification,
dragOffset: existing?.dragOffset,
dragOpacity: existing?.dragOpacity !== undefined ? existing.dragOpacity : 1
};
});
this.notifications = notifications;
});
},
methods: {
@@ -105,66 +76,6 @@ export default {
button.action();
}
},
startDrag(event, notificationId) {
const clientX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX;
this.dragState = {
isDragging: true,
startX: clientX,
currentX: clientX,
notificationId: notificationId
};
},
handleDrag(event, notificationId) {
if (!this.dragState.isDragging || this.dragState.notificationId !== notificationId) {
return;
}
event.preventDefault();
const clientX = event.type.includes('touch') ? event.touches[0].clientX : event.clientX;
this.dragState.currentX = clientX;
const deltaX = clientX - this.dragState.startX;
// Allow dragging in both directions (left and right)
const dragOffset = deltaX;
const absoluteDeltaX = Math.abs(deltaX);
const dragOpacity = Math.max(0.4, 1 - (absoluteDeltaX / 150));
// Update the notification position and opacity
const notificationIndex = this.notifications.findIndex(n => n.id === notificationId);
if (notificationIndex !== -1) {
this.notifications[notificationIndex].dragOffset = dragOffset;
this.notifications[notificationIndex].dragOpacity = dragOpacity;
}
},
endDrag(notificationId) {
if (!this.dragState.isDragging || this.dragState.notificationId !== notificationId) {
return;
}
const deltaX = this.dragState.currentX - this.dragState.startX;
const absoluteDeltaX = Math.abs(deltaX);
const notificationIndex = this.notifications.findIndex(n => n.id === notificationId);
if (notificationIndex !== -1) {
// Check if drag distance is enought to close the notification (120px or 25% of screen width)
const threshold = Math.min(120, window.innerWidth * 0.25);
if (absoluteDeltaX > threshold) {
// If the swipe is enought, close
this.closeNotification(notificationId);
} else {
// If not, returnt it back to the original position
this.notifications[notificationIndex].dragOffset = 0;
this.notifications[notificationIndex].dragOpacity = 1;
}
}
// Reset drag state
this.dragState = {
isDragging: false,
startX: 0,
currentX: 0,
notificationId: null
};
},
// Handle mouse leave for both drag and auto-close
handleMouseLeave(notificationId) {
this.endDrag(notificationId);
this.resumeAutoClose(notificationId);
},
// Hover persistence methods
pauseAutoClose(notificationId) {
notify.pauseAutoClose(notificationId);
@@ -207,10 +118,16 @@ export default {
transition: right 1s ease;
z-index: 21;
pointer-events: all;
user-select: none;
user-select: text;
overflow: hidden;
}
/* selection color for better visibility (since the selection color is the same as --primaryColor) */
.notification-item ::selection {
background: rgba(255, 255, 255, 0.8);
color: #000;
}
.notification-content-wrapper {
flex: 1;
display: flex;
+51 -47
View File
@@ -6,7 +6,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="textPosition == 'middle'"
v-if="textPosition === 'middle'"
>
{{ displayed_text }}
</div>
@@ -16,7 +16,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
<div
class="vue-simple-progress-text"
:style="text_style"
v-if="textPosition == 'inside'"
v-if="textPosition === 'inside'"
>
{{ displayed_text }}
<i
@@ -32,12 +32,16 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
</template>
<script>
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
import { mutations } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes.js";
// We're leaving this untouched as you can read in the beginning
var isNumber = function (n) {
return !isNaN(parseFloat(n)) && isFinite(n);
const isNumber = (n) => {
const num = Number(n);
// Since 'isNaN' nd 'isFinite' are marked as unsafe. We need to do this
// to still matching the old behavior (the styling).
if (num === 0 && (n === null || n === '' || n === false)) return true;
return !Number.isNaN(num) && Number.isFinite(num);
};
export default {
@@ -120,14 +124,14 @@ export default {
pct() {
if (!this.isValNumeric) return 100;
if (this.max <= 0) return 0;
var pct = (this.val / this.max) * 100;
const pct = (this.val / this.max) * 100;
return Math.max(0, Math.min(pct.toFixed(2), 100));
},
pctBackground() {
if (this.valBackground === null) return 0;
if (!isNumber(this.valBackground)) return 0;
if (this.max <= 0) return 0;
var pct = (this.valBackground / this.max) * 100;
const pct = (this.valBackground / this.max) * 100;
return Math.max(0, Math.min(pct.toFixed(2), 100));
},
displayed_text() {
@@ -197,21 +201,21 @@ export default {
return isNumber(this.fontSize) ? this.fontSize : 13;
},
progress_style() {
var style = {
const style = {
background: this.bgColor,
position: 'relative'
};
if (this.status === 'indexing') {
style['background'] = '#fff8d6'; // Muted yellow/amber color
style.background = '#fff8d6'; // Muted yellow/amber color
}
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["min-height"] = this.size_px + "px";
if (this.textPosition === "middle" || this.textPosition === "inside") {
style.minHeight = `${this.size_px}px`;
}
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
style.borderRadius = `${this.barBorderRadius}px`;
}
return style;
@@ -230,32 +234,32 @@ export default {
if (percentage < 1) {
percentage = 0;
}
var style = {
width: percentage + "%",
height: this.size_px + "px",
const style = {
width: `${percentage}%`,
height: `${this.size_px}px`,
background: barColor,
transition: this.barTransition,
position: this.valBackground !== null ? 'relative' : undefined,
"z-index": this.valBackground !== null ? 1 : undefined,
zIndex: this.valBackground !== null ? 1 : undefined,
};
// Add pulse animation for indexing status
if (this.status === 'indexing') {
style['animation'] = 'progress-pulse 4s ease-in-out infinite';
style['background'] = 'rgb(237 161 41)'; // orange
style.animation = 'progress-pulse 4s ease-in-out infinite';
style.background = 'rgb(237 161 41)'; // orange
}
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
style.borderRadius = `${this.barBorderRadius}px`;
}
if (this.textPosition == "middle") {
style["position"] = "absolute";
style["top"] = "0";
style["height"] = "100%";
style["min-width"] = "1.5em";
style["min-height"] = this.size_px + "px";
style["z-index"] = "-1";
if (this.textPosition === "middle") {
style.position = 'absolute';
style.top = '0';
style.height = '100%';
style.minWidth = '1.5em';
style.minHeight = `${this.size_px}px`;
style.zIndex = '-1';
}
return style;
@@ -265,9 +269,9 @@ export default {
if (percentage < 1) {
percentage = 0;
}
var style = {
width: percentage + "%",
height: this.size_px + "px",
const style = {
width: `${percentage}%`,
height: `${this.size_px}px`,
background: '#9e9e9e',
transition: this.barTransition,
position: 'absolute',
@@ -276,40 +280,40 @@ export default {
};
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
style.borderRadius = `${this.barBorderRadius}px`;
}
return style;
},
text_style() {
var style = {
const style = {
"color": this.textFgColor,
"font-size": this.text_font_size + "px",
"font-size": `${this.text_font_size}px`,
"text-align": this.textAlign,
};
if (this.textPosition === 'inside') {
style['position'] = 'absolute';
style['left'] = '0';
style['right'] = '0';
style['top'] = '50%';
style['transform'] = 'translateY(-50%)';
style['width'] = '100%';
style['padding'] = '0 0.5em';
style['box-sizing'] = 'border-box';
style['z-index'] = this.valBackground !== null ? 2 : undefined;
style.position = 'absolute';
style.left = '0';
style.right = '0';
style.top = '50%';
style.transform = 'translateY(-50%)';
style.width = '100%';
style.padding = '0 0.5em';
style.boxSizing = 'border-box';
style.zIndex = this.valBackground !== null ? 2 : undefined;
}
if (
this.textPosition == "top" ||
this.textPosition == "middle"
this.textPosition === "top" ||
this.textPosition === "middle"
)
style["padding-bottom"] = this.text_padding + "px";
style.paddingBottom = `${this.text_padding}px`;
if (
this.textPosition == "bottom" ||
this.textPosition == "middle"
this.textPosition === "bottom" ||
this.textPosition === "middle"
)
style["padding-top"] = this.text_padding + "px";
style.paddingTop = `${this.text_padding}px`;
return style;
},
},
+35 -42
View File
@@ -3,7 +3,7 @@
<!-- Search input section -->
<div class="search-input-container">
<!-- Close button visible when search is active -->
<button v-if="active" class="action" @click="close" :aria-label="$t('general.close')"
<button v-if="active" type="button" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<i class="material-symbols">close</i>
</button>
@@ -109,7 +109,7 @@
:hasPreview="showPreviewImages && (s.hasPreview || false)"
:thumbnailUrl="showPreviewImages ? getThumbnailUrl(s) : ''" />
<span class="text-container">
{{ basePath(s.path, s.type == "directory") }}{{ baseName(s.path) }}
{{ basePath(s.path, s.type === "directory") }}{{ baseName(s.path) }}
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
@@ -130,20 +130,20 @@
</template>
<script>
import ButtonGroup from "./ButtonGroup.vue";
import { toolsApi, resourcesApi } from "@/api";
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { url } from "@/utils/";
import { resourcesApi, toolsApi } from "@/api";
import Icon from "@/components/files/Icon.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import { globalVars } from "@/utils/constants";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { utcStartOfDaySecondsFromDateInput } from "@/utils/moment";
import SettingsItem from "@/components/settings/SettingsItem.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import router from "@/router";
import { getters, mutations, state } from "@/store";
import { url } from "@/utils/";
import { globalVars } from "@/utils/constants";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { utcStartOfDaySecondsFromDateInput } from "@/utils/moment";
import ButtonGroup from "./ButtonGroup.vue";
var boxes = {
const boxes = {
folder: { label: "folders", icon: "folder" },
file: { label: "files", icon: "insert_drive_file" },
archive: { label: "archives", icon: "archive" },
@@ -293,7 +293,7 @@ export default {
return getters.isDarkMode();
},
showBoxes() {
return this.searchTypes == "";
return this.searchTypes === "";
},
boxes() {
return boxes;
@@ -322,7 +322,7 @@ export default {
// Construct the same full path as addSelected does
const context = url.removeTrailingSlash(this.getContext);
const pathStr = url.removeLeadingSlash(url.removeTrailingSlash(result.path));
const fullPath = context + '/' + pathStr;
const fullPath = `${context}/${pathStr}`;
return selectedPaths.has(fullPath);
});
},
@@ -340,7 +340,7 @@ export default {
if (this.selectedSource === "__all__" || (this.selectedSource === "" && this.multipleSources)) {
return "/";
}
let result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
const result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
if (this.selectedSource === "" || result.source === this.selectedSource) {
return result.path;
} else {
@@ -412,7 +412,7 @@ export default {
// Ensure exactly one slash between context and path
const context = url.removeTrailingSlash(this.getContext);
const path = url.removeLeadingSlash(url.removeTrailingSlash(s.path));
const fullPath = context + '/' + path;
const fullPath = `${context}/${path}`;
return url.buildItemUrl(source, fullPath, true);
},
humanSize(size) {
@@ -423,11 +423,11 @@ export default {
if (!isDir) {
result = url.removeLeadingSlash(result); // fix weird rtl thing
}
return result + "/";
return `${result}/`;
},
baseName(str) {
let parts = url.removeTrailingSlash(str).split("/");
let part = parts.pop();
const parts = url.removeTrailingSlash(str).split("/");
const part = parts.pop();
return part;
},
/** Clear query state whenever the overlay opens (open paths do not always call close()). */
@@ -449,16 +449,15 @@ export default {
this.close(event);
return;
}
this.results.length === 0;
},
addToTypes(string) {
if (this.searchTypes.includes(string)) {
return true;
}
if (string == null || string == "") {
if (string === null || string === "") {
return false;
}
this.searchTypes = this.searchTypes + string + " ";
this.searchTypes = `${this.searchTypes + string} `;
},
resetSearchFilters() {
this.searchTypes = "";
@@ -466,10 +465,10 @@ export default {
this.showPreviewImages = false;
},
removeFromTypes(string) {
if (string == null || string == "") {
if (string === null || string === "") {
return false;
}
this.searchTypes = this.searchTypes.replaceAll(string + " ", "");
this.searchTypes = this.searchTypes.replaceAll(`${string} `, "");
if (state.isMobile) {
this.$refs.input.focus();
}
@@ -482,7 +481,7 @@ export default {
},
async submit(event) {
this.results = [];
if (event != undefined) {
if (event !== undefined) {
event.preventDefault();
}
if (this.value === "" || this.value.length < globalVars.minSearchLength) {
@@ -490,11 +489,11 @@ export default {
return;
}
let searchTypesFull = this.searchTypes;
if (this.largerThan != "") {
searchTypesFull = searchTypesFull + "type:largerThan=" + this.largerThan + " ";
if (this.largerThan !== "") {
searchTypesFull = `${searchTypesFull}type:largerThan=${this.largerThan} `;
}
if (this.smallerThan != "") {
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
if (this.smallerThan !== "") {
searchTypesFull = `${searchTypesFull}type:smallerThan=${this.smallerThan} `;
}
const dateParams = {};
const olderUnix = utcStartOfDaySecondsFromDateInput(this.modifiedOlderThan);
@@ -511,7 +510,7 @@ export default {
this.ongoing++;
// Determine which sources to search
let sourcesToSearch = [];
let sourcesToSearch;
if (this.selectedSource === "__all__" || this.selectedSource === "") {
// Search all sources
sourcesToSearch = Object.keys(this.sourceInfo);
@@ -532,7 +531,7 @@ export default {
);
this.ongoing--;
if (this.results.length == 0 && this.ongoing == 0) {
if (this.results.length === 0 && this.ongoing === 0) {
this.noneMessage = this.$t("search.noResults");
}
},
@@ -561,10 +560,10 @@ export default {
// Ensure exactly one slash between context and path
const context = url.removeTrailingSlash(this.getContext);
const path = url.removeLeadingSlash(url.removeTrailingSlash(s.path));
const fullPath = context + '/' + path;
const fullPath = `${context}/${path}`;
const modified = s.modified || "";
return resourcesApi.getPreviewURL(source, fullPath, modified);
} catch (err) {
} catch (_err) {
return "";
}
},
@@ -589,7 +588,7 @@ export default {
});
},
openInAdvancedSearch() {
let sourcesToSearch = [];
let sourcesToSearch;
if (this.selectedSource === "__all__" || this.selectedSource === "") {
sourcesToSearch = Object.keys(this.sourceInfo || {});
} else {
@@ -663,7 +662,7 @@ export default {
// Combine context (scope) with the result path - ensure exactly one slash between
const context = url.removeTrailingSlash(this.getContext);
const pathStr = url.removeLeadingSlash(url.removeTrailingSlash(s.path));
let path = context + '/' + pathStr;
const path = `${context}/${pathStr}`;
const modifiedItem = {
name: pathParts.pop(),
path: path,
@@ -746,11 +745,7 @@ export default {
-webkit-animation: SlideDown 0.5s forwards;
animation: SlideDown 0.5s forwards;
border-radius: 1em;
max-height: 100%;
border-top: none;
border-top-width: initial;
border-top-style: none;
border-top-color: initial;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border: 2px solid var(--surfaceSecondary);
@@ -799,7 +794,6 @@ export default {
.search-input-container {
background-color: rgba(100, 100, 100, 0.2);
display: flex;
height: 100%;
padding: 0.5em 0.75em;
border-radius: 1em;
border-style: unset;
@@ -910,8 +904,7 @@ export default {
text-align: left;
color: rgba(0, 0, 0, 0.6);
height: 0;
transition: 2s ease height, 2s ease padding;
transition: 2s ease width, 2s ease padding;
transition: 2s ease height, 2s ease padding, 2s ease width, 2s ease padding;
z-index: 3;
}
+3 -3
View File
@@ -23,7 +23,7 @@
import Breadcrumbs from "@/components/files/Breadcrumbs.vue";
import ListingHeader from "@/components/files/ListingHeader.vue";
import DuplicateFinderActions from "@/components/tools/DuplicateFinderActions.vue";
import { state, getters } from "@/store";
import { getters, state } from "@/store";
import { eventBus } from "@/store/eventBus";
export default {
@@ -78,7 +78,7 @@ export default {
// Check if any file has duration metadata
if (!state.req?.items) return false;
return state.req.items.some(item =>
item.type !== 'directory' && item.metadata && item.metadata.duration
item.type !== 'directory' && item.metadata?.duration
);
},
showDuplicateFinderActions() {
@@ -88,7 +88,7 @@ export default {
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
return {
left: state.sidebar.width + 'em',
left: `${state.sidebar.width}em`,
};
}
return {};
+12 -14
View File
@@ -44,7 +44,7 @@
</template>
<script>
import { state, getters, mutations } from "@/store";
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
export default {
@@ -93,7 +93,7 @@ export default {
state.selected.forEach(index => {
if (index >= 0 && index < state.req.items.length) {
const item = state.req.items[index];
if (item && item.size) {
if (item?.size) {
total += item.size;
}
}
@@ -133,29 +133,27 @@ export default {
if (dirs === 0 && files === 0) {
return this.$t('files.lonely');
}
let parts = [];
const parts = [];
if (dirs > 0) parts.push(`${dirs} ${this.foldersLabel}`);
if (files > 0) parts.push(`${files} ${this.filesLabel}`);
return parts.join(' | ') + ' ' + sizeText;
return `${parts.join(' | ')} ${sizeText}`;
},
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
return {
left: state.sidebar.width + 'em',
left: `${state.sidebar.width}em`,
};
}
return {};
},
editorStatsText() {
const { lines, words, chars } = state.editorStats;
return [
[words, chars]
.map((v, i) => v != null && this.$t(i === 0 ? 'editor.words' : 'editor.chars', { count: v }))
.filter(Boolean)
.join(', '),
lines != null && this.$t('editor.lines', { count: lines })
].filter(Boolean).join(' | ');
const parts = [];
if (words !== null) parts.push(this.$t('editor.words', { count: words }));
if (chars !== null) parts.push(this.$t('editor.chars', { count: chars }));
if (lines !== null) parts.push(this.$t('editor.lines', { count: lines }));
return parts.join(' | ');
},
editorFontSize: {
get() {
@@ -220,7 +218,7 @@ export default {
const advSearch = (state.route?.path || "").startsWith("/tools/advancedSearch");
if (this.currentView === "listingView" || advSearch) {
event.preventDefault();
let newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
const newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
if (newSize !== this.gallerySize) {
this.gallerySize = newSize;
mutations.setGallerySize(newSize);
@@ -228,7 +226,7 @@ export default {
}
} else if (this.currentView === 'editor') {
event.preventDefault();
let newSize = Math.min(24, Math.max(8, this.editorFontSize - delta));
const newSize = Math.min(24, Math.max(8, this.editorFontSize - delta));
if (newSize !== this.editorFontSize) {
this.editorFontSize = newSize;
}
+1 -1
View File
@@ -20,7 +20,7 @@
</template>
<script>
import { state, getters, mutations } from "@/store";
import { getters, mutations, state } from "@/store";
export default {
name: "Tooltip",
+2 -1
View File
@@ -28,6 +28,7 @@
<!-- Lock button -->
<button
v-if="lyrics.length && syncedLyrics"
type="button"
class="lyrics-lock-btn"
@click="lyricsScrollLocked = !lyricsScrollLocked"
:title="lyricsScrollLocked ? $t('player.unlockLyrics') : $t('player.lockLyrics')"
@@ -46,7 +47,7 @@
@click.stop="syncedLyrics && $emit('seek', line.timestamp)"
:role="syncedLyrics ? 'button' : undefined"
:tabindex="syncedLyrics ? 0 : undefined"
:aria-label="syncedLyrics ? 'Seek to ' + line.text : undefined"
:aria-label="syncedLyrics ? `Seek to ${line.text}` : undefined"
>
{{ line.text }}
</p>
+12 -12
View File
@@ -14,7 +14,7 @@
<li class="item" v-for="(link, index) in items" :key="index">
<router-link
:to="link.url"
:aria-label="'breadcrumb-link-' + link.name"
:aria-label="`breadcrumb-link-${link.name}`"
:title="link.name"
:key="index"
:class="{ changeAvailable: hasUpdate,
@@ -32,10 +32,10 @@
</template>
<script>
import { state, getters, mutations } from "@/store";
import { url } from "@/utils";
import { resourcesApi } from "@/api";
import { notify } from "@/notify";
import { getters, mutations, state } from "@/store";
import { url } from "@/utils";
export default {
name: "breadcrumbs",
@@ -88,9 +88,9 @@ export default {
if (!req.items || !req.path) {
return [];
}
let encodedPathString = url.encodedPath(state.req.path);
let originalParts = state.req.path.split("/");
let encodedParts = encodedPathString.split("/");
const encodedPathString = url.encodedPath(state.req.path);
const originalParts = state.req.path.split("/");
const encodedParts = encodedPathString.split("/");
// Remove empty strings from both arrays consistently
if (originalParts[0] === "") {
originalParts.shift();
@@ -100,14 +100,14 @@ export default {
originalParts.pop();
encodedParts.pop();
}
let breadcrumbs = [];
const breadcrumbs = [];
let buildRef = this.base;
let accumulatedPath = "";
for (let i = 0; i < originalParts.length; i++) {
const origPart = originalParts[i];
const encodedElement = encodedParts[i];
buildRef = buildRef + encodedElement + "/";
buildRef = `${buildRef + encodedElement}/`;
accumulatedPath = accumulatedPath ? `${accumulatedPath}/${origPart}` : origPart;
breadcrumbs.push({
@@ -207,7 +207,7 @@ export default {
// Build list of items to move from selected items
let itemsToMove = [];
for (let i of state.selected) {
for (const i of state.selected) {
if (i < 0 || i >= state.req.length) continue;
const selectedItem = state.req.items[i];
@@ -238,7 +238,7 @@ export default {
const toDir = normalizePath(item.to);
// Check if destination is inside the directory
if (toDir.startsWith(fromDir + "/")) {
if (toDir.startsWith(`${fromDir}/`)) {
return false;
}
}
@@ -255,7 +255,7 @@ export default {
const response = await resourcesApi.fetchFiles(source, targetPath);
targetDirItems = response?.items;
}
} catch (error) {
} catch (_e) {
notify.showError(this.$t("files.cannotAccesDir"));
return;
}
@@ -293,7 +293,7 @@ export default {
});
mutations.closeTopPrompt();
mutations.setReload(true);
} catch (error) {
} catch (_e) {
mutations.closeTopPrompt();
notify.showError(this.$t("prompts.moveFailed"));
}
+69 -41
View File
@@ -35,13 +35,13 @@
</template>
<script>
import { state, mutations, getters } from '@/store';
import throttle from "@/utils/throttle";
import { notify } from "@/notify";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { notify } from "@/notify";
import { getters, mutations, state } from '@/store';
import { globalVars } from "@/utils/constants";
import { getBestCachedImage } from "@/utils/imageCache";
import { isRawImageMimeType } from "@/utils/mimetype";
import { globalVars } from "@/utils/constants";
import throttle from "@/utils/throttle";
export default {
components: {
@@ -95,6 +95,7 @@ export default {
edgeCommitX: 130,
edgeCommitY: 110,
edgeRubberMax: 100,
pinchActive: false,
};
},
computed: {
@@ -127,7 +128,7 @@ export default {
return getBestCachedImage(source, this.path, state.req?.modified);
},
navigationGestureAllowed() {
return state.navigation.enabled && getters.currentPrompt() == null;
return state.navigation.enabled && getters.currentPrompt() === null;
},
hasImagePrevious() {
return this.navigationGestureAllowed && state.navigation.previousLink !== '';
@@ -174,8 +175,10 @@ export default {
});
}
let container = this.$refs.container;
this.classList.forEach((className) => container.classList.add(className));
const container = this.$refs.container;
this.classList.forEach((className) => {
container.classList.add(className);
});
if (getComputedStyle(container).width === "0px") {
container.style.width = "100%";
}
@@ -191,6 +194,10 @@ export default {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
if (this.disabledTimer) {
clearTimeout(this.disabledTimer);
this.disabledTimer = null;
}
this.teardownEdgeMouseListeners();
mutations.setNavigationGestureHint({});
window.removeEventListener("resize", this.onResize);
@@ -251,7 +258,7 @@ export default {
const actualSrc = img?.src || '';
// If the error is due to &amp; in URL, try to fix it
if (actualSrc && actualSrc.includes('&amp;')) {
if (actualSrc?.includes('&amp;')) {
const fixedSrc = actualSrc.replace(/&amp;/g, '&');
if (img) {
img.src = fixedSrc;
@@ -284,7 +291,7 @@ export default {
imgex.src = URL.createObjectURL(blob);
imgex.onload = () => URL.revokeObjectURL(imgex.src);
}
} catch (error) {
} catch (_e) {
notify.showError("Error decoding TIFF");
}
},
@@ -537,7 +544,7 @@ export default {
setCenter() {
const container = this.$refs.container;
const img = this.$refs.imgex;
if (!container || !img || !img.clientWidth || !img.clientHeight) {
if (!container || !img?.clientWidth || !img?.clientHeight) {
return;
}
this.position.relative = { x: 0, y: 0 };
@@ -585,6 +592,13 @@ export default {
this.lastX = null;
this.lastY = null;
this.lastTouchDistance = null;
if (event.targetTouches.length >= 2) {
this.pinchActive = true;
this.resetEdgeGestureImmediate();
this.applyImgTransform();
event.preventDefault();
return;
}
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.edgeStartX = touch.pageX;
@@ -597,7 +611,7 @@ export default {
this.resetEdgeGestureImmediate();
this.applyImgTransform();
}
if (event.targetTouches.length < 2) {
if (event.targetTouches.length === 2) {
setTimeout(() => {
this.touches = 0;
}, 300);
@@ -620,7 +634,6 @@ export default {
this.scale = 4;
break;
default:
case 4:
this.scale = 1;
this.setCenter();
break;
@@ -631,7 +644,34 @@ export default {
touchMove(event) {
// Default is prevented via @touchmove.prevent so the browser cannot steal
// vertical drags (pull-to-refresh) before our edge-gesture thresholds.
if (event.targetTouches.length === 1 && this.scale === 1) {
if (event.targetTouches.length === 2) {
if (!this.pinchActive) {
this.pinchActive = true;
this.resetEdgeGestureImmediate();
this.applyImgTransform();
}
this.moveDisabled = true;
clearTimeout(this.disabledTimer);
this.disabledTimer = setTimeout(() => (this.moveDisabled = false), this.moveDisabledTime);
const p1 = event.targetTouches[0];
const p2 = event.targetTouches[1];
const touchDistance = Math.sqrt((p2.pageX - p1.pageX) ** 2 + (p2.pageY - p1.pageY) ** 2);
if (!this.lastTouchDistance) {
this.lastTouchDistance = touchDistance;
return;
}
let newScale = this.scale + (touchDistance - this.lastTouchDistance) / (this.$refs.imgex.width / 5);
newScale = Math.max(this.minScale, Math.min(this.maxScale, newScale));
if (newScale !== this.scale) {
this.scale = newScale;
}
this.lastTouchDistance = touchDistance;
this.setZoom();
return;
}
if (event.targetTouches.length === 1 && this.scale === 1 && !this.pinchActive) {
const touch = event.targetTouches[0];
this.edgeDx = touch.pageX - this.edgeStartX;
this.edgeDy = touch.pageY - this.edgeStartY;
@@ -639,36 +679,15 @@ export default {
this.applyEdgeVisuals();
return;
}
if (this.lastX === null) {
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
return;
}
let step = this.$refs.imgex.width / 5;
if (event.targetTouches.length === 2) {
this.moveDisabled = true;
clearTimeout(this.disabledTimer);
this.disabledTimer = setTimeout(
() => (this.moveDisabled = false),
this.moveDisabledTime
);
let p1 = event.targetTouches[0];
let p2 = event.targetTouches[1];
let touchDistance = Math.sqrt(
Math.pow(p2.pageX - p1.pageX, 2) + Math.pow(p2.pageY - p1.pageY, 2)
);
if (!this.lastTouchDistance) {
this.lastTouchDistance = touchDistance;
if (event.targetTouches.length === 1 && this.scale > 1) {
if (this.moveDisabled) return;
if (this.lastX === null) {
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
return;
}
this.scale += (touchDistance - this.lastTouchDistance) / step;
this.lastTouchDistance = touchDistance;
this.setZoom();
} else if (event.targetTouches.length === 1 && this.scale > 1) {
if (this.moveDisabled) return;
let x = event.targetTouches[0].pageX - this.lastX;
let y = event.targetTouches[0].pageY - this.lastY;
if (Math.abs(x) >= step && Math.abs(y) >= step) return;
const x = event.targetTouches[0].pageX - this.lastX;
const y = event.targetTouches[0].pageY - this.lastY;
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
this.doMove(x, y);
@@ -696,6 +715,15 @@ export default {
return +style.replace("px", "");
},
touchEnd(event) {
if (this.pinchActive) {
if (event.touches.length === 0) {
this.pinchActive = false;
this.resetEdgeGestureImmediate();
this.applyImgTransform();
}
event.preventDefault();
return;
}
if (this.scale === 1 && event.changedTouches.length > 0) {
const t = event.changedTouches[0];
this.edgeDx = t.pageX - this.edgeStartX;
+28 -11
View File
@@ -43,6 +43,7 @@
:clickable="false"
:forceFilesApi="!!browseSource"
:showLimitedOptions="true"
:inlinePin="true"
@click.prevent="(event) => handleItemClick(item, index, event)"
@dblclick.prevent="(event) => handleItemDblClick(item, index, event)"
/>
@@ -56,6 +57,7 @@ import { url } from "@/utils";
import { resourcesApi } from "@/api";
import ListingItem from "@/components/files/ListingItem.vue";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { sortedItems } from "@/utils/sort.js";
export default {
name: "file-list",
@@ -145,7 +147,7 @@ export default {
},
availableSources() {
// Get all available sources from state.sources.info
return state.sources && state.sources.info ? Object.keys(state.sources.info) : [state.req.source];
return state.sources?.info ? Object.keys(state.sources.info) : [state.req.source];
},
showSourceSelector() {
if (this.hideDestinationSource) {
@@ -225,7 +227,7 @@ export default {
if (this.path !== "/" && this.showFolders) {
this.items.push({
name: "..",
path: url.removeLastDir(this.path) + "/",
path: `${url.removeLastDir(this.path)}/`,
source: this.source,
type: "directory",
});
@@ -290,6 +292,20 @@ export default {
// Fetch files for the share
this.withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(this.fillOptions));
},
// Sort items to make pinned items appear on top always (it was working before, but just for the first load)
// after navigate the pinned was lost, this will also keep our sorting preference in all prompts that use FileList
sortItemsWithPinned(items, source, path) {
if (!items?.length) return items;
const parentEntry = items.find(i => i.name === "..");
const rest = items.filter(i => i.name !== "..");
const dirs = rest.filter(i => i.type === 'directory');
const files = rest.filter(i => i.type !== 'directory');
const pinnedPaths = getters.pinnedPathsFor(source, path);
const sorting = getters.sorting();
const sortedDirs = sortedItems(dirs, sorting.by, sorting.asc, pinnedPaths);
const sortedFiles = sortedItems(files, sorting.by, sorting.asc, pinnedPaths);
return parentEntry ? [parentEntry, ...sortedDirs, ...sortedFiles] : [...sortedDirs, ...sortedFiles];
},
fillOptions(req) {
// Sets the current path and resets the current items.
// Use this.path (the path we're browsing) instead of req.path (which may be relative)
@@ -311,7 +327,7 @@ export default {
if (this.path !== "/" && this.showFolders) {
this.items.push({
name: "..",
path: url.removeLastDir(this.path) + "/",
path: `${url.removeLastDir(this.path)}/`,
source: this.source,
type: "directory",
});
@@ -319,7 +335,7 @@ export default {
// If this folder has no items or items is not an array, finish here.
if (!req.items || !Array.isArray(req.items)) return;
for (let item of req.items) {
for (const item of req.items) {
if (!this.showFolders && item.type === "directory") continue;
if (!this.showFiles && item.type !== "directory") continue;
// Filter by file type if specified (only for files, not directories)
@@ -332,14 +348,15 @@ export default {
originalItem: item, // Store original item for Icon component
});
}
this.items = this.sortItemsWithPinned(this.items, this.source, this.path);
},
next: function (event) {
// Retrieves the URL of the directory the user
// just clicked in and fill the options with its
// content.
let path = event.currentTarget.dataset.path;
let clickedItem = this.items.find(item => item.path === path);
let sourceToUse = clickedItem ? clickedItem.source : this.source;
const path = event.currentTarget.dataset.path;
const clickedItem = this.items.find(item => item.path === path);
const sourceToUse = clickedItem ? clickedItem.source : this.source;
// If showFiles and showFolders is true, and clicked item is a file (not a directory), select it directly
if (this.showFiles && clickedItem && clickedItem.type !== "directory") {
@@ -377,7 +394,7 @@ export default {
},
touchstart(event) {
let url = event.currentTarget.dataset.path;
const url = event.currentTarget.dataset.path;
// In 300 milliseconds, we shall reset the count.
setTimeout(() => {
@@ -438,7 +455,7 @@ export default {
this.next(syntheticEvent);
},
select: function (event) {
let path = event.currentTarget.dataset.path;
const path = event.currentTarget.dataset.path;
// If the element is already selected, unselect it.
if (this.selected === path) {
this.selected = null;
@@ -454,7 +471,7 @@ export default {
}
// Otherwise select the element.
this.selected = path;
let clickedItem = this.items.find(item => item.path === path);
const clickedItem = this.items.find(item => item.path === path);
this.selectedSource = clickedItem ? clickedItem.source : this.source;
this.selectedType = clickedItem ? clickedItem.type : null;
const isFile = clickedItem && clickedItem.type !== "directory";
@@ -487,7 +504,7 @@ export default {
if (!source) {
return;
}
let p = path == null || path === "" ? "/" : String(path);
let p = path === null || path === "" ? "/" : String(path);
if (!p.startsWith("/")) {
p = `/${p}`;
}
+6 -6
View File
@@ -9,7 +9,7 @@
<i class="material-symbols">error</i>
<span>{{ $t("prompts.error") }}</span>
</div>
<ul v-if="effectiveNodes && effectiveNodes.length" class="tree-list">
<ul v-if="effectiveNodes?.length" class="tree-list">
<li v-for="node in effectiveNodes" :key="node.path" class="tree-item">
<div
class="tree-node"
@@ -204,7 +204,7 @@ export default {
},
beforeUnmount() {
// Clear all expand timeouts
this.expandTimeouts.forEach(timeout => clearTimeout(timeout));
this.expandTimeouts.forEach(timeout => { clearTimeout(timeout); });
this.expandTimeouts.clear();
if (this.unwatchReload) {
this.unwatchReload();
@@ -237,7 +237,7 @@ export default {
}
},
async fetchItems(path) {
let items = [];
let items;
if (this.isShare) {
const res = await resourcesApi.fetchFilesPublic(path, this.shareHash, state.shareInfo?.password, false, false, true);
items = res.items || [];
@@ -486,7 +486,7 @@ export default {
this.rootNodes.forEach(clearNode);
// Clear all pending expand timeouts
this.expandTimeouts.forEach(timeout => clearTimeout(timeout));
this.expandTimeouts.forEach(timeout => { clearTimeout(timeout); });
this.expandTimeouts.clear();
},
@@ -534,7 +534,7 @@ export default {
// Prevent dropping onto itself or into its own subdirectory
if (item.path === node.path) continue; // cannot drop onto itself
if (node.path.startsWith(item.path + '/')) continue; // cannot drop into its own subdirectory
if (node.path.startsWith(`${item.path}/`)) continue; // cannot drop into its own subdirectory
items.push({
from: item.path,
@@ -657,7 +657,7 @@ export default {
// Compare node to each candidate (usually only one, but safe to loop)
return candidates.some(selected => {
if (!selected || !selected.path) return false;
if (!selected?.path) return false;
if (this.isShare) {
return selected.path === node.path;
} else {
+109 -71
View File
@@ -29,16 +29,13 @@
</template>
<script>
import { fetchPreviewImage } from "@/api/resources";
import { globalVars } from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
import ThreeJs from "@/views/files/ThreeJs.vue";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = globalVars.baseURL + "public/static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "public/static/img/placeholder.png";
export default {
name: "Icon",
components: {
@@ -99,15 +96,24 @@ export default {
classes: "",
svgPath: "",
previewTimeouts: [],
previewAbortController: null,
preloadAbortControllers: [],
imageBlobUrl: null,
// UPDATED: Manage image state directly
imageState: "loading", // Can be 'loading', 'loaded', or 'error'
imageTargetSrc: "", // This is now a data property
imageTargetSrc: "", // API URL being loaded
currentThumbnail: "", // Add currentThumbnail to data
color: "", // Add color to data
threeJsError: false, // Track if 3D preview failed
};
},
computed: {
placeholderUrl() {
return `${globalVars.baseURL}public/static/img/placeholder.png`;
},
errorUrl() {
return `${globalVars.baseURL}public/static/img/placeholder.png`;
},
isFile() {
return this.mimetype !== "directory";
},
@@ -115,7 +121,7 @@ export default {
if (state.shareInfo?.disableThumbnails) {
return false;
}
if (this.thumbnailUrl == "") {
if (this.thumbnailUrl === "") {
return false;
}
if (!this.hasPreview) {
@@ -138,7 +144,7 @@ export default {
if ((simpleType === "document" || simpleType === "text") && !getters.previewPerms().office) {
return false;
}
if (!getters.previewPerms().folder && this.mimetype == "directory") {
if (!getters.previewPerms().folder && this.mimetype === "directory") {
return false;
}
// 3D models - show preview thumbnails (if backend provides them)
@@ -148,20 +154,18 @@ export default {
return this.imageState !== 'error' && !this.disablePreviewExt && !this.officeFileDisabled;
},
disablePreviewExt() {
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
// @ts-ignore
const ext = `.${(this.filename.split(".").pop() || "").toLowerCase()}`; // Ensure lowercase and dot
return state.user?.disablePreviewExt?.includes(ext);
},
officeFileDisabled() {
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
// @ts-ignore
const ext = `.${(this.filename.split(".").pop() || "").toLowerCase()}`; // Ensure lowercase and dot
return state.user?.disablePreviewExt?.includes(ext);
},
pdfConvertable() {
if (!globalVars.muPdfAvailable) {
return false; // If muPDF is not available
}
const ext = "." + (this.filename.split(".").pop() || "").toLowerCase(); // Ensure lowercase and dot
const ext = `.${(this.filename.split(".").pop() || "").toLowerCase()}`; // Ensure lowercase and dot
const pdfConvertCompatibleFileExtensions = {
".pdf": true,
".xps": true,
@@ -183,17 +187,23 @@ export default {
// NEW: A single computed property to determine the final image src
imageDisplaySrc() {
if (this.imageState === "error") {
return ERROR_URL;
return this.errorUrl;
}
// Show placeholder only for the initial load, not during hover animations
if (this.imageState === "loading") {
return PLACEHOLDER_URL;
return this.placeholderUrl;
}
return this.imageTargetSrc;
return this.imageBlobUrl || this.placeholderUrl;
},
showLargeIcon() {
return getters.viewMode() === "gallery";
},
popupPreviewUrl() {
if (!this.thumbnailUrl) {
return "";
}
return `${this.thumbnailUrl}&size=xlarge`;
},
showLarger() {
return getters.viewMode() === "gallery" || getters.viewMode() === "normal";
},
@@ -229,13 +239,35 @@ export default {
// Also includes view mode to handle view changes
return `${state.user?.gallerySize || 1}-${getters.viewMode()}`;
},
routePath() {
return state.route?.path ?? "";
},
},
methods: {
handle3DError() {
// When 3D preview fails, fall back to material icon
this.threeJsError = true;
},
// NEW: Centralized method to load any image and handle its state
cancelPreloadRequests() {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
for (const controller of this.preloadAbortControllers) {
controller.abort();
}
this.preloadAbortControllers = [];
},
cancelActivePreviews() {
this.cancelPreloadRequests();
if (this.previewAbortController) {
this.previewAbortController.abort();
this.previewAbortController = null;
}
if (this.imageBlobUrl) {
URL.revokeObjectURL(this.imageBlobUrl);
this.imageBlobUrl = null;
}
},
// Load preview via fetch so requests can be aborted on navigation
/**
* @param {string} url
*/
@@ -245,33 +277,42 @@ export default {
return;
}
this.cancelActivePreviews();
this.imageState = "loading";
const targetImage = new Image();
targetImage.onload = () => {
// Prevent race conditions: only update if this is the image we still want.
if (this.imageTargetSrc === url) {
this.imageState = "loaded";
// Mark this image as loaded in our cache tracker
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const size = this.showLargeIcon ? 'large' : 'small';
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, size, modified, url);
const controller = new AbortController();
this.previewAbortController = controller;
const loadUrl = url;
fetchPreviewImage(loadUrl, controller.signal)
.then((blobUrl) => {
if (this.previewAbortController !== controller) {
URL.revokeObjectURL(blobUrl);
return;
}
}
};
targetImage.onerror = () => {
// Prevent race conditions: only show an error if this is the image that failed.
if (this.imageTargetSrc === url) {
this.imageState = "error";
}
};
targetImage.src = url;
if (this.imageTargetSrc !== loadUrl) {
URL.revokeObjectURL(blobUrl);
return;
}
this.imageBlobUrl = blobUrl;
this.imageState = "loaded";
this.previewAbortController = null;
if (this.path) {
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const size = this.showLargeIcon ? "large" : "small";
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, size, modified, loadUrl);
}
})
.catch((err) => {
if (err?.name === "AbortError") {
return;
}
if (this.imageTargetSrc === loadUrl && this.previewAbortController === controller) {
this.imageState = "error";
this.previewAbortController = null;
}
});
},
handleMouseEnter() {
if (!getters.previewPerms().popup || !this.path) {
@@ -297,11 +338,10 @@ export default {
return;
}
// Image (and other preview types): use thumbnail URL
const imageUrl = this.thumbnailUrl + "&size=large";
// Image (and other preview types): popup always uses xlarge preview
const imageUrl = this.popupPreviewUrl;
if (this.imageState === "loaded") {
mutations.setPreviewSource(imageUrl);
state.popupPreviewSourceInfo = { source, path: this.path, size: "large", url: imageUrl, modified };
state.popupPreviewSourceInfo = { source, path: this.path, size: "xlarge", url: imageUrl, modified };
}
// Motion preview: video (atPercentage) or folder (cycle next previewable image)
const useVideoMotion = getters.previewPerms().motionVideoPreview && this.hasMotion;
@@ -312,9 +352,9 @@ export default {
const sequence = [
imageUrl,
imageUrl + "&atPercentage=25",
imageUrl + "&atPercentage=50",
imageUrl + "&atPercentage=75",
`${imageUrl}&atPercentage=25`,
`${imageUrl}&atPercentage=50`,
`${imageUrl}&atPercentage=75`,
];
let index = 0;
@@ -331,33 +371,31 @@ export default {
this.currentThumbnail = sequence[index];
}
// Preload the next image
// Preload the next frame (cancellable fetch warms HTTP cache)
const nextIndex = (index + 1) % sequence.length;
const preloadImg = new Image();
preloadImg.onload = () => {
// Track that this image was loaded (motion preview frames use the same path)
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const frameUrl = sequence[nextIndex];
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, 'large', modified, frameUrl);
}
};
preloadImg.src = sequence[nextIndex];
const frameUrl = sequence[nextIndex];
const preloadController = new AbortController();
this.preloadAbortControllers.push(preloadController);
fetchPreviewImage(frameUrl, preloadController.signal)
.then((blobUrl) => {
URL.revokeObjectURL(blobUrl);
if (this.path) {
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, "xlarge", modified, frameUrl);
}
})
.catch(() => {});
// Schedule next update
index = nextIndex;
const timeoutId = setTimeout(updateThumbnail, 750);
// @ts-ignore
this.previewTimeouts.push(timeoutId);
};
updateThumbnail();
},
handleMouseLeave() {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
this.cancelPreloadRequests();
mutations.setPreviewSource("");
// Clear popup preview source info when mouse leaves
state.popupPreviewSourceInfo = null;
@@ -368,11 +406,11 @@ export default {
return getTypeInfo(this.mimetype);
},
updateImageTargetSrc() {
let newSrc = this.thumbnailUrl || PLACEHOLDER_URL;
let newSrc = this.thumbnailUrl || this.placeholderUrl;
// If we need large thumbnails and have a thumbnail URL, append &size=large
// Otherwise use the URL as-is (defaults to small)
if (this.thumbnailUrl && this.showLargeIcon) {
newSrc = this.thumbnailUrl + "&size=large";
newSrc = `${this.thumbnailUrl}&size=large`;
}
if (this.imageTargetSrc !== newSrc) {
@@ -381,6 +419,10 @@ export default {
},
},
watch: {
routePath() {
this.cancelActivePreviews();
this.imageState = "loading";
},
thumbnailUrl() {
this.updateImageTargetSrc();
},
@@ -422,17 +464,13 @@ export default {
mounted() {
const result = this.getIconForType();
this.classes = result.classes || "material-symbols";
// @ts-ignore
this.color = result.color || "lightgray";
this.materialSymbol = result.materialSymbol || "";
// @ts-ignore
this.svgPath = result.svgPath || "";
this.updateImageTargetSrc();
},
beforeUnmount() {
// Clean up any pending animation timeouts
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
this.cancelActivePreviews();
},
};
</script>
+3 -3
View File
@@ -60,14 +60,14 @@ export function getStatusLabel(status, $t) {
}
export function getHumanReadableTime(lastIndex, locale) {
if (isNaN(Number(lastIndex))) return "";
let val = Number(lastIndex);
if (Number.isNaN(Number(lastIndex))) return "";
const val = Number(lastIndex);
if (val === 0) return "now";
return fromNow(val, locale);
}
export function formatDuration(seconds) {
if (isNaN(Number(seconds))) return '';
if (Number.isNaN(Number(seconds))) return '';
return Number(seconds);
}
+13 -12
View File
@@ -117,15 +117,12 @@ export default {
return "arrow_upward";
},
quickDownloadEnabled() {
// @ts-ignore
if (state.isMobile) {
return false
}
if (getters.isShare()) {
// @ts-ignore
return state.shareInfo?.quickDownload;
}
// @ts-ignore
return state.user?.quickDownload && !this.galleryView;
},
},
@@ -159,7 +156,6 @@ export default {
box-sizing: border-box;
border-top-left-radius: 1em;
border-top-right-radius: 1em;
border: unset;
margin-bottom: 0 !important;
justify-content: space-between;
}
@@ -191,15 +187,21 @@ span {
flex: 1;
}
.desktop-view .modified{
min-width: 15%;
flex: 0;
}
.desktop-view .size,
.desktop-view .duration {
min-width: 10%;
flex: 0;
width: 12%;
min-width: 80px;
flex: 0 0 auto;
justify-content: flex-end;
text-align: right;
}
.desktop-view .modified {
width: 18%;
min-width: 110px;
flex: 0 0 auto;
justify-content: flex-end;
text-align: right;
}
.size,
@@ -210,7 +212,6 @@ span {
text-align: end;
}
i {
font-size: 1.5em;
vertical-align: middle;
+66 -63
View File
@@ -5,6 +5,7 @@
class="listing-item clickable no-select"
:class="{
activebutton: isSelected,
'pinned-item': isPinned,
hiddenFile: isHiddenNotSelected && this && !this.isDraggedOver,
'half-selected': isDraggedOver,
'drag-hover': isDraggedOver,
@@ -47,37 +48,43 @@
</div>
<div class="text">
<p class="name">{{ displayName }}</p>
<p
class="size"
:data-order="humanSize"
>
{{ humanSize }}
<!-- For list/compact pin inside .name (inline), or if inlinePin is true (like on FileList) -->
<p v-if="isListMode || inlinePin" class="name">
<span>{{ displayName }}</span>
<i v-if="isPinned" class="material-symbols pinned-indicator">push_pin</i>
</p>
<p class="modified">
<time :datetime="modified">{{ formattedTime }}</time>
<!-- For other views pin is separate -->
<p v-else class="name">
<span>{{ displayName }}</span>
</p>
<p class="size" :data-order="humanSize">{{ humanSize }}</p>
<p class="modified"><time :datetime="modified">{{ formattedTime }}</time></p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
</div>
<Icon
@click.stop="downloadFile"
v-if="quickDownloadEnabled"
:filename="name"
:hasPreview="hasPreview"
mimetype="file_download"
class="download-icon"
role="button"
aria-label="Download"
tabindex="0"
:clickable=true
:isShared="isShared"
/>
<div v-if="isPinned && !isListMode && !inlinePin" class="pin-icon-wrapper">
<i class="material-symbols pinned-indicator">push_pin</i>
</div>
<Icon
@click.stop="downloadFile"
v-if="quickDownloadEnabled"
:filename="name"
:hasPreview="hasPreview"
mimetype="file_download"
class="download-icon"
role="button"
aria-label="Download"
tabindex="0"
:clickable="true"
:isShared="isShared"
/>
</a>
<div
v-else
class="listing-item no-select clickable"
:class="{
activebutton: isSelected,
'pinned-item': isPinned,
hiddenFile: isHiddenNotSelected && this && !this.isDraggedOver,
'half-selected': isDraggedOver,
'drag-hover': isDraggedOver,
@@ -114,25 +121,26 @@
</div>
<div class="text">
<p class="name">{{ displayName }}</p>
<p
class="size"
:data-order="humanSize"
>
{{ humanSize }}
<p v-if="isListMode || inlinePin" class="name">
<span>{{ displayName }}</span>
<i v-if="isPinned" class="material-symbols pinned-indicator">push_pin</i>
</p>
<p class="modified">
<time :datetime="modified">{{ formattedTime }}</time>
<p v-else class="name">
<span>{{ displayName }}</span>
</p>
<p class="size" :data-order="humanSize">{{ humanSize }}</p>
<p class="modified"><time :datetime="modified">{{ formattedTime }}</time></p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
</div>
<div v-if="isPinned && !isListMode && !inlinePin" class="pin-icon-wrapper">
<i class="material-symbols pinned-indicator">push_pin</i>
</div>
</div>
</template>
<script>
import { globalVars } from "@/utils/constants";
import downloadFiles from "@/utils/download";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { resourcesApi } from "@/api";
import * as upload from "@/utils/upload";
@@ -205,6 +213,10 @@ export default {
type: Boolean,
default: false,
},
inlinePin: {
type: Boolean,
default: false,
},
},
computed: {
displayName() {
@@ -215,21 +227,15 @@ export default {
return getters.viewMode() === "gallery";
},
quickDownloadEnabled() {
// @ts-ignore
if (getters.isShare()) {
// @ts-ignore
return state.shareInfo?.quickDownload && !this.galleryView;
}
// @ts-ignore
return state.user?.quickDownload && !this.galleryView;
},
quickDownloadPlaceholder() {
// @ts-ignore
if (getters.isShare()) {
// @ts-ignore
return state.shareInfo?.quickDownload && !this.galleryView && this.isDir;
}
// @ts-ignore
return state.user?.quickDownload && !this.galleryView && getters.viewMode() !== "icons" && getters.viewMode() !== "normal" && this.isDir;
},
isHiddenNotSelected() {
@@ -248,7 +254,6 @@ export default {
return state.selected;
},
isClicked() {
// @ts-ignore
if (state.user.singleClick || !this.allowedView) {
return false;
}
@@ -262,8 +267,7 @@ export default {
return state.selected.indexOf(this.index) !== -1;
},
isDraggable() {
// @ts-ignore
return this.readOnly == undefined && state.user.permissions?.modify || state.shareInfo.allowCreate;
return this.readOnly === undefined && state.user.permissions?.modify || state.shareInfo.allowCreate;
},
canDrop() {
if (!this.isDir) return false;
@@ -271,16 +275,13 @@ export default {
for (const i of this.selected) {
if (
// @ts-ignore
state.req.items[i].path === this.path &&
// @ts-ignore
state.req.source === this.source
) {
return false;
}
// Also check if we're trying to drop an item onto itself
// @ts-ignore
if (state.req.items[i].index === this.index) {
return false;
}
@@ -305,14 +306,12 @@ export default {
// If forceFilesApi is true, always use authenticated files API
if (this.forceFilesApi) {
// @ts-ignore
return resourcesApi.getPreviewURL(this.source || state?.req?.source, previewPath, this.modified);
}
if (getters.isShare()) {
return resourcesApi.getPreviewURLPublic(previewPath);
}
// @ts-ignore
return resourcesApi.getPreviewURL(this.source || state.req.source, previewPath, this.modified);
},
isThumbsEnabled() {
@@ -321,9 +320,15 @@ export default {
isClickable() {
return this.clickable;
},
isPinned() {
return getters.isItemPinned({
path: this.path,
source: this.source,
});
},
// Computed properties for display values - Vue caches these automatically!
humanSize() {
return this.type == "invalid_link"
return this.type === "invalid_link"
? "invalid link"
: getHumanReadableFilesize(this.size);
},
@@ -331,7 +336,7 @@ export default {
return getters.getTime(this.modified);
},
formattedDuration() {
if (!this.metadata || !this.metadata.duration) {
if (!this.metadata?.duration) {
return "";
}
const seconds = this.metadata.duration;
@@ -343,6 +348,10 @@ export default {
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
},
isListMode() {
const mode = getters.viewMode();
return mode === 'list' || mode === 'compact';
},
},
mounted() {
// Set up IntersectionObserver for lazy-loading thumbnails
@@ -381,7 +390,6 @@ export default {
event.stopPropagation();
if (this.updateGlobalState) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
downloadFiles(state.selected);
} else {
@@ -399,13 +407,11 @@ export default {
const movementThreshold = 10; // Adjust as needed
if (deltaX > movementThreshold || deltaY > movementThreshold) {
this.isSwipe = true;
// @ts-ignore
this.cancelContext(); // Cancel long press if swipe is detected
}
},
handleTouchEnd() {
if (!state.isSafari) return;
// @ts-ignore
this.cancelContext(); // Clear timeout
this.isSwipe = false; // Reset swipe state
},
@@ -423,9 +429,9 @@ export default {
},
getUrl() {
if (this.hash) {
return globalVars.baseURL + "public/share/" + this.hash + "/" + url.encodedPath(this.path);
return `${globalVars.baseURL}public/share/${this.hash}/${url.encodedPath(this.path)}`;
}
return globalVars.baseURL + "files/" + encodeURIComponent(this.source) + url.encodedPath(this.path);
return `${globalVars.baseURL}files/${encodeURIComponent(this.source)}${url.encodedPath(this.path)}`;
},
/** @param {MouseEvent} event */
onRightClick(event) {
@@ -434,7 +440,6 @@ export default {
// If one or fewer items are selected, reset the selection
if (!state.multiple && getters.selectedCount() < 2) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
}
} else {
@@ -451,7 +456,6 @@ export default {
index: this.index,
};
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(selectedItem);
}
@@ -495,7 +499,6 @@ export default {
if (this.updateGlobalState) {
if (state.selected.indexOf(this.index) === -1) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
}
} else {
@@ -548,7 +551,7 @@ export default {
event.stopPropagation();
let items = [];
for (let i of state.selected) {
for (const i of state.selected) {
items.push({
from: state.req.items[i].path,
fromSource: state.req.items[i].source,
@@ -571,7 +574,7 @@ export default {
const destinationDir = this.path;
// If destination dir is the same as or contains the source path, skip
if (fromPath === destinationDir || fromPath.startsWith(destinationDir + '/')) {
if (fromPath === destinationDir || fromPath.startsWith(`${destinationDir}/`)) {
return false;
}
@@ -583,7 +586,7 @@ export default {
return;
}
let checkAction = async () => {
const checkAction = async () => {
if (getters.isShare()) {
return await resourcesApi.fetchFilesPublic(this.path, state.shareInfo.hash);
} else {
@@ -597,7 +600,7 @@ export default {
* @param {boolean} overwrite
* @param {boolean} rename
*/
let action = async (overwrite, rename) => {
const action = async (overwrite, rename) => {
// Show move prompt with operation in progress
mutations.showPrompt({
name: "move",
@@ -675,7 +678,6 @@ export default {
if (state.multiple) {
return;
}
// @ts-ignore
this.contextTimeout = setTimeout(() => {
if (!this.isSwipe) {
// Only reset selection if this item is not already selected
@@ -683,7 +685,6 @@ export default {
if (!this.isSelected) {
if (this.updateGlobalState) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
} else {
this.$emit('select', { index: this.index, selected: true });
@@ -736,7 +737,6 @@ export default {
for (; fi <= la; fi++) {
if (this.selected.indexOf(fi) === -1) {
// @ts-ignore
mutations.addSelected(fi);
}
}
@@ -759,7 +759,6 @@ export default {
if (this.selected.length > 1) {
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(this.index);
mutations.setLastSelectedIndex(this.index);
}
@@ -802,7 +801,7 @@ export default {
// Check if state.req.items exists and has the item at this index
// This prevents errors when ListingItem is used outside of the main file listing (e.g., duplicate finder)
let previousHistoryItem = null;
if (state.req.items && state.req.items[this.index]) {
if (state.req.items?.[this.index]) {
previousHistoryItem = {
name: state.req.items[this.index].name,
source: state.req.source,
@@ -829,6 +828,10 @@ export default {
-webkit-touch-callout: none; /* Disable the default long press preview */
}
.listing-item.pinned-item {
border-color: color-mix(in srgb, var(--primaryColor) 35%, transparent);
}
/* Disable transitions and hide content for out-of-view items for better performance */
.listing-item.out-of-view {
transition: none !important;
@@ -8,7 +8,7 @@
:is-thumbnail="true"
/>
</div>
<img v-else-if="sourceInfo && sourceInfo.url" :src="sourceInfo.url" alt="Popup image" @load="onImageLoad" />
<img v-else-if="sourceInfo?.url" :src="sourceInfo.url" alt="Popup image" @load="onImageLoad" />
</div>
</template>
+60 -16
View File
@@ -4,14 +4,14 @@
<div
class="custom-scrollbar"
ref="scrollbar"
:class="{ ready: isReady, visible: isVisible && isScrollable }"
:class="{ ready: isReady, visible: isVisible && canScroll && showScrollbar }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div
class="thumb no-select"
ref="thumb"
:class="{ ready: isReady, visible: isVisible && isScrollable }"
:class="{ ready: isReady, visible: isVisible && canScroll && showScrollbar }"
@mousedown="startDrag"
@touchstart.prevent="startDrag"
>
@@ -19,15 +19,13 @@
<hr />
</div>
<div v-else class="thumb-letters no-select">
<i class="material-symbols" :class="{ 'primary-icons': isFolder }"> {{ isFolder ? "folder" : "description" }} </i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols" :class="{ 'primary-icons': thumbIcon === 'push_pin' || isFolder }">
{{ thumbIcon }}
</i>
</div>
</div>
<div
:class="{ hidden: isNotListing }"
class="thumb-section-id no-select"
ref="sectionId"
>
{{ this.letter() }}
<div :class="{ hidden: isNotListing }" class="thumb-section-id no-select" ref="sectionId">
<span>{{ letter() }}</span>
</div>
</div>
</div>
@@ -52,6 +50,9 @@ export default {
isReady: false,
isVisible: false,
scrollFrame: null,
canScroll: false,
resizeObserver: null,
mutationObserver: null,
};
},
computed: {
@@ -59,7 +60,7 @@ export default {
return getters.isScrollable();
},
isNotListing() {
return getters.currentView() != "listingView";
return getters.currentView() !== "listingView";
},
isFolder() {
return this.category() === "folders";
@@ -76,6 +77,15 @@ export default {
}
return {};
},
thumbIcon() {
const category = this.category();
if (category === 'pinned') return 'push_pin';
return this.isFolder ? 'folder' : 'description';
},
showScrollbar() {
const view = getters.currentView();
return view === 'listingView' || view === 'settings' || view === 'tools' || view === 'markdownViewer';
},
},
methods: {
handleResize() {
@@ -83,6 +93,7 @@ export default {
// Force scroll event to re-compute thumb position
const content = this.$refs.wrapper;
this.updateThumbPosition(content.scrollTop);
this.updateScrollableContent();
},
category() {
return state.listing.category;
@@ -91,6 +102,7 @@ export default {
return state.listing.letter;
},
handleMouseEnter() {
if (!this.showScrollbar || !this.canScroll) return;
this.isHovering = true;
this.isVisible = true;
this.clearHideTimeout();
@@ -115,7 +127,7 @@ export default {
}, 800);
},
handleMouseMove(e) {
if (!this.isReady) return;
if (!this.showScrollbar || !this.canScroll) return;
const wrapper = this.$refs.wrapper;
const bounds = wrapper.getBoundingClientRect();
const relativeX = e.clientX - bounds.left;
@@ -125,11 +137,14 @@ export default {
}
},
updateThumbPosition(scrollTop) {
if (!this.showScrollbar || !this.canScroll) return;
const content = this.$refs.wrapper;
const scrollbar = this.$refs.scrollbar;
const thumb = this.$refs.thumb;
const sectionId = this.$refs.sectionId;
const scrollRatio = scrollTop / (content.scrollHeight - content.clientHeight);
const scrollableHeight = content.scrollHeight - content.clientHeight;
if (scrollableHeight <= 0) return;
const scrollRatio = scrollTop / scrollableHeight;
const thumbHeight = thumb.clientHeight;
const maxThumbTop = scrollbar.clientHeight - thumbHeight - (getters.showStatusBar() ? offsetFromBottomListing : offsetFromBottomFull);
const thumbPosition = scrollRatio * maxThumbTop;
@@ -140,9 +155,10 @@ export default {
},
handleScroll() {
if (!this.isReady) return;
this.updateScrollableContent();
if (!this.showScrollbar || !this.canScroll || this.scrollFrame) return;
// Use requestAnimationFrame to throttle updates
if (this.scrollFrame) return;
this.pendingScrollAnimation = requestAnimationFrame(() => {
this.scrollFrame = requestAnimationFrame(() => {
const content = this.$refs.wrapper;
this.isVisible = true;
this.scheduleHide();
@@ -159,6 +175,7 @@ export default {
});
},
startDrag(e) {
if (!this.showScrollbar || !this.canScroll) return;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
this.isDragging = true;
this.startY = clientY;
@@ -180,8 +197,7 @@ export default {
const deltaY = clientY - this.startY;
const scrollableHeight = content.scrollHeight - content.clientHeight;
const offsetFromBottom = getters.showStatusBar() ? offsetFromBottomListing : offsetFromBottomFull;
const scrollbarHeight =
scrollbar.clientHeight - thumb.clientHeight - offsetFromBottom;
const scrollbarHeight = scrollbar.clientHeight - thumb.clientHeight - offsetFromBottom;
const scrollRatio = scrollableHeight / scrollbarHeight;
let newScrollTop = this.startScrollTop + deltaY * scrollRatio;
@@ -200,6 +216,15 @@ export default {
document.removeEventListener("touchmove", this.onDrag);
document.removeEventListener("touchend", this.stopDrag);
},
updateScrollableContent() {
const wrapper = this.$refs.wrapper;
if (!wrapper) return;
const isOverflowing = wrapper.scrollHeight > wrapper.clientHeight;
if (this.canScroll !== isOverflowing) {
this.canScroll = isOverflowing;
if (!isOverflowing) this.isVisible = false;
}
},
},
mounted() {
setTimeout(() => {
@@ -208,6 +233,19 @@ export default {
this.$refs.wrapper.addEventListener("mousemove", this.handleMouseMove);
this.$refs.wrapper.addEventListener("scroll", this.handleScroll, { passive: true });
window.addEventListener("resize", this.handleResize);
// Observe container size changes
this.resizeObserver = new ResizeObserver(() => this.updateScrollableContent());
this.resizeObserver.observe(this.$refs.wrapper);
// Same here, but with DOM mutations
this.mutationObserver = new MutationObserver(() => this.updateScrollableContent());
this.mutationObserver.observe(this.$refs.wrapper, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'class'],
characterData: true,
});
this.updateScrollableContent();
},
beforeUnmount() {
// Cancel any pending animation frame
@@ -217,6 +255,12 @@ export default {
window.removeEventListener("resize", this.handleResize);
this.$refs.wrapper.removeEventListener("mousemove", this.handleMouseMove);
this.$refs.wrapper.removeEventListener("scroll", this.handleScroll);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.mutationObserver) {
this.mutationObserver.disconnect();
}
},
};
</script>
+6 -6
View File
@@ -53,7 +53,7 @@ export default {
computed: {
showShareInfo() {
// Don't show file/folder info if req is not loaded or empty
if (!state.req || !state.req.name) {
if (!state.req?.name) {
return false;
}
if (state.shareInfo?.shareType !== 'normal') {
@@ -71,18 +71,18 @@ export default {
return state.req;
},
humanSize() {
if (!state.req || !state.req.modified) return "";
if (state.req.type == "directory") {
return state.req.items.length + " items (" + getHumanReadableFilesize(state.req.size) + ")";
if (!state.req?.modified) return "";
if (state.req.type === "directory") {
return `${state.req.items.length} items (${getHumanReadableFilesize(state.req.size)})`;
}
return getHumanReadableFilesize(state.req.size);
},
humanTime() {
if (!state.req || !state.req.modified) return "";
if (!state.req?.modified) return "";
return getters.getTime(state.req.modified);
},
modTime() {
if (!state.req || !state.req.modified) return "";
if (!state.req?.modified) return "";
return new Date(Date.parse(state.req.modified)).toLocaleString();
},
isImage() {
@@ -19,6 +19,7 @@
<!-- Previous button -->
<button
v-if="enabled && hasPrevious"
type="button"
@click.prevent="handlePrevClick"
@mousedown="startDrag($event, 'previous')"
@touchstart="handleTouchStart($event, 'previous')"
@@ -48,6 +49,7 @@
<!-- Next button -->
<button
v-if="enabled && hasNext"
type="button"
@click.prevent="handleNextClick"
@mousedown="startDrag($event, 'next')"
@touchstart="handleTouchStart($event, 'next')"
@@ -142,7 +144,7 @@ export default {
return state.sidebar?.width || 20;
},
enabled() {
return state.navigation.enabled && getters.currentPrompt() == null;
return state.navigation.enabled && getters.currentPrompt() === null;
},
showNav() {
return state.navigation.show || this.hoverNav || !!state.navigation.gestureHint;
@@ -383,7 +385,7 @@ export default {
// Close the prompt after successful save
mutations.closeTopPrompt();
resolve(true); // Allow navigation
} catch (error) {
} catch (_e) {
// Save failed - keep prompt open by not resolving
resolve(false); // Block navigation
}
@@ -432,7 +434,7 @@ export default {
return;
}
let listing = null;
let listing;
// Try to get listing from current request first
if (state.req.items) {
@@ -450,7 +452,7 @@ export default {
res = await resourcesApi.fetchFiles(state.req.source, directoryPath);
}
listing = res.items;
} catch (error) {
} catch (_e) {
if (gen !== this.navigationSetupGeneration) {
return;
}
+9 -9
View File
@@ -8,7 +8,7 @@
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
<i class="material-symbols">warning</i>
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
<button class="button button--flat button--blue" @click="startPathReassignment">
<button type="button" class="button button--flat button--blue" @click="startPathReassignment">
{{ $t("messages.reassignPath") }}
</button>
</div>
@@ -55,7 +55,7 @@
<datalist id="group-suggestions">
<option v-for="group in groups" :key="group" :value="group"></option>
</datalist>
<button class="button form-button flat-left form-compact" @click="submitAdd">
<button type="button" class="button form-button flat-left form-compact" @click="submitAdd">
<i class="material-symbols">add</i>
</button>
</div>
@@ -73,13 +73,13 @@
<th>{{ $t("general.name", { suffix: '' }) }}</th>
<th>{{ $t("general.edit") }}</th>
</tr>
<tr v-for="entry in entries" :key="entry.type + '-' + entry.name">
<tr v-for="entry in entries" :key="`${entry.type}-${entry.name}`">
<td>{{ entry.allow ? $t("access.allow") : $t("access.deny") }}</td>
<td>{{ entry.type === 'user' ? $t("general.user") : (entry.type === 'group' ? $t("general.group") :
$t('access.all')) }}</td>
<td>{{ entry.name }}</td>
<td>
<button @click="deleteAccess(entry)" class="action" :aria-label="$t('general.delete')"
<button type="button" @click="deleteAccess(entry)" class="action" :aria-label="$t('general.delete')"
:title="$t('general.delete')">
<i class="material-symbols">delete</i>
</button>
@@ -92,10 +92,10 @@
</div>
<div class="card-actions">
<template v-if="isEditingPath">
<button class="button button--flat" @click="cancelPathChange" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
<button type="button" class="button button--flat" @click="cancelPathChange" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button class="button button--flat" @click="confirmPathChange" :aria-label="$t('general.ok')" :title="$t('general.ok')">
<button type="button" class="button button--flat" @click="confirmPathChange" :aria-label="$t('general.ok')" :title="$t('general.ok')">
{{ $t("general.ok") }}
</button>
</template>
@@ -187,7 +187,7 @@ export default {
* @param {{path: string, source: string}} pathOrData
*/
updateTempPath(pathOrData) {
if (pathOrData && pathOrData.path) {
if (pathOrData?.path) {
this.tempPath = pathOrData.path;
this.tempSource = pathOrData.source;
}
@@ -231,7 +231,7 @@ export default {
try {
const response = await accessApi.getGroups();
this.groups = response.groups;
} catch (e) {
} catch (_e) {
this.groups = [];
}
},
@@ -243,7 +243,7 @@ export default {
this.rule = response;
this.sourceDenyDefault = response.sourceDenyDefault || false;
this.pathExists = response.pathExists !== false;
} catch (e) {
} catch (_e) {
this.rule = { denyAll: false, deny: { users: [], groups: [] }, allow: { users: [], groups: [] } };
this.sourceDenyDefault = false;
this.pathExists = true;
@@ -3,6 +3,7 @@
<!-- API Token Section -->
<div class="api-section">
<button
type="button"
class="action api-key-button"
@click.stop="copyToClipboard(name)"
:aria-label="$t('buttons.copyToClipboard')"
@@ -12,6 +13,7 @@
<i class="material-symbols">content_paste</i>
</button>
<button
type="button"
class="action api-key-value-button"
@click.stop="copyToClipboard(info.token)"
:aria-label="$t('api.clickToCopyKey')"
@@ -59,6 +61,7 @@
<div class="card-actions">
<button
type="button"
class="button button--flat button--red"
@click="deleteApi"
:title="$t('general.delete')"
+129 -7
View File
@@ -53,9 +53,11 @@
</template>
</div>
</div>
<div class="card-actions">
<div class="card-actions" :class="{ 'split-buttons': showFileList }">
<template v-if="showFileList">
<button
type="button"
v-if="!showNewDirInput"
class="button button--flat button--grey"
@click="showFileList = false"
:aria-label="$t('general.cancel')"
@@ -64,6 +66,37 @@
{{ $t("general.cancel") }}
</button>
<button
type="button"
v-if="canCreateFolder && showNewDirInput"
class="button button--flat"
@click="cancelNewDir"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
{{ $t("general.cancel") }}
</button>
<button
type="button"
v-if="canCreateFolder && !showNewDirInput"
class="button button--flat"
@click="createNewDir"
:aria-label="$t('files.newFolder')"
:title="$t('files.newFolder')"
>
<span>{{ $t("files.newFolder") }}</span>
</button>
<input
v-if="showNewDirInput"
ref="newDirInput"
class="input new-dir-input"
:class="{ 'form-invalid': !isDirNameValid }"
v-model.trim="newDirName"
:placeholder="$t('prompts.newDirMessage')"
@keydown.enter="handleEnter"
/>
<button
type="button"
v-if="!showNewDirInput"
class="button button--flat"
@click="showFileList = false"
:aria-label="$t('general.select', { suffix: '' })"
@@ -71,9 +104,19 @@
>
{{ $t("general.select", { suffix: "" }) }}
</button>
<button
type="button"
v-if="showNewDirInput"
class="button button--flat"
@click="createDirectory"
:disabled="!newDirName || !isDirNameValid"
>
{{ $t("general.create") }}
</button>
</template>
<template v-else>
<button
type="button"
class="button button--flat button--grey"
@click="closeTopPrompt"
:aria-label="$t('general.cancel')"
@@ -82,6 +125,7 @@
{{ $t("general.cancel") }}
</button>
<button
type="button"
class="button button--flat"
:disabled="!canSubmit || creating"
:aria-label="$t('prompts.archive')"
@@ -95,7 +139,7 @@
</template>
<script>
import { state, mutations } from "@/store";
import { state, mutations, getters } from "@/store";
import { notify } from "@/notify";
import { resourcesApi } from "@/api";
import { goToItem } from "@/utils/url";
@@ -129,18 +173,26 @@ export default {
compression: 0,
creating: false,
showFileList: false,
deleteAfter: state.user?.deleteAfterArchive == true,
showNewDirInput: false,
newDirName: "",
deleteAfter: state.user?.deleteAfterArchive === true,
};
},
watch: {
deleteAfter(newVal) {
mutations.updateCurrentUser({ deleteAfterArchive: newVal });
}
},
showFileList(newVal) {
if (!newVal) {
this.showNewDirInput = false;
this.newDirName = "";
}
},
},
mounted() {
if (this.currentPath) {
this.destPath = this.currentPath.replace(/\/+$/, "") || "/";
if (!this.destPath.startsWith("/")) this.destPath = "/" + this.destPath;
if (!this.destPath.startsWith("/")) this.destPath = `/${this.destPath}`;
}
},
computed: {
@@ -151,10 +203,17 @@ export default {
const name = this.archiveName || this.defaultArchiveName;
return name.length > 0 && this.destPath;
},
canCreateFolder() {
const perms = getters.permissions();
return !!perms?.create;
},
isDirNameValid() {
return this.validateDirName(this.newDirName);
},
fullDestination() {
const name = this.archiveName || this.defaultArchiveName;
const base = (this.destPath || "/").replace(/\/+$/, "");
const path = base === "" ? "/" + name : base + "/" + name;
const path = base === "" ? `/${name}` : `${base}/${name}`;
return path.replace(/\/+/g, "/");
},
},
@@ -165,11 +224,66 @@ export default {
updateDestination(pathOrData) {
if (typeof pathOrData === "string") {
this.destPath = pathOrData;
} else if (pathOrData && pathOrData.path) {
} else if (pathOrData?.path) {
this.destPath = pathOrData.path;
this.destSource = pathOrData.source;
}
},
createNewDir() {
this.showNewDirInput = true;
this.newDirName = "";
this.$nextTick(() => {
this.$refs.newDirInput?.focus();
});
},
validateDirName(value) {
if (this.$refs.fileList?.items) {
const currentItems = this.$refs.fileList.items.filter((item) => item.name !== "..");
return !currentItems.some((item) => item.name.toLowerCase() === value.toLowerCase());
}
return true;
},
cancelNewDir() {
this.showNewDirInput = false;
this.newDirName = "";
},
handleEnter(event) {
event.stopPropagation();
event.preventDefault();
if (this.newDirName && this.isDirNameValid) {
this.createDirectory();
}
},
async createDirectory() {
if (!this.newDirName || !this.isDirNameValid) return;
try {
this.creating = true;
const currentPath = this.$refs.fileList.path;
const currentSource = this.$refs.fileList.source;
const fullPath = currentPath.endsWith("/")
? `${currentPath + this.newDirName}/`
: `${currentPath}/${this.newDirName}/`;
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, fullPath, "", false, undefined, {}, true);
} else {
await resourcesApi.post(currentSource, fullPath, "", false, undefined, {}, true);
}
if (getters.isShare()) {
resourcesApi.fetchFilesPublic(currentPath, state.shareInfo?.hash)
.then((req) => this.$refs.fileList.fillOptions(req, true));
} else {
resourcesApi.fetchFiles(currentSource, currentPath)
.then((req) => this.$refs.fileList.fillOptions(req, true));
}
mutations.setReload(true);
this.showNewDirInput = false;
this.newDirName = "";
} catch (error) {
console.error("Error creating directory:", error);
} finally {
this.creating = false;
}
},
async submit() {
if (!this.canSubmit) return;
this.creating = true;
@@ -245,4 +359,12 @@ export default {
.card-content {
position: relative;
}
.new-dir-input {
justify-self: left;
}
.split-buttons {
justify-content: space-between;
}
</style>
@@ -5,6 +5,7 @@
<div class="card-actions">
<button
type="button"
class="button button--flat button--grey"
@click="stayOnPrompt"
:aria-label="$t('general.stay')"
@@ -12,6 +13,7 @@
{{ $t("general.stay") }}
</button>
<button
type="button"
class="button button--flat button--red"
@click="closeAnyway"
:aria-label="$t('buttons.closeAnyway')"
@@ -46,13 +48,13 @@ export default {
},
methods: {
stayOnPrompt() {
if (this.currentPrompt && this.currentPrompt.cancel) {
if (this.currentPrompt?.cancel) {
this.currentPrompt.cancel();
}
// Don't call closeTopHover here - the cancel callback handles it
},
closeAnyway() {
if (this.currentPrompt && this.currentPrompt.confirm) {
if (this.currentPrompt?.confirm) {
this.currentPrompt.confirm();
}
// Don't call closeTopHover here - the confirm callback handles it
@@ -60,4 +62,3 @@ export default {
},
};
</script>
@@ -25,6 +25,7 @@
<div class="card-actions">
<button
type="button"
@click="closeTopPrompt"
class="button button--flat button--grey"
:aria-label="$t('general.cancel')"
@@ -32,6 +33,7 @@
{{ $t("general.cancel") }}
</button>
<button
type="button"
@click="confirm"
class="button button--flat"
:aria-label="operation === 'copy' ? $t('general.copy') : $t('general.move')"
@@ -44,7 +44,11 @@
</div>
<div class="card-actions">
<button class="button button--flat button--blue" @click="createAPIKey" :title="$t('general.create')">
<button
type="button"
class="button button--flat button--blue"
@click="createAPIKey"
:title="$t('general.create')">
{{ $t("general.create") }}
</button>
</div>
@@ -123,7 +127,7 @@ export default {
eventBus.emit('apiKeysChanged');
notify.showSuccessToast(this.$t("api.createKeySuccess"));
mutations.closeTopPrompt();
} catch (error) {
} catch (_e) {
notify.showError(this.$t("api.createKeyFailed"));
} finally {
this.creating = false;
+14 -6
View File
@@ -45,11 +45,19 @@
</div>
</div>
<div class="card-actions">
<button @click="closeTopPrompt" class="button button--flat button--grey" :aria-label="$t('general.cancel')"
<button
type="button"
@click="closeTopPrompt"
class="button button--flat button--grey"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button @click="submit" class="button button--flat button--red" aria-label="Confirm-Delete"
<button
type="button"
@click="submit"
class="button button--flat button--red"
aria-label="Confirm-Delete"
:title="$t('general.delete')">
{{ $t("general.delete") }}
</button>
@@ -104,9 +112,9 @@ export default {
}
// Otherwise, compute from state (backward compatibility)
let items = [];
const items = [];
if (state.isSearchActive || getters.currentView() == "preview") {
if (state.isSearchActive || getters.currentView() === "preview") {
const selected = state.selected[0];
const item = state.req.items?.[selected] || selected;
const previewUrl = this.getPreviewUrl(item.source || state.req.source, item.path, item.modified, item.type);
@@ -132,7 +140,7 @@ export default {
hasPreview: item.hasPreview,
});
} else {
for (let index of state.selected) {
for (const index of state.selected) {
const item = state.req.items[index];
const previewUrl = this.getPreviewUrl(item.source || state.req.source, item.path, item.modified, item.type);
items.push({
@@ -198,7 +206,7 @@ export default {
try {
return resourcesApi.getPreviewURL(source, path, modified);
} catch (e) {
} catch (_e) {
return null;
}
},
+33 -19
View File
@@ -3,6 +3,7 @@
<p>{{ $t("prompts.downloadMessage") }}</p>
<button
type="button"
v-for="(ext, format) in formats"
:key="format"
class="button button--block"
@@ -13,7 +14,7 @@
{{ ext }}
</button>
</div>
<div v-if="!hasDownloads && (!currentPrompt || !currentPrompt.confirm)" class="card-content lonely-message">
<div v-if="!hasDownloads && (!currentPrompt?.confirm)" class="card-content lonely-message">
<span>{{ $t("files.lonely") }}</span>
</div>
<div v-if="hasDownloads" class="card-content">
@@ -43,12 +44,21 @@
</progress-bar>
</div>
<div class="file-actions">
<button v-if="download.status === 'error'" @click="retryDownload(download.id)" class="action"
:aria-label="$t('general.retry')" :title="$t('general.retry')">
<button
type="button"
v-if="download.status === 'error'"
@click="retryDownload(download.id)"
class="action"
:aria-label="$t('general.retry')" :title="$t('general.retry')"
>
<i class="material-symbols">replay</i>
</button>
<button @click="cancelDownload(download.id)" class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<button
type="button"
@click="cancelDownload(download.id)"
class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
<i class="material-symbols">close</i>
</button>
</div>
@@ -61,8 +71,14 @@
</div>
<div class="card-actions">
<div v-if="hasDownloads" class="spacer"></div>
<button v-if="hasDownloads && hasClearable" @click="clearCompleted" class="button button--flat" :disabled="!hasClearable"
:aria-label="$t('buttons.clearCompleted')" :title="$t('buttons.clearCompleted')">
<button
type="button"
v-if="hasDownloads && hasClearable"
@click="clearCompleted"
class="button button--flat"
:disabled="!hasClearable"
:aria-label="$t('buttons.clearCompleted')" :title="$t('buttons.clearCompleted')"
>
{{ $t("buttons.clearCompleted") }}
</button>
</div>
@@ -79,14 +95,12 @@ export default {
components: {
ProgressBar,
},
data: function () {
return {
formats: {
zip: "zip",
targz: "tar.gz",
},
};
},
data: () => ({
formats: {
zip: "zip",
targz: "tar.gz",
},
}),
computed: {
currentPrompt() {
return getters.currentPrompt();
@@ -118,7 +132,7 @@ export default {
return this.$t("prompts.downloadFailed", { message: detail });
},
handleFormatSelect(format) {
if (this.currentPrompt && this.currentPrompt.confirm) {
if (this.currentPrompt?.confirm) {
this.currentPrompt.confirm(format);
}
},
@@ -135,7 +149,7 @@ export default {
downloadManager.remove(id);
try {
if (
download.archiveFormat != null &&
download.archiveFormat !== null &&
Array.isArray(download.archiveFiles) &&
download.archiveFiles.length > 0
) {
@@ -159,9 +173,9 @@ export default {
},
close() {
// Only close if no active downloads
if (!downloadManager || !downloadManager.hasActive()) {
if (!downloadManager?.hasActive()) {
const prompt = getters.currentPrompt();
if (prompt && prompt.name === 'download') {
if (prompt?.name === 'download') {
mutations.closeTopPrompt();
} else {
prompt?.cancel?.();
@@ -2,6 +2,7 @@
<div class="card-content" v-html="body"></div>
<div class="card-actions">
<button
type="button"
v-for="(button, index) in displayButtons"
:key="index"
:class="getButtonClass(button)"
+43 -35
View File
@@ -1,4 +1,3 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<div class="card-content help-content">
<p class="help-description">
@@ -13,19 +12,19 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.functionKeys") }}</h3>
<div class="shortcut-item">
<kbd>F1</kbd>
<kbd>F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>F2</kbd>
<kbd>F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>DEL</kbd>
<kbd>DEL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.del") }}</span>
</div>
<div class="shortcut-item">
<kbd>ESC</kbd>
<kbd>ESC</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.esc") }}</span>
</div>
</div>
@@ -34,126 +33,135 @@
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.mouseActions") }}</h3>
<div class="shortcut-item">
<kbd>Click</kbd>
<kbd>CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>Double click</kbd>
<kbd>DOUBLE CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.dbClick") }}</span>
</div>
<div class="shortcut-item">
<kbd>Click + Drag</kbd>
<kbd>CLICK + DRAG</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.marqueeSelect") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + Click</kbd>
<kbd>CTRL + CLICK</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.click") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + SCROLL</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.scroll") }}</span>
</div>
</div>
<!-- File Operations Section -->
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.fileOperations") }}</h3>
<div class="shortcut-item">
<kbd>CTRL + C</kbd>
<kbd>CTRL + C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.c") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + X</kbd>
<kbd>CTRL + X</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.x") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + V</kbd>
<kbd>CTRL + V</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.v") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + D</kbd>
<kbd>CTRL + D</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.d") }}</span>
</div>
</div>
<!-- Navigation Section -->
<div class="shortcut-section">
<h3 class="section-title">{{ $t("general.navigation") }}</h3>
<div class="shortcut-item">
<kbd>CTRL + B</kbd>
<kbd>BACKSPACE {{ $t("general.or") }} ALT + ↑</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.altArrowUp") }}</span>
</div>
<div class="shortcut-item">
<kbd>ENTER {{ $t("general.or") }} ALT + ↓</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.altArrowDown") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + B</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.b") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + ,</kbd>
<kbd>CTRL + ,</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.comma") }}</span>
</div>
<div class="shortcut-item">
<kbd>space or /</kbd>
<kbd>SPACE {{ $t("general.or") }} /</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.slash") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + E</kbd>
<kbd>CTRL + E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.e") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F1</kbd>
<kbd>CTRL + F1</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.f1") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F2</kbd>
<kbd>CTRL + F2</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.f2") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + F3</kbd>
<kbd>CTRL + F3</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.ctrl.f3") }}</span>
</div>
<div class="shortcut-item">
<kbd>CTRL + Scroll</kbd>
<span>{{ $t("help.ctrl.scroll") }}</span>
</div>
</div>
<!-- Media Section -->
<div class="shortcut-section">
<h3 class="section-title">{{ $t("help.media.title") }}</h3>
<div class="shortcut-item">
<kbd>Space / K</kbd>
<kbd>SPACE / K</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.playPause") }}</span>
</div>
<div class="shortcut-item">
<kbd>1 to 9</kbd>
<kbd>1 {{ $t("general.to") }} 9</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.seekPercentage") }}</span>
</div>
<div class="shortcut-item">
<kbd>← / →</kbd>
<kbd>← / →</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.seek") }}</span>
</div>
<div class="shortcut-item">
<kbd>↑ / ↓</kbd>
<kbd>↑ / ↓</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.volume") }}</span>
</div>
<div class="shortcut-item">
<kbd>Q</kbd>
<kbd>Q</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.playbackQueue") }}</span>
</div>
<div class="shortcut-item">
<kbd>E</kbd>
<kbd>E</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.switchTabs") }}</span>
</div>
<div class="shortcut-item">
<kbd>L</kbd>
<kbd>L</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.loop") }}</span>
</div>
<div class="shortcut-item">
<kbd>P</kbd>
<kbd>P</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.playbackModes") }}</span>
</div>
<div class="shortcut-item">
<kbd>F</kbd>
<kbd>F</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.fullscreen") }}</span>
</div>
<div class="shortcut-item">
<kbd>M</kbd>
<kbd>M</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.mute") }}</span>
</div>
<div class="shortcut-item">
<kbd>C</kbd>
<kbd>C</kbd> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<span>{{ $t("help.media.captions") }}</span>
</div>
</div>
@@ -22,7 +22,12 @@
ref="searchInput"
style="padding-left: 2.5em;"
/>
<button v-if="searchQuery" @click="searchQuery = ''" class="button button--flat clear-button">
<button
type="button"
v-if="searchQuery"
@click="searchQuery = ''"
class="button button--flat clear-button"
>
<i class="material-symbols">close</i>
</button>
</div>
@@ -77,6 +82,7 @@
<div class="card-actions">
<button
type="button"
@click="closeTopPrompt"
class="button button--flat button--grey"
:aria-label="$t('general.cancel')"
+8 -2
View File
@@ -71,8 +71,14 @@
<div class="form-flex-group">
<input id="hash-result" class="input form-form flat-right" type="text" :value="hashResult" readonly
:placeholder="$t('prompts.selectHashAlgorithm')" />
<button class="button form-button flat-left" @click="copyToClipboard" :disabled="!hashResult"
:title="$t('buttons.copyToClipboard')" :aria-label="$t('buttons.copyToClipboard')">
<button
type="button"
class="button form-button flat-left"
@click="copyToClipboard"
:disabled="!hashResult"
:title="$t('buttons.copyToClipboard')"
:aria-label="$t('buttons.copyToClipboard')"
>
<i class="material-symbols-outlined" style="font-size: 16px;">content_copy</i>
</button>
</div>
+50 -33
View File
@@ -26,10 +26,18 @@
</div>
</div>
<div class="card-actions split-buttons" >
<button v-if="canCreateFolder && showNewDirInput" class="button button--flat" @click="cancelNewDir" :aria-label="$t('general.cancel')" :title="$t('general.cancel')">
<button
type="button"
v-if="canCreateFolder && showNewDirInput"
class="button button--flat"
@click="cancelNewDir"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
{{ $t("general.cancel") }}
</button>
<button
type="button"
v-if="canCreateFolder && !showNewDirInput"
class="button button--flat"
@click="createNewDir"
@@ -40,12 +48,23 @@
</button>
<input v-if="showNewDirInput" ref="newDirInput" class="input new-dir-input" :class="{ 'form-invalid': !isDirNameValid }"
v-model.trim="newDirName" :placeholder="$t('prompts.newDirMessage')" @keydown.enter="handleEnter" />
<button v-else :disabled="destContainsSrc" class="button button--flat" @click="performOperation"
<button
type="button"
v-else :disabled="destContainsSrc"
class="button button--flat"
@click="performOperation"
:aria-label="operation === 'move' ? $t('general.move') : $t('general.copy')"
:title="operation === 'move' ? $t('general.move') : $t('general.copy')">
:title="operation === 'move' ? $t('general.move') : $t('general.copy')"
>
{{ operation === 'move' ? $t('general.move') : $t('general.copy') }}
</button>
<button v-if="showNewDirInput" class="button button--flat" @click="createDirectory" :disabled="!newDirName || !isDirNameValid">
<button
type="button"
v-if="showNewDirInput"
class="button button--flat"
@click="createDirectory"
:disabled="!newDirName || !isDirNameValid"
>
{{ $t("general.create") }}
</button>
</div>
@@ -83,17 +102,15 @@ export default {
default: null,
},
},
data: function () {
return {
current: window.location.pathname,
destPath: "/", // Start at root of selected source
destSource: null, // Will be set by FileList component
localItems: [], // Will hold the items to operate on
isLoading: false, // Track loading state for spinner
showNewDirInput: false, // When true will replace the new folder button with a input field
newDirName: "",
};
},
data: () => ({
current: window.location.pathname,
destPath: "/", // Start at root of selected source
destSource: null, // Will be set by FileList component
localItems: [], // Will hold the items to operate on
isLoading: false, // Track loading state for spinner
showNewDirInput: false, // When true will replace the new folder button with a input field
newDirName: "",
}),
computed: {
destContainsSrc() {
if (!this.destPath) {
@@ -113,14 +130,14 @@ export default {
}
// For move, prevent moving to the same directory, but for copy allow it.
if (this.operation === "move") {
const parentDir = url.removeLastDir(itemPath) + "/";
const parentDir = `${url.removeLastDir(itemPath)}/`;
// Only disable if moving to the exact same directory
if (this.destPath === parentDir) {
return true;
}
}
// Prevent move/copy into itself or subdirectories (into itself too)
return this.destPath.startsWith(itemPath + "/") || this.destPath === itemPath;
return this.destPath.startsWith(`${itemPath}/`) || this.destPath === itemPath;
},
canCreateFolder() {
const perms = getters.permissions();
@@ -143,7 +160,7 @@ export default {
}));
} else if (state.isSearchActive) {
// Add null checks to prevent undefined values
if (state.selected && state.selected[0] && state.selected[0].path) {
if (state.selected?.[0]?.path) {
this.localItems = [
{
from: state.selected[0].path,
@@ -153,10 +170,10 @@ export default {
];
}
} else {
if (state.selected && state.req && state.req.items) {
for (let item of state.selected) {
if (state.selected && state.req?.items) {
for (const item of state.selected) {
const reqItem = state.req.items[item];
if (reqItem && reqItem.path) {
if (reqItem?.path) {
this.localItems.push({
from: reqItem.path,
fromSource: state.req.source,
@@ -188,7 +205,7 @@ export default {
},
validateDirName(value) {
// Check if a folder with the same name already exists in current directory
if (this.$refs.fileList && this.$refs.fileList.items) {
if (this.$refs.fileList?.items) {
const currentItems = this.$refs.fileList.items.filter(item => item.name !== '..');
return !currentItems.some(item => item.name.toLowerCase() === value.toLowerCase());
}
@@ -214,7 +231,7 @@ export default {
// Get current navigation from FileList
const currentPath = this.$refs.fileList.path;
const currentSource = this.$refs.fileList.source;
const fullPath = currentPath.endsWith('/') ? currentPath + this.newDirName + '/' : currentPath + '/' + this.newDirName + '/';
const fullPath = currentPath.endsWith('/') ? `${currentPath + this.newDirName}/` : `${currentPath}/${this.newDirName}/`;
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, fullPath, "", false, undefined, {}, true);
} else {
@@ -244,7 +261,7 @@ export default {
this.destPath = pathOrData;
// For backward compatibility, keep the current source
// This will be updated when FileList is modified to emit both
} else if (pathOrData && pathOrData.path) {
} else if (pathOrData?.path) {
this.destPath = pathOrData.path;
// Update destSource from FileList's selection
this.destSource = pathOrData.source;
@@ -262,7 +279,7 @@ export default {
// Build items with destination paths
const itemsToProcess = this.localItems.map(item => {
// Ensure proper path construction without double slashes
const destPath = this.destPath.endsWith('/') ? this.destPath : this.destPath + '/';
const destPath = this.destPath.endsWith('/') ? this.destPath : `${this.destPath}/`;
return {
...item,
to: destPath + item.name,
@@ -270,7 +287,7 @@ export default {
};
});
// Define the action function
let action = async (overwrite, rename) => {
const action = async (overwrite, rename) => {
buttons.loading(this.operation);
let result;
if (getters.isShare()) {
@@ -296,7 +313,7 @@ export default {
this.isLoading = false;
// Check if any item is being copied/moved to itself
const isSameFile = itemsToProcess.some(item => {
const destPath = this.destPath.endsWith('/') ? this.destPath : this.destPath + '/';
const destPath = this.destPath.endsWith('/') ? this.destPath : `${this.destPath}/`;
const targetPath = destPath + item.name;
return item.from === targetPath && item.fromSource === this.destSource;
});
@@ -310,8 +327,8 @@ export default {
operation: this.operation
},
confirm: async (event, option) => {
overwrite = option == "overwrite";
rename = option == "rename";
overwrite = option === "overwrite";
rename = option === "rename";
event.preventDefault();
try {
this.isLoading = true;
@@ -331,8 +348,8 @@ export default {
}
// Check if there were any failures in the result
const hasFailures = result && result.failed && result.failed.length > 0;
const hasSuccesses = result && result.succeeded && result.succeeded.length > 0;
const hasFailures = result?.failed && result.failed.length > 0;
const hasSuccesses = result?.succeeded && result.succeeded.length > 0;
if (hasFailures && !hasSuccesses) {
// All operations failed - show error but DON'T close prompt
@@ -423,9 +440,9 @@ export default {
let errorMessage = null;
// Check if error has a response body with failed items
if (error && error.failed && error.failed.length > 0 && error.failed[0]?.message) {
if (error?.failed && error.failed.length > 0 && error.failed[0]?.message) {
errorMessage = error.failed[0].message;
} else if (error && error.message) {
} else if (error?.message) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
+15 -10
View File
@@ -13,11 +13,19 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<button
type="button"
class="button button--flat button--grey"
@click="closeTopPrompt"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
{{ $t("general.cancel") }}
</button>
<button class="button button--flat" :aria-label="$t('general.create')" :title="$t('general.create')"
<button
type="button"
class="button button--flat" :aria-label="$t('general.create')"
:title="$t('general.create')"
@click="submit">
{{ $t("general.create") }}
</button>
@@ -123,7 +131,7 @@ export default {
try {
const parentPath = this.parentInfo.path;
const source = this.parentInfo.source;
const newPath = url.joinPath(parentPath, this.name) + "/";
const newPath = `${url.joinPath(parentPath, this.name)}/`;
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", overwrite, undefined, {}, true);
@@ -162,7 +170,7 @@ export default {
const newName = counter === 1 ? `${originalName} (1)` : `${originalName} (${counter})`;
const parentPath = this.parentInfo.path;
const source = this.parentInfo.source;
const newPath = url.joinPath(parentPath, newName) + "/";
const newPath = `${url.joinPath(parentPath, newName)}/`;
if (getters.isShare()) {
await resourcesApi.postPublic(state.shareInfo?.hash, newPath, "", false, undefined, {}, true);
@@ -183,16 +191,13 @@ export default {
// Show notification
this.showNotification(source, newPath);
} catch (renameError) {
if (renameError.message === "conflict") {
// Continue to next iteration
continue;
} else {
if (renameError.message !== "conflict") {
throw renameError;
}
}
}
if (!success) {
throw new Error("Could not find a unique name after " + maxAttempts + " attempts");
throw new Error(`Could not find a unique name after ${maxAttempts} attempts`);
}
}
} catch (retryError) {
+13 -8
View File
@@ -13,11 +13,19 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<button
type="button"
class="button button--flat button--grey"
@click="closeTopPrompt"
:aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
{{ $t("general.cancel") }}
</button>
<button class="button button--flat" @click="submit" :aria-label="$t('general.create')"
<button
type="button"
class="button button--flat"
@click="submit" :aria-label="$t('general.create')"
:title="$t('general.create')">
{{ $t("general.create") }}
</button>
@@ -180,16 +188,13 @@ export default {
// Show notification
this.showNotification(source, newPath);
} catch (renameError) {
if (renameError.message === "conflict") {
// Continue to next iteration
continue;
} else {
if (renameError.message !== "conflict") {
throw renameError;
}
}
}
if (!success) {
throw new Error("Could not find a unique name after " + maxAttempts + " attempts");
throw new Error(`Could not find a unique name after ${maxAttempts} attempts`);
}
}
} catch (retryError) {
@@ -17,15 +17,15 @@
{{ isShare ? `${$t("onlyoffice.shareHash")}: ${shareHash}` : $t("onlyoffice.userRequest") }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div v-if="clientConfig && clientConfig.document" class="debug-section debug-section-config">
<div v-if="clientConfig?.document" class="debug-section debug-section-config">
<div class="debug-section-header">
<strong class="debug-section-title">🔧 {{ $t("onlyoffice.configurationDetails") }}</strong> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
{{ $t("onlyoffice.documentKey") }}: {{ clientConfig.document.key }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.fileType") }}: {{ clientConfig.document.fileType }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.editMode") }}: {{ clientConfig.editorConfig ? clientConfig.editorConfig.mode : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.downloadURL") }}: {{ clientConfig.document.url ? clientConfig.document.url.substring(0, 80) + '...' : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.callbackURL") }}: {{ clientConfig.editorConfig && clientConfig.editorConfig.callbackUrl ? clientConfig.editorConfig.callbackUrl.substring(0, 80) + '...' : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.downloadURL") }}: {{ clientConfig.document.url ? `${clientConfig.document.url.substring(0, 80)}...` : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.callbackURL") }}: {{ clientConfig.editorConfig?.callbackUrl ? `${clientConfig.editorConfig.callbackUrl.substring(0, 80)}...` : 'N/A' }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<br/>
<strong>{{ $t("onlyoffice.networkFlow") }}:</strong><br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("onlyoffice.browser") }} ({{ windowOrigin }}) ↔ {{ $t("onlyoffice.onlyOffice") }}: {{ onlyOfficeUrl }}<br/> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
@@ -109,7 +109,7 @@ export default {
return window.location.origin;
},
internalUrlInfo() {
if (this.clientConfig && this.clientConfig.document && this.clientConfig.document.url) {
if (this.clientConfig?.document?.url) {
try {
const docUrlOrigin = new URL(this.clientConfig.document.url).origin;
const windowOrigin = window.location.origin;
@@ -125,7 +125,7 @@ export default {
message: "Not set, using window.location"
};
}
} catch (e) {
} catch (_e) {
return {
isSet: false,
message: "⚠️ Error parsing document URL"
@@ -138,10 +138,10 @@ export default {
};
},
downloadDomain() {
if (this.clientConfig && this.clientConfig.document && this.clientConfig.document.url) {
if (this.clientConfig?.document?.url) {
try {
return new URL(this.clientConfig.document.url).origin;
} catch (e) {
} catch (_e) {
return 'N/A';
}
}
@@ -202,7 +202,7 @@ export default {
const configData = await officeApi.getConfig(state.req);
this.clientConfig = configData;
if (this.clientConfig.document && this.clientConfig.document.key) {
if (this.clientConfig.document?.key) {
this.documentId = this.clientConfig.document.key;
}
@@ -221,7 +221,7 @@ export default {
},
updateDebugStatus(message) {
this.debugInfo = this.debugInfo + "<br/>" + message;
this.debugInfo = `${this.debugInfo}<br/>${message}`;
},
checkOnlyOfficeServer() {
@@ -118,10 +118,10 @@ export default {
if (typeof pathOrData === 'string') {
this.selectedPath = pathOrData;
this.selectionIsValid = !this.requireFileSelection;
} else if (pathOrData && pathOrData.path) {
} else if (pathOrData?.path) {
this.selectedPath = pathOrData.path;
this.selectedSource = pathOrData.source;
if (Object.prototype.hasOwnProperty.call(pathOrData, 'isValid')) {
if (Object.hasOwn(pathOrData, 'isValid')) {
this.selectionIsValid = !!pathOrData.isValid;
} else {
this.selectionIsValid = !this.requireFileSelection;
@@ -152,4 +152,3 @@ export default {
min-height: 300px;
}
</style>
@@ -43,7 +43,12 @@
</div>
<div class="card-actions">
<button class="button button--flat" @click.stop="cyclePlaybackModes" :title="$t('player.changePlaybackMode')">
<button
type="button"
class="button button--flat"
@click.stop="cyclePlaybackModes"
:title="$t('player.changePlaybackMode')"
>
<i class="material-symbols">swap_vert</i> {{ $t('player.changePlaybackMode') }}
</button>
</div>
@@ -226,7 +231,7 @@ export default {
if (item.type?.startsWith('video/')) return 'movie';
},
updatePromptTitle() {
if (this.embedded || this.promptId == null) return;
if (this.embedded || this.promptId === null) return;
const base = this.$t('player.QueuePlayback');
const title = this.queueCount > 0
? `${base} (${this.queueCount})`
+8 -8
View File
@@ -1,5 +1,5 @@
<template>
<Teleport to="body" v-for="prompt in prompts" :key="'prompt-' + prompt.id">
<Teleport to="body" v-for="prompt in prompts" :key="`prompt-${prompt.id}`">
<div
ref="promptWindow"
class="floating-window"
@@ -14,13 +14,13 @@
@mousedown="makeTopPrompt(prompt.id)"
:style="{
transform: `translate(calc(-50% + ${(dragOffsets[prompt.id]?.x || 0)}px), calc(-50% + ${(dragOffsets[prompt.id]?.y || 0)}px))`,
width: sizes[prompt.id]?.width ? sizes[prompt.id].width + 'px' : null,
height: sizes[prompt.id]?.height ? sizes[prompt.id].height + 'px' : null,
width: sizes[prompt.id]?.width ? `${sizes[prompt.id].width}px` : null,
height: sizes[prompt.id]?.height ? `${sizes[prompt.id].height}px` : null,
maxWidth: sizes[prompt.id]?.width ? 'none' : null,
maxHeight: sizes[prompt.id]?.height ? 'none' : null,
zIndex: 5 + prompts.indexOf(prompt),
}"
:aria-label="prompt.name + '-prompt'"
:aria-label="`${prompt.name}-prompt`"
>
<header
class="prompt-taskbar"
@@ -207,7 +207,7 @@ export default {
beforeUnmount() {
window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('keydown', this.onDocumentKeydown);
Object.values(this.flashBorderTimers).forEach((tid) => clearTimeout(tid));
Object.values(this.flashBorderTimers).forEach((tid) => { clearTimeout(tid); });
},
methods: {
triggerPromptBorderFlash(id) {
@@ -456,7 +456,7 @@ export default {
},
getPointerPos(e, type) {
if (type === "touch") {
const t = e.touches && e.touches[0];
const t = e.touches?.[0];
return t ? { x: t.clientX, y: t.clientY } : null;
}
return { x: e.clientX, y: e.clientY };
@@ -466,7 +466,7 @@ export default {
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const headerEl = el.querySelector && el.querySelector(".prompt-taskbar");
const headerEl = el.querySelector?.(".prompt-taskbar");
const headerHeight = headerEl ? headerEl.getBoundingClientRect().height : 40;
const rect = el.getBoundingClientRect();
const windowHeight = rect.height;
@@ -499,7 +499,7 @@ export default {
this.makeTopPrompt(id);
if (type === "mouse" && e.button !== 0) return;
if (type === "touch") {
this.touchIds[id] = e.changedTouches && e.changedTouches[0] && e.changedTouches[0].identifier;
this.touchIds[id] = e.changedTouches?.[0]?.identifier;
}
const pos = this.getPointerPos(e, type);
+14 -12
View File
@@ -27,8 +27,12 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<button
type="button"
class="button button--flat button--grey"
@click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
{{ $t("general.cancel") }}
</button>
<button @click="submit" class="button button--flat" :disabled="!canRename"
@@ -63,19 +67,17 @@ export default {
},
data() {
const itemName = this.item.name;
let fileName = "";
let fileExtension = "";
if (this.item.type !== 'directory') {
const ext = getFileExtension(this.item.name);
const filenamePrefix = this.item.name.substring(0, this.item.name.length - ext.length);
return {
fileName: filenamePrefix,
fileExtension: removePrefix(ext, "."),
name: itemName, // Initialize name for non-directory items
renaming: false,
};
fileName = filenamePrefix;
fileExtension = removePrefix(ext, ".");
}
return {
fileName: "",
fileExtension: "",
fileName,
fileExtension,
name: itemName,
renaming: false,
};
@@ -243,10 +245,10 @@ export default {
// Parse the error response structure (similar to Delete.vue)
let errorMessage = this.$t("prompts.renameFailed");
if (error && error.failed && error.failed.length > 0) {
if (error?.failed && error.failed.length > 0) {
// Get message from first failed item
errorMessage = error.failed[0].message || errorMessage;
} else if (error && error.message) {
} else if (error?.message) {
errorMessage = error.message;
}
+13 -4
View File
@@ -4,12 +4,21 @@
</div>
<div class="card-actions">
<button class="button button--flat button--grey" @click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<button
type="button"
class="button button--flat button--grey"
@click="closeTopPrompt" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')"
>
{{ $t("general.cancel") }}
</button>
<button class="button button--flat button--red" @click="currentPrompt.confirm" :aria-label="$t('general.replace')"
:title="$t('general.replace')">
<button
type="button"
class="button button--flat button--red"
@click="currentPrompt.confirm"
:aria-label="$t('general.replace')"
:title="$t('general.replace')"
>
{{ $t("general.replace") }}
</button>
</div>
@@ -3,14 +3,23 @@
<p>{{ $t("prompts.replaceMessage") }}</p>
</div>
<div class="card-actions">
<button class="button button--flat button--blue" @click="(event) => currentPrompt.confirm(event, 'rename')"
:aria-label="$t('general.rename')" :title="$t('general.rename')" tabindex="2">
<button
type="button"
class="button button--flat button--blue"
@click="(event) => currentPrompt.confirm(event, 'rename')"
:aria-label="$t('general.rename')"
:title="$t('general.rename')"
>
{{ $t("general.rename") }}
</button>
<button id="focus-prompt" class="button button--flat button--red"
<button
type="button"
id="focus-prompt"
class="button button--flat button--red"
:disabled="isSameFile"
@click="(event) => currentPrompt.confirm(event, 'overwrite')" :aria-label="$t('general.replace')"
:title="$t('general.replace')" tabindex="1">
@click="(event) => currentPrompt.confirm(event, 'overwrite')"
:aria-label="$t('general.replace')"
:title="$t('general.replace')">
{{ $t("general.replace") }}
</button>
</div>
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more