Compare commits

...
23 Commits
Author SHA1 Message Date
Graham Steffaniak f2faa19661 updated with small changes (#2232) 2026-04-02 16:50:46 -05:00
Graham Steffaniak 901611aff0 add ldap and jwt login method choices (#2231) 2026-04-02 15:55:57 -05:00
Graham Steffaniak 85a2cab950 fix a positioning issue (#2230) 2026-04-02 12:16:36 -05:00
Kevin B. f7ffc906ee Status bar for editor and markdown viewer (#2226) 2026-04-02 11:43:10 -05:00
Graham Steffaniak 15d71488dc Fix db startup delay (#2225) 2026-04-02 10:23:29 -05:00
Graham Steffaniak fdd18befe6 lots of animations and style changes (#2214) 2026-04-01 16:50:33 -05:00
Graham Steffaniak aba645ecf4 added additional proxy user check (#2222) 2026-04-01 15:57:44 -05:00
Graham Steffaniak 014f5d101c multi search fix (#2220) 2026-04-01 15:36:30 -05:00
Graham Steffaniak 92c54df205 fix upload chunk pausing and file copy (#2218) 2026-04-01 15:01:34 -05:00
Graham Steffaniak 97599e1a19 updated media metadata fetching (#2217) 2026-04-01 13:31:48 -05:00
Graham Steffaniak 94092b2d1f display subtitle files with language for video captions fix (#2213) 2026-03-31 16:07:13 -05:00
Graham Steffaniak 83d04982b9 updated with universal key actions (#2211) 2026-03-31 15:38:07 -05:00
84474fb4d8 update to latest material-symbols, add more icons, and mimetypes (#2203)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-03-31 15:30:22 -05:00
Graham Steffaniak b48f200db5 updated with listing view changes (#2210) 2026-03-30 19:11:57 -05:00
Graham Steffaniak 9d57067087 updated docker images without upx 2026-03-30 16:56:41 -05:00
Graham Steffaniak 1f897bd51f Fix upload/download prompt chunked (#2208) 2026-03-30 16:53:09 -05:00
Graham Steffaniak d469e61625 Merge branch 'main' into dev/v1.3.3 2026-03-30 16:00:37 -05:00
Kevin B. 7ef581c092 Allow pasting files from the Clipboard (#2197) 2026-03-30 15:51:15 -05:00
Graham Steffaniak b0f217e579 updated with more info added to archives (#2184) 2026-03-25 11:25:35 -05:00
Graham Steffaniak 066408549b updated with more info added to archives (#2184) 2026-03-25 10:02:51 -05:00
Graham SteffaniakandKurami32 3bd4f6249a update translation script (#2183)
Co-authored-by: Kurami32 <kurami32_kbm@proton.me>
2026-03-23 19:23:06 -05:00
Kevin B. 79e0bca45f Add fbx models back (#2180) 2026-03-23 19:21:04 -05:00
Graham Steffaniak 3962753ec0 updated docker builds (#2181) 2026-03-23 19:06:35 -05:00
134 changed files with 5694 additions and 1787 deletions

No files matched your search

+10 -1
View File
@@ -56,4 +56,13 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run i18n:check
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- run: git diff --exit-code
+36 -1
View File
@@ -2,6 +2,42 @@
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.3.3
**New Features**:
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
- Caption font size can be adjusted in video settings
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
- Status bar for editor and markdown viewer (#2226)
**Notes**:
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- videos double tap to fast-forward and rewind added.
- Adjust Image album swipe behavior (#2068)
- swipe animation for next/previous
- swipe down on image to close and go to parent folder.
- supports videos
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- The Icons in the UI were updated! (#2203)
- More supported icons in the Icon Picker tool.
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
**BugFixes**:
- Archives don't preserve file metadata (#2063)
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
- Chunked download stops after first chunk, add message explaining (#2074)
- Esc to Cancel and Enter to confirm popup (#2079)
- Language-tagged subtitle files not detected for videos (#2199)
- Unable to use proxy auth since 1.3.0-beta (#2173)
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
## v1.3.2-beta
**Security**:
@@ -32,7 +68,6 @@ All notable changes to this project will be documented in this file. For commit
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
## v1.3.1-beta
**Security**:
+1 -2
View File
@@ -4,13 +4,12 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev upx
RUN apk update && apk add --no-cache gcc musl-dev
ENV CGO_ENABLED=1
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
+1 -2
View File
@@ -3,12 +3,11 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add upx
RUN apk update && apk add
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
+68 -87
View File
@@ -28,6 +28,36 @@ import (
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService == nil {
return
}
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if !isItemAudio && !isItemVideo {
continue
}
isAudio := isItemAudio
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
}
}
})
}
}
// processDirectoryMetadata extracts metadata for audio/video files in directories
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
metadataCount := 0
@@ -39,11 +69,8 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
@@ -52,54 +79,9 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
})
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
response.HasMetadata = true
}
// Only process metadata if explicitly requested
if opts.Metadata && metadataCount > 0 {
processedCount := 0
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
wg.Go(func() {
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
} else {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
}
})
}
}
}
addMetadataToChildren(response, opts, &wg)
}
// Wait for all goroutines to complete
wg.Wait()
}
@@ -290,48 +272,47 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
if isFolder {
return
}
if opts.Metadata {
if isVideo {
if isVideo {
// Extract duration for video
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
} else {
info.Metadata = extItem.Metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
} else {
info.Metadata = extItem.Metadata
}
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
}
return
}
// Handle subtitles if requested
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
if isAudio {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
}
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
}
return
}
if isAudio {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
}
// Process text content for non-video, non-audio files
+49 -9
View File
@@ -108,7 +108,6 @@ var types = map[string]string{
".chrt": "application/vnd.kde.kchart",
".class": "application/java",
".com": ContentTextHeaderValue,
".conf": ContentTextHeaderValue,
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/mac-compactpro",
@@ -151,7 +150,8 @@ var types = map[string]string{
".evy": "application/envoy",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".bat": "application/x-msdownload",
".bat": "application/x-msdos-program",
".cmd": "application/x-msdos-program",
".msi": "application/x-msdownload",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
@@ -228,7 +228,6 @@ var types = map[string]string{
".js": ContentJavascriptHeaderValue,
".mjs": ContentJavascriptHeaderValue,
".json": ContentJSONHeaderValue,
".vue": ContentJavascriptHeaderValue,
".jut": "image/jutvision",
".kar": "audio/midi",
".karbon": "application/vnd.kde.karbon",
@@ -260,7 +259,6 @@ var types = map[string]string{
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
@@ -310,7 +308,6 @@ var types = map[string]string{
".ncm": "application/vndnokiaconfiguration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
@@ -346,15 +343,14 @@ var types = map[string]string{
".p7m": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".p": "text/x-pascal",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/vndhp-pcl",
".pct": "image/x-pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pdfa": "application/pdf",
".pfunk": "audio/make",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
@@ -446,9 +442,11 @@ var types = map[string]string{
".sprite": "application/x-sprite",
".spx": "audio/ogg",
".src": "application/x-wais-source",
".srt": "text/plain",
".srt": "text/subtitle-srt",
".ass": "text/subtitle-ass",
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".sbv": "text/plain",
".ssa": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vndms-pkicertstore",
@@ -603,10 +601,52 @@ var types = map[string]string{
".webm": "video/webm",
".weba": "audio/webm",
".caf": "audio/x-caf",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m3u8": "text/x-mpegURL",
".pls": "text/x-scpls",
".xspf": "application/xspf+xml",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
".db": "application/x-db",
".db2": "application/x-db",
".db3": "application/x-db",
".sql": "application/sql",
".sqlite": "application/vnd.sqlite3",
".sqlite3": "application/vnd.sqlite3",
".yaml": "application/yaml",
".app": "application/x-application",
".geojson": "application/geo+json",
".sketch": "application/x-sketch",
".bak": "application/backup",
".backup": "application/backup",
".xfc": "application/x-xcf",
".vue": "text/vue",
".conf": "text/config-file",
".ini": "text/ini",
".ps1": "application/x-powershell",
".ps2": "application/x-powershell",
".ps3": "application/x-powershell",
".dart": "application/vnd.dart",
".flutter": "text/flutter",
".tsx": "text/x-typescript",
".ts": "text/x-typescript",
".php": "application/x-php",
".php2": "application/x-php",
".php3": "application/x-php",
".phtml": "application/x-php",
".p": "text/x-pascal",
".pas": "text/x-pascal",
".zig": "text/x-zig",
".ex": "text/x-elixir",
".exs": "text/x-elixir",
".json5": "application/json5",
".pmx": "model/x-pmx",
".nix": "text/nix-lang",
".cjs": "text/javascript",
".torrent": "application/x-bittorrent",
".tmp": "text/tmp",
".temp": "text/tmp",
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
+2 -3
View File
@@ -31,10 +31,9 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
continue
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
if filepath.Base(scope.Scope) != cleanedUserName && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
@@ -48,7 +47,7 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
} else if filepath.Base(scope.Scope) == cleanedUserName {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
-1
View File
@@ -156,7 +156,6 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: true,
DefaultUserScope: "/defaultA",
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
+17 -16
View File
@@ -682,10 +682,11 @@ func setDefaults(generate bool) Settings {
CacheDir: "tmp",
MaxArchiveSizeGB: 20,
IndexSqlConfig: IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
},
Filesystem: Filesystem{
CreateFilePermission: "644",
@@ -707,19 +708,19 @@ func setDefaults(generate bool) Settings {
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
PreferEditorForMarkdown: false,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
+16 -5
View File
@@ -76,11 +76,22 @@ type Filesystem struct {
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
const (
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
IndexStartupIntegrityQuickCheck = "quickCheck"
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
IndexStartupIntegrityProbe = "probe"
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
IndexStartupIntegrityOff = "off"
)
type IndexSqlConfig struct {
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
}
type Integrations struct {
@@ -203,7 +214,7 @@ type SourceConfig struct {
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
// hidden but used internally - optimized map lookups for conditional rules
ResolvedRules ResolvedRulesConfig `json:"-"`
+4
View File
@@ -211,5 +211,9 @@ func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
if sourceName == "" || assetPath == "" {
return "", "", fmt.Errorf("source or path not found in asset configuration")
}
return sourceName, assetPath, nil
}
+66 -18
View File
@@ -2,6 +2,7 @@ package sql
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
@@ -90,7 +91,8 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
// For persistent databases, check integrity on startup
// If corrupted, delete and recreate the database
if !disableReuse {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
mode := normalizeStartupIntegrityMode(settings.Config.Server.IndexSqlConfig.StartupIntegrityCheck)
if err := idxDB.runStartupIntegrityCheck(mode); err != nil {
// Database was recreated due to corruption
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
isNewDb = true
@@ -112,10 +114,73 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
return idxDB, isNewDb, nil
}
func normalizeStartupIntegrityMode(m string) string {
switch m {
case "", settings.IndexStartupIntegrityQuickCheck:
return settings.IndexStartupIntegrityQuickCheck
case settings.IndexStartupIntegrityProbe:
return settings.IndexStartupIntegrityProbe
case settings.IndexStartupIntegrityOff:
return settings.IndexStartupIntegrityOff
default:
logger.Warningf("[DB_INIT] unknown indexSqlConfig.startupIntegrityCheck %q, using %s", m, settings.IndexStartupIntegrityQuickCheck)
return settings.IndexStartupIntegrityQuickCheck
}
}
// runStartupIntegrityCheck runs the configured startup check (quick_check, probe, or off).
func (db *IndexDB) runStartupIntegrityCheck(mode string) error {
switch mode {
case settings.IndexStartupIntegrityOff:
logger.Debugf("[DB_INTEGRITY] startup integrity skipped (startupIntegrityCheck=off)")
return nil
case settings.IndexStartupIntegrityProbe:
return db.checkAccessibilityProbe()
default:
// quickCheck (including normalized default)
return db.checkIntegrityAndRecreateIfNeeded()
}
}
// checkAccessibilityProbe verifies the DB file is readable: sqlite_master and optionally one row from index_items.
// If index_items is absent (brand-new file), succeeds so CreateIndexTable can create it.
func (db *IndexDB) checkAccessibilityProbe() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] startup probe completed in %v", time.Since(start))
}()
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items'`).Scan(&n)
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read sqlite_master: %v", err)
return db.deleteCorruptedDatabase()
}
if n == 0 {
logger.Debugf("[DB_INTEGRITY] probe: index_items not present yet (new database), skipping row read")
return nil
}
var dummy int
err = db.QueryRow(`SELECT 1 FROM index_items LIMIT 1`).Scan(&dummy)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read index_items: %v", err)
return db.deleteCorruptedDatabase()
}
return nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
// If corruption is detected, deletes the database file and returns an error to trigger recreation.
// Returns nil if integrity is OK, error if database needs to be recreated.
func (db *IndexDB) checkIntegrityAndRecreateIfNeeded() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] PRAGMA quick_check completed in %v", time.Since(start))
}()
// Quick integrity check
var result string
err := db.QueryRow("PRAGMA quick_check").Scan(&result)
@@ -1198,23 +1263,6 @@ func (db *IndexDB) GetSizeGroupsForDuplicates(source string, minSize int64, path
return sizes, sizeCounts, rows.Err()
}
// GetTotalSize returns the sum of all file sizes in the index for a specific source (excluding directories).
func (db *IndexDB) GetTotalSize(source string) (uint64, error) {
query := `SELECT COALESCE(SUM(size), 0) FROM index_items WHERE source = ? AND is_dir = 0`
var totalSize int64
err := db.QueryRow(query, source).Scan(&totalSize)
if err != nil {
// Soft failure: DB is busy or locked, return 0
if isBusyError(err) || isTransactionError(err) {
return 0, nil
}
return 0, err
}
return uint64(totalSize), nil
}
// Helper functions
// getNextPathPrefix returns the lexicographically next string after the given prefix.
+129
View File
@@ -0,0 +1,129 @@
package sql
import (
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestMain(m *testing.M) {
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0o644, 0o755)
}
os.Exit(m.Run())
}
func TestNormalizeStartupIntegrityMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", settings.IndexStartupIntegrityQuickCheck},
{"quickCheck", settings.IndexStartupIntegrityQuickCheck},
{"probe", settings.IndexStartupIntegrityProbe},
{"off", settings.IndexStartupIntegrityOff},
{"not-a-real-mode", settings.IndexStartupIntegrityQuickCheck},
}
for _, tt := range tests {
if got := normalizeStartupIntegrityMode(tt.in); got != tt.want {
t.Errorf("normalizeStartupIntegrityMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// pushTestIndexConfig sets CacheDir and index SQL options for NewIndexDB; returned func restores prior values.
func pushTestIndexConfig(t *testing.T, cacheDir string, idx settings.IndexSqlConfig) func() {
t.Helper()
prevDir := settings.Config.Server.CacheDir
prevIdx := settings.Config.Server.IndexSqlConfig
settings.Config.Server.CacheDir = cacheDir
settings.Config.Server.IndexSqlConfig = idx
return func() {
settings.Config.Server.CacheDir = prevDir
settings.Config.Server.IndexSqlConfig = prevIdx
}
}
func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
return settings.IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: mode,
}
}
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_new", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupProbe_ReopenWithData(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
Hidden: false,
},
}
if err = db.InsertItem("src1", "/a.txt", info); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
var db2 *IndexDB
db2, _, err = NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db2.Close()
}
func TestNewIndexDB_StartupQuickCheckStillWorks(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityQuickCheck))
defer pop()
db, _, err := NewIndexDB("quick_default", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupOff(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
defer pop()
db, _, err := NewIndexDB("off_mode", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
+168 -30
View File
@@ -8,10 +8,12 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -416,16 +418,21 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: int64(fileutils.PermDir),
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
header, err := tar.FileInfoHeader(fileInfo, "")
if err != nil {
return err
}
header.Name = relPath + "/"
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
zh, err := zip.FileInfoHeader(fileInfo)
if err != nil {
return err
}
zh.Name = relPath + "/"
zh.Method = zip.Store
_, err = zipWriter.CreateHeader(zh)
return err
}
return nil
@@ -663,6 +670,117 @@ func safeExtractPath(destDir, name string) (string, error) {
return abs, nil
}
// applyArchivedTimesAndPerm sets permission bits and modification time from an archive entry using
func applyArchivedTimesAndPerm(path string, mode fs.FileMode, modTime time.Time) error {
switch {
case mode.Type() == fs.ModeSymlink:
if !modTime.IsZero() {
_ = os.Chtimes(path, modTime, modTime)
}
return nil
case mode.Type() == fs.ModeDir:
if p := mode.Perm(); p != 0 {
_ = os.Chmod(path, p)
}
if modTime.IsZero() {
return nil
}
return os.Chtimes(path, modTime, modTime)
case mode.IsRegular():
if p := mode.Perm(); p != 0 {
_ = os.Chmod(path, p)
}
if modTime.IsZero() {
return nil
}
return os.Chtimes(path, modTime, modTime)
default:
if !modTime.IsZero() {
_ = os.Chtimes(path, modTime, modTime)
}
return nil
}
}
// symlinkTargetStaysUnderDest rejects absolute targets and paths that would resolve outside destRoot.
func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
if linkname == "" {
return fmt.Errorf("empty symlink target")
}
if filepath.IsAbs(linkname) {
return fmt.Errorf("symlink has absolute target")
}
destAbs, err := filepath.Abs(destRoot)
if err != nil {
return err
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(destPath), linkname))
resAbs, err := filepath.Abs(resolved)
if err != nil {
return err
}
sep := string(filepath.Separator)
if resAbs != destAbs && !strings.HasPrefix(resAbs, destAbs+sep) {
return fmt.Errorf("symlink target escapes destination directory")
}
return nil
}
// extractArchivedDir creates a directory from an archive entry and reapplies mode and mtime.
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
perm := mode.Perm()
if perm == 0 {
perm = fileutils.PermDir
}
if err := os.MkdirAll(destPath, perm); err != nil {
return err
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
defer func() { _ = src.Close() }()
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
perm := mode.Perm()
if perm == 0 {
perm = fileutils.PermFile
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
_, copyErr := io.Copy(out, src)
cerr := out.Close()
if copyErr != nil {
return copyErr
}
if cerr != nil {
return cerr
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
target = strings.TrimSpace(target)
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
return err
}
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Symlink(target, destPath); err != nil {
return err
}
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
@@ -671,33 +789,49 @@ func extractZip(archivePath, destDir string) error {
defer r.Close()
for _, f := range r.File {
destPath, err := safeExtractPath(destDir, f.Name)
var destPath string
destPath, err = safeExtractPath(destDir, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
fi := f.FileInfo()
mode := fi.Mode()
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
return err
}
continue
}
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
if mode.Type() == fs.ModeSymlink {
var rc io.ReadCloser
rc, err = f.Open()
if err != nil {
return err
}
buf, rerr := io.ReadAll(rc)
rc.Close()
if rerr != nil {
return rerr
}
if err = extractArchivedSymlink(destDir, destPath, string(buf), f.Modified); err != nil {
return err
}
continue
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if !mode.IsRegular() {
continue
}
var rc io.ReadCloser
rc, err = f.Open()
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
_, err = io.Copy(out, rc)
rc.Close()
out.Close()
if err != nil {
if err = extractArchivedRegularFile(destPath, mode, f.Modified, rc); err != nil {
return err
}
}
@@ -726,29 +860,33 @@ func extractTarGz(archivePath, destDir string) error {
if err != nil {
return err
}
destPath, err := safeExtractPath(destDir, h.Name)
var destPath string
destPath, err = safeExtractPath(destDir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
mode := h.FileInfo().Mode()
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
mode := h.FileInfo().Mode()
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
return err
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
case tar.TypeSymlink:
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
default:
if h.Size > 0 {
if _, err = io.CopyN(io.Discard, tr, h.Size); err != nil {
return err
}
}
out.Close()
}
}
return nil
+5 -1
View File
@@ -119,6 +119,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
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("POST /resources/pause", withUser(resourcePauseHandler))
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
publicApi.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
@@ -128,6 +129,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
// Legacy routes (backwards compatibility)
api.HandleFunc("GET /raw", withUser(downloadHandler))
publicApi.HandleFunc("GET /raw", withHashFile(publicDownloadHandler))
@@ -171,9 +173,11 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
// ========================================
// Media Routes - /api/media/
// Media Routes - /api/media/ (with public routes)
// ========================================
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
// ========================================
// OnlyOffice Routes - /api/office/ (with public routes)
+4 -10
View File
@@ -80,17 +80,11 @@ func TestPickUserEntry(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pickUserEntry(tt.entries)
if tt.wantNil {
if got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
}
return
if tt.wantNil && got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
}
if got == nil {
t.Fatal("pickUserEntry() = nil, want non-nil entry")
}
if got.DN != tt.wantDN {
t.Errorf("pickUserEntry() DN = %v, want %v", got.DN, tt.wantDN)
if !tt.wantNil && got == nil {
t.Errorf("pickUserEntry() = nil, want non-nil entry")
}
})
}
+70
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -99,3 +101,71 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, 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
// @Description Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the directory"
// @Param source query string true "Source name"
// @Success 200 {object} iteminfo.ExtendedFileInfo
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /api/media/metadata [get]
func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
SkipExtendedAttrs: false,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
return renderJSON(w, r, fileInfo)
}
// publicMetadataHandler is the share-link variant of metadataHandler.
// @Summary Directory with media metadata (public share)
// @Tags Shares
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Success 200 {object} iteminfo.ExtendedFileInfo
// @Router /public/api/media/metadata [get]
func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
path := r.URL.Query().Get("path")
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: true,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ShowHidden: d.share.ShowHidden,
FollowSymlinks: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return errToStatus(err), err
}
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
return renderJSON(w, r, fileInfo)
}
+8 -25
View File
@@ -116,7 +116,9 @@ func withHashFileHelper(fn handleFunc) handleFunc {
}
data.IndexPath = pathWithoutUserScope
// skip file fetch for certain apis
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") || r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items") {
if (r.Method == "POST" && strings.Contains(r.URL.Path, "/resources")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
return fn(w, r, data)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
@@ -314,30 +316,11 @@ func withoutUserHelper(fn handleFunc) handleFunc {
// allow user without OTP to pass
func userWithoutOTPhelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Check if request has a valid admin token first
if tokenStr, err := extractToken(r); err == nil && tokenStr != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var tk users.AuthToken
if token, err := jwt.ParseWithClaims(tokenStr, &tk, keyFunc); err == nil && token.Valid {
if !auth.IsRevokedApiToken(store.Access, tokenStr) {
if tk.BelongsTo == 0 {
// Minimal token - look up user ID
userID, found := store.Access.GetUserIDFromToken(tokenStr)
if found {
tk.BelongsTo = userID
}
}
if tk.BelongsTo > 0 {
user, err := store.Users.Get(tk.BelongsTo)
if err == nil && user.Permissions.Admin {
// Valid admin token - allow operation
d.user = user
return fn(w, r, d)
}
}
}
if config.Auth.Methods.ProxyAuth.Enabled {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if proxyUser != "" {
return getProxyUser(w, r, d, fn, proxyUser)
}
}
+112 -9
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -22,9 +23,24 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var pauseCache = cache.NewCache[string](1 * time.Minute)
var publicPauseCache = cache.NewCache[string](1 * time.Minute)
// pauseCacheKeySep separates segments in pause cache keys (not used in source names/paths).
const pauseCacheKeySep = "\x1e"
func pauseUploadCacheKey(source, path string) string {
return source + pauseCacheKeySep + path
}
func publicPauseUploadCacheKey(shareHash, source, indexPath string) string {
return shareHash + pauseCacheKeySep + source + pauseCacheKeySep + indexPath
}
// validateMoveOperation checks if a move/rename operation is valid at the HTTP level
// It prevents moving a directory into itself or its subdirectories
func validateMoveOperation(src, dst string, isSrcDir bool) error {
@@ -61,10 +77,10 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param skipExtendedAttrs query string false "Skip extended attributes for faster retrieval, no hasPreview"
// @Param skipExtendedAttrs query string false "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param metadata query string false "Extract audio/video metadata if true"
// @Param metadata query string false "When true, run audio/video metadata extraction, subtitles, and directory media batch processing"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -367,6 +383,74 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
return renderJSON(w, r, response, statusCode)
}
// resourcePauseHandler registers a graceful pause for an in-flight chunked upload.
// Call immediately before aborting the chunk POST so the server keeps partial data.
// @Summary Register graceful chunked upload pause
// @Description Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Destination path (same as upload)"
// @Param source query string true "Source name (same as upload)"
// @Success 200 "Pause registered"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Source not found"
// @Router /api/resources/pause [post]
func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share != nil {
return http.StatusBadRequest, fmt.Errorf("use public pause endpoint for share uploads")
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to pause uploads")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, err
}
path = cleanPath
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if _, err := d.user.GetScopeForSourceName(source); err != nil {
return http.StatusForbidden, err
}
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
pauseCache.Set(pauseUploadCacheKey(source, path), "1")
return http.StatusOK, nil
}
// publicPauseHandler registers a graceful pause for a public share chunked upload.
// @Summary Register graceful chunked upload pause (public share)
// @Description Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string true "Path within the share (same as upload)"
// @Success 200 "Pause registered"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Source not found"
// @Router /public/api/resources/pause [post]
func publicPauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("pausing uploads is not allowed for this share")
}
src, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
sourceName := src.Name
publicPauseCache.Set(publicPauseUploadCacheKey(d.share.Hash, sourceName, d.IndexPath), "1")
return http.StatusOK, nil
}
// resourcePostHandler creates or uploads a new resource.
// @Summary Create or upload a resource
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
@@ -506,13 +590,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Create a unique name for the temporary file to avoid collisions.
hasher := md5.New()
hasher.Write([]byte(realPath))
uploadID := hex.EncodeToString(hasher.Sum(nil))
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "uploads", uploadID)
if err = os.MkdirAll(filepath.Dir(tempFilePath), fileutils.PermDir); err != nil {
logger.Debugf("could not create temp dir: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not create temp dir: %v", err)
}
tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
@@ -534,6 +612,31 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
chunkSize, err = io.Copy(outFile, r.Body)
if err != nil {
logger.Debugf("could not write chunk to temp file: %v", err)
if truncErr := outFile.Truncate(offset); truncErr != nil {
logger.Debugf("could not truncate temp file after failed chunk (offset=%d): %v", offset, truncErr)
}
_ = outFile.Sync()
gracefulPause := false
if d.share != nil {
k := publicPauseUploadCacheKey(d.share.Hash, source, path)
if _, ok := publicPauseCache.Get(k); ok {
gracefulPause = true
publicPauseCache.Delete(k)
}
} else {
k := pauseUploadCacheKey(source, path)
if _, ok := pauseCache.Get(k); ok {
gracefulPause = true
pauseCache.Delete(k)
}
}
if gracefulPause {
logger.Debugf("chunk upload ended after graceful pause; keeping partial file (source=%s path=%s)", source, path)
return 499, nil
}
_ = os.Remove(tempFilePath)
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
}
// check if the file is complete
+11 -8
View File
@@ -14,14 +14,14 @@ import (
)
type searchOptions struct {
query string
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
query string
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
}
// searchHandler handles search requests for files based on the provided query.
@@ -143,6 +143,9 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
if err != nil {
return nil, err
}
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
+1
View File
@@ -214,6 +214,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
-4
View File
@@ -210,10 +210,6 @@ func TestProcessDirectoryItem(t *testing.T) {
return
}
if itemInfo == nil {
t.Fatalf("expected non-nil itemInfo")
}
if size != tt.expectSize {
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
}
+1
View File
@@ -282,6 +282,7 @@ var model3DMimeType = map[string]string{
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
-1
View File
@@ -56,6 +56,5 @@ type ExtendedFileInfo struct {
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
+10 -4
View File
@@ -27,6 +27,8 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
return
}
// could be myvideo.mov
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
@@ -34,25 +36,29 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
// handle case myvideo.srt
fileExt := filepath.Ext(f.Name)
fileBase := strings.TrimSuffix(f.Name, fileExt)
fileBaseWithoutExt := strings.TrimSuffix(f.Name, fileExt)
// handle case myvideo.en.srt
langExt := filepath.Ext(fileBaseWithoutExt)
fileBaseWithoutExt2 := strings.TrimSuffix(fileBaseWithoutExt, langExt)
matches := fileBaseWithoutExt == baseWithoutExt || fileBaseWithoutExt2 == baseWithoutExt
// Check if this file has the same base name and a subtitle extension
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
if matches && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBase, ".")
parts := strings.Split(fileBaseWithoutExt, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
+16 -14
View File
@@ -316,7 +316,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
idx.SetUsage(totalBytes)
// Also fetch OS-reported partition used space (total - free)
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
if err != nil {
@@ -326,7 +326,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
idx.mu.Lock()
idx.UsedAlt = usedAlt
idx.mu.Unlock()
utils.DiskUsageCache.Set(cacheKey, true)
}
@@ -337,22 +337,24 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
scannerInfos = append(scannerInfos, &ScannerInfo{
Path: scanner.scanPath,
CurrentSchedule: scanner.currentSchedule,
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
QuickScanTime: scanner.quickScanTime,
FullScanTime: scanner.fullScanTime,
NumDirs: scanner.numDirs,
NumFiles: scanner.numFiles,
},
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
QuickScanTime: scanner.quickScanTime,
FullScanTime: scanner.fullScanTime,
NumDirs: scanner.numDirs,
NumFiles: scanner.numFiles,
},
})
}
reducedIdx := idx.ReducedIndex
// Compute DiskUsed from database (total size of all files indexed)
diskUsed, err := idx.db.GetTotalSize(idx.Name)
if err != nil {
logger.Errorf("Failed to get total size for index %s: %v", sourceName, err)
var diskUsed uint64
rootTotal, ok := idx.GetFolderSize("/")
if !ok {
logger.Errorf("Failed to get root-level indexed total for index %s", sourceName)
diskUsed = 0
} else {
diskUsed = rootTotal
}
reducedIdx.DiskUsed = diskUsed
reducedIdx.UsedAlt = idx.UsedAlt
+342 -3
View File
@@ -1074,6 +1074,63 @@ const docTemplate = `{
}
}
},
"/api/media/metadata": {
"get": {
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Directory with media metadata",
"parameters": [
{
"type": "string",
"description": "Path to the directory",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
@@ -1384,7 +1441,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Skip extended attributes for faster retrieval, no hasPreview",
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
"name": "skipExtendedAttrs",
"in": "query"
},
@@ -1403,7 +1460,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "Extract audio/video metadata if true",
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
"name": "metadata",
"in": "query"
},
@@ -2029,6 +2086,69 @@ const docTemplate = `{
}
}
},
"/api/resources/pause": {
"post": {
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Register graceful chunked upload pause",
"parameters": [
{
"type": "string",
"description": "Destination path (same as upload)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name (same as upload)",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -3130,6 +3250,40 @@ const docTemplate = `{
}
}
},
"/public/api/media/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Directory with media metadata (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -3740,6 +3894,60 @@ const docTemplate = `{
}
}
},
"/public/api/resources/pause": {
"post": {
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Register graceful chunked upload pause (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share (same as upload)",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources/preview": {
"get": {
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
@@ -4441,6 +4649,99 @@ const docTemplate = `{
}
}
},
"iteminfo.ExtendedFileInfo": {
"type": "object",
"properties": {
"checksums": {
"description": "checksums for the file",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"description": "text content of a file, if requested",
"type": "string"
},
"files": {
"description": "files in the directory with optional metadata",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hash": {
"description": "hash for the file -- used for sharing",
"type": "string"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"isShared": {
"description": "whether the file or folder is shared",
"type": "boolean"
},
"metadata": {
"description": "media metadata for audio/video files (includes duration)",
"allOf": [
{
"$ref": "#/definitions/iteminfo.MediaMetadata"
}
]
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"onlyOfficeId": {
"description": "id for onlyoffice files",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"source": {
"description": "associated index source for the file",
"type": "string"
},
"subtitles": {
"description": "subtitles for video files",
"type": "array",
"items": {
"$ref": "#/definitions/utils.SubtitleTrack"
}
},
"token": {
"description": "token for the file -- used for sharing",
"type": "string"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
},
"iteminfo.ExtendedItemInfo": {
"type": "object",
"properties": {
@@ -4862,6 +5163,15 @@ const docTemplate = `{
"description": "enable to always create a new indexing database on startup.",
"type": "boolean"
},
"startupIntegrityCheck": {
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
"type": "string",
"enum": [
"quickCheck",
"probe",
"off"
]
},
"walMode": {
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
"type": "boolean"
@@ -5408,7 +5718,7 @@ const docTemplate = `{
]
},
"createUserDir": {
"description": "create a user directory for each user under defaultUserScope + username",
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
"type": "boolean"
},
"defaultEnabled": {
@@ -6568,6 +6878,35 @@ const docTemplate = `{
"type": "string"
}
}
},
"utils.SubtitleTrack": {
"type": "object",
"properties": {
"codec": {
"description": "codec name for embedded subtitles",
"type": "string"
},
"embedded": {
"description": "true for embedded subtitles, false for external files",
"type": "boolean"
},
"index": {
"description": "stream index for embedded subtitles (nil for external)",
"type": "integer"
},
"language": {
"description": "language code",
"type": "string"
},
"name": {
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
}
}
}
}
}`
+342 -3
View File
@@ -1063,6 +1063,63 @@
}
}
},
"/api/media/metadata": {
"get": {
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Directory with media metadata",
"parameters": [
{
"type": "string",
"description": "Path to the directory",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
@@ -1373,7 +1430,7 @@
},
{
"type": "string",
"description": "Skip extended attributes for faster retrieval, no hasPreview",
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
"name": "skipExtendedAttrs",
"in": "query"
},
@@ -1392,7 +1449,7 @@
},
{
"type": "string",
"description": "Extract audio/video metadata if true",
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
"name": "metadata",
"in": "query"
},
@@ -2018,6 +2075,69 @@
}
}
},
"/api/resources/pause": {
"post": {
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Register graceful chunked upload pause",
"parameters": [
{
"type": "string",
"description": "Destination path (same as upload)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name (same as upload)",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -3119,6 +3239,40 @@
}
}
},
"/public/api/media/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Directory with media metadata (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -3729,6 +3883,60 @@
}
}
},
"/public/api/resources/pause": {
"post": {
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Register graceful chunked upload pause (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share (same as upload)",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources/preview": {
"get": {
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
@@ -4430,6 +4638,99 @@
}
}
},
"iteminfo.ExtendedFileInfo": {
"type": "object",
"properties": {
"checksums": {
"description": "checksums for the file",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"description": "text content of a file, if requested",
"type": "string"
},
"files": {
"description": "files in the directory with optional metadata",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hash": {
"description": "hash for the file -- used for sharing",
"type": "string"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"isShared": {
"description": "whether the file or folder is shared",
"type": "boolean"
},
"metadata": {
"description": "media metadata for audio/video files (includes duration)",
"allOf": [
{
"$ref": "#/definitions/iteminfo.MediaMetadata"
}
]
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"onlyOfficeId": {
"description": "id for onlyoffice files",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"source": {
"description": "associated index source for the file",
"type": "string"
},
"subtitles": {
"description": "subtitles for video files",
"type": "array",
"items": {
"$ref": "#/definitions/utils.SubtitleTrack"
}
},
"token": {
"description": "token for the file -- used for sharing",
"type": "string"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
},
"iteminfo.ExtendedItemInfo": {
"type": "object",
"properties": {
@@ -4851,6 +5152,15 @@
"description": "enable to always create a new indexing database on startup.",
"type": "boolean"
},
"startupIntegrityCheck": {
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
"type": "string",
"enum": [
"quickCheck",
"probe",
"off"
]
},
"walMode": {
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
"type": "boolean"
@@ -5397,7 +5707,7 @@
]
},
"createUserDir": {
"description": "create a user directory for each user under defaultUserScope + username",
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
"type": "boolean"
},
"defaultEnabled": {
@@ -6557,6 +6867,35 @@
"type": "string"
}
}
},
"utils.SubtitleTrack": {
"type": "object",
"properties": {
"codec": {
"description": "codec name for embedded subtitles",
"type": "string"
},
"embedded": {
"description": "true for embedded subtitles, false for external files",
"type": "boolean"
},
"index": {
"description": "stream index for embedded subtitles (nil for external)",
"type": "integer"
},
"language": {
"description": "language code",
"type": "string"
},
"name": {
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
}
}
}
}
}
+243 -4
View File
@@ -359,6 +359,72 @@ definitions:
type:
type: string
type: object
iteminfo.ExtendedFileInfo:
properties:
checksums:
additionalProperties:
type: string
description: checksums for the file
type: object
content:
description: text content of a file, if requested
type: string
files:
description: files in the directory with optional metadata
items:
$ref: '#/definitions/iteminfo.ExtendedItemInfo'
type: array
folders:
description: folders in the directory
items:
$ref: '#/definitions/iteminfo.ItemInfo'
type: array
hasPreview:
description: whether the file has a thumbnail preview
type: boolean
hash:
description: hash for the file -- used for sharing
type: string
hidden:
description: whether the file is hidden
type: boolean
isShared:
description: whether the file or folder is shared
type: boolean
metadata:
allOf:
- $ref: '#/definitions/iteminfo.MediaMetadata'
description: media metadata for audio/video files (includes duration)
modified:
description: modification time
type: string
name:
description: name of the file
type: string
onlyOfficeId:
description: id for onlyoffice files
type: string
path:
description: path scoped to the associated index
type: string
size:
description: length in bytes for regular files
type: integer
source:
description: associated index source for the file
type: string
subtitles:
description: subtitles for video files
items:
$ref: '#/definitions/utils.SubtitleTrack'
type: array
token:
description: token for the file -- used for sharing
type: string
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
iteminfo.ExtendedItemInfo:
properties:
hasPreview:
@@ -685,6 +751,14 @@ definitions:
disableReuse:
description: enable to always create a new indexing database on startup.
type: boolean
startupIntegrityCheck:
description: 'the method used to check the integrity of the index database
on startup (default: quickCheck)'
enum:
- quickCheck
- probe
- "off"
type: string
walMode:
description: enable the more complex WAL journaling mode. Slower, more memory
usage, but better for deployments with constant user activity.
@@ -1103,8 +1177,8 @@ definitions:
- $ref: '#/definitions/settings.ConditionalFilter'
description: 'deprecated: use source.rules instead'
createUserDir:
description: create a user directory for each user under defaultUserScope
+ username
description: 'deprecated: create a user directory for each user under defaultUserScope
+ username (always true now)'
type: boolean
defaultEnabled:
description: should be added as a default source for new users?
@@ -1953,6 +2027,27 @@ definitions:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
type: object
utils.SubtitleTrack:
properties:
codec:
description: codec name for embedded subtitles
type: string
embedded:
description: true for embedded subtitles, false for external files
type: boolean
index:
description: stream index for embedded subtitles (nil for external)
type: integer
language:
description: language code
type: string
name:
description: filename for external, or descriptive name for embedded
type: string
title:
description: title/description
type: string
type: object
info:
contact: {}
paths:
@@ -2670,6 +2765,45 @@ paths:
summary: List API tokens
tags:
- Auth
/api/media/metadata:
get:
consumes:
- application/json
description: Same ExtendedFileInfo as resources GET with metadata=true (typically
used for directories).
parameters:
- description: Path to the directory
in: query
name: path
required: true
type: string
- description: Source name
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Directory with media metadata
tags:
- Resources
/api/media/subtitles:
get:
consumes:
@@ -2915,7 +3049,8 @@ paths:
name: path
required: true
type: string
- description: Skip extended attributes for faster retrieval, no hasPreview
- description: When true, omit index-level extended fields (e.g. hasPreview);
does not disable ffmpeg/media extraction
in: query
name: skipExtendedAttrs
type: string
@@ -2928,7 +3063,8 @@ paths:
in: query
name: content
type: string
- description: Extract audio/video metadata if true
- description: When true, run audio/video metadata extraction, subtitles, and
directory media batch processing
in: query
name: metadata
type: string
@@ -3339,6 +3475,50 @@ paths:
summary: Get directory items
tags:
- Resources
/api/resources/pause:
post:
consumes:
- application/json
description: Sets a short-lived server flag for the given source and path. When
the active chunk POST then fails (e.g. client abort), the server trims the
incomplete chunk but keeps the temp partial file for resume.
parameters:
- description: Destination path (same as upload)
in: query
name: path
required: true
type: string
- description: Source name (same as upload)
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
"200":
description: Pause registered
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Source not found
schema:
additionalProperties:
type: string
type: object
summary: Register graceful chunked upload pause
tags:
- Resources
/api/resources/preview:
get:
consumes:
@@ -4103,6 +4283,28 @@ paths:
schema:
$ref: '#/definitions/http.HttpResponse'
summary: Health Check
/public/api/media/metadata:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
summary: Directory with media metadata (public share)
tags:
- Shares
/public/api/resources:
get:
consumes:
@@ -4528,6 +4730,43 @@ paths:
summary: Get directory items (public share)
tags:
- Shares
/public/api/resources/pause:
post:
consumes:
- application/json
description: Same as authenticated pause, for upload shares. Uses hash and path
query parameters like other public resource APIs.
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share (same as upload)
in: query
name: path
required: true
type: string
produces:
- application/json
responses:
"200":
description: Pause registered
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Source not found
schema:
additionalProperties:
type: string
type: object
summary: Register graceful chunked upload pause (public share)
tags:
- Shares
/public/api/resources/preview:
get:
consumes:
+4 -4
View File
@@ -42,6 +42,7 @@
"eslint-plugin-vue": "^9.24.0",
"fs-extra": "^11.3.0",
"jsdom": "^25.0.1",
"typescript": "^6.0.0",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
@@ -5699,12 +5700,11 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+3
View File
@@ -16,6 +16,8 @@
"lint:fix": "eslint --fix --ext .js,.vue,.ts src/",
"i18n:sync": "node ./scripts/sync-translations.js",
"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 .",
"test": "vitest run "
},
@@ -57,6 +59,7 @@
"eslint-plugin-vue": "^9.24.0",
"fs-extra": "^11.3.0",
"jsdom": "^25.0.1",
"typescript": "^6.0.0",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
+1 -1
View File
@@ -43,7 +43,6 @@ server:
ignoreSymlinks: false # Excludes symbolic links
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
defaultEnabled: true # should be added as a default source for new users?
createUserDir: false # create a user directory for each user under defaultUserScope + username
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
@@ -58,6 +57,7 @@ server:
cacheSizeMB: 32 # size of the SQLite cache in MB
walMode: false # enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
disableReuse: false # enable to always create a new indexing database on startup.
startupIntegrityCheck: "quickCheck" # the method used to check the integrity of the index database on startup (default: quickCheck) validate:omitempty,oneof=quickCheck probe off
disableWebDAV: false # disable webdav support (default: false)
auth:
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -13,8 +13,7 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-icons.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols.woff2" as="font" type="font/woff2" crossorigin>
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
+119 -52
View File
@@ -8,6 +8,8 @@ import * as deepl from 'deepl-node';
// Parse command line arguments
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('-c');
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
@@ -19,13 +21,14 @@ const targetLocaleFiles = glob.sync(path.join(localesDir, '*.json'))
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
.filter(file => path.basename(file) !== 'is.json'); // Exclude Icelandic - DeepL doesn't support it
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (!checkOnly && !DEEPL_API_KEY) {
if (requireApiKey && !DEEPL_API_KEY) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
process.exit(1);
}
const translator = checkOnly ? null : new deepl.Translator(DEEPL_API_KEY);
const translator = requireApiKey ? new deepl.Translator(DEEPL_API_KEY) : null;
const deeplLangMap = {
'zh-cn': 'ZH-HANS',
@@ -95,8 +98,23 @@ async function translateText(text, targetLanguage, keyPath = '') {
}
}
// Reorder objects to match eng (master lang) key order
function reorderObject(obj, masterObj) {
// If obj is not an object, return as is
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)) {
newObj[key] = reorderObject(obj[key], masterObj[key]);
}
}
}
return newObj;
}
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
let changesMade = false;
let meaningfulChanges = 0; // Only count meaningful changes
@@ -109,19 +127,17 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
const masterValue = masterObj[key];
// Special handling for "languages" key - always copy the entire object from master
if (key === 'languages' && currentPathParts.length === 0) {
if (checkOnly) {
// Don't print or count languages object copying - it's routine
} else {
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
if (!checkOnly && !doEnforceOrder) {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
}
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
changesMade = true;
continue;
}
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
if (!targetObj[key] || typeof targetObj[key] !== 'object') {
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
if (checkOnly) {
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
meaningfulChanges++; // Creating new object structures is meaningful
@@ -133,41 +149,55 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
}
changesMade = true;
}
const result = await processKeys(masterValue, targetObj[key], targetLangCode, currentPathPartsNext);
if (result == "UNSUPPORTED") {
if (targetObj[key] && typeof targetObj[key] === 'object') {
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
doEnforceOrder, doCleanupOnly);
if (result == "UNSUPPORTED") {
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
} else if (typeof masterValue === 'string') {
if (!targetObj.hasOwnProperty(key) || targetObj[key] === '' || targetObj[key] === null) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else {
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
} else if (!doCleanupOnly) {
// Only handle non‑object values
if (typeof masterValue === 'string') {
const existingTargetValue = targetObj[key];
const isMissing = !Object.prototype.hasOwnProperty.call(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
if (isMissing) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else if (doEnforceOrder) {
// Skip translations for new keys
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
} else {
// Sync translation
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
changesMade = true;
}
targetObj[key] = result;
}
changesMade = true;
}
} else {
if (!targetObj.hasOwnProperty(key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
} else {
if (!Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
}
changesMade = true;
}
}
}
@@ -177,7 +207,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
const keysToRemove = [];
for (const key in targetObj) {
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (!masterObj.hasOwnProperty(key)) {
if (!Object.prototype.hasOwnProperty.call(masterObj, key)) {
keysToRemove.push(key);
}
}
@@ -200,9 +230,17 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// --- Main synchronization ---
async function syncAllTranslations() {
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
const isEnforceOrder = enforceOrder && !cleanupOnly;
const isCleanup = cleanupOnly;
if (checkOnly) {
console.log("--- Checking for translation changes (no translations will be performed) ---");
} else {
} else if (isCleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else if (isEnforceOrder) {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
} else if (isSync) {
console.warn("--- Using DeepL API for translation ---");
}
@@ -220,38 +258,61 @@ async function syncAllTranslations() {
for (const targetFile of targetLocaleFiles) {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
let originalTargetContent = null;
let fileExisted = await fs.pathExists(targetFile);
if (fileExisted) {
try {
targetContent = await fs.readJson(targetFile);
originalTargetContent = await fs.readJson(targetFile);
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
} catch (e) {
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
targetContent = {};
originalTargetContent = null;
}
} else {
if (isCleanup) continue;
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
}
const result = await processKeys(masterContent, targetContent, targetLangCode);
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
let reorderedContent = targetContent;
let orderChanged = false;
if (isEnforceOrder && originalTargetContent && !checkOnly) {
reorderedContent = reorderObject(targetContent, masterContent);
// Compare the reordered content with the original to see if order changed
const origStr = JSON.stringify(originalTargetContent);
const reorderedStr = JSON.stringify(reorderedContent);
if (origStr !== reorderedStr) {
orderChanged = true;
console.log(`- Reordered keys in ${targetLangCode}.json`);
}
}
if (checkOnly) {
let wouldChange = false;
if (typeof result === 'number' && result > 0) {
meaningfulChanges += result;
hasMeaningfulChanges = true;
wouldChange = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
} else if (!fileExisted) {
meaningfulChanges++;
hasMeaningfulChanges = true;
}
if (!fileExisted && !isCleanup) {
wouldChange = true;
console.log(`Would create new file for ${targetLangCode}.json`);
} else {
// Only print if there are meaningful changes
}
if (wouldChange) {
hasMeaningfulChanges = true;
}
} else {
if (result || !fileExisted) {
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
if (needsWrite) {
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
try {
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
if (!isCleanup && !isEnforceOrder) {
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
}
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
}
@@ -270,7 +331,13 @@ async function syncAllTranslations() {
return 0; // Exit code 0 for no meaningful changes
}
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
if (isCleanup) {
console.log('\n✅ Cleanup complete (obsolete keys removed).');
} else if (isEnforceOrder) {
console.log('\n✅ Enforce order complete.');
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
}
return 0;
}
}
+42 -2
View File
@@ -1,6 +1,7 @@
import { fetchURL } from "./utils";
import { fetchURL, adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { state } from "@/store";
// GET /api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
@@ -18,4 +19,43 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
// GET /api/media/metadata — same directory shape as resources with metadata=true (adjusted to items[]).
/** @returns {Promise<object>} directory resource (adjustedData) */
export async function fetchDirectoryMediaMetadata(source, path) {
const apiPath = getApiPath("media/metadata", { source, path });
const res = await fetchURL(apiPath);
const data = await res.json();
return adjustedData(data);
}
// GET /public/api/media/metadata
/** @returns {Promise<object>} directory resource (adjustedData) */
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "") {
const params = {
path,
hash,
...(state.shareInfo.token && { token: state.shareInfo.token }),
};
const apiPath = getPublicApiPath("media/metadata", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": password || "" },
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
try {
data = await response.json();
} catch (e) {
// ignore
}
if (data?.message) {
error.message = data.message;
}
/** @type {any} */ (error).status = response.status;
throw error;
}
const data = await response.json();
return adjustedData(data);
}
+57 -3
View File
@@ -47,6 +47,31 @@ export async function getItems(source, path, only = "") {
}
}
/**
* Registers a graceful pause for the active chunked upload at this path.
* Call immediately before aborting the chunk XHR so the server keeps partial data.
*/
export async function signalUploadPause(source, path, shareHash = null) {
if (shareHash) {
const apiPath = getPublicApiPath('resources/pause', {
hash: shareHash,
path: path,
})
const headers = {}
const sharePassword = localStorage.getItem('sharepass:' + shareHash)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
await fetchURL(apiPath, { method: 'POST', headers })
return
}
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
const apiPath = getApiPath('resources/pause', { path, source })
await fetchURL(apiPath, { method: 'POST' })
}
async function resourceAction(source, path, method, content) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
@@ -194,6 +219,19 @@ export async function download(format, files, shareHash = "") {
}, 100)
}
/** Best-effort text from a failed download response (consumes body). */
async function readDownloadErrorBody(response) {
try {
const text = await response.text()
if (text && text.length > 0) {
return text.length > 300 ? `${text.slice(0, 300)}…` : text
}
} catch {
// ignore
}
return ''
}
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
@@ -259,7 +297,18 @@ async function downloadChunked(file, shareHash = "") {
})
if (!response.ok && response.status !== 206) {
throw new Error(`Failed to download chunk: ${response.statusText}`)
const body = (await readDownloadErrorBody(response)).trim()
const fromHttp =
body ||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim()
throw new Error(fromHttp || "Request failed")
}
if (!response.body) {
throw new Error(
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim() ||
"No response body"
)
}
// Track progress within the chunk using ReadableStream
@@ -303,6 +352,10 @@ async function downloadChunked(file, shareHash = "") {
throw readError; // Re-throw other errors
}
if (chunkLoaded < expectedChunkSize) {
throw new Error("Connection closed before the full chunk was received")
}
// Combine chunk parts into single ArrayBuffer
const chunk = new Uint8Array(chunkLoaded);
let position = 0;
@@ -355,8 +408,9 @@ async function downloadChunked(file, shareHash = "") {
// Don't throw error or show notification for user-initiated cancellation
return;
}
downloadManager.setError(downloadId, error.message || 'Download failed')
notify.showError(`Chunked download failed: ${error.message}`)
const message =
error instanceof Error ? error.message || "Unknown error" : String(error)
downloadManager.setError(downloadId, message)
throw error
}
}
+2 -2
View File
@@ -30,8 +30,8 @@
:title="label"
class="action no-select"
>
<i v-if="icon == 'table_rows_narrow'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-icons">{{ icon }}</i>
<i v-if="icon == 'table_rows_narrow' || icon == 'content_copy'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-symbols">{{ icon }}</i>
<span>{{ label }}</span>
<span v-if="counter > 0" class="counter">{{ counter }}</span>
+4 -4
View File
@@ -20,8 +20,8 @@
v-if="showCreateButton"
@click="toggleShowCreate"
>
<i v-if="!showCreate" class="material-icons">add</i>
<i v-else class="material-icons">arrow_back</i>
<i v-if="!showCreate" class="material-symbols">add</i>
<i v-else class="material-symbols">arrow_back</i>
</div>
<div
v-if="selectedCount > 0"
@@ -84,7 +84,7 @@
/>
<action
v-if="showRename"
icon="mode_edit"
icon="edit"
:label="$t('general.rename')"
@action="showRenamePrompt"
/>
@@ -158,7 +158,7 @@
<action icon="info" :label="$t('general.info')" @action="showInfoPrompt"/>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
<action v-if="isPreview && permissions.modify" icon="edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
<action v-if="showWatch" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchivePromptFromPreview" />
+4 -4
View File
@@ -19,10 +19,10 @@
}"
>
<!-- Close button - always present on every notification, separate from optional buttons array -->
<i class="material-icons" @click="closeNotification(notification.id)">close</i>
<i class="material-symbols" @click="closeNotification(notification.id)">close</i>
<div class="notification-content-wrapper">
<div class="notification-header">
<i v-if="notification.icon" class="material-icons notification-icon">
<i v-if="notification.icon" class="material-symbols notification-icon">
{{ notification.icon }}
</i>
<div class="notification-message">{{ notification.message }}</div>
@@ -255,13 +255,13 @@ export default {
border-color: rgba(255, 255, 255, 0.5);
}
.notification-item > .material-icons:first-child {
.notification-item > .material-symbols:first-child {
cursor: pointer;
font-size: 1.75em;
opacity: 0.8;
}
.notification-item > .material-icons:first-child:hover {
.notification-item > .material-symbols:first-child:hover {
opacity: 1;
}
+4 -2
View File
@@ -21,7 +21,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
{{ displayed_text }}
<i
v-if="helpText && status === 'error'"
class="no-select material-symbols-outlined tooltip-info-icon"
class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
@@ -354,7 +354,9 @@ export default {
opacity: 0.7;
}
.tooltip-info-icon:hover {
.tooltip-info-icon:hover,
.tooltip-info-icon:hover .material-symbols-outlined {
font-variation-settings: 'FILL' 1;
opacity: 1;
}
+5 -8
View File
@@ -5,10 +5,10 @@
<!-- Close button visible when search is active -->
<button v-if="active" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<i class="material-icons">close</i>
<i class="material-symbols">close</i>
</button>
<!-- Search icon when search is not active -->
<i v-else class="material-icons">search</i>
<i v-else class="material-symbols">search</i>
<!-- Input field for search -->
<input id="search-input" type="text"
@keyup.exact="keyup" @input="submit" ref="input" :autofocus="active" v-model.trim="value"
@@ -73,7 +73,7 @@
<!-- Message when no results are found -->
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
<i class="material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
@mouseleave="hideTooltip">
help
</i>
@@ -381,9 +381,6 @@ export default {
const fullPath = context + '/' + path;
return url.buildItemUrl(source, fullPath, true);
},
getIcon(mimetype) {
return getMaterialIconForType(mimetype);
},
enableOptions() {
this.hiddenOptions = false;
this.toggleOptionButton = [{ label: "Hide Options" }];
@@ -694,12 +691,12 @@ export default {
width: 100%;
}
.search-input-container .material-icons {
.search-input-container .material-symbols {
font-size: 1.25em;
color: rgba(255, 255, 255, 0.7);
}
#search.active .search-input-container .material-icons {
#search.active .search-input-container .material-symbols {
color: inherit;
}
+76 -11
View File
@@ -1,15 +1,20 @@
<template>
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-content" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div v-if="selectedCount > 0" class="status-info">
<span class="button">{{ selectedCount }}</span>
<span>{{ selectedItemsText }}</span>
</div>
<div v-else class="status-info">
<span class="directory-info">{{ directoryInfoText }}</span>
<!-- Left side: selection/directory info and stats for the editor and markdown viewer -->
<div class="status-info">
<template v-if="isEditorOrMarkdownView">
<span>{{ editorStatsText }}</span>
</template>
<template v-else>
<span v-if="selectedCount > 0" class="button">{{ selectedCount }}</span>
<span v-if="selectedCount > 0">{{ selectedItemsText }}</span>
<span v-else>{{ directoryInfoText }}</span>
</template>
</div>
<!-- Right side: Slider to control the listing size or the font size in editor -->
<div class="status-controls">
<div v-if="showGallerySize" class="gallery-size-control">
<div v-if="currentView === 'listingView'" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="gallerySize"
@@ -22,6 +27,17 @@
@change="commitGallerySize"
/>
</div>
<div v-if="currentView === 'editor'" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="editorFontSize"
type="range"
min="10"
max="24"
step="1"
/>
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
</div>
</div>
@@ -39,11 +55,17 @@ export default {
};
},
computed: {
currentView() {
return getters.currentView();
},
isEditorOrMarkdownView() {
return this.currentView === 'editor' || this.currentView === 'markdownViewer';
},
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return getters.currentView() === "listingView";
return this.currentView === "listingView" || this.isEditorOrMarkdownView;
},
isDarkMode() {
return getters.isDarkMode();
@@ -57,9 +79,6 @@ export default {
numFiles() {
return getters.reqNumFiles();
},
showGallerySize() {
return getters.isCardView() && state.req?.items?.length > 0;
},
totalDirectorySize() {
if (!Array.isArray(state.req?.items)) return 0;
return state.req.items.reduce((total, item) => total + (item.size || 0), 0);
@@ -117,6 +136,30 @@ export default {
}
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(' | ');
},
editorFontSize: {
get() {
return state.editorFontSize;
},
set(value) {
mutations.setEditorFontSize(value);
}
},
},
mounted() {
window.addEventListener('wheel', this.handleWheel, { passive: false });
},
beforeUnmount() {
window.removeEventListener('wheel', this.handleWheel);
},
methods: {
updateGallerySize(event) {
@@ -157,6 +200,28 @@ export default {
mutations.updateCurrentUser({ viewMode: newMode });
}
},
// Ctrl + Mouse Wheel to adjust the slider sizes
handleWheel(event) {
if (!event.ctrlKey) return;
const delta = event.deltaY > 0 ? 1 : -1; // Scroll down increases, up decreases
if (this.currentView === 'listingView') {
event.preventDefault();
let newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
if (newSize !== this.gallerySize) {
this.gallerySize = newSize;
mutations.setGallerySize(newSize);
this.adjustViewMode();
}
} else if (this.currentView === 'editor') {
event.preventDefault();
let newSize = Math.min(24, Math.max(10, this.editorFontSize - delta));
if (newSize !== this.editorFontSize) {
this.editorFontSize = newSize;
}
}
}
},
};
</script>
+1 -1
View File
@@ -12,7 +12,7 @@
</template>
<script>
import { getIconClass } from "@/utils/material-icons";
import { getIconClass } from "@/utils/material-symbols";
export default {
name: "Toast",
+29 -53
View File
@@ -8,7 +8,7 @@
@dragleave.prevent="dragLeave($event, homeLink)"
@dragover.prevent="dragOver($event, homeLink)"
@drop.prevent="drop($event, homeLink)">
<i class="material-icons">home</i>
<i class="material-symbols">home</i>
</router-link>
</li>
<li class="item" v-for="(link, index) in items" :key="index">
@@ -324,6 +324,8 @@ export default {
<style scoped>
#breadcrumbs {
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
@@ -331,12 +333,6 @@ export default {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
#breadcrumbs {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
#breadcrumbs * {
box-sizing: unset;
}
@@ -350,15 +346,16 @@ export default {
#breadcrumbs ul li {
display: inline-block;
margin: 0 8px 0 0;
position: relative;
}
#breadcrumbs ul li a {
display: flex;
height: 0.85em;
background: color-mix(in srgb, var(--alt-background) 90%, transparent);
background: var(--surfacePrimary);
text-align: center;
padding: 0.85em;
padding-right: 1.6em;
padding-left: 1.7em;
position: relative;
text-decoration: none;
@@ -370,6 +367,15 @@ export default {
user-select: none;
white-space: nowrap;
max-width: 90vw;
clip-path: polygon(
0% 0%,
calc(100% - 1.275em) 0%,
100% 50%,
calc(100% - 1.275em) 100%,
0% 100%,
1.275em 50%);
margin-right: -0.85em;
border: 1px solid rgba(0, 0, 0, 0.1);
}
#breadcrumbs ul li a .breadcrumb-text {
@@ -379,51 +385,29 @@ export default {
min-width: 0;
}
#breadcrumbs ul li a::after {
content: "";
border-top: 1.275em solid transparent;
border-bottom: 1.275em solid transparent;
border-left: 1.275em solid var(--alt-background);
border-left-color: color-mix(in srgb, var(--alt-background) 90%, transparent);
position: absolute;
right: -1.25em;
top: 0;
z-index: 5;
transition: all 0.2s ease;
}
#breadcrumbs ul li a::before {
content: "";
border-top: 1.275em solid transparent;
border-bottom: 1.275em solid transparent;
border-left: 1.275em solid var(--background);
border-left-color: color-mix(in srgb, var(--background) 85%, transparent);
position: absolute;
left: 0;
top: 0;
z-index: 1;
transition: all 0.2s ease;
}
#breadcrumbs ul li:first-child a {
border-top-left-radius: 0.85em;
border-bottom-left-radius: 0.85em;
padding-left: 1.275em;
z-index: 3;
}
#breadcrumbs ul li:first-child a::before {
display: none;
clip-path: polygon(
0% 0%,
calc(100% - 1.275em) 0%,
100% 50%,
calc(100% - 1.275em) 100%,
0% 100%);
padding-left: 1.1em;
}
#breadcrumbs ul li:last-child a {
padding-right: 1.275em;
border-top-right-radius: 0.85em;
border-bottom-right-radius: 0.85em;
}
#breadcrumbs ul li:last-child a::after {
display: none;
clip-path: polygon(
0% 0%,
100% 0%,
100% 100%,
0% 100%,
1.275em 50%);
margin-right: 0;
}
#breadcrumbs ul li a:hover {
@@ -431,10 +415,6 @@ export default {
color: white;
}
#breadcrumbs ul li a:hover::after {
border-left-color: var(--primaryColor);
}
#breadcrumbs ul li:last-child a.changeAvailable {
filter: contrast(0.8) hue-rotate(200deg) saturate(1);
}
@@ -445,10 +425,6 @@ export default {
z-index: 2;
}
.drag-over::after {
border-left-color: var(--primaryColor) !important;
}
@keyframes breadcrumbPulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
+378 -104
View File
@@ -1,5 +1,5 @@
<template>
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove.prevent="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
@mousedown="mousedownStart" @mousemove="mouseMove" @mouseup="mouseUp" @wheel="wheelMove">
<!-- Thumbnail placeholder (shown while full image loads, only if cached thumbnail exists) -->
<img
@@ -35,7 +35,7 @@
</template>
<script>
import { state, mutations, getters } from "@/store";
import { state, mutations, getters } from '@/store';
import throttle from "@/utils/throttle";
import { notify } from "@/notify";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
@@ -76,18 +76,25 @@ export default {
maxScale: 4,
minScale: 1,
isTiff: false,
// Swipe navigation properties
swipeStartTime: null,
swipeStartX: 0,
swipeStartY: 0,
swipeCurrentX: 0,
swipeCurrentY: 0,
isSwipeGesture: false,
hasStartedSwipe: false,
// Full-frame edge gestures (touch + mouse), zoom scale === 1 only
edgeKind: null, // null | 'horizontal' | 'vertical-dismiss'
edgeStartX: 0,
edgeStartY: 0,
edgeDx: 0,
edgeDy: 0,
dragOffsetX: 0,
dragOffsetY: 0,
gestureDecided: false,
swipeMinDistance: 150,
swipeMaxTime: 500,
swipeMaxVerticalDistance: 50,
gestureSnapBack: false,
edgeMouseActive: false,
showNavHint: false,
navHintDir: 'next', // 'prev' | 'next' — chevrons match nextPrevious.vue
showDismissHint: false,
dismissFlashActive: false,
edgeHintPx: 44,
edgeCommitX: 130,
edgeCommitY: 110,
edgeRubberMax: 100,
};
},
computed: {
@@ -119,6 +126,38 @@ export default {
const source = getters.isShare() ? state.shareInfo?.hash : this.source;
return getBestCachedImage(source, this.path, state.req?.modified);
},
navigationGestureAllowed() {
return state.navigation.enabled && getters.currentPrompt() == null;
},
hasImagePrevious() {
return this.navigationGestureAllowed && state.navigation.previousLink !== '';
},
hasImageNext() {
return this.navigationGestureAllowed && state.navigation.nextLink !== '';
},
navPrevCommitReady() {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
return (
this.hasImagePrevious &&
this.edgeDx >= this.edgeCommitX &&
ax >= ay
);
},
navNextCommitReady() {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
return (
this.hasImageNext &&
this.edgeDx <= -this.edgeCommitX &&
ax >= ay
);
},
dismissCommitReady() {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
return this.edgeDy >= this.edgeCommitY && ay >= ax;
},
},
mounted() {
this.isTiff = this.checkIfTiff(this.src);
@@ -152,10 +191,32 @@ export default {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.teardownEdgeMouseListeners();
mutations.setNavigationGestureHint({});
window.removeEventListener("resize", this.onResize);
document.removeEventListener("mouseup", this.onMouseUp);
},
methods: {
syncNavigationGestureHintToStore() {
let kind = null;
let commitReady = false;
let flashClose = false;
if (this.dismissFlashActive) {
kind = 'close';
commitReady = this.dismissCommitReady;
flashClose = true;
} else if (this.showDismissHint) {
kind = 'close';
commitReady = this.dismissCommitReady;
} else if (this.showNavHint && this.navHintDir === 'prev' && this.hasImagePrevious) {
kind = 'previous';
commitReady = this.navPrevCommitReady;
} else if (this.showNavHint && this.navHintDir === 'next' && this.hasImageNext) {
kind = 'next';
commitReady = this.navNextCommitReady;
}
mutations.setNavigationGestureHint({ kind, commitReady, flashClose });
},
loadFullImage() {
if (!this.src) return;
mutations.setLoading("preview-img", true);
@@ -179,8 +240,11 @@ export default {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.setCenter();
mutations.setLoading("preview-img", false);
// fullImageLoaded flips display from none → block via :style. Vue hasn't
// updated the DOM yet, so setCenter() in the same tick sees clientWidth/Height 0.
// Defer until after layout so centering / transform apply correctly.
this.scheduleSetCenter();
},
onImageError(event) {
const img = event.target;
@@ -201,6 +265,7 @@ export default {
if (img) {
img.style.display = 'block';
}
this.scheduleSetCenter();
},
checkIfTiff(src) {
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
@@ -226,44 +291,292 @@ export default {
onMouseUp() {
this.inDrag = false;
},
scheduleSetCenter() {
this.$nextTick(() => {
requestAnimationFrame(() => {
this.setCenter();
const img = this.$refs.imgex;
const container = this.$refs.container;
if (
img &&
this.fullImageLoaded &&
(!img.clientWidth || !img.clientHeight)
) {
requestAnimationFrame(() => this.setCenter());
}
// Force layout + compositor to pick up the decoded bitmap. Without this,
// WebKit/Blink sometimes leave the promoted layer black until display/position
// is toggled in DevTools or the window is resized.
requestAnimationFrame(() => {
if (!img || !this.fullImageLoaded) {
return;
}
void container?.offsetHeight;
void img.offsetHeight;
const prevOp = img.style.opacity;
img.style.opacity = '0.9999';
requestAnimationFrame(() => {
img.style.opacity = prevOp;
});
});
});
});
},
onResize: throttle(function () {
if (this.imageLoaded) {
this.setCenter();
this.doMove(this.position.relative.x, this.position.relative.y);
}
}, 100),
rubberband(value, max) {
const sign = value < 0 ? -1 : 1;
const a = Math.abs(value);
if (a <= max) {
return value;
}
return sign * (max + (a - max) * 0.32);
},
applyImgTransform() {
const img = this.$refs.imgex;
if (!img) {
return;
}
const transition = this.gestureSnapBack
? 'transform 0.22s cubic-bezier(0.32, 0.72, 0, 1)'
: 'none';
img.style.transition = transition;
if (this.scale === 1) {
img.style.transform = `translate3d(calc(-50% + ${this.dragOffsetX}px), calc(-50% + ${this.dragOffsetY}px), 0) scale(1)`;
} else {
const { x: rx, y: ry } = this.position.relative;
img.style.transform = `translate3d(calc(-50% + ${rx}px), calc(-50% + ${ry}px), 0) scale(${this.scale})`;
}
},
decideEdgeKind() {
if (this.edgeKind) {
return;
}
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax < 12 && ay < 12) {
return;
}
if (this.edgeDy > ax * 1.12 && this.edgeDy > 14) {
this.edgeKind = 'vertical-dismiss';
this.gestureDecided = true;
} else if (ax > ay * 1.12 && ax > 14) {
this.edgeKind = 'horizontal';
this.gestureDecided = true;
}
},
applyEdgeVisuals() {
if (!this.edgeKind) {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax <= 8 && ay <= 8) {
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.showNavHint = false;
this.showDismissHint = false;
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
return;
}
if (ax > ay) {
this.dragOffsetX = this.rubberband(this.edgeDx, this.edgeRubberMax);
this.dragOffsetY = 0;
this.showNavHint = ax >= this.edgeHintPx;
this.navHintDir = this.edgeDx > 0 ? 'prev' : 'next';
if (this.navHintDir === 'prev' && !this.hasImagePrevious) {
this.showNavHint = false;
}
if (this.navHintDir === 'next' && !this.hasImageNext) {
this.showNavHint = false;
}
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
}
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
return;
}
if (this.edgeKind === 'horizontal') {
this.dragOffsetX = this.rubberband(this.edgeDx, this.edgeRubberMax);
this.dragOffsetY = 0;
const adx = Math.abs(this.edgeDx);
this.showNavHint = adx >= this.edgeHintPx;
this.navHintDir = this.edgeDx > 0 ? 'prev' : 'next';
if (this.navHintDir === 'prev' && !this.hasImagePrevious) {
this.showNavHint = false;
}
if (this.navHintDir === 'next' && !this.hasImageNext) {
this.showNavHint = false;
}
this.showDismissHint = false;
} else {
this.dragOffsetX = 0;
const downward = this.edgeDy > 0 ? this.edgeDy : 0;
this.dragOffsetY = this.rubberband(downward, this.edgeRubberMax);
this.showDismissHint = this.edgeDy >= this.edgeHintPx;
this.showNavHint = false;
}
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
},
snapBackEdgeGesture() {
this.gestureSnapBack = true;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.showNavHint = false;
this.showDismissHint = false;
this.edgeKind = null;
this.gestureDecided = false;
this.edgeDx = 0;
this.edgeDy = 0;
this.applyImgTransform();
mutations.setNavigationGestureHint({});
setTimeout(() => {
this.gestureSnapBack = false;
this.applyImgTransform();
}, 240);
},
resetEdgeGestureImmediate() {
this.edgeKind = null;
this.gestureDecided = false;
this.edgeDx = 0;
this.edgeDy = 0;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.showNavHint = false;
this.showDismissHint = false;
this.gestureSnapBack = false;
this.dismissFlashActive = false;
mutations.setNavigationGestureHint({});
},
finishEdgeGesture() {
if (this.scale !== 1) {
this.resetEdgeGestureImmediate();
this.applyImgTransform();
return;
}
let kind = this.edgeKind;
if (!kind) {
const ax = Math.abs(this.edgeDx);
const ay = Math.abs(this.edgeDy);
if (ax < this.edgeHintPx && ay < this.edgeHintPx) {
this.snapBackEdgeGesture();
return;
}
kind = ax >= ay ? 'horizontal' : 'vertical-dismiss';
}
if (kind === 'horizontal') {
if (this.edgeDx >= this.edgeCommitX && this.hasImagePrevious) {
this.$emit('navigate-previous');
this.resetEdgeGestureImmediate();
this.applyImgTransform();
return;
}
if (this.edgeDx <= -this.edgeCommitX && this.hasImageNext) {
this.$emit('navigate-next');
this.resetEdgeGestureImmediate();
this.applyImgTransform();
return;
}
} else if (kind === 'vertical-dismiss') {
if (this.edgeDy >= this.edgeCommitY) {
this.dismissFlashActive = true;
this.showDismissHint = true;
this.dragOffsetX = 0;
this.dragOffsetY = 0;
this.edgeKind = null;
this.gestureDecided = false;
this.applyImgTransform();
this.syncNavigationGestureHintToStore();
setTimeout(() => {
this.$emit('close-preview');
}, 120);
setTimeout(() => {
this.dismissFlashActive = false;
this.showDismissHint = false;
mutations.setNavigationGestureHint({});
}, 420);
return;
}
}
this.snapBackEdgeGesture();
},
teardownEdgeMouseListeners() {
document.removeEventListener('mousemove', this.onEdgeMouseMove, true);
document.removeEventListener('mouseup', this.onEdgeMouseUp, true);
this.edgeMouseActive = false;
},
onEdgeMouseMove(event) {
if (!this.edgeMouseActive || this.scale !== 1) {
return;
}
this.edgeDx = event.clientX - this.edgeStartX;
this.edgeDy = event.clientY - this.edgeStartY;
this.decideEdgeKind();
this.applyEdgeVisuals();
event.preventDefault();
},
onEdgeMouseUp(event) {
if (!this.edgeMouseActive) {
return;
}
this.teardownEdgeMouseListeners();
this.edgeDx = event.clientX - this.edgeStartX;
this.edgeDy = event.clientY - this.edgeStartY;
this.finishEdgeGesture();
event.preventDefault();
},
setCenter() {
const container = this.$refs.container;
const img = this.$refs.imgex;
if (!container || !img || !img.clientWidth || !img.clientHeight) {
return;
}
// Images are centered using CSS (top: 50%, left: 50%, transform: translate(-50%, -50%))
// Reset pan position when centering
this.position.relative = { x: 0, y: 0 };
this.position.center.x = Math.floor((container.clientWidth - img.clientWidth) / 2);
this.position.center.y = Math.floor((container.clientHeight - img.clientHeight) / 2);
// Update transform to reflect centered state
if (this.scale === 1) {
img.style.transform = 'translate(-50%, -50%) scale(1)';
} else {
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
this.resetEdgeGestureImmediate();
this.applyImgTransform();
},
mousedownStart(event) {
if (this.scale === 1) {
this.teardownEdgeMouseListeners();
this.edgeMouseActive = true;
this.edgeStartX = event.clientX;
this.edgeStartY = event.clientY;
this.edgeDx = 0;
this.edgeDy = 0;
this.edgeKind = null;
this.gestureDecided = false;
document.addEventListener('mousemove', this.onEdgeMouseMove, true);
document.addEventListener('mouseup', this.onEdgeMouseUp, true);
event.preventDefault();
return;
}
this.lastX = null;
this.lastY = null;
this.inDrag = true;
event.preventDefault();
},
mouseMove(event) {
if (!this.inDrag) return;
this.doMove(event.movementX, event.movementY);
event.preventDefault();
if (this.scale > 1 && this.inDrag) {
this.doMove(event.movementX, event.movementY);
event.preventDefault();
}
},
mouseUp(event) {
this.inDrag = false;
event.preventDefault();
if (this.scale > 1) {
this.inDrag = false;
event.preventDefault();
}
},
touchStart(event) {
this.lastX = null;
@@ -271,16 +584,15 @@ export default {
this.lastTouchDistance = null;
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeStartTime = Date.now();
this.swipeStartX = touch.pageX;
this.swipeStartY = touch.pageY;
this.swipeCurrentX = touch.pageX;
this.swipeCurrentY = touch.pageY;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.edgeStartX = touch.pageX;
this.edgeStartY = touch.pageY;
this.edgeDx = 0;
this.edgeDy = 0;
this.edgeKind = null;
this.gestureDecided = false;
} else {
this.resetSwipeTracking();
this.resetEdgeGestureImmediate();
this.applyImgTransform();
}
if (event.targetTouches.length < 2) {
setTimeout(() => {
@@ -314,30 +626,15 @@ export default {
event.preventDefault();
},
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) {
const touch = event.targetTouches[0];
this.swipeCurrentX = touch.pageX;
this.swipeCurrentY = touch.pageY;
if (!this.gestureDecided) {
const deltaX = Math.abs(this.swipeCurrentX - this.swipeStartX);
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
if (deltaX > 10 || deltaY > 10) {
this.gestureDecided = true;
if (deltaX > deltaY * 2) {
this.isSwipeGesture = true;
event.preventDefault();
} else {
this.isSwipeGesture = false;
}
}
}
if (this.gestureDecided && this.isSwipeGesture) {
event.preventDefault();
return;
}
}
if (this.scale > 1 || event.targetTouches.length >= 2) {
event.preventDefault();
this.edgeDx = touch.pageX - this.edgeStartX;
this.edgeDy = touch.pageY - this.edgeStartY;
this.decideEdgeKind();
this.applyEdgeVisuals();
return;
}
if (this.lastX === null) {
this.lastX = event.targetTouches[0].pageX;
@@ -377,11 +674,7 @@ export default {
doMove(x, y) {
this.position.relative.x += x;
this.position.relative.y += y;
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
this.applyImgTransform();
},
wheelMove(event) {
event.preventDefault()
@@ -392,52 +685,23 @@ export default {
this.scale = Math.max(this.minScale, Math.min(this.maxScale, this.scale));
if (this.scale === 1) {
this.position.relative = { x: 0, y: 0 };
this.resetEdgeGestureImmediate();
}
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
this.applyImgTransform();
},
pxStringToNumber(style) {
return +style.replace("px", "");
},
touchEnd(event) {
let handledSwipe = false;
if (this.isSwipeGesture && this.swipeStartTime && this.scale === 1) {
const swipeEndTime = Date.now();
const swipeDuration = swipeEndTime - this.swipeStartTime;
const deltaX = this.swipeCurrentX - this.swipeStartX;
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
const absDelataX = Math.abs(deltaX);
if (
swipeDuration <= this.swipeMaxTime &&
absDelataX >= this.swipeMinDistance &&
deltaY <= this.swipeMaxVerticalDistance
) {
if (deltaX > 0) {
this.$emit('navigate-previous');
} else {
this.$emit('navigate-next');
}
handledSwipe = true;
event.preventDefault();
}
}
if (!handledSwipe && this.scale > 1) {
if (this.scale === 1 && event.changedTouches.length > 0) {
const t = event.changedTouches[0];
this.edgeDx = t.pageX - this.edgeStartX;
this.edgeDy = t.pageY - this.edgeStartY;
this.finishEdgeGesture();
event.preventDefault();
} else if (this.scale > 1) {
event.preventDefault();
}
this.resetSwipeTracking();
},
resetSwipeTracking() {
this.swipeStartTime = null;
this.swipeStartX = 0;
this.swipeStartY = 0;
this.swipeCurrentX = 0;
this.swipeCurrentY = 0;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false;
},
},
watch: {
@@ -471,7 +735,9 @@ export default {
if (!this.fullImageLoaded && !this.imageLoaded) {
// Show the image even if load event didn't fire (might be partially loaded)
this.fullImageLoaded = true;
this.imageLoaded = true;
mutations.setLoading("preview-img", false);
this.scheduleSetCenter();
}
}, 30000); // 30 second timeout
});
@@ -479,7 +745,8 @@ export default {
this.scale = 1;
this.position.relative = { x: 0, y: 0 };
this.resetSwipeTracking();
this.resetEdgeGestureImmediate();
this.applyImgTransform();
},
},
};
@@ -487,12 +754,18 @@ export default {
<style>
.image-ex-container {
width: 100%;
height: 100%;
min-height: 0;
max-width: 100%;
max-height: 100%;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
/* Block browser pull-to-refresh / overscroll so vertical dismiss & edge gestures win */
overscroll-behavior: none;
touch-action: none;
}
.image-ex-img {
@@ -501,7 +774,8 @@ export default {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/* translate3d tends to composite more reliably than translate with decoded <img> bitmaps */
transform: translate3d(-50%, -50%, 0);
object-fit: contain;
}
@@ -509,7 +783,7 @@ export default {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform: translate3d(-50%, -50%, 0);
z-index: 10;
display: flex;
align-items: center;
+4 -4
View File
@@ -6,7 +6,7 @@
</div>
<!-- Error state -->
<div v-if="isRootInstance && error" class="tree-error">
<i class="material-icons">error</i>
<i class="material-symbols">error</i>
<span>{{ $t("prompts.error") }}</span>
</div>
<ul v-if="effectiveNodes && effectiveNodes.length" class="tree-list">
@@ -31,7 +31,7 @@
class="expand-icon"
@click.stop="toggleExpand(node, true)"
>
<i class="material-icons">
<i class="material-symbols">
{{ node.expanded ? 'expand_more' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</span>
@@ -55,7 +55,7 @@
:currentPath="currentPath"
/>
<div v-else-if="node.expanded && node.children && node.children.length === 0" class="tree-empty-folder">
<i class="material-icons">sentiment_dissatisfied</i>
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
<span>{{ $t('files.lonely') }}</span>
</div>
<div v-else-if="node.expanded && node.loading" class="tree-loading">
@@ -68,7 +68,7 @@
</ul>
<!-- Empty state -->
<div v-if="isRootInstance && effectiveNodes && effectiveNodes.length === 0 && !loading && !error" class="tree-empty">
<i class="material-icons">sentiment_dissatisfied</i>
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
{{ $t('files.lonely') }}
</div>
</div>
+69 -59
View File
@@ -2,9 +2,9 @@
<!-- Unified preview container for all types -->
<span v-if="hasPreviewImage || shouldUse3DPreview" class="image-preview" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<!-- Overlay icons (folder/animation) positioned top-left -->
<i v-if="hasPreviewImage && hasMotion && isFile" class="material-icons overlay-icon muted">animation</i>
<i v-else-if="hasPreviewImage && !isFile" class="material-icons overlay-icon muted">folder</i>
<i v-if="isShared" class="material-icons overlay-icon">group</i>
<i v-if="hasPreviewImage && hasMotion && isFile" class="material-symbols-outlined overlay-icon">animation</i>
<i v-else-if="hasPreviewImage && !isFile" class="material-symbols overlay-icon">folder</i>
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
<!-- Preview content: image, 3D, or fallback -->
<img v-if="hasPreviewImage" :key="imageTargetSrc" :src="imageDisplaySrc" ref="thumbnail" />
<ThreeJs v-else-if="shouldUse3DPreview && !threeJsError"
@@ -23,8 +23,8 @@
<!-- Regular material icon (no preview) -->
<span v-else class="image-preview">
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialIcon }} </i>
<i v-if="isShared" class="material-icons overlay-icon">group</i>
<i :class="[classes, { active: active, clickable: clickable }]"> {{ materialSymbol }} </i>
<i v-if="isShared" class="material-symbols overlay-icon">group</i>
</span>
</template>
@@ -95,7 +95,7 @@ export default {
},
data() {
return {
materialIcon: "",
materialSymbol: "",
classes: "",
svgPath: "",
previewTimeouts: [],
@@ -213,9 +213,6 @@ export default {
this.hasPreview
);
},
isMaterialIcon() {
return this.materialIcon !== "";
},
shouldUse3DPreview() {
// Check if we should use 3D preview instead of regular icon
if (!this.isFile || !this.size || !this.path) return false;
@@ -424,10 +421,10 @@ export default {
},
mounted() {
const result = this.getIconForType();
this.classes = result.classes || "material-icons";
this.classes = result.classes || "material-symbols";
// @ts-ignore
this.color = result.color || "lightgray";
this.materialIcon = result.materialIcon || "";
this.materialSymbol = result.materialSymbol || "";
// @ts-ignore
this.svgPath = result.svgPath || "";
this.updateImageTargetSrc();
@@ -453,9 +450,6 @@ export default {
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
z-index: 2;
color: white;
}
.overlay-icon.muted {
opacity: 0.7;
}
@@ -474,13 +468,11 @@ export default {
.icon {
font-size: 1.5rem;
/* Default size */
fill: currentColor;
/* Uses inherited color */
border-radius: 0.2em;
padding: 0.1em;
background: var(--iconBackground);
/* Performance optimization */
will-change: auto;
transform: translateZ(0);
}
@@ -489,16 +481,9 @@ export default {
background: var(--background);
}
.purple-icons {
color: purple;
}
/* Icon Colors */
.blue-icons {
color: var(--icon-blue);
}
/* Icon Colors */
/* -----------
Icon Colors
----------- */
.primary-icons {
color: var(--primaryColor);
}
@@ -507,11 +492,55 @@ export default {
text-shadow: 0px 0px 1px #000;
}
/* blue variations */
.blue-icons {
color: var(--icon-blue);
}
.deep-blue-icons {
color: rgb(29, 95, 191);
}
.lightblue-icons {
color: lightskyblue;
}
.skyblue-icons {
color: rgb(42, 170, 242);
}
/* purple variations */
.purple-icons {
color: purple;
}
.plum-icons {
color: plum;
}
/* yellow */
.yellow-icons {
color: yellow;
}
/* orange/red variations */
.orange-icons {
color: orange;
}
.red-icons {
color: rgb(211, 16, 16);
}
.deep-orange-icons {
color: rgb(255, 111, 0);
}
.brown-icons {
color: brown;
}
.coral-icons {
color: lightcoral;
}
@@ -519,53 +548,31 @@ export default {
color: tan;
}
.plum-icons {
color: plum;
}
.red-icons {
color: rgb(246, 70, 70);
}
.white-icons {
color: white;
}
.brown-icons {
color: brown;
}
#listingView.gallery .listing-item i.white-icons,
.active.white-icons {
color: var(--activeWhiteIcon);
}
.deep-blue-icons {
color: rgb(29, 95, 191);
}
/* green variations */
.green-icons {
color: rgb(23, 128, 74);
}
.red-orange-icons {
color: rgb(255, 147, 111);
.light-green-icons {
color: rgb(48, 207, 117);
}
/* white variations */
.white-icons {
color: white;
}
.gray-icons {
color: gray;
}
.skyblue-icons {
color: rgb(42, 170, 242);
}
.lightgray-icons {
color: rgb(176, 176, 176);
}
.yellow-icons {
color: yellow;
#listingView.gallery .listing-item i.white-icons,
.active.white-icons {
color: var(--activeWhiteIcon);
}
/* Unified .image-preview container - works universally, always square */
@@ -594,6 +601,9 @@ export default {
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
/* 3D viewers - universal */
@@ -10,7 +10,7 @@
:aria-label="$t('files.sortByName')"
>
<span>{{ $t("general.name") }}</span>
<i v-if="nameSorted" class="material-icons">{{ nameIcon }}</i>
<i v-if="nameSorted" class="material-symbols">{{ nameIcon }}</i>
</p>
<p
@@ -22,8 +22,8 @@
:title="$t('files.sortBySize')"
:aria-label="$t('files.sortBySize')"
>
<i v-if="sizeSorted" class="material-symbols">{{ sizeIcon }}</i>
<span>{{ $t("general.size") }}</span>
<i v-if="sizeSorted" class="material-icons">{{ sizeIcon }}</i>
</p>
<p
@@ -35,8 +35,8 @@
:title="$t('files.sortByLastModified')"
:aria-label="$t('files.sortByLastModified')"
>
<i v-if="modifiedSorted" class="material-symbols">{{ modifiedIcon }}</i>
<span>{{ $t("files.lastModified") }}</span>
<i v-if="modifiedSorted" class="material-icons">{{ modifiedIcon }}</i>
</p>
<p
@@ -49,8 +49,8 @@
:title="$t('files.sortByDuration')"
:aria-label="$t('files.sortByDuration')"
>
<i v-if="durationSorted" class="material-symbols">{{ durationIcon }}</i>
<span>{{ $t("files.duration") }}</span>
<i v-if="durationSorted" class="material-icons">{{ durationIcon }}</i>
</p>
<span v-if="quickDownloadEnabled" class="placeholder"></span>
</div>
@@ -188,18 +188,23 @@ span {
flex: 1;
}
.desktop-view .size,
.desktop-view .modified,
.desktop-view .duration {
.desktop-view .modified{
min-width: 15%;
flex: 0;
}
.desktop-view .size,
.desktop-view .duration {
min-width: 10%;
flex: 0;
}
.size,
.modified,
.duration {
flex: 1;
padding-left: 1em;
justify-content: flex-end;
text-align: end;
}
+1 -1
View File
@@ -19,7 +19,7 @@
<hr />
</div>
<div v-else class="thumb-letters no-select">
<i class="material-icons" :class="{ 'primary-icons': isFolder }"> {{ isFolder ? "folder" : "description" }} </i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols" :class="{ 'primary-icons': isFolder }"> {{ isFolder ? "folder" : "description" }} </i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<div
+164 -20
View File
@@ -31,7 +31,7 @@
hidden: !showNav,
disabled: !hasPrevious,
dragging: dragState.type === 'previous',
active: dragState.atFullExtent && dragState.type === 'previous',
active: (dragState.atFullExtent && dragState.type === 'previous') || (gestureHint === 'previous' && gestureHintCommitReady),
'dark-mode': isDarkMode,
'media-mode': isMediaQueueMode,
'sidebar-resizing': isSidebarResizing,
@@ -40,7 +40,7 @@
:aria-label="$t('general.previous')"
:title="$t('general.previous')"
>
<i class="material-icons">
<i class="material-symbols">
{{ dragState.type === 'previous' && dragState.atFullExtent ? 'list_alt' : 'chevron_left' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</button>
@@ -56,16 +56,35 @@
@mouseover="setHoverNav(true)"
@mouseleave="setHoverNav(false)"
class="nav-button nav-next"
:class="{ hidden: !showNav, dragging: dragState.type === 'next', active: dragState.atFullExtent && dragState.type === 'next','dark-mode': isDarkMode, 'media-mode': isMediaQueueMode}"
:class="{ hidden: !showNav, dragging: dragState.type === 'next', active: (dragState.atFullExtent && dragState.type === 'next') || (gestureHint === 'next' && gestureHintCommitReady), 'dark-mode': isDarkMode, 'media-mode': isMediaQueueMode}"
:style="nextButtonStyle"
:aria-label="$t('general.next')"
:title="$t('general.next')"
>
<i class="material-icons">
<i class="material-symbols">
{{ dragState.type === 'next' && dragState.atFullExtent ? 'list_alt' : 'chevron_right' }} <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</i>
</button>
<!-- Close preview (same control as swipe-down / back) -->
<button
v-if="enabled && showPreviewCloseButton"
type="button"
@click.prevent="handleClosePreviewClick"
class="nav-button nav-close"
:class="{
hidden: !showCloseNavChrome,
active: gestureHint === 'close' && gestureHintCommitReady,
'dark-mode': isDarkMode,
'media-mode': isMediaQueueMode,
'gesture-flash': gestureHintFlashClose,
'sidebar-resizing': isSidebarResizing,
}"
:style="closeButtonStyle"
>
<i class="material-symbols">close</i>
</button>
<!-- Prefetch links for better performance -->
<link v-if="previousRaw" rel="prefetch" :href="previousRaw" />
<link v-if="nextRaw" rel="prefetch" :href="nextRaw" />
@@ -96,6 +115,8 @@ export default {
},
// State tracking
navigationTimeout: null,
/** Bumps on each setupNavigationForCurrentItem start; stale async completions skip applying. */
navigationSetupGeneration: 0,
isSwipe: false,
touchStartX: 0,
touchStartY: 0,
@@ -123,8 +144,46 @@ export default {
return state.navigation.enabled && getters.currentPrompt() == null;
},
showNav() {
const shouldShow = state.navigation.show || this.hoverNav;
return shouldShow;
return state.navigation.show || this.hoverNav || !!state.navigation.gestureHint;
},
gestureHint() {
return state.navigation.gestureHint;
},
gestureHintCommitReady() {
return state.navigation.gestureHintCommitReady;
},
gestureHintFlashClose() {
return state.navigation.gestureHintFlashClose;
},
showPreviewCloseButton() {
return (
previewViews.includes(this.currentView) &&
state.req &&
state.req.type !== 'directory'
);
},
autoShowNavForMediaPreview() {
const pt = getters.previewType();
return pt === 'image' || pt === 'video';
},
/** Close mirrors swipe-down dismiss only (not with prev/next on load or edge hover). */
showCloseNavChrome() {
return (
this.enabled &&
this.showPreviewCloseButton &&
this.gestureHint === 'close'
);
},
/** Centers on the main column when #main has padding-left matching the sticky sidebar (same offset as StatusBar). */
closeButtonStyle() {
const styles = { right: 'auto' };
if (this.moveWithSidebar) {
const half = this.sidebarWidth / 2;
styles.left = `calc(50% + ${half}em)`;
} else {
styles.left = '50%';
}
return styles;
},
hasPrevious() {
if (this.isMediaQueueMode) {
@@ -262,7 +321,9 @@ export default {
window.addEventListener("touchmove", this.handleDrag, { passive: false });
window.addEventListener("touchend", this.endDrag);
document.addEventListener("click", this.handleDocumentClick);
window.addEventListener("mousemove", this.handleGlobalMouseMove);
// Capture phase: run before preview gesture handlers (document-level capture mousemove)
// that may stopPropagation, which would skip bubble-phase window listeners.
window.addEventListener("mousemove", this.handleGlobalMouseMove, true);
// Calculate 10em threshold in pixels
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
@@ -288,7 +349,7 @@ export default {
window.removeEventListener("touchmove", this.handleDrag);
window.removeEventListener("touchend", this.endDrag);
document.removeEventListener("click", this.handleDocumentClick);
window.removeEventListener("mousemove", this.handleGlobalMouseMove);
window.removeEventListener("mousemove", this.handleGlobalMouseMove, true);
// Clear our local timeout
if (this.navigationTimeout) {
@@ -350,8 +411,12 @@ export default {
});
},
async setupNavigationForCurrentItem() {
const gen = ++this.navigationSetupGeneration;
if (!this.enabled || !state.req || state.req.type === 'directory') {
// Clear navigation when not applicable
if (gen !== this.navigationSetupGeneration) {
return;
}
mutations.clearNavigation();
return;
}
@@ -366,7 +431,9 @@ export default {
// Special case: if we're viewing a shared single file (where the share itself is the file)
// and directoryPath equals req.path, there's no directory to navigate within
if (getters.isShare() && directoryPath === state.req.path) {
// This is a single file share with no siblings to navigate to
if (gen !== this.navigationSetupGeneration) {
return;
}
mutations.clearNavigation();
return;
}
@@ -390,17 +457,23 @@ export default {
}
listing = res.items;
} catch (error) {
// If we can't fetch the directory listing, navigation isn't possible
if (gen !== this.navigationSetupGeneration) {
return;
}
mutations.clearNavigation();
return;
}
} else {
// This is a file at root where directoryPath === req.path
// This shouldn't normally happen for non-share cases, but handle gracefully
if (gen !== this.navigationSetupGeneration) {
return;
}
mutations.clearNavigation();
return;
}
if (gen !== this.navigationSetupGeneration) {
return;
}
mutations.setupNavigation({
listing: listing,
currentItem: state.req,
@@ -409,10 +482,10 @@ export default {
},
showInitialNavigation() {
// Show navigation initially for 3 seconds when navigation is set up
if (this.enabled && (this.hasPrevious || this.hasNext)) {
if (this.enabled && (this.hasPrevious || this.hasNext || this.autoShowNavForMediaPreview)) {
mutations.setNavigationShow(true);
// Clear any existing timeout
mutations.clearNavigationTimeout();
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
@@ -424,8 +497,27 @@ export default {
}
this.navigationTimeout = null;
}, 3000);
mutations.setNavigationTimeout(this.navigationTimeout);
}
},
async handleClosePreviewClick() {
if (!(await this.checkForUnsavedChanges())) {
return;
}
mutations.closeHovers();
this.hoverNav = false;
mutations.setNavigationGestureHint({});
if (state.previousHistoryItem?.name) {
url.goToItem(
state.previousHistoryItem.source,
state.previousHistoryItem.path,
state.previousHistoryItem,
);
return;
}
const parentPath = url.removeLastDir(state.route.path);
this.$router.push({ path: parentPath });
},
async prev() {
if (this.hasPrevious) {
this.hoverNav = false;
@@ -824,6 +916,7 @@ export default {
},
cancelNavigationTimeout() {
mutations.clearNavigationTimeout();
if (this.navigationTimeout) {
clearTimeout(this.navigationTimeout);
this.navigationTimeout = null;
@@ -1095,10 +1188,61 @@ export default {
}
/* Smooth show animation for better UX */
.nav-button:not(.hidden):not(.sidebar-resizing) {
.nav-button:not(.hidden):not(.sidebar-resizing):not(.nav-close) {
animation: nav-button-show 0.4s ease-out;
}
.nav-button.nav-close {
/* Below header; +4em vs previous placement; horizontal position from closeButtonStyle when sidebar offsets main */
top: calc(1.25em + 4em);
right: auto;
margin-top: 0;
transform: translateX(-50%);
}
.nav-button.nav-close.hidden {
transform: translateX(-50%) scale(0.9);
}
.nav-button.nav-close:not(.hidden):not(.sidebar-resizing) {
animation: nav-button-show-close 0.4s ease-out;
}
@keyframes nav-button-show-close {
0% {
opacity: 0;
transform: translateX(-50%) scale(0.8);
}
100% {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
.nav-button.nav-close:hover:not(.hidden),
.nav-button.nav-close.active:not(.hidden) {
transform: translateX(-50%) scale(1.1);
}
.nav-button.nav-close.gesture-flash:not(.hidden) {
animation: nav-close-gesture-flash 0.38s ease-out;
}
@keyframes nav-close-gesture-flash {
0% {
transform: translateX(-50%) scale(1);
filter: brightness(1);
}
40% {
transform: translateX(-50%) scale(1.12);
filter: brightness(1.08);
}
100% {
transform: translateX(-50%) scale(1);
filter: brightness(1);
}
}
@keyframes nav-button-show {
0% {
opacity: 0;
@@ -1116,14 +1260,14 @@ export default {
transition: none !important; /* Disable transitions during drag */
}
.nav-button i.material-icons {
.nav-button i.material-symbols {
font-size: 24px;
line-height: 1;
transition: transform 0.2s ease;
}
.nav-button:hover i.material-icons,
.nav-button.active i.material-icons {
.nav-button:hover i.material-symbols,
.nav-button.active i.material-symbols {
transform: scale(1.1);
}
@@ -1134,7 +1278,7 @@ export default {
height: 44px;
}
.nav-button i.material-icons {
.nav-button i.material-symbols {
font-size: 20px;
}
+5 -5
View File
@@ -6,7 +6,7 @@
<template v-else>
<!-- Warning banner for missing path -->
<div v-if="!pathExists && !isEditingPath" class="warning-banner">
<i class="material-icons">warning</i>
<i class="material-symbols">warning</i>
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
<button class="button button--flat button--blue" @click="startPathReassignment">
{{ $t("messages.reassignPath") }}
@@ -24,10 +24,10 @@
<!-- Default behavior banner -->
<div class="card item">
<div class="card-content banner-content">
<i class="material-icons">{{ sourceDenyDefault ? 'block' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols">{{ sourceDenyDefault ? 'do_not_disturb_on' : 'check_circle' }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
{{ $t("access.defaultBehavior", { suffix: ":" }) }} {{ sourceDenyDefault ? $t("access.deny") : $t("access.allow")
}}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('access.defaultBehaviorDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -51,7 +51,7 @@
<option v-for="group in groups" :key="group" :value="group"></option>
</datalist>
<button class="button form-button flat-left form-compact" @click="submitAdd">
<i class="material-icons">add</i>
<i class="material-symbols">add</i>
</button>
</div>
<!-- Cascade Delete Toggle -->
@@ -76,7 +76,7 @@
<td>
<button @click="deleteAccess(entry)" class="action" :aria-label="$t('general.delete')"
:title="$t('general.delete')">
<i class="material-icons">delete</i>
<i class="material-symbols">delete</i>
</button>
</td>
</tr>
@@ -9,7 +9,7 @@
:title="$t('buttons.copyToClipboard')"
>
<span class="api-key-name">{{ name }}</span>
<i class="material-icons">content_paste</i>
<i class="material-symbols">content_paste</i>
</button>
<button
class="action copy-clipboard api-key-value-button"
@@ -18,7 +18,7 @@
:title="$t('api.clickToCopyKey')"
>
<span class="api-key-value">{{ $t('api.clickToCopyKey') }}</span>
<i class="material-icons">content_paste</i>
<i class="material-symbols">content_paste</i>
</button>
</div>
@@ -183,8 +183,8 @@ export default {
font-style: italic;
}
.api-key-button .material-icons,
.api-key-value-button .material-icons {
.api-key-button .material-symbols,
.api-key-value-button .material-symbols {
font-size: 1.2em;
color: var(--textSecondary);
flex-shrink: 0;
+66 -27
View File
@@ -18,34 +18,43 @@
</div>
<div v-if="hasDownloads" class="card-content">
<div class="download-list">
<div v-for="download in downloads" :key="download.id" class="download-item">
<i class="material-icons file-icon">insert_drive_file</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="file-info">
<p class="file-name">{{ download.name }}</p>
<progress-bar
:val="download.status === 'completed'
? $t('prompts.completed')
: download.status === 'error'
? $t('prompts.error')
: download.loaded
"
:unit="download.status === 'completed' || download.status === 'error' ? '' : 'bytes'"
:max="download.size"
:status="download.status"
text-position="inside"
size="20"
:help-text="download.errorDetails || ''">
</progress-bar>
<div
v-for="download in downloads"
:key="download.id"
class="download-item-wrapper"
:class="{ 'has-error': download.status === 'error' }"
>
<div class="download-item">
<i class="material-symbols file-icon">insert_drive_file</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="file-info">
<p class="file-name">{{ download.name }}</p>
<progress-bar
:val="download.status === 'completed'
? $t('prompts.completed')
: download.status === 'error'
? $t('prompts.error')
: download.loaded
"
:unit="download.status === 'completed' || download.status === 'error' ? '' : 'bytes'"
:max="download.size"
:status="download.status"
text-position="inside"
size="20">
</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')">
<i class="material-symbols">replay</i>
</button>
<button @click="cancelDownload(download.id)" class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<i class="material-symbols">close</i>
</button>
</div>
</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')">
<i class="material-icons">replay</i>
</button>
<button @click="cancelDownload(download.id)" class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<i class="material-icons">close</i>
</button>
<div v-if="download.status === 'error'" class="error-banner" role="alert">
{{ formatDownloadError(download) }}
</div>
</div>
</div>
@@ -98,6 +107,13 @@ export default {
},
},
methods: {
formatDownloadError(download) {
if (download.status !== "error") {
return "";
}
const detail = download.errorDetails ? String(download.errorDetails) : this.$t("prompts.error");
return this.$t("prompts.downloadFailed", { message: detail });
},
handleFormatSelect(format) {
if (this.currentPrompt && this.currentPrompt.confirm) {
this.currentPrompt.confirm(format);
@@ -151,6 +167,29 @@ export default {
min-height: 0;
}
.download-item-wrapper {
margin-bottom: 0.5rem;
}
.download-item-wrapper:last-child {
margin-bottom: 0;
}
.download-item-wrapper.has-error {
border-left: 3px solid var(--errorColor, #f44336);
padding-left: 0.5rem;
}
.error-banner {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--errorBackground, rgba(244, 67, 54, 0.1));
color: var(--errorColor, #f44336);
border-radius: 4px;
font-size: 0.875rem;
word-break: break-word;
}
.download-item {
display: flex;
align-items: center;
+13 -13
View File
@@ -4,7 +4,7 @@
<!-- External Link -->
<div class="external-link-banner">
<i class="material-icons">open_in_new</i>
<i class="material-symbols">open_in_new</i>
<span>{{ $t('tools.materialIconPicker.browseFullLibrary') }}</span>
<a href="https://fonts.google.com/icons" target="_blank" rel="noopener noreferrer">
{{ $t('tools.materialIconPicker.iconLibraryUrl') }}
@@ -13,7 +13,7 @@
<!-- Search Box -->
<div class="search-box">
<i class="material-icons search-icon">search</i>
<i class="material-symbols search-icon">search</i>
<input
v-model="searchQuery"
type="text"
@@ -23,7 +23,7 @@
style="padding-left: 2.5em;"
/>
<button v-if="searchQuery" @click="searchQuery = ''" class="button button--flat clear-button">
<i class="material-icons">close</i>
<i class="material-symbols">close</i>
</button>
</div>
@@ -70,7 +70,7 @@
<!-- Empty State -->
<div v-if="!showCustomPreview && visibleIcons.length === 0" class="empty-state">
<i class="material-icons">search_off</i>
<i class="material-symbols">search_off</i>
<p>{{ $t('tools.materialIconPicker.noResults') }}</p>
</div>
</div>
@@ -89,9 +89,9 @@
<script>
import {
allMaterialIcons,
materialSymbols,
getIconClass,
} from "@/utils/material-icons";
} from "@/utils/material-symbols";
import { mutations } from "@/store";
export default {
@@ -105,7 +105,7 @@ export default {
data() {
return {
searchQuery: "",
allMaterialIcons,
materialSymbols,
};
},
computed: {
@@ -113,11 +113,11 @@ export default {
// Apply search filter
if (this.searchQuery.trim()) {
const query = this.searchQuery.toLowerCase();
return this.allMaterialIcons.filter((icon) =>
return this.materialSymbols.filter((icon) =>
icon.toLowerCase().includes(query)
);
}
return this.allMaterialIcons;
return this.materialSymbols;
},
showCustomPreview() {
// Show custom preview if:
@@ -127,7 +127,7 @@ export default {
if (!trimmed) return false;
// Check if it's an exact match
const exactMatch = this.allMaterialIcons.some(
const exactMatch = this.materialSymbols.some(
(icon) => icon.toLowerCase() === trimmed.toLowerCase()
);
@@ -177,7 +177,7 @@ export default {
color: var(--textSecondary);
}
.external-link-banner .material-icons {
.external-link-banner .material-symbols {
font-size: 1.2em;
color: var(--primaryColor);
}
@@ -218,7 +218,7 @@ export default {
padding: 0.25em !important;
}
.clear-button .material-icons {
.clear-button .material-symbols {
font-size: 1.2em;
}
@@ -298,7 +298,7 @@ export default {
color: var(--textSecondary);
}
.empty-state .material-icons {
.empty-state .material-symbols {
font-size: 3em;
margin-bottom: 0.5em;
opacity: 0.5;
+1 -1
View File
@@ -73,7 +73,7 @@
:placeholder="$t('prompts.selectHashAlgorithm')" />
<button class="button form-button flat-left" @click="copyToClipboard" :disabled="!hashResult"
:title="$t('buttons.copyToClipboard')" :aria-label="$t('buttons.copyToClipboard')">
<i class="material-icons" style="font-size: 16px;">content_copy</i>
<i class="material-symbols-outlined" style="font-size: 16px;">content_copy</i>
</button>
</div>
</div>
@@ -9,7 +9,7 @@
:title="$t('general.cancel')">
{{ $t("general.cancel") }}
</button>
<button class="button button--flat" @click="confirmSelection" :aria-label="$t('general.select')"
<button type="button" class="button button--flat" @click="confirmSelection" :aria-label="$t('general.select')"
:title="$t('general.select')">
{{ $t("general.select") }}
</button>
@@ -3,7 +3,7 @@
<!-- Playback mode display -->
<div class="playback-mode">
<div class="mode-info">
<i class="material-icons">{{ currentModeIcon }}</i>
<i class="material-symbols">{{ currentModeIcon }}</i>
<span>{{ currentModeLabel }}</span>
</div>
</div>
@@ -19,7 +19,7 @@
@click="navigateToItem(index)"
>
<div class="item-icon">
<i class="material-icons">{{ getFileIcon(item) }}</i>
<i class="material-symbols">{{ getFileIcon(item) }}</i>
</div>
<div class="item-name">
<span class="name">{{ item.name }}</span>
@@ -27,7 +27,7 @@
<div class="item-indicator">
<span v-if="index === currentQueueIndex" class="current-track">
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<i class="material-icons">{{ isPlaying ? 'pause' : 'play_arrow' }}</i>
<i class="material-symbols">{{ isPlaying ? 'pause' : 'play_arrow' }}</i>
</span>
<span v-else class="track-number">{{ index + 1 }}</span>
</div>
@@ -37,14 +37,14 @@
<!-- Empty state -->
<div v-else class="empty">
<i class="material-icons">queue_music</i>
<i class="material-symbols">queue_music</i>
<p>{{ $t('player.emptyQueue') }}</p>
</div>
</div>
<div class="card-actions">
<button class="button button--flat" @click.stop="cyclePlaybackModes" :title="$t('player.changePlaybackMode')">
<i class="material-icons">swap_vert</i> {{ $t('player.changePlaybackMode') }}
<i class="material-symbols">swap_vert</i> {{ $t('player.changePlaybackMode') }}
</button>
</div>
</template>
@@ -258,7 +258,7 @@ export default {
font-weight: 500;
}
.mode-info i.material-icons {
.mode-info i.material-symbols {
color: var(--primaryColor);
user-select: none;
}
@@ -309,7 +309,7 @@ export default {
user-select: none;
}
.item-icon i.material-icons {
.item-icon i.material-symbols {
color: var(--textSecondary);
user-select: none;
}
@@ -330,7 +330,7 @@ export default {
color: var(--textSecondary);
}
.empty i.material-icons {
.empty i.material-symbols {
font-size: 3rem;
opacity: 0.5;
margin-bottom: 1rem;
+59 -2
View File
@@ -37,7 +37,7 @@
@mousedown.stop
@touchstart.stop
>
<i class="material-icons">close</i>
<i class="material-symbols">close</i>
</button>
<div class="prompt-taskbar-drag">
<span class="prompt-title">{{ prompt?.props?.title || getDisplayTitle(prompt?.name) }}</span>
@@ -183,9 +183,12 @@ export default {
},
mounted() {
window.addEventListener('resize', this.handleWindowResize);
// Bubble phase so prompt inputs (e.g. Rename) handle Enter before we trigger primary action
window.addEventListener('keydown', this.onDocumentKeydown);
},
beforeUnmount() {
window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('keydown', this.onDocumentKeydown);
},
methods: {
handleWindowResize() {
@@ -364,6 +367,60 @@ export default {
this.draggingIds.delete(id);
delete this.sizes[id];
},
/**
* Escape closes the top prompt. Enter clicks the last `.card-actions` button (far right in DOM).
* If that button is disabled, Enter does nothing. Skips when focus is in a text field / contenteditable.
*/
onDocumentKeydown(event) {
if (this.prompts.length === 0) {
return;
}
// get last prompt by id
const top = getters.currentPrompt();
if (this.isBlocked(top)) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeTopPrompt(top.id);
return;
}
if (event.key !== 'Enter' || event.repeat) {
return;
}
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) {
return;
}
if (event.defaultPrevented) {
return;
}
const primary = this.findPrimaryActionButton(top.id);
if (!primary) {
return;
}
event.preventDefault();
event.stopPropagation();
primary.click();
},
findPrimaryActionButton(promptId) {
const root = this.getPromptElement(promptId);
if (!root) {
return null;
}
const buttons = root.querySelectorAll('.card-actions button');
if (!buttons.length) {
return null;
}
const last = buttons[buttons.length - 1];
if (last.disabled) {
return null;
}
return last;
},
getPointerPos(e, type) {
if (type === "touch") {
const t = e.touches && e.touches[0];
@@ -762,7 +819,7 @@ export default {
filter: brightness(1.1);
}
.prompt-close .material-icons {
.prompt-close .material-symbols {
font-size: 1em;
}
+23 -23
View File
@@ -2,7 +2,7 @@
<div class="card-content">
<!-- Warning banner for missing path when editing a share -->
<div v-if="!pathExists && isEditMode && !isEditingPath" class="warning-banner">
<i class="material-icons">warning</i>
<i class="material-symbols">warning</i>
<span>{{ $t("messages.pathNotFoundMessage") }}</span>
<button class="button button--flat button--blue" @click="startPathReassignment">
{{ $t("messages.reassignPath") }}
@@ -60,26 +60,26 @@
<td class="small">
<button class="action" @click="editLink(link)" :aria-label="$t('general.edit')"
:title="$t('general.edit')">
<i class="material-icons">edit</i>
<i class="material-symbols">edit</i>
</button>
</td>
<td class="small">
<button class="action copy-clipboard" :data-clipboard-text="link.shareURL"
:aria-label="$t('buttons.copyToClipboard')" :title="$t('buttons.copyToClipboard')">
<i class="material-icons">content_paste</i>
<i class="material-symbols">content_paste</i>
</button>
</td>
<td class="small">
<button :disabled="link.shareType == 'upload'" class="action copy-clipboard"
:data-clipboard-text="link.downloadURL" :aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')">
<i class="material-icons">content_paste_go</i>
<i class="material-symbols">content_paste_go</i>
</button>
</td>
<td class="small">
<button class="action" @click="deleteLink($event, link)" :aria-label="$t('general.delete')"
:title="$t('general.delete')">
<i class="material-icons">delete</i>
<i class="material-symbols">delete</i>
</button>
</td>
</tr>
@@ -90,7 +90,7 @@
<div v-if="!showMoreExpanded">
<p>
{{ $t("files.duration") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareDurationDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -106,21 +106,21 @@
</div>
<p>
{{ $t("prompts.optionalPassword") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.passwordDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<div v-if="hasExistingPassword && !isChangingPassword" class="password-change-section">
<button class="button button--flat button--blue" @click="isChangingPassword = true" style="width: 100%;">
<i class="material-icons">lock_reset</i>
<i class="material-symbols">lock_reset</i>
{{ $t("general.change") }}
</button>
</div>
<input v-else class="input" type="password" autocomplete="new-password" v-model.trim="password" />
<p>
{{ $t("share.shareType") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareTypeDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -130,7 +130,7 @@
<option value="upload">{{ $t("share.uploadShare") }}</option>
</select>
<button @click="openSidebarLinksCustomization" class="button button--flat customize-sidebar-links-button">
<i class="material-icons">link</i>
<i class="material-symbols">link</i>
{{ $t('share.customizeSidebarLinksButton') }}
</button>
<div class="settings-items" style="margin-top: 0.5em;">
@@ -150,7 +150,7 @@
<div class="settings-items">
<p>
{{ $t("prompts.shareTheme") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareThemeDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -167,7 +167,7 @@
<div v-if="shareType === 'normal'">
<p>
{{ $t("share.defaultViewMode") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.defaultViewModeDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -201,7 +201,7 @@
:name="$t('share.enableOnlyOffice')" :description="$t('share.enableOnlyOfficeDescription')" />
<p>
{{ $t("share.enforceDarkLightMode") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))"
@mouseleave="hideTooltip">
help
@@ -237,7 +237,7 @@
<div v-if="shareType === 'normal'">
<p>
{{ $t("prompts.downloadsLimit") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.downloadsLimitDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -245,7 +245,7 @@
<input class="input" type="number" min="0" v-model.number="downloadsLimit" />
<p>
{{ $t("prompts.maxBandwidth") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.maxBandwidthDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -256,7 +256,7 @@
<p>
{{ $t("prompts.shareThemeColor") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareThemeColorDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -265,7 +265,7 @@
<p>
{{ $t("prompts.shareTitle") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareTitleDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -274,7 +274,7 @@
<p>
{{ $t("prompts.shareDescription") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareDescriptionHelp'))" @mouseleave="hideTooltip">
help
</i>
@@ -283,7 +283,7 @@
<p>
{{ $t("prompts.shareBanner") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareBannerDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -291,13 +291,13 @@
<div class="file-picker-input-group">
<input class="input file-picker-input" type="text" v-model.trim="banner" />
<div class="file-picker-button clickable" @click="openBannerPicker" :title="$t('share.browseFiles')">
<i class="material-icons">folder_open</i>
<i class="material-symbols">folder_open</i>
</div>
</div>
<p>
{{ $t("prompts.shareFavicon") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareFaviconDescription'))" @mouseleave="hideTooltip">
help
</i>
@@ -305,7 +305,7 @@
<div class="file-picker-input-group">
<input class="input file-picker-input" type="text" v-model.trim="favicon" />
<div class="file-picker-button clickable" @click="openFaviconPicker" :title="$t('share.browseFiles')">
<i class="material-icons">folder_open</i>
<i class="material-symbols">folder_open</i>
</div>
</div>
</SettingsItem>
@@ -1043,7 +1043,7 @@ export default {
background: var(--surfaceTertiary);
}
.file-picker-button .material-icons {
.file-picker-button .material-symbols {
color: var(--primaryColor);
font-size: 1.25em;
}
@@ -14,10 +14,10 @@
@dragover.prevent="handleDragOver($event, index)" @drop="handleDrop($event, index)">
<div draggable="true" @dragstart="handleDragStart($event, index)" @dragend="handleDragEnd"
class="link-drag-handle">
<i class="material-icons">drag_indicator</i>
<i class="material-symbols">drag_indicator</i>
</div>
<div v-if="link.category === 'divider'" class="link-icon">
<i class="material-icons">horizontal_rule</i>
<i class="material-symbols">horizontal_rule</i>
</div>
<div v-else class="link-icon">
<i :class="getIconClass(link.icon)">{{ link.icon }}</i>
@@ -27,10 +27,10 @@
<span class="link-category">{{ getCategoryLabel(link.category) }}</span>
</div>
<button class="action" @click="editLink(index)" :aria-label="$t('general.edit')">
<i class="material-icons">edit</i>
<i class="material-symbols">edit</i>
</button>
<button class="action" @click="removeLink(index)" :aria-label="$t('general.delete')">
<i class="material-icons">delete</i>
<i class="material-symbols">delete</i>
</button>
</div>
</div>
@@ -39,7 +39,7 @@
<!-- Add New Link Section -->
<div v-if="!showAddForm" class="add-link-section">
<button @click="showAddForm = true" class="button button--flat button--blue add-link-button">
<i class="material-icons">add</i>
<i class="material-symbols">add</i>
{{ $t('sidebar.addNewLink') }}
</button>
</div>
@@ -201,7 +201,7 @@
:placeholder="$t('sidebar.linkIconPlaceholder')" />
<div class="icon-preview clickable" @click="openIconPicker" :title="$t('sidebar.browseIcons')">
<i v-if="newLink.icon" :class="getIconClass(newLink.icon)">{{ newLink.icon }}</i>
<i v-else class="material-icons icon-preview-placeholder">interests</i>
<i v-else class="material-symbols icon-preview-placeholder">interests</i>
</div>
</div>
</div>
@@ -249,7 +249,7 @@ import { state, mutations } from "@/store";
import { notify } from "@/notify";
import { usersApi, shareApi } from "@/api";
import { tools } from "@/utils/constants";
import { getIconClass } from "@/utils/material-icons";
import { getIconClass } from "@/utils/material-symbols";
import FileList from "../files/FileList.vue";
import ToggleSwitch from "@/components/settings/ToggleSwitch.vue";
import { eventBus } from "@/store/eventBus";
@@ -936,7 +936,7 @@ export default {
color: var(--primaryColor);
}
.icon-preview .material-icons,
.icon-preview .material-symbols,
.icon-preview .material-symbols-outlined {
font-size: 2em;
}
+26 -19
View File
@@ -7,7 +7,7 @@
<div class="settings-number-input item">
<div class="no-padding">
<label for="maxConcurrentUpload">{{ $t("fileLoading.maxConcurrentUpload") }}</label>
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('fileLoading.maxConcurrentUploadHelp'))" @mouseleave="hideTooltip">
help
</i>
@@ -20,7 +20,7 @@
<div class="settings-number-input item">
<div class="no-padding">
<label for="uploadChunkSizeMb">{{ $t("fileLoading.uploadChunkSizeMb") }}</label>
<i class="no-select material-symbols-outlined tooltip-info-icon"
<i class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('fileLoading.uploadChunkSizeMbHelp'))" @mouseleave="hideTooltip">
help
</i>
@@ -37,7 +37,7 @@
<div class="upload-prompt" :class="{ dropping: isDragging }" @dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver" @dragleave.prevent="onDragLeave" @drop.prevent="onDrop">
<div class="upload-prompt-container">
<i v-if="files.length === 0" class="material-icons">cloud_upload</i>
<i v-if="files.length === 0" class="material-symbols">cloud_upload</i>
<p v-if="files.length === 0">{{ $t("prompts.dragAndDrop") }}</p>
<div class="button-group">
<button @click="triggerFilePicker" class="button button--flat">
@@ -65,7 +65,7 @@
</div>
<div class="upload-list">
<div v-for="file in files" :key="file.id" class="upload-item">
<i class="material-icons file-icon">{{ file.type === "directory" ? "folder" : "insert_drive_file" }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<i class="material-symbols file-icon">{{ file.type === "directory" ? "folder" : "insert_drive_file" }}</i> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<div class="file-info">
<p class="file-name">{{ file.name }}</p>
<progress-bar v-if="file.type !== 'directory'" :val="file.status === 'completed'
@@ -86,23 +86,23 @@
<div class="file-actions">
<button v-if="file.status === 'uploading'" @click="uploadManager.pause(file.id)" class="action"
:aria-label="$t('general.pause')" :title="$t('general.pause')">
<i class="material-icons">pause</i>
<i class="material-symbols">pause</i>
</button>
<button v-if="file.status === 'paused'" @click="uploadManager.resume(file.id)" class="action"
:aria-label="$t('general.resume')" :title="$t('general.resume')">
<i class="material-icons">play_arrow</i>
<i class="material-symbols">play_arrow</i>
</button>
<button v-if="file.status === 'error'" @click="uploadManager.retry(file.id)" class="action"
:aria-label="$t('general.retry')" :title="$t('general.retry')">
<i class="material-icons">replay</i>
<i class="material-symbols">replay</i>
</button>
<button v-if="file.status === 'conflict'" @click="handleConflictAction(file)" class="action"
:aria-label="$t('general.replace')" :title="$t('general.replace')">
<i class="material-icons">sync_problem</i>
<i class="material-symbols">sync_problem</i>
</button>
<button @click="cancelUpload(file.id)" class="action" :aria-label="$t('general.cancel')"
:title="$t('general.cancel')">
<i class="material-icons">close</i>
<i class="material-symbols">close</i>
</button>
</div>
</div>
@@ -171,10 +171,10 @@ export default {
return state.shareInfo;
},
uploadSettingsDescription() {
const maxConcurrentUpload = state.user.fileLoading?.maxConcurrentUpload || 3;
let uploadChunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb || 5;
const maxConcurrentUpload = state.user.fileLoading?.maxConcurrentUpload;
const uploadChunkSizeMb = state.user.fileLoading?.uploadChunkSizeMb;
if (uploadChunkSizeMb === 0) {
uploadChunkSizeMb = 5;
return this.$t("prompts.uploadSettingsNoChunk", { maxConcurrentUpload });
}
return this.$t("prompts.uploadSettingsChunked", {
maxConcurrentUpload,
@@ -193,11 +193,8 @@ export default {
let wakeLock = null;
// Upload settings
const maxConcurrentUpload = ref(state.user.fileLoading?.maxConcurrentUpload || 3);
const uploadChunkSizeMb = ref(state.user.fileLoading?.uploadChunkSizeMb || 5);
if (uploadChunkSizeMb.value === 0) {
uploadChunkSizeMb.value = 5;
}
const maxConcurrentUpload = ref(state.user.fileLoading?.maxConcurrentUpload);
const uploadChunkSizeMb = ref(state.user.fileLoading?.uploadChunkSizeMb);
const clearAll = ref(state.user.fileLoading?.clearAll || false);
const showTooltip = (event, text) => {
@@ -386,13 +383,19 @@ export default {
const destination = getDestinationPath();
// When items are passed as a prop from ListingView, they can be either
// an array of DataTransferItem (from drag and drop) or an array of File (from input).
// or an array of objects (from clipboard paste of the OS).
if (Array.isArray(items)) {
if (items.length > 0 && items[0] instanceof File) {
if (items.length === 0) return;
const first = items[0];
if (first instanceof File) {
// This is an array of File objects from the input fallback in ListingView
processFileList(items, destination);
} else {
} else if (typeof first.getAsFile === 'function') {
// This is an array of DataTransferItem from drag and drop in ListingView
await processDroppedItems(items, destination);
} else if (first.file instanceof File && typeof first.relativePath === 'string') {
// This is an array of objects {file, relativePath} for paste from clipboard OS.
uploadManager.add(destination, items);
}
} else if (items) {
// This case handles a FileList object from the upload prompt's own input fields.
@@ -760,4 +763,8 @@ export default {
.sizeInput {
max-width: 100px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
</style>
+15 -9
View File
@@ -2,7 +2,7 @@
<div class="card-content">
<errors v-if="error" :errorCode="error.status" />
<h2 class="message" v-if="user.loginMethod != 'password' && !stateUser.permissions.admin">
<i class="material-icons">sentiment_dissatisfied</i>
<i class="material-symbols-outlined">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span>
</h2>
<div v-if="user.loginMethod == 'password' && globalVars.passwordAvailable && !isNew">
@@ -73,13 +73,13 @@
@input="updateParent({ source: source, input: $event })" :value="source.scope"
:class="{ 'flat-right': selectedSources.length > 1 }" />
<button v-if="selectedSources.length > 1" class="button flat-left no-height" @click="removeScope(index)">
<i class="material-icons material-size">delete</i>
<i class="material-symbols material-size">delete</i>
</button>
</div>
</div>
<button v-if="hasMoreSources" @click="addNewScopeSource" class="button no-height">
<i class="material-icons material-size">add</i>
<i class="material-symbols material-size">add</i>
</button>
<div class="settings-items">
@@ -94,12 +94,11 @@
<div v-if="stateUser.permissions.admin">
<label for="loginMethod">{{ $t("settings.loginMethodDescription") }}</label>
<select v-model="user.loginMethod" class="input" id="loginMethod">
<option v-if="globalVars.passwordAvailable" value="password">{{ $t("settings.loginMethods.password") }}
</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option v-if="globalVars.oidcAvailable" value="oidc">{{ $t("settings.loginMethods.oidc") }}</option>
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option v-if="globalVars.proxyAvailable" value="proxy">{{ $t("settings.loginMethods.proxy") }}</option>
<!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option v-if="globalVars.passwordAvailable" value="password">{{ $t("settings.loginMethods.password") }}</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option v-if="globalVars.oidcAvailable" value="oidc">OIDC</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option v-if="globalVars.proxyAvailable" value="proxy">Proxy</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option v-if="globalVars.ldapAvailable" value="ldap">LDAP</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
<option value="jwt">JWT</option> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</select>
</div>
<permissions v-if="stateUser.permissions.admin" :permissions="user.permissions" />
@@ -213,6 +212,7 @@ export default {
if (this.globalVars.passwordAvailable) return "password";
if (this.globalVars.oidcAvailable) return "oidc";
if (this.globalVars.proxyAvailable) return "proxy";
if (this.globalVars.ldapAvailable) return "ldap";
return "password"; // fallback
},
},
@@ -251,6 +251,8 @@ export default {
if (this.globalVars.passwordAvailable) validMethods.push("password");
if (this.globalVars.oidcAvailable) validMethods.push("oidc");
if (this.globalVars.proxyAvailable) validMethods.push("proxy");
if (this.globalVars.ldapAvailable) validMethods.push("ldap");
if (this.globalVars.jwtAvailable) validMethods.push("jwt");
if (!this.user.loginMethod || !validMethods.includes(this.user.loginMethod)) {
this.user.loginMethod = this.firstAvailableLoginMethod;
@@ -282,6 +284,8 @@ export default {
if (this.globalVars.passwordAvailable) validMethods.push("password");
if (this.globalVars.oidcAvailable) validMethods.push("oidc");
if (this.globalVars.proxyAvailable) validMethods.push("proxy");
if (this.globalVars.ldapAvailable) validMethods.push("ldap");
if (this.globalVars.jwtAvailable) validMethods.push("jwt");
if (!this.user.loginMethod || !validMethods.includes(this.user.loginMethod)) {
this.user.loginMethod = this.firstAvailableLoginMethod;
@@ -470,6 +474,8 @@ export default {
if (this.globalVars.passwordAvailable) validMethods.push("password");
if (this.globalVars.oidcAvailable) validMethods.push("oidc");
if (this.globalVars.proxyAvailable) validMethods.push("proxy");
if (this.globalVars.ldapAvailable) validMethods.push("ldap");
if (this.globalVars.jwtAvailable) validMethods.push("jwt");
const isValidMethod = validMethods.includes(this.user.loginMethod);
@@ -4,7 +4,7 @@
<span class="toggle-name">{{ name }}</span>
<i
v-if="description"
class="no-select material-symbols-outlined tooltip-info-icon"
class="material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
+23 -142
View File
@@ -1,13 +1,13 @@
<template>
<div class="card headline-card">
<div v-if="isDataLoaded && shouldShowLogin" class="card-wrapper user-card">
<div v-if="isDataLoaded && shouldShowLogin" class="card-wrapper user-card" :class="{ 'extra-padding': !disableQuickToggles }">
<div v-if="settingsAllowed" class="inner-card user-card__profile">
<a href="/settings#profile-main" class="person-button action button"
@click.prevent="navigateTo('/settings', '#profile-main')"
@mouseenter="showTooltip($event, $t('index.settingsHover'))" @mouseleave="hideTooltip">
<i class="material-icons person-button__icon">person</i>
<i class="material-symbols person-button__icon">person</i>
<span class="person-button__name">{{ user.username }}</span>
<i aria-label="settings" class="material-icons person-button__icon">settings</i>
<i aria-label="settings" class="material-symbols person-button__icon">settings</i>
</a>
</div>
<div v-else-if="user.username === 'anonymous' && shouldShowLogin" @click="navigateToLogin"
@@ -18,7 +18,7 @@
</div>
<div v-else-if="user.username !== 'anonymous'" class="inner-card user-card__profile">
<button class="person-button action button" type="button">
<i class="material-icons person-button__icon">person</i>
<i class="material-symbols person-button__icon">person</i>
<span class="person-button__name">{{ user.username }}</span>
</button>
</div>
@@ -26,7 +26,7 @@
<div class="inner-card" v-if="canLogout" @click="logout">
<button aria-label="logout-button" class="logout-button action button"
@mouseenter="showTooltip($event, $t('general.logout'))" @mouseleave="hideTooltip">
<i class="material-icons">exit_to_app</i>
<i class="material-symbols">exit_to_app</i>
</button>
</div>
</div>
@@ -35,16 +35,16 @@
<div class="quick-toggles" :class="{ 'extra-padding': hasCreateOptions }">
<div class="clickable" :class="{ active: user?.singleClick }" @click="toggleClick"
@mouseenter="showTooltip($event, $t('index.toggleClick'))" @mouseleave="hideTooltip" v-if="!isInvalidShare">
<i class="material-icons">ads_click</i>
<i class="material-symbols">ads_click</i>
</div>
<div aria-label="Toggle Theme" v-if="darkModeTogglePossible" class="clickable"
:class="{ active: user?.darkMode }" @click="toggleDarkMode"
@mouseenter="showTooltip($event, $t('index.toggleDark'))" @mouseleave="hideTooltip">
<i class="material-icons">dark_mode</i>
<i class="material-symbols">dark_mode</i>
</div>
<div class="clickable" :class="{ active: isStickySidebar }" @click="toggleSticky"
@mouseenter="showTooltip($event, $t('index.toggleSticky'))" @mouseleave="hideTooltip" v-if="!isMobile">
<i class="material-icons">push_pin</i>
<i class="material-symbols">push_pin</i>
</div>
</div>
</div>
@@ -54,7 +54,7 @@
@leave="leave">
<div v-if="!hideSidebarFileActions && isListingView" class="card-wrapper">
<button @click="openContextMenu" aria-label="File-Actions" data-testid="file-actions-button" class="action file-actions">
<i class="material-icons">add</i>
<i class="material-symbols">add</i>
{{ $t("sidebar.fileActions") }}
</button>
</div>
@@ -75,7 +75,6 @@
import * as auth from "@/utils/auth";
import { globalVars } from "@/utils/constants";
import { state, getters, mutations } from "@/store";
import { fromNow } from "@/utils/moment";
import SidebarLinks from "./Links.vue";
export default {
@@ -104,7 +103,6 @@ export default {
},
shareInfo: () => state.shareInfo,
disableQuickToggles: () => state.user?.disableQuickToggles,
hasSourceInfo: () => state.sources.hasSourceInfo,
hideSidebarFileActions() {
return state.user?.hideSidebarFileActions || getters.isInvalidShare() || !this.hasCreateOptions;
},
@@ -115,13 +113,8 @@ export default {
isInvalidShare: () => getters.isInvalidShare(),
isListingView: () => getters.currentView() == "listingView",
user: () => (state.user || {username: 'anonymous'}),
isDarkMode: () => getters.isDarkMode(),
isShare: () => getters.isShare(),
showSources: () => !getters.isShare(),
currentPrompt: () => getters.currentPrompt(),
active: () => getters.isSidebarVisible(),
signup: () => globalVars.signup,
disableExternal: () => globalVars.disableExternal,
canLogout: () => !globalVars.noAuth && state.user?.username !== 'anonymous',
route: () => state.route,
realtimeActive: () => state.realtimeActive,
@@ -160,32 +153,6 @@ export default {
checkLogin() {
return getters.isLoggedIn() && !getters.routePath().startsWith("/share");
},
getComplexityLabel(complexity) {
// Translate complexity integer to i18n label
// Frontend interprets: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
if (complexity === 0) return this.$t("index.unknown");
if (complexity === 1) return this.$t("index.simple");
if (complexity >= 2 && complexity <= 6) return this.$t("index.normal");
if (complexity >= 7 && complexity <= 9) return this.$t("index.complex");
if (complexity === 10) return this.$t("index.highlyComplex");
return this.$t("index.unknown");
},
getStatusLabel(status) {
// Translate status string to i18n label
switch (status) {
case "ready":
return this.$t("index.ready");
case "indexing":
return this.$t("index.indexing");
case "unavailable":
return this.$t("index.unavailable");
case "error":
return this.$t("index.error");
case "unknown":
default:
return this.$t("index.unknown");
}
},
toggleClick() {
mutations.updateCurrentUser({ singleClick: !state.user.singleClick });
},
@@ -249,78 +216,18 @@ export default {
el.addEventListener('transitionend', onTransitionEnd);
});
},
showTooltip(event, text) {
if (text) {
mutations.showTooltip({
content: text,
x: event.clientX,
y: event.clientY,
});
}
},
hideTooltip() {
mutations.hideTooltip();
},
showSourceTooltip(event, info) {
if (info) {
const tooltipContent = this.buildSourceTooltipContent(info);
mutations.showTooltip({
content: tooltipContent,
x: event.clientX,
y: event.clientY,
});
}
},
buildSourceTooltipContent(info) {
const getHumanReadable = (lastIndex) => {
if (isNaN(Number(lastIndex))) return "";
let val = Number(lastIndex);
if (val === 0) {
return "now";
}
return fromNow(val, state.user.locale);
};
return `
<table style="border-collapse: collapse; text-align: left;">
<thead>
<tr>
<th colspan="2" style="text-align: center; font-weight: bold; font-size: 1.1em; padding-bottom: 0.3em; border-bottom: 1px solid #888;">${info.name || 'Source'}</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("general.status")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.getStatusLabel(info.status)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.assessment")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.getComplexityLabel(info.complexity || 0)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("general.files")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.files || 0}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("general.folders")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${info.folders || 0}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.lastScanned")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${getHumanReadable(info.lastIndex)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${this.$t("index.quickScan")}</td>
<td style="padding: 0.2em 0.5em; border-bottom: 1px solid #ccc;">${isNaN(Number(info.quickScanDurationSeconds)) ? '' : Number(info.quickScanDurationSeconds)}</td>
</tr>
<tr>
<td style="padding: 0.2em 0.5em;">${this.$t("index.fullScan")}</td>
<td style="padding: 0.2em 0.5em;">${isNaN(Number(info.fullScanDurationSeconds)) ? '' : Number(info.fullScanDurationSeconds)}</td>
</tr>
</tbody>
</table>
`;
},
showTooltip(event, text) {
if (text) {
mutations.showTooltip({
content: text,
x: event.clientX,
y: event.clientY,
});
}
},
hideTooltip() {
mutations.hideTooltip();
},
},
};
</script>
@@ -333,7 +240,6 @@ export default {
align-items: center;
width: 100%;
color: var(--textPrimary);
margin-bottom: 0.5em;
}
.user-card > .user-card__profile {
@@ -370,12 +276,6 @@ export default {
width: 100%;
}
.usage-info {
display: flex;
flex-direction: column;
text-align: center;
}
.quick-toggles div {
border-radius: 10em;
background-color: var(--surfaceSecondary);
@@ -406,7 +306,6 @@ button.action {
padding: 0px !important;
}
.card-wrapper {
display: flex !important;
flex-direction: column;
@@ -425,10 +324,10 @@ button.action {
width: 100%;
box-sizing: border-box;
display: flex;
padding-right: 1em !important;
padding-right: 0;
justify-content: flex-start;
align-items: center;
gap: 0.35em;
gap: 0.3em;
}
.person-button__icon {
@@ -443,11 +342,6 @@ button.action {
white-space: nowrap;
}
a.person-button {
text-decoration: none;
cursor: pointer;
}
.file-actions {
padding: 0.25em !important;
margin-top: 0.5em !important;
@@ -460,19 +354,6 @@ a.person-button {
padding: 0em !important;
}
.expand-enter-active,
.expand-leave-active {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
will-change: opacity, height;
}
.expand-enter,
.expand-leave-to {
height: 0 !important;
opacity: 0;
}
.extra-padding {
padding-bottom: 0.5em !important;
}
+11 -11
View File
@@ -4,16 +4,16 @@
<!-- Links section header -->
<div class="sidebar-links-header"
:class="{ 'with-top-spacing': isShare && !disableShareCard }">
<i :class="{ 'disabled': !isLoggedIn }" @click="goHome()" class="material-icons action">home</i>
<i :class="{ 'disabled': !isLoggedIn }" @click="goHome()" class="material-symbols action">home</i>
<!-- Mode button (is the title) -->
<button @click="cycleMode" class="mode-toggle" :title="$t('sidebar.switchMode')" @mouseenter="showTooltip($event, $t('sidebar.switchMode'))" @mouseleave="hideTooltip">
<button @click="cycleMode" class="mode-toggle" @mouseenter="showTooltip($event, $t('sidebar.switchMode'))" @mouseleave="hideTooltip">
{{ mode === 'links' ? $t('general.links') : $t('general.navigation') }}
</button>
<i v-if="isShare" aria-label="Edit Share" @mouseenter="showTooltip($event, editShareText())" @mouseleave="hideTooltip"
:class="{ 'disabled': !canEdit }"
@click="showEditShareHover" class="material-icons action">edit</i>
@click="showEditShareHover" class="material-symbols action">edit</i>
<i v-else @mouseenter="showTooltip($event, $t('sidebar.customizeLinks'))" @mouseleave="hideTooltip"
@click="openSidebarLinksPrompt" class="material-icons action">edit</i>
@click="openSidebarLinksPrompt" class="material-symbols action">edit</i>
</div>
<!-- Scrollable Content Area -->
<div class="sidebar-links-content">
@@ -34,7 +34,7 @@
<a v-else-if="link.category === 'custom' && link.name === 'sourceLocation'" :href="link.target"
:aria-label="link.name" class="action button sidebar-link-button" @click.prevent="handleLinkClick(link)">
<div class="link-container">
<i class="material-icons link-icon">open_in_new</i>
<i class="material-symbols link-icon">open_in_new</i>
<span>{{ $t('buttons.goToSource') }}</span>
</div>
</a>
@@ -57,7 +57,7 @@
<circle class="center" cx="50%" cy="50%" r="7px"></circle>
<circle class="pulse" cx="50%" cy="50%" r="10px"></circle>
</svg>
<i v-else class="material-icons warning-icon"
<i v-else class="material-symbols warning-icon"
@mouseenter="showTooltip($event, $t('sidebar.sourceNotAccessible'))" @mouseleave="hideTooltip">
warning
</i>
@@ -126,7 +126,7 @@
<circle class="center" cx="50%" cy="50%" r="7px"></circle>
<circle class="pulse" cx="50%" cy="50%" r="10px"></circle>
</svg>
<i v-else class="material-icons warning-icon"
<i v-else class="material-symbols warning-icon"
@mouseenter="showTooltip($event, $t('sidebar.sourceNotAccessible'))" @mouseleave="hideTooltip">
warning
</i>
@@ -141,7 +141,7 @@
</i>
<!-- Dropdown arrow -->
<button class="source-dropdown-button" @click.stop="toggleSourceDropdown" ref="dropdownTrigger">
<i class="material-icons">keyboard_arrow_down</i>
<i class="material-symbols">keyboard_arrow_down</i>
</button>
</div>
<div v-if="hasUsageInfo(activeSourceLink)" class="usage-info">
@@ -187,7 +187,7 @@
import { state, getters, mutations } from "@/store";
import ProgressBar from "@/components/ProgressBar.vue";
import { goToItem } from "@/utils/url";
import { getIconClass } from "@/utils/material-icons";
import { getIconClass } from "@/utils/material-symbols";
import { buildIndexInfoTooltipHTML } from "@/components/files/IndexInfo.vue";
import { globalVars } from "@/utils/constants";
import { resourcesApi } from "@/api";
@@ -636,13 +636,13 @@ export default {
flex-shrink: 0;
}
.sidebar-links-header .material-icons.action {
.sidebar-links-header .material-symbols.action {
padding: 0.25em 0.25em;
border-radius: 0.5em;
transition: background 0.2s;
}
.sidebar-links-header .material-icons.action:hover {
.sidebar-links-header .material-symbols.action:hover {
background: var(--surfaceSecondary);
}
@@ -61,6 +61,11 @@ export default {
color: white !important;
}
.active-settings .settings-icon,
.settings-card:hover .material-symbols-outlined {
font-variation-settings: 'FILL' 1;
}
.settings-card {
display: flex;
align-items: center;
+1 -13
View File
@@ -6,7 +6,7 @@
>
<div v-if="shouldShow" class="button release-banner">
<a :href="releaseUrl">{{ $t("sidebar.updateIsAvailable") }}</a>
<i @click="setSeenUpdate" aria-label="close-banner" class="material-icons">close</i>
<i @click="setSeenUpdate" aria-label="close-banner" class="material-symbols">close</i>
</div>
<SidebarSettings v-if="isSettings"></SidebarSettings>
<SidebarGeneral v-if="!isSettings"></SidebarGeneral>
@@ -99,10 +99,8 @@ export default {
computed: {
externalLinks: () => globalVars.externalLinks,
name: () => globalVars.name,
isValidShare: () => getters.isValidShare(),
releaseUrl: () => globalVars.updateAvailable,
isDarkMode: () => getters.isDarkMode(),
isLoggedIn: () => getters.isLoggedIn(),
isSettings: () => getters.isSettings(),
isMobile: () => getters.isMobile(),
active: () => getters.isSidebarVisible(),
@@ -175,11 +173,6 @@ export default {
</script>
<style>
.sidebar-scroll-list {
overflow: auto;
margin-bottom: 0px !important;
}
#sidebar {
display: flex;
flex-direction: column;
@@ -263,11 +256,6 @@ body.rtl .action {
margin-left: 0;
}
.credits a,
.credits a:hover {
cursor: pointer;
}
.buffer {
flex-grow: 1;
}
@@ -1,8 +1,8 @@
<template>
<div v-if="selectedCount > 0" class="duplicate-finder-actions">
<button @click="handleDelete" class="button delete-button" :disabled="deleting">
<i v-if="deleting" class="material-icons spin">autorenew</i>
<i v-else class="material-icons">delete</i>
<i v-if="deleting" class="material-symbols spin">autorenew</i>
<i v-else class="material-symbols">delete</i>
<span>{{ $t('general.delete') }} {{ selectedCount }}</span>
</button>
<button @click="handleClear" class="button">
@@ -56,7 +56,7 @@ export default {
flex-shrink: 0;
}
.button .material-icons {
.button .material-symbols {
font-size: 1.2rem;
}
+2 -2
View File
@@ -35,7 +35,7 @@
gap: 0.75rem;
}
.warning-message .material-icons {
.warning-message .material-symbols {
font-size: 1.5rem;
color: #f57c00;
flex-shrink: 0;
@@ -58,7 +58,7 @@
gap: 0.75rem;
}
.success-message .material-icons {
.success-message .material-symbols {
font-size: 1.5rem;
color: #28a745;
flex-shrink: 0;
+15 -27
View File
@@ -113,40 +113,16 @@
unicode-range: U+0370-03FF;
}
/* Material Icons - Optimized with preload */
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
font-display: block;
src: url(/fonts/material-icons.woff2) format('woff2');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/material-symbols-outlined.woff2) format('woff2');
src: url(/fonts/material-symbols.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
user-select: none;
}
.material-symbols-outlined {
.material-symbols-outlined,
.material-symbols {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
@@ -159,5 +135,17 @@
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-webkit-user-select: none;
user-select: none;
transition: 0.25s ease;
}
/* Filled variant */
.material-symbols {
font-variation-settings: 'FILL' 1;
}
/* Outlined variant */
.material-symbols-outlined {
font-variation-settings: 'FILL' 0;
}
+19 -8
View File
@@ -1,7 +1,7 @@
.listing-items {
--item-selected: white;
animation-duration: 0.25s;
transition: box-shadow 0.2s ease-in-out, transform 0.1s ease-in-out;
transition: 0.25s ease;
user-select: none;
padding-bottom: 2.5em; /* Space for status bar - this prevents items from going behind */
}
@@ -32,7 +32,7 @@ body.rtl .listing-items {
display: flex;
flex-wrap: nowrap;
color: #6f6f6f;
transition: .1s ease background, .1s ease opacity;
transition: 0.25s ease;
align-items: center;
cursor: pointer;
user-select: none;
@@ -267,14 +267,23 @@ body.rtl .listing-items {
background: transparent;
}
.listing-items.list .listing-item .size,
.listing-items.compact .listing-item .size,
.listing-items.list .listing-item .duration,
.listing-items.compact .listing-item .duration,
.listing-items.list .listing-item .modified,
.listing-items.compact .listing-item .modified {
min-width: 15%;
flex: 0;
text-align: end;
padding-right: 1em;
}
.listing-items.list .listing-item .size,
.listing-items.compact .listing-item .size,
.listing-items.list .listing-item .duration,
.listing-items.compact .listing-item .duration {
min-width: 10%;
flex: 0;
text-align: end;
padding-right: 1em;
}
.listing-items.list .listing-item .name,
@@ -293,7 +302,7 @@ body.rtl .listing-items {
height: 2em;
}
.listing-items.icons .image-preview.download-icon .material-icons {
.listing-items.icons .image-preview.download-icon .material-symbols {
font-size: 2em;
width: 100%;
text-align: center;
@@ -321,10 +330,12 @@ body.rtl .listing-items {
.listing-item[aria-selected=true] {
background-color: color-mix(in srgb, var(--primaryColor) 85%, transparent) !important;
color: var(--item-selected) !important;
scroll-margin-top: 9em;
scroll-margin-bottom: 3em;
}
.listing-items.gallery .listing-item[aria-selected=true] {
border: 0.2em solid var(--primaryColor) !important;
border: 0.45em solid var(--primaryColor) !important;
}
.listing-items.gallery .listing-item .size,
+15
View File
@@ -13,6 +13,12 @@
/* Loader / skeleton: single source in public/css/loadingSpinners.css (also inlined in index.html for first paint). */
@import "../../public/css/loadingSpinners.css";
/* Selection color */
::selection {
background-color: color-mix(in srgb, var(--primaryColor), transparent 25%);
color: inherit;
}
.link {
color: var(--blue);
}
@@ -359,6 +365,15 @@ button:hover,
/* Slightly enlarges the element */
}
.listing-item[aria-selected="true"] .material-symbols-outlined,
.listing-item:hover .material-symbols-outlined,
.tree-node:hover .material-symbols-outlined,
.tree-node.current-item .material-symbols-outlined,
.tree-node.drag-over .material-symbols-outlined,
.search-entry:hover .material-symbols-outlined {
font-variation-settings: 'FILL' 1;
}
.flat-right {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "أرشيف المقتطفات",
"unarchiveMessage": "اختر مكان استخراج الأرشيف.",
"unarchiveDestination": "مقتطف إلى:",
"unarchiveSuccess": "تم استخراج الأرشيف بنجاح!"
"unarchiveSuccess": "تم استخراج الأرشيف بنجاح!",
"downloadFailed": "فشل التحميل المقطوع - إذا تكرر ذلك، حاول تقليل حجم القطعة: {message}"
},
"search": {
"smallerThan": "أصغر من:",
@@ -343,9 +344,7 @@
"brandingHelp": "يمكنك تخصيص شكل ومظهر مثيل متصفح الملفات الخاص بك عن طريق تغيير اسمه، واستبدال الشعار، وإضافة أنماط مخصصة، وحتى تعطيل الروابط الخارجية إلى GitHub.\nلمزيد من المعلومات حول العلامة التجارية المخصصة، يرجى الاطلاع على {0}.",
"commandRunnerHelp": "هنا يمكنك تعيين الأوامر التي يتم تنفيذها في الأحداث المسماة. يجب عليك كتابة أمر واحد لكل سطر. سيكون متغيرا البيئة {0} و {1} متاحين، حيث {0} بالنسبة إلى {1}. لمزيد من المعلومات حول هذه الميزة ومتغيرات البيئة المتاحة، يرجى قراءة {2}.",
"loginMethods": {
"password": "كلمة المرور",
"oidc": "OIDC",
"proxy": "الوكيل"
"password": "كلمة المرور"
},
"newUserHintSubFolder": "على سبيل المثال '/مجلد فرعي' - فارغ للافتراضي",
"permissionsHelp": "تتحكم أذونات المستخدم في الوصول إلى نوع الإجراءات التي يمكن للمستخدم تنفيذها. يمنح الوصول إلى المسؤول التحكم الكامل لتحديث وتغيير إعدادات أي مستخدم بالإضافة إلى العديد من إعدادات النظام.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "تم إجهاض عملية الحفظ. الملف النشط للتطبيق ({activeFile}) لا يتطابق مع الملف الذي تحاول حفظه ({tryingToSave}).",
"syncFailed": "فشل مزامنة الحالة مع مسار \"{filename}\" بعد 5 محاولات. إجهاض إعداد المحرر لمنع تلف البيانات.",
"fileSaved": "تم حفظ الملف!",
"errorSavingFile": "خطأ في حفظ الملف: {error}"
"errorSavingFile": "خطأ في حفظ الملف: {error}",
"lines": "{count} سطور",
"words": "{count} كلمات",
"chars": "{count} أحرف"
},
"player": {
"LoopEnabled": "تفعيل التكرار",
+7 -5
View File
@@ -318,7 +318,8 @@
"unarchive": "Výpis archivu",
"unarchiveMessage": "Vyberte, kam se má archiv rozbalit.",
"unarchiveDestination": "Výpis na:",
"unarchiveSuccess": "Archiv úspěšně extrahován!"
"unarchiveSuccess": "Archiv úspěšně extrahován!",
"downloadFailed": "Stahování po částech se nezdařilo - pokud se tato situace opakuje, zkuste zmenšit velikost části: {message}"
},
"search": {
"smallerThan": "Menší než:",
@@ -487,9 +488,7 @@
"brandingHelp": "Vzhled instance Průzkumníka souborů si můžete přizpůsobit změnou názvu, nahrazením loga, přidáním vlastních stylů a dokonce i zakázáním externích odkazů na GitHub.\nDalší informace o vlastním brandingu naleznete na stránce {0}.",
"commandRunnerHelp": "Zde můžete nastavit příkazy, které se provedou v pojmenovaných událostech. Na každý řádek musíte napsat jeden. K dispozici budou proměnné prostředí {0} a {1}, přičemž {0} bude relativní vůči {1}. Další informace o této funkci a dostupných proměnných prostředí naleznete na {2}.",
"loginMethods": {
"password": "Heslo",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Heslo"
},
"newUserHintSubFolder": "např. '/subfolder' - prázdný pro výchozí nastavení",
"permissionsHelp": "Oprávnění uživatele řídí přístup k typům akcí, které může uživatel provádět. Přístup administrátora poskytuje plnou kontrolu nad aktualizací a změnou nastavení libovolného uživatele i nad mnoha nastaveními systému.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Operace ukládání byla přerušena. Aktivní soubor aplikace ({activeFile}) neodpovídá souboru, který se pokoušíte uložit ({tryingToSave}).",
"syncFailed": "po 5 pokusech se nepodařilo synchronizovat stav s trasou pro \"{filename}\". Přerušení nastavení editoru, aby se zabránilo poškození dat.",
"fileSaved": "Soubor uložen!",
"errorSavingFile": "Chyba při ukládání souboru: {error}"
"errorSavingFile": "Chyba při ukládání souboru: {error}",
"lines": "{count} řádků",
"words": "{count} slov",
"chars": "{count} znaků"
},
"player": {
"LoopEnabled": "Smyčka povolena",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Archiv extrahieren",
"unarchiveMessage": "Wählen Sie, wohin das Archiv entpackt werden soll.",
"unarchiveDestination": "Auszug zu:",
"unarchiveSuccess": "Archiv erfolgreich entpackt!"
"unarchiveSuccess": "Archiv erfolgreich entpackt!",
"downloadFailed": "Chunked Download fehlgeschlagen - falls sich dies wiederholt, versuchen Sie, die Chunk-Größe zu verringern: {message}"
},
"search": {
"smallerThan": "Kleiner als:",
@@ -343,9 +344,7 @@
"brandingHelp": "Sie können das Aussehen Ihrer Dateibrowser-Instanz anpassen, indem Sie den Namen ändern, das Logo ersetzen, eigene Stile hinzufügen und sogar externe Links zu GitHub deaktivieren.\nWeitere Informationen zum benutzerdefinierten Branding finden Sie unter {0}.",
"commandRunnerHelp": "Hier können Sie Befehle eingeben, die bei den genannten Ereignissen ausgeführt werden. Sie müssen einen pro Zeile schreiben. Die Umgebungsvariablen {0} und {1} werden verfügbar sein, wobei {0} relativ zu {1} ist. Weitere Informationen über diese Funktion und die verfügbaren Umgebungsvariablen finden Sie unter {2}.",
"loginMethods": {
"password": "Passwort",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Passwort"
},
"newUserHintSubFolder": "z.B. '/subfolder' - leer für Standard",
"permissionsHelp": "Die Benutzerrechte steuern den Zugriff auf die Art der Aktionen, die der Benutzer durchführen kann. Der Administrator-Zugang gibt Ihnen die volle Kontrolle über die Aktualisierung und Änderung der Benutzereinstellungen sowie vieler Systemeinstellungen.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Der Speichervorgang wurde abgebrochen. Die aktive Datei der Anwendung ({activeFile}) stimmt nicht mit der Datei überein, die Sie zu speichern versuchen ({tryingToSave}).",
"syncFailed": "Nach 5 Versuchen ist es nicht gelungen, den Status mit der Route für \"{filename}\" zu synchronisieren. Abbruch der Editor-Einrichtung, um Datenbeschädigung zu vermeiden.",
"fileSaved": "Datei gespeichert!",
"errorSavingFile": "Fehler beim Speichern der Datei: {error}"
"errorSavingFile": "Fehler beim Speichern der Datei: {error}",
"lines": "{count} Zeilen",
"words": "{count} Wörter",
"chars": "{count} Zeichen"
},
"player": {
"LoopEnabled": "Wiederholung aktiviert",
+7 -5
View File
@@ -243,7 +243,8 @@
"unarchive": "Απόσπασμα αρχείου",
"unarchiveMessage": "Επιλέξτε πού θα εξαγάγετε το αρχείο.",
"unarchiveDestination": "Απόσπασμα προς:",
"unarchiveSuccess": "Το αρχείο εξήχθη με επιτυχία!"
"unarchiveSuccess": "Το αρχείο εξήχθη με επιτυχία!",
"downloadFailed": "Αποτυχημένη λήψη σε τεμάχια - αν αυτό επαναληφθεί, δοκιμάστε να μειώσετε το μέγεθος των τεμαχίων: {message}"
},
"search": {
"smallerThan": "Μικρότερο από:",
@@ -347,9 +348,7 @@
"brandingHelp": "Μπορείτε να προσαρμόσετε την εμφάνιση και την αίσθηση του File Browser αλλάζοντας το όνομά του, αντικαθιστώντας το λογότυπο, προσθέτοντας προσαρμοσμένα στυλ και ακόμη και απενεργοποιώντας τους εξωτερικούς συνδέσμους προς το GitHub.\nΓια περισσότερες πληροφορίες σχετικά με την προσαρμοσμένη επωνυμία, ανατρέξτε στη διεύθυνση {0}.",
"commandRunnerHelp": "Εδώ μπορείτε να ορίσετε τις εντολές που εκτελούνται στα συμβάντα που ονομάζονται. Πρέπει να γράψετε μία ανά γραμμή. Οι περιβαλλοντικές μεταβλητές {0} και {1} θα είναι διαθέσιμες, ενώ η {0} θα είναι σχετική με την {1}. Για περισσότερες πληροφορίες σχετικά με αυτή τη λειτουργία και τις διαθέσιμες μεταβλητές περιβάλλοντος, διαβάστε το {2}.",
"loginMethods": {
"password": "Κωδικός πρόσβασης",
"oidc": "OIDC",
"proxy": "Πληρεξούσιος"
"password": "Κωδικός πρόσβασης"
},
"newUserHintSubFolder": "π.χ. '/subfolder' - κενό για προεπιλογή",
"permissionsHelp": "Τα δικαιώματα χρήστη ελέγχουν την πρόσβαση σε είδος ενεργειών που μπορεί να εκτελέσει ο χρήστης. Η πρόσβαση διαχειριστή παρέχει πλήρη έλεγχο για την ενημέρωση και την αλλαγή των ρυθμίσεων οποιουδήποτε χρήστη, καθώς και πολλών ρυθμίσεων του συστήματος.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Διακοπή της λειτουργίας αποθήκευσης. Το ενεργό αρχείο της εφαρμογής ({activeFile}) δεν ταιριάζει με το αρχείο που προσπαθείτε να αποθηκεύσετε ({tryingToSave}).",
"syncFailed": "απέτυχε να συγχρονίσει την κατάσταση με τη διαδρομή για το \"{filename}\" μετά από 5 προσπάθειες. Διακοπή της ρύθμισης του συντάκτη για να αποφευχθεί η αλλοίωση των δεδομένων.",
"fileSaved": "Το αρχείο αποθηκεύτηκε!",
"errorSavingFile": "Σφάλμα αποθήκευσης αρχείου: {error}"
"errorSavingFile": "Σφάλμα αποθήκευσης αρχείου: {error}",
"lines": "{count} γραμμές",
"words": "{count} λέξεις",
"chars": "{count} χαρακτήρες"
},
"player": {
"LoopEnabled": "Ενεργή επανάληψη",
+6 -4
View File
@@ -377,6 +377,7 @@
"displayName": "Display Name:",
"download": "Download files",
"downloadMessage": "Choose the format you want to download.",
"downloadFailed": "Chunked download failed - if this repeats, try reducing the chunk size: {message}",
"archive": "Create archive",
"archiveMessage": "Create an archive on the server from the selected items.",
"archiveDestination": "Choose destination folder (then enter archive name below):",
@@ -545,9 +546,7 @@
"passwordsDoNotMatch": "Passwords do not match",
"loginMethod": "Login method",
"loginMethods": {
"password": "Password",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Password"
},
"loginMethodDescription": "allowed login method for the user.",
"modifyOtherUser": "Modify user:",
@@ -792,7 +791,10 @@
"saveAbortedMessage":"Save operation aborted. The application's active file ({activeFile}) does not match the file you are trying to save ({tryingToSave}).",
"syncFailed":"failed to sync state with the route for \"{filename}\" after 5 attempts. Aborting editor setup to prevent data corruption.",
"fileSaved":"File saved!",
"errorSavingFile":"Error saving file: {error}"
"errorSavingFile":"Error saving file: {error}",
"lines": "{count} lines",
"words": "{count} words",
"chars": "{count} characters"
},
"player": {
"LoopEnabled": "Loop Enabled",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Extraer",
"unarchiveMessage": "Elije dónde quieres extraer el archivo:",
"unarchiveDestination": "Extraer a:",
"unarchiveSuccess": "¡Archivo extraído con éxito!"
"unarchiveSuccess": "¡Archivo extraído con éxito!",
"downloadFailed": "Fallo en la descarga por trozos. Si se repite, intente reducir el tamaño de los trozos: {message}"
},
"search": {
"smallerThan": "Menor que:",
@@ -335,9 +336,7 @@
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"loginMethod": "Método de inicio de sesión",
"loginMethods": {
"password": "Contraseña",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Contraseña"
},
"shareSettings": "Recursos compartidos",
"adminOptions": "Opciones de administrador",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "No se ha podido guardar el archivo. El nombre del archivo \"{activeFile}\" no coincide con el que estas intentando guardar ({tryingToSave}).",
"syncFailed": "No se ha podido encontrar la ruta para \"{filename}\" después de 5 intentos - Se cerrará el editor para evitar la corrupción de datos.",
"fileSaved": "!Se guardo el archivo correctamente!",
"errorSavingFile": "Error al guardar el archivo: {error}"
"errorSavingFile": "Error al guardar el archivo: {error}",
"lines": "{count} líneas",
"words": "{count} palabras",
"chars": "{count} caracteres"
},
"player": {
"LoopEnabled": "Bucle activado",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Extraire l'archive",
"unarchiveMessage": "Choisissez l'endroit où extraire l'archive.",
"unarchiveDestination": "Extrait à :",
"unarchiveSuccess": "L'archive a été extraite avec succès !"
"unarchiveSuccess": "L'archive a été extraite avec succès !",
"downloadFailed": "Échec du téléchargement groupé - si cela se répète, essayez de réduire la taille des morceaux : {message}"
},
"search": {
"smallerThan": "Plus petit que :",
@@ -345,9 +346,7 @@
"brandingHelp": "Vous pouvez personnaliser l'apparence de votre instance du navigateur de fichiers en changeant son nom, en remplaçant le logo, en ajoutant des styles personnalisés et même en désactivant les liens externes vers GitHub.\nPour plus d'informations sur la personnalisation de l'image de marque, consultez le site {0}.",
"commandRunnerHelp": "Vous pouvez définir ici les commandes qui seront exécutées dans les événements nommés. Vous devez en écrire une par ligne. Les variables d'environnement {0} et {1} seront disponibles, {0} étant relatif à {1}. Pour plus d'informations sur cette fonctionnalité et les variables d'environnement disponibles, veuillez consulter le site {2}.",
"loginMethods": {
"password": "Mot de passe",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Mot de passe"
},
"newUserHintSubFolder": "par exemple \"/sous-dossier\" - vide par défaut",
"permissionsHelp": "Les autorisations de l'utilisateur contrôlent l'accès au type d'actions que l'utilisateur peut effectuer. L'accès administrateur permet de mettre à jour et de modifier les paramètres de tout utilisateur ainsi que de nombreux paramètres du système.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "L'opération d'enregistrement a été interrompue. Le fichier actif de l'application ({activeFile}) ne correspond pas au fichier que vous essayez d'enregistrer ({tryingToSave}).",
"syncFailed": "n'a pas réussi à synchroniser l'état avec l'itinéraire pour \"{filename}\" après 5 tentatives. Abandon de la configuration de l'éditeur pour éviter la corruption des données.",
"fileSaved": "Fichier sauvegardé !",
"errorSavingFile": "Erreur d'enregistrement du fichier : {error}"
"errorSavingFile": "Erreur d'enregistrement du fichier : {error}",
"lines": "{count} lignes",
"words": "{count} mots",
"chars": "{count} caractères"
},
"player": {
"LoopEnabled": "Boucle activée",
+18 -16
View File
@@ -92,17 +92,17 @@
"wiki": "מסמכים רשמיים של FileBrowser Quantum",
"marqueeSelect": "בחר מספר קבצים או ספריות על ידי גרירה על שטח ריק",
"media": {
"title": "נגן מדיה",
"playPause": "הפעל/השהה נגינה",
"seekPercentage": "דילוג מ-10% ל-90%",
"seek": "דילוג 10 שניות, אחורה או קדימה",
"volume": "הגבר או הנמך עוצמת קול",
"mute": "השתק/בטל השתקת שמע",
"loop": "החלף בין מצב לולאה למצב יחיד",
"playbackModes": "החלף בין מצבי נגינה (למעט לולאה ויחיד)",
"fullscreen": "החלף מצב מסך מלא בסרטונים",
"captions": "הפעל/כבה כתוביות"
}
"title": "נגן מדיה",
"playPause": "הפעל/השהה נגינה",
"seekPercentage": "דילוג מ-10% ל-90%",
"seek": "דילוג 10 שניות, אחורה או קדימה",
"volume": "הגבר או הנמך עוצמת קול",
"mute": "השתק/בטל השתקת שמע",
"loop": "החלף בין מצב לולאה למצב יחיד",
"playbackModes": "החלף בין מצבי נגינה (למעט לולאה ויחיד)",
"fullscreen": "החלף מצב מסך מלא בסרטונים",
"captions": "הפעל/כבה כתוביות"
}
},
"languages": {
"he": "עברית",
@@ -240,7 +240,8 @@
"unarchive": "חלץ ארכיון",
"unarchiveMessage": "בחר היכן לחלץ את הארכיון.",
"unarchiveDestination": "חלץ אל:",
"unarchiveSuccess": "הארכיון חולץ בהצלחה!"
"unarchiveSuccess": "הארכיון חולץ בהצלחה!",
"downloadFailed": "ההורדה המקוטעת נכשלה - אם הבעיה חוזרת על עצמה, נסה להקטין את גודל החלקים: {message}"
},
"search": {
"smallerThan": "קטן מ:",
@@ -343,9 +344,7 @@
"brandingHelp": "ניתן להתאים אישית את המראה והתחושה של דפדפן הקבצים על ידי שינוי שמו, החלפת הלוגו, הוספת סגנונות מותאמים אישית ואפילו השבתת קישורים חיצוניים ל-GitHub.\nלמידע נוסף על מיתוג מותאם אישית, בקרו ב- {0}.",
"commandRunnerHelp": "כאן תוכל להגדיר פקודות שיבוצעו באירועים המוזכרים. עליך לכתוב פקודה אחת בכל שורה. המשתנים הסביבתיים {0} ו- {1} יהיו זמינים, והם יהיו {0} ביחס ל- {1}. למידע נוסף על תכונה זו ועל המשתנים הסביבתיים הזמינים, אנא קרא את {2}.",
"loginMethods": {
"password": "סיסמה",
"oidc": "OIDC",
"proxy": "פרוקסי"
"password": "סיסמה"
},
"newUserHintSubFolder": "לדוגמה, '/subfolder' - ריק עבור ברירת המחדל",
"permissionsHelp": "הרשאות המשתמש שולטות בגישה לסוגי הפעולות שהמשתמש יכול לבצע. גישה לניהול מעניקה שליטה מלאה לעדכון ולשינוי הגדרות המשתמש, כמו גם הגדרות רבות של המערכת.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "פעולת השמירה בוטלה. הקובץ הפעיל של היישום ({activeFile}) אינו תואם לקובץ שאתה מנסה לשמור ({tryingToSave}).",
"syncFailed": "לא הצליח לסנכרן את המצב עם המסלול עבור \"{filename}\" לאחר 5 ניסיונות. מבטל את הגדרת העורך כדי למנוע פגיעה בנתונים.",
"fileSaved": "הקובץ נשמר!",
"errorSavingFile": "שגיאה בשמירת הקובץ: {error}"
"errorSavingFile": "שגיאה בשמירת הקובץ: {error}",
"lines": "{count} שורות",
"words": "{count} מילים",
"chars": "{count} תווים"
},
"player": {
"LoopEnabled": "לולאה מופעלת",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Kivonat archívum",
"unarchiveMessage": "Válassza ki, hová szeretné kinyomtatni az archívumot.",
"unarchiveDestination": "Kivonat a következő címre:",
"unarchiveSuccess": "Az archívum sikeresen kinyomtatva!"
"unarchiveSuccess": "Az archívum sikeresen kinyomtatva!",
"downloadFailed": "A darabos letöltés sikertelen - ha ez ismétlődik, próbálja meg csökkenteni a darabos letöltés méretét: {message}"
},
"search": {
"smallerThan": "Kisebb, mint:",
@@ -343,9 +344,7 @@
"brandingHelp": "Testreszabhatja a Fájlböngésző példányának megjelenését és érzetét a név megváltoztatásával, a logó cseréjével, egyéni stílusok hozzáadásával és akár a GitHubra mutató külső hivatkozások letiltásával.\nAz egyéni márkaépítéssel kapcsolatos további információkat a {0} oldalon talál.",
"commandRunnerHelp": "Itt állíthatja be azokat a parancsokat, amelyek a megnevezett események során végrehajtásra kerülnek. Soronként egyet kell írni. A {0} és a {1} környezeti változók lesznek elérhetőek, a {0} a {1}-hoz képest . A funkcióról és a rendelkezésre álló környezeti változókról bővebb információt a {2} oldalon talál.",
"loginMethods": {
"password": "Jelszó",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Jelszó"
},
"newUserHintSubFolder": "pl. '/subfolder' - üres az alapértelmezéshez",
"permissionsHelp": "A felhasználói engedélyek szabályozzák, hogy a felhasználó milyen típusú műveleteket hajthat végre. A rendszergazdai hozzáférés teljes körű ellenőrzést biztosít bármely felhasználó beállításainak, valamint számos rendszerbeállításnak a frissítéséhez és módosításához.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "A mentési művelet megszakadt. Az alkalmazás aktív fájlja ({activeFile}) nem egyezik meg a menteni kívánt fájllal ({tryingToSave}).",
"syncFailed": "5 próbálkozás után nem sikerült szinkronizálni az állapotot a \"{filename}\" útvonallal. A szerkesztő beállításának megszakítása az adatok sérülésének elkerülése érdekében.",
"fileSaved": "Fájl mentve!",
"errorSavingFile": "Hiba a fájl mentésében: {error}"
"errorSavingFile": "Hiba a fájl mentésében: {error}",
"lines": "{count} sor",
"words": "{count} szó",
"chars": "{count} karakter"
},
"player": {
"LoopEnabled": "Hurok engedélyezve",
+4 -1
View File
@@ -626,7 +626,10 @@
"saveAborted": "Vistun hætt við. Skráarheittið passar ekki við virka skrána.",
"saveDisabled": "Vistun deildra skráa er ekki studd.",
"saveAbortedMessage": "Vistunaraðgerð hætt við. Virka skrá forritsins ({activeFile}) passar ekki við skrána sem þú ert að reyna að vista ({tryingToSave}).",
"syncFailed": "tókst ekki að samstilla stöðu við slóðina fyrir \"{filename}\" eftir 5 tilraunir. Hætti við ritstjóra uppsetningu til að koma í veg fyrir gagnaspa."
"syncFailed": "tókst ekki að samstilla stöðu við slóðina fyrir \"{filename}\" eftir 5 tilraunir. Hætti við ritstjóra uppsetningu til að koma í veg fyrir gagnaspa.",
"lines": "{count} línur",
"words": "{count} orð",
"chars": "{count} stafir"
},
"player": {
"LoopEnabled": "Endurtekning Virk",
+7 -5
View File
@@ -449,7 +449,8 @@
"halloweenBody": "Il tema di Halloween è stato applicato. Puoi disabilitarlo o cambiarlo permanentemente nelle impostazioni del profilo.",
"moveFailed": "Spostamento fallito",
"operationFailed": "Operazione fallita",
"partialSuccess": "Alcune operazioni sono fallite: {succeeded} riuscite, {failed} fallite"
"partialSuccess": "Alcune operazioni sono fallite: {succeeded} riuscite, {failed} fallite",
"downloadFailed": "Il download in chunk non è riuscito: se il problema si ripete, provare a ridurre la dimensione del chunk: {message}"
},
"search": {
"searchContext": "Contesto di ricerca: {context}",
@@ -545,9 +546,7 @@
"passwordsDoNotMatch": "Le password non corrispondono",
"loginMethod": "Metodo di accesso",
"loginMethods": {
"password": "Password",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Password"
},
"loginMethodDescription": "Metodo di accesso consentito per l'utente.",
"modifyOtherUser": "Modifica utente:",
@@ -792,7 +791,10 @@
"saveAbortedMessage": "Operazione annullata. Il file attivo ({activeFile}) non corrisponde a quello che stai salvando ({tryingToSave}).",
"syncFailed": "Impossibile sincronizzare lo stato per \"{filename}\" dopo 5 tentativi. Interruzione per prevenire corruzione dati.",
"fileSaved": "File salvato!",
"errorSavingFile": "Errore nel salvataggio del file: {error}"
"errorSavingFile": "Errore nel salvataggio del file: {error}",
"lines": "{count} righe",
"words": "{count} parole",
"chars": "{count} caratteri"
},
"player": {
"LoopEnabled": "Loop Abilitato",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "アーカイブの展開",
"unarchiveMessage": "アーカイブを展開する場所を選択します。",
"unarchiveDestination": "に抜粋する:",
"unarchiveSuccess": "アーカイブは正常に抽出されました!"
"unarchiveSuccess": "アーカイブは正常に抽出されました!",
"downloadFailed": "チャンクダウンロードに失敗しました - これが繰り返される場合は、チャンクサイズを小さくしてみてください:{message}"
},
"search": {
"smallerThan": "より小さい:",
@@ -343,9 +344,7 @@
"brandingHelp": "File Browserインスタンスの見た目と操作感は、名前の変更、ロゴの置き換え、カスタムスタイルの追加、GitHubへの外部リンクの無効化などでカスタマイズできます。\nカスタムブランディングの詳細については、{0}をご覧ください。",
"commandRunnerHelp": "ここでは、指定したイベントで実行されるコマンドを設定できます。1行に1つ記述する必要があります。環境変数{0}と{1}が使用可能になり、{0}は{1}の相対変数になります。この機能と使用可能な環境変数の詳細については、{2}をお読みください。",
"loginMethods": {
"password": "パスワード",
"oidc": "OIDC",
"proxy": "プロキシ"
"password": "パスワード"
},
"newUserHintSubFolder": "例 '/subfolder' - デフォルトは空白",
"permissionsHelp": "ユーザー権限は、ユーザーが実行できるアクションの種類へのアクセスを制御します。管理者アクセスは、多くのシステム設定だけでなく、任意のユーザーの設定を更新および変更するための完全なコントロールを提供します。",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "保存操作が中断されました。アプリケーションのアクティブファイル({activeFile})と保存しようとしているファイル({tryingToSave})が一致しません。",
"syncFailed": "\"{filename}\"のルートとステートの同期に失敗しました。データの破損を防ぐためにエディタのセットアップを中止しています。",
"fileSaved": "ファイルが保存された!",
"errorSavingFile": "ファイルの保存でエラーが発生しました:{error}"
"errorSavingFile": "ファイルの保存でエラーが発生しました:{error}",
"lines": "{count} 行",
"words": "{count} 単語",
"chars": "{count} 文字"
},
"player": {
"LoopEnabled": "ループ有効",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "아카이브 추출",
"unarchiveMessage": "아카이브를 추출할 위치를 선택합니다.",
"unarchiveDestination": "추출 대상:",
"unarchiveSuccess": "아카이브가 성공적으로 추출되었습니다!"
"unarchiveSuccess": "아카이브가 성공적으로 추출되었습니다!",
"downloadFailed": "청크 다운로드에 실패했습니다 - 이 문제가 반복되면 청크 크기를 줄여 보세요: {message}"
},
"search": {
"smallerThan": "보다 작습니다:",
@@ -343,9 +344,7 @@
"brandingHelp": "파일 브라우저 인스턴스의 이름을 변경하고, 로고를 바꾸고, 사용자 지정 스타일을 추가하고, GitHub에 대한 외부 링크를 비활성화하여 모양과 느낌을 사용자 지정할 수 있습니다.\n사용자 지정 브랜딩에 대한 자세한 내용은 {0} 을 참조하세요.",
"commandRunnerHelp": "여기에서 명명된 이벤트에서 실행되는 명령을 설정할 수 있습니다. 한 줄에 하나씩 작성해야 합니다. 환경 변수 {0} 및 {1} 을 사용할 수 있으며, {0} 은 {1} 에 상대적입니다. 이 기능 및 사용 가능한 환경 변수에 대한 자세한 내용은 {2} 을 참조하세요.",
"loginMethods": {
"password": "비밀번호",
"oidc": "OIDC",
"proxy": "프록시"
"password": "비밀번호"
},
"newUserHintSubFolder": "예: '/subfolder' - 기본값은 비어 있습니다.",
"permissionsHelp": "사용자 권한은 사용자가 수행할 수 있는 작업 유형에 대한 액세스를 제어합니다. 관리자 권한은 모든 사용자 설정은 물론 여러 시스템 설정을 업데이트하고 변경할 수 있는 모든 권한을 부여합니다.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "저장 작업이 중단되었습니다. 애플리케이션의 활성 파일({activeFile})이 저장하려는 파일({tryingToSave})과 일치하지 않습니다.",
"syncFailed": "5번 시도 후 \"{filename}\"의 경로와 상태를 동기화하지 못했습니다. 데이터 손상을 방지하기 위해 편집기 설정을 중단합니다.",
"fileSaved": "파일이 저장되었습니다!",
"errorSavingFile": "파일 저장 중 오류가 발생했습니다: {error}"
"errorSavingFile": "파일 저장 중 오류가 발생했습니다: {error}",
"lines": "{count} 줄",
"words": "{count} 단어",
"chars": "{count} 문자"
},
"player": {
"LoopEnabled": "반복 활성화",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Archief uitpakken",
"unarchiveMessage": "Kies waar het archief moet worden uitgepakt.",
"unarchiveDestination": "Uittreksel naar:",
"unarchiveSuccess": "Archief met succes uitgepakt!"
"unarchiveSuccess": "Archief met succes uitgepakt!",
"downloadFailed": "Chunkedownload mislukt - als dit zich herhaalt, probeer dan de grootte van de chunk te verkleinen: {message}"
},
"search": {
"smallerThan": "Kleiner dan:",
@@ -343,9 +344,7 @@
"brandingHelp": "Je kunt aanpassen hoe je File Browser instance eruit ziet en aanvoelt door de naam te veranderen, het logo te vervangen, aangepaste stijlen toe te voegen en zelfs externe links naar GitHub uit te schakelen.\nVoor meer informatie over het aanpassen van je branding, kun je kijken op {0}.",
"commandRunnerHelp": "Hier kun je commando's instellen die worden uitgevoerd in de genoemde gebeurtenissen. Je moet er één per regel schrijven. De omgevingsvariabelen {0} en {1} zullen beschikbaar zijn, waarbij {0} relatief is ten opzichte van {1}. Lees voor meer informatie over deze functie en de beschikbare omgevingsvariabelen de {2}.",
"loginMethods": {
"password": "Wachtwoord",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Wachtwoord"
},
"newUserHintSubFolder": "bijv. '/submap' - leeg voor standaard",
"permissionsHelp": "Gebruikersrechten bepalen welke acties de gebruiker kan uitvoeren. Administrar toegang geeft volledige controle om de instellingen van elke gebruiker bij te werken en te wijzigen, evenals vele systeeminstellingen.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Opslaan afgebroken. Het actieve bestand van de toepassing ({activeFile}) komt niet overeen met het bestand dat u probeert op te slaan ({tryingToSave}).",
"syncFailed": "Na 5 pogingen is het niet gelukt om de status te synchroniseren met de route voor \"{filename}\". Installatie van editor afgebroken om gegevensbeschadiging te voorkomen.",
"fileSaved": "Bestand opgeslagen!",
"errorSavingFile": "Fout bij het opslaan van bestand: {error}"
"errorSavingFile": "Fout bij het opslaan van bestand: {error}",
"lines": "{count} regels",
"words": "{count} woorden",
"chars": "{count} tekens"
},
"player": {
"LoopEnabled": "Herhaling ingeschakeld",
+7 -5
View File
@@ -449,7 +449,8 @@
"halloweenBody": "Het Halloween-thema is toegepast. U kunt het uitschakelen als u dit niet prettig vindt, of permanent uitschakelen in de profielinstellingen door een ander thema te selecteren.",
"moveFailed": "Verplaatsen mislukt",
"operationFailed": "Bewerking is mislukt",
"partialSuccess": "Enkele bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt."
"partialSuccess": "Enkele bewerkingen zijn mislukt: {succeeded} gelukt, {failed} mislukt.",
"downloadFailed": "Chunkedownload mislukt - als dit zich herhaalt, probeer dan de grootte van de chunk te verkleinen: {message}"
},
"search": {
"searchContext": "Zoekcontext: {context}",
@@ -545,9 +546,7 @@
"passwordsDoNotMatch": "Wachtwoorden komen niet overeen",
"loginMethod": "Aanmeldingsmethode",
"loginMethods": {
"password": "Wachtwoord",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Wachtwoord"
},
"loginMethodDescription": "toegestane aanmeldingsmethode voor de gebruiker.",
"modifyOtherUser": "Gebruiker wijzigen:",
@@ -792,7 +791,10 @@
"saveAbortedMessage": "Opslaan afgebroken. Het actieve bestand van de toepassing ({activeFile}) komt niet overeen met het bestand dat u probeert op te slaan ({tryingToSave}).",
"syncFailed": "Na 5 pogingen is het niet gelukt om de status te synchroniseren met de route voor \"{filename}\". Installatie van editor afgebroken om gegevensbeschadiging te voorkomen.",
"fileSaved": "Bestand opgeslagen!",
"errorSavingFile": "Fout bij het opslaan van bestand: {error}"
"errorSavingFile": "Fout bij het opslaan van bestand: {error}",
"lines": "{count} regels",
"words": "{count} woorden",
"chars": "{count} tekens"
},
"player": {
"LoopEnabled": "Herhaling ingeschakeld",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Wyodrębnij archiwum",
"unarchiveMessage": "Wybierz miejsce rozpakowania archiwum.",
"unarchiveDestination": "Wyciąg do:",
"unarchiveSuccess": "Archiwum rozpakowane pomyślnie!"
"unarchiveSuccess": "Archiwum rozpakowane pomyślnie!",
"downloadFailed": "Pobieranie fragmentów nie powiodło się - jeśli sytuacja się powtórzy, spróbuj zmniejszyć rozmiar fragmentu: {message}"
},
"search": {
"smallerThan": "Mniejszy niż:",
@@ -343,9 +344,7 @@
"brandingHelp": "Możesz dostosować wygląd i sposób działania swojej instancji przeglądarki plików, zmieniając jej nazwę, zastępując logo, dodając niestandardowe style, a nawet wyłączając zewnętrzne łącza do GitHub.\nWięcej informacji na temat niestandardowego brandingu można znaleźć na stronie {0}.",
"commandRunnerHelp": "Tutaj można ustawić polecenia, które będą wykonywane w nazwanych zdarzeniach. Należy wpisać jedną komendę w wierszu. Dostępne będą zmienne środowiskowe {0} i {1}, przy czym {0} będzie relatywna do {1}. Więcej informacji na temat tej funkcji i dostępnych zmiennych środowiskowych można znaleźć na stronie {2}.",
"loginMethods": {
"password": "Hasło",
"oidc": "OIDC",
"proxy": "Pełnomocnik"
"password": "Hasło"
},
"newUserHintSubFolder": "np. '/subfolder' - domyślnie puste",
"permissionsHelp": "Uprawnienia użytkownika kontrolują dostęp do rodzaju działań, które użytkownik może wykonywać. Dostęp administratora daje pełną kontrolę nad aktualizacją i zmianą ustawień dowolnego użytkownika, a także wielu ustawień systemowych.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Operacja zapisywania została przerwana. Aktywny plik aplikacji ({activeFile}) nie jest zgodny z plikiem, który próbujesz zapisać ({tryingToSave}).",
"syncFailed": "Nie udało się zsynchronizować stanu z trasą dla \"{filename}\" po 5 próbach. Przerwano konfigurację edytora, aby zapobiec uszkodzeniu danych.",
"fileSaved": "Plik zapisany!",
"errorSavingFile": "Błąd zapisu pliku: {error}"
"errorSavingFile": "Błąd zapisu pliku: {error}",
"lines": "{count} wierszy",
"words": "{count} słów",
"chars": "{count} znaków"
},
"player": {
"LoopEnabled": "Pętla włączona",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Extrair arquivo",
"unarchiveMessage": "Escolha onde extrair o arquivo.",
"unarchiveDestination": "Extrair para:",
"unarchiveSuccess": "Arquivo extraído com sucesso!"
"unarchiveSuccess": "Arquivo extraído com sucesso!",
"downloadFailed": "Falha no download em pedaços - se isso se repetir, tente reduzir o tamanho do pedaço: {message}"
},
"search": {
"smallerThan": "Menor que:",
@@ -343,9 +344,7 @@
"brandingHelp": "Você pode personalizar a aparência da instância do Navegador de arquivos alterando seu nome, substituindo o logotipo, adicionando estilos personalizados e até mesmo desativando links externos para o GitHub.\nPara obter mais informações sobre branding personalizado, consulte {0}.",
"commandRunnerHelp": "Aqui você pode definir comandos que são executados nos eventos nomeados. Você deve escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para obter mais informações sobre esse recurso e as variáveis de ambiente disponíveis, leia {2}.",
"loginMethods": {
"password": "Senha",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Senha"
},
"newUserHintSubFolder": "Por exemplo, '/subfolder' - em branco para o padrão",
"permissionsHelp": "As permissões de usuário controlam o acesso aos tipos de ações que o usuário pode executar. O acesso de administrador dá controle total para atualizar e alterar as configurações de qualquer usuário, bem como muitas configurações do sistema.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Operação de salvamento abortada. O arquivo ativo do aplicativo ({activeFile}) não corresponde ao arquivo que você está tentando salvar ({tryingToSave}).",
"syncFailed": "Falha ao sincronizar o estado com a rota para \"{filename}\" após 5 tentativas. Abortar a configuração do editor para evitar a corrupção de dados.",
"fileSaved": "Arquivo salvo!",
"errorSavingFile": "Erro ao salvar o arquivo: {error}"
"errorSavingFile": "Erro ao salvar o arquivo: {error}",
"lines": "{count} linhas",
"words": "{count} palavras",
"chars": "{count} caracteres"
},
"player": {
"LoopEnabled": "Repetição ativada",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Extrair arquivo",
"unarchiveMessage": "Escolha onde extrair o arquivo.",
"unarchiveDestination": "Extrair para:",
"unarchiveSuccess": "Arquivo extraído com sucesso!"
"unarchiveSuccess": "Arquivo extraído com sucesso!",
"downloadFailed": "Falha na transferência em pedaços - se isto se repetir, tente reduzir o tamanho dos pedaços: {message}"
},
"search": {
"smallerThan": "Mais pequeno do que:",
@@ -343,9 +344,7 @@
"brandingHelp": "É possível personalizar a aparência da instância do Navegador de arquivos alterando seu nome, substituindo o logotipo, adicionando estilos personalizados e até mesmo desativando links externos para o GitHub.\nPara obter mais informações sobre a marca personalizada, consulte {0}.",
"commandRunnerHelp": "Aqui pode definir comandos que são executados nos eventos nomeados. Deve escrever um por linha. As variáveis de ambiente {0} e {1} estarão disponíveis, sendo {0} relativo a {1}. Para obter mais informações sobre este recurso e as variáveis de ambiente disponíveis, leia {2}.",
"loginMethods": {
"password": "Palavra-passe",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Palavra-passe"
},
"newUserHintSubFolder": "por exemplo, '/subpasta' - em branco por defeito",
"permissionsHelp": "As permissões de utilizador controlam o acesso ao tipo de acções que o utilizador pode executar. O acesso de administrador dá controlo total para atualizar e alterar as definições de qualquer utilizador, bem como muitas definições do sistema.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Operação de gravação abortada. O ficheiro ativo da aplicação ({activeFile}) não corresponde ao ficheiro que está a tentar guardar ({tryingToSave}).",
"syncFailed": "não conseguiu sincronizar o estado com a rota para \"{filename}\" após 5 tentativas. Abortar a configuração do editor para evitar a corrupção de dados.",
"fileSaved": "Ficheiro guardado!",
"errorSavingFile": "Erro ao guardar o ficheiro: {error}"
"errorSavingFile": "Erro ao guardar o ficheiro: {error}",
"lines": "{count} linhas",
"words": "{count} palavras",
"chars": "{count} caracteres"
},
"player": {
"LoopEnabled": "Repetição ativada",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Extras arhivă",
"unarchiveMessage": "Alegeți unde să extrageți arhiva.",
"unarchiveDestination": "Extras la:",
"unarchiveSuccess": "Arhiva extrasă cu succes!"
"unarchiveSuccess": "Arhiva extrasă cu succes!",
"downloadFailed": "Descărcarea fragmentată a eșuat - dacă acest lucru se repetă, încercați să reduceți dimensiunea fragmentului: {message}"
},
"search": {
"smallerThan": "Mai mic decât:",
@@ -343,9 +344,7 @@
"brandingHelp": "Puteți personaliza aspectul instanței File Browser prin schimbarea numelui, înlocuirea logo-ului, adăugarea de stiluri personalizate și chiar dezactivarea linkurilor externe către GitHub.\nPentru mai multe informații despre brandingul personalizat, consultați {0}.",
"commandRunnerHelp": "Aici puteți seta comenzi care sunt executate în cadrul evenimentelor numite. Trebuie să scrieți câte una pe linie. Variabilele de mediu {0} și {1} vor fi disponibile, {0} fiind relativ la {1}. Pentru mai multe informații despre această caracteristică și variabilele de mediu disponibile, vă rugăm să citiți {2}.",
"loginMethods": {
"password": "Parolă",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Parolă"
},
"newUserHintSubFolder": "de exemplu \"/subfolder\" - gol pentru implicit",
"permissionsHelp": "Permisiunile utilizatorului controlează accesul la ce tip de acțiuni poate efectua utilizatorul. Accesul de administrator oferă control deplin pentru actualizarea și modificarea setărilor oricărui utilizator, precum și a multor setări de sistem.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Operațiunea de salvare a fost întreruptă. Fișierul activ al aplicației ({activeFile}) nu corespunde cu fișierul pe care încercați să îl salvați ({tryingToSave}).",
"syncFailed": "nu a reușit să sincronizeze starea cu ruta pentru \"{filename}\" după 5 încercări. Întreruperea configurării editorului pentru a preveni coruperea datelor.",
"fileSaved": "Fișier salvat!",
"errorSavingFile": "Eroare la salvarea fișierului: {error}"
"errorSavingFile": "Eroare la salvarea fișierului: {error}",
"lines": "{count} rânduri",
"words": "{count} cuvinte",
"chars": "{count} caractere"
},
"player": {
"LoopEnabled": "Buclă activată",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Распаковать архив",
"unarchiveMessage": "Выберите, куда распаковать архив.",
"unarchiveDestination": "Отрывок в:",
"unarchiveSuccess": "Архив извлечен успешно!"
"unarchiveSuccess": "Архив извлечен успешно!",
"downloadFailed": "Не удалось загрузить чанк - если ситуация повторяется, попробуйте уменьшить размер чанка: {message}"
},
"search": {
"smallerThan": "Меньше, чем:",
@@ -343,9 +344,7 @@
"brandingHelp": "Вы можете настроить внешний вид экземпляра File Browser, изменив его название, заменив логотип, добавив пользовательские стили и даже отключив внешние ссылки на GitHub.\nБолее подробную информацию о пользовательском брендинге можно найти на сайте {0}.",
"commandRunnerHelp": "Здесь вы можете задать команды, которые будут выполняться в названных событиях. Вы должны написать по одной команде в строке. Будут доступны переменные окружения {0} и {1}, причем {0} будет относиться к {1}. Для получения дополнительной информации об этой функции и доступных переменных окружения, пожалуйста, прочтите {2}.",
"loginMethods": {
"password": "Пароль",
"oidc": "OIDC",
"proxy": "Прокси-сервер"
"password": "Пароль"
},
"newUserHintSubFolder": "например, '/subfolder' - пустое место по умолчанию",
"permissionsHelp": "Разрешения пользователя контролируют, какие действия он может выполнять. Доступ администратора дает полный контроль над обновлением и изменением настроек любого пользователя, а также многих системных настроек.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Операция сохранения прервана. Активный файл приложения ({activeFile}) не совпадает с файлом, который вы пытаетесь сохранить ({tryingToSave}).",
"syncFailed": "не удалось синхронизировать состояние с маршрутом для \"{filename}\" после 5 попыток. Прерываю настройку редактора, чтобы предотвратить повреждение данных.",
"fileSaved": "Файл сохранен!",
"errorSavingFile": "Ошибка при сохранении файла: {error}"
"errorSavingFile": "Ошибка при сохранении файла: {error}",
"lines": "{count} строк",
"words": "{count} слов",
"chars": "{count} символов"
},
"player": {
"LoopEnabled": "Повтор включен",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Výpis archívu",
"unarchiveMessage": "Vyberte, kam sa má archív extrahovať.",
"unarchiveDestination": "Výpis na:",
"unarchiveSuccess": "Archív úspešne extrahovaný!"
"unarchiveSuccess": "Archív úspešne extrahovaný!",
"downloadFailed": "Sťahovanie po častiach zlyhalo - ak sa to opakuje, skúste zmenšiť veľkosť časti: {message}"
},
"search": {
"smallerThan": "Menšie ako:",
@@ -343,9 +344,7 @@
"brandingHelp": "Vzhľad a pôsobenie inštancie Prieskumníka súborov môžete prispôsobiť zmenou názvu, výmenou loga, pridaním vlastných štýlov a dokonca aj zakázaním externých prepojení na GitHub.\nViac informácií o používaní vlastnej značky nájdete na stránke {0}.",
"commandRunnerHelp": "Tu môžete nastaviť príkazy, ktoré sa vykonajú v pomenovaných udalostiach. Na každý riadok musíte napísať jeden. K dispozícii budú premenné prostredia {0} a {1}, pričom {0} bude relatívne k {1}. Viac informácií o tejto funkcii a dostupných premenných prostredia nájdete na stránke {2}.",
"loginMethods": {
"password": "Heslo",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Heslo"
},
"newUserHintSubFolder": "napr. '/subfolder' - prázdne pre predvolené nastavenie",
"permissionsHelp": "Oprávnenia používateľa riadia prístup k typom akcií, ktoré môže používateľ vykonávať. Prístup administrátora poskytuje úplnú kontrolu nad aktualizáciou a zmenou nastavení ktoréhokoľvek používateľa, ako aj nad mnohými systémovými nastaveniami.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Operácia uloženia sa prerušila. Aktívny súbor aplikácie ({activeFile}) sa nezhoduje so súborom, ktorý sa pokúšate uložiť ({tryingToSave}).",
"syncFailed": "sa nepodarilo synchronizovať stav s trasou pre \"{filename}\" po 5 pokusoch. Prerušenie nastavenia editora, aby sa zabránilo poškodeniu údajov.",
"fileSaved": "Súbor uložený!",
"errorSavingFile": "Chyba pri ukladaní súboru: {error}"
"errorSavingFile": "Chyba pri ukladaní súboru: {error}",
"lines": "{count} riadkov",
"words": "{count} slov",
"chars": "{count} znakov"
},
"player": {
"LoopEnabled": "Slučka povolená",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Extrahera arkiv",
"unarchiveMessage": "Välj var du vill extrahera arkivet.",
"unarchiveDestination": "Extrahera till:",
"unarchiveSuccess": "Arkivet har extraherats framgångsrikt!"
"unarchiveSuccess": "Arkivet har extraherats framgångsrikt!",
"downloadFailed": "Nedladdningen av chunked misslyckades - om detta upprepas, försök att minska chunkstorleken: {message}"
},
"search": {
"smallerThan": "Mindre än:",
@@ -343,9 +344,7 @@
"brandingHelp": "Du kan anpassa hur din File Browser-instans ser ut och känns genom att ändra dess namn, byta ut logotypen, lägga till anpassade stilar och till och med inaktivera externa länkar till GitHub.\nFör mer information om anpassad branding, se {0}.",
"commandRunnerHelp": "Här kan du ställa in kommandon som utförs i de namngivna händelserna. Du måste skriva ett per rad. Miljövariablerna {0} och {1} kommer att vara tillgängliga, {0} i förhållande till {1}. För mer information om denna funktion och de tillgängliga miljövariablerna, läs {2}.",
"loginMethods": {
"password": "Lösenord",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Lösenord"
},
"newUserHintSubFolder": "t.ex. \"/undermapp\" - tom för standard",
"permissionsHelp": "Användarbehörigheter styr vilka typer av åtgärder som användaren kan utföra. Administratörsbehörighet ger full kontroll över uppdatering och ändring av alla användarinställningar samt många systeminställningar.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Spara åtgärden avbröts. Programmets aktiva fil ({activeFile}) stämmer inte överens med den fil du försöker spara ({tryingToSave}).",
"syncFailed": "lyckades inte synkronisera tillstånd med rutten för \"{filename}\" efter 5 försök. Avbryter redigeringsinställningen för att förhindra datakorruption.",
"fileSaved": "Filen är sparad!",
"errorSavingFile": "Fel vid lagring av fil: {error}"
"errorSavingFile": "Fel vid lagring av fil: {error}",
"lines": "{count} rader",
"words": "{count} ord",
"chars": "{count} tecken"
},
"player": {
"LoopEnabled": "Loop aktiverad",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Arşivi ayıkla",
"unarchiveMessage": "Arşivin nereye çıkarılacağını seçin.",
"unarchiveDestination": "Özetle:",
"unarchiveSuccess": "Arşiv başarıyla çıkarıldı!"
"unarchiveSuccess": "Arşiv başarıyla çıkarıldı!",
"downloadFailed": "Yığın indirme başarısız oldu - bu tekrarlanırsa yığın boyutunu küçültmeyi deneyin: {message}"
},
"search": {
"smallerThan": "Daha küçük:",
@@ -343,9 +344,7 @@
"brandingHelp": "Adını değiştirerek, logoyu değiştirerek, özel stiller ekleyerek ve hatta GitHub'a harici bağlantıları devre dışı bırakarak Dosya Tarayıcı örneğinizin nasıl göründüğünü ve hissettirdiğini özelleştirebilirsiniz.\nÖzel markalama hakkında daha fazla bilgi için lütfen {0} adresine göz atın.",
"commandRunnerHelp": "Burada, adlandırılmış olaylarda yürütülecek komutları ayarlayabilirsiniz. Her satıra bir tane yazmalısınız. {0} ve {1} ortam değişkenleri kullanılabilir olacaktır, {0} ise {1} ile ilişkilidir. Bu özellik ve mevcut ortam değişkenleri hakkında daha fazla bilgi için lütfen {2} adresini okuyun.",
"loginMethods": {
"password": "Şifre",
"oidc": "OIDC",
"proxy": "Proxy"
"password": "Şifre"
},
"newUserHintSubFolder": "örneğin '/subfolder' - varsayılan için boş",
"permissionsHelp": "Kullanıcı izinleri, kullanıcının ne tür eylemler gerçekleştirebileceğine erişimi kontrol eder. Yönetici erişimi, herhangi bir kullanıcının ayarlarının yanı sıra birçok sistem ayarını güncellemek ve değiştirmek için tam kontrol sağlar.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Kaydetme işlemi iptal edildi. Uygulamanın etkin dosyası ({activeFile}) kaydetmeye çalıştığınız dosyayla ({tryingToSave}) eşleşmiyor.",
"syncFailed": "\"{filename}\" için 5 denemeden sonra rota ile durum senkronize edilemedi. Veri bozulmasını önlemek için düzenleyici kurulumu iptal ediliyor.",
"fileSaved": "Dosya kaydedildi!",
"errorSavingFile": "Dosya kaydedilirken hata oluştu: {error}"
"errorSavingFile": "Dosya kaydedilirken hata oluştu: {error}",
"lines": "{count} satır",
"words": "{count} kelime",
"chars": "{count} karakter"
},
"player": {
"LoopEnabled": "Döngü Etkin",
+7 -5
View File
@@ -240,7 +240,8 @@
"unarchive": "Витягнути архів",
"unarchiveMessage": "Виберіть, куди розпакувати архів.",
"unarchiveDestination": "Витягнути до:",
"unarchiveSuccess": "Архів успішно розпаковано!"
"unarchiveSuccess": "Архів успішно розпаковано!",
"downloadFailed": "Не вдалося завантажити фрагмент - якщо це повториться, спробуйте зменшити розмір фрагмента: {message}"
},
"search": {
"smallerThan": "Менше, ніж:",
@@ -343,9 +344,7 @@
"brandingHelp": "Ви можете налаштувати зовнішній вигляд вашого екземпляра File Browser, змінивши його назву, замінивши логотип, додавши власні стилі і навіть вимкнувши зовнішні посилання на GitHub.\nЩоб дізнатися більше про кастомне брендування, будь ласка, відвідайте сторінку {0}.",
"commandRunnerHelp": "Тут ви можете задати команди, які будуть виконуватися у названих подіях. Ви повинні писати по одній у рядку. Змінні оточення {0} і {1} будуть доступні, будучи {0} відносно {1}. Для отримання додаткової інформації про цю можливість і доступні змінні оточення, будь ласка, прочитайте {2}.",
"loginMethods": {
"password": "Пароль",
"oidc": "OIDC",
"proxy": "Довірена особа"
"password": "Пароль"
},
"newUserHintSubFolder": "наприклад, '/subfolder' - порожній за замовчуванням",
"permissionsHelp": "Дозволи користувача контролюють доступ до того, які дії може виконувати користувач. Доступ адміністратора дає повний контроль над оновленням і зміною будь-яких налаштувань користувача, а також багатьох системних налаштувань.",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "Операцію збереження перервано. Активний файл програми ({activeFile}) не відповідає файлу, який ви намагаєтеся зберегти ({tryingToSave}).",
"syncFailed": "не вдалося синхронізувати стан з маршрутом для \"{filename}\" після 5 спроб. Переривання налаштування редактора для запобігання пошкодженню даних.",
"fileSaved": "Файл збережено!",
"errorSavingFile": "Помилка збереження файлу: {error}"
"errorSavingFile": "Помилка збереження файлу: {error}",
"lines": "{count} рядків",
"words": "{count} слів",
"chars": "{count} символів"
},
"player": {
"LoopEnabled": "Повтор увімкнено",
+8 -6
View File
@@ -13,7 +13,7 @@
"generateNewOtp": "重置并生成新的双因素代码",
"copyDownloadLinkToClipboard": "将下载链接复制到剪贴板",
"pauseAll": "全部暂停",
"resumeAll": "全部简历",
"resumeAll": "全部继续",
"clearCompleted": "清除完毕",
"openParentFolder": "打开父文件夹",
"selectedCount": "选择执行操作的项目数",
@@ -240,7 +240,8 @@
"unarchive": "提取档案",
"unarchiveMessage": "选择提取存档的位置。",
"unarchiveDestination": "摘录至",
"unarchiveSuccess": "档案提取成功!"
"unarchiveSuccess": "档案提取成功!",
"downloadFailed": "分块下载失败 - 如果重复出现这种情况,请尝试减小分块大小:{message}"
},
"search": {
"smallerThan": "小于",
@@ -343,9 +344,7 @@
"brandingHelp": "您可以更改文件浏览器实例的名称、更换徽标、添加自定义样式,甚至禁用指向 GitHub 的外部链接,从而自定义文件浏览器实例的外观和感觉。\n有关自定义品牌的更多信息,请查阅{0} 。",
"commandRunnerHelp": "您可以在此设置在命名事件中执行的命令。每行必须写一条。环境变量{0} 和{1} 将可用,{0} 与{1} 相对。有关此功能和可用环境变量的更多信息,请阅读{2} 。",
"loginMethods": {
"password": "密码",
"oidc": "OIDC",
"proxy": "代理"
"password": "密码"
},
"newUserHintSubFolder": "例如\"/子文件夹\"--默认为空",
"permissionsHelp": "用户权限控制用户可以执行哪些类型的操作。管理员权限可完全控制更新和更改任何用户的设置以及许多系统设置。",
@@ -784,7 +783,10 @@
"saveAbortedMessage": "保存操作中止。应用程序的活动文件 ({activeFile}) 与您要保存的文件 ({tryingToSave}) 不匹配。",
"syncFailed": "经过 5 次尝试后,未能将 \"{filename}\" 的状态与路由同步。终止编辑器设置,以防数据损坏。",
"fileSaved": "文件已保存!",
"errorSavingFile": "保存文件出错:{error}"
"errorSavingFile": "保存文件出错:{error}",
"lines": "{count} 行",
"words": "{count} 词",
"chars": "{count} 字符"
},
"player": {
"LoopEnabled": "循环已启用",
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more