mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
23
Commits
main
...
v1.4.3-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1ab7dda42 | ||
|
|
22174b5575 | ||
|
|
f7e09430e6 | ||
|
|
f3f4bbe80c | ||
|
|
863f8f64a3 | ||
|
|
ec80963d5f | ||
|
|
630016a36c | ||
|
|
1b748adc53 | ||
|
|
6571e3adca | ||
|
|
5d88f223e5 | ||
|
|
8a9347c9df | ||
|
|
6014139f2d | ||
|
|
4ff600dab1 | ||
|
|
07530de13b | ||
|
|
d6e4063f3b | ||
|
|
2539b893d7 | ||
|
|
b30b1641fc | ||
|
|
010c4728ae | ||
|
|
8658b27a97 | ||
|
|
36bf16a68b | ||
|
|
c135b8610d | ||
|
|
27eb86421d | ||
|
|
7359640dec |
No files matched your search
@@ -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
@@ -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`
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, "/") {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+1174
-1327
File diff suppressed because it is too large.
Load diff
+11
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,5 +1,5 @@
|
||||
import { fetchJSON } from './utils'
|
||||
import { getApiPath } from '@/utils/url.js'
|
||||
import { fetchJSON } from './utils'
|
||||
|
||||
/**
|
||||
* @param {string} source
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/utils/constants', () => {
|
||||
return {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
|
||||
export default {
|
||||
name: "Tooltip",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 & in URL, try to fix it
|
||||
if (actualSrc && actualSrc.includes('&')) {
|
||||
if (actualSrc?.includes('&')) {
|
||||
const fixedSrc = actualSrc.replace(/&/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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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})`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user