beta/v0.4.1 release (#307)

This commit is contained in:
Graham Steffaniak 2025-01-25 19:31:40 -05:00 committed by GitHub
parent a30bfcf7d0
commit c168599c91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 524 additions and 182 deletions

2
.gitignore vendored
View File

@ -6,6 +6,8 @@ _old
rice-box.go rice-box.go
.idea/ .idea/
/backend/backend /backend/backend
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe /backend/backend.exe
/frontend/dist /frontend/dist
/frontend/pkg /frontend/pkg

View File

@ -2,6 +2,21 @@
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). 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).
## v0.4.1-beta
**New Features**
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
**Bugfixes**:
- calculating checksums errors.
- copy/move issues for some circumstances.
- The previous position wasn't returned when closing a preview window https://github.com/gtsteffaniak/filebrowser/issues/298
- fixed sources configuration mapping error (advanced `server.sources` config)
## v0.4.0-beta ## v0.4.0-beta
**New Features** **New Features**

View File

@ -130,7 +130,6 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true")) logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"))
logger.Info(database) logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sources)) logger.Info(fmt.Sprintf("Sources : %v", sources))
serverConfig := settings.Config.Server serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL swagInfo.BasePath = serverConfig.BaseURL

Binary file not shown.

View File

@ -5,6 +5,13 @@ server:
auth: auth:
method: password method: password
signup: false signup: false
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults: userDefaults:
darkMode: true darkMode: true
disableSettings: false disableSettings: false

View File

@ -208,11 +208,11 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
index := GetIndex(source) index := GetIndex(source)
// refresh info for source and dest // refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{ err = index.RefreshFileInfo(FileOptions{
Path: filepath.Dir(realsrc), Path: realsrc,
IsDir: isSrcDir, IsDir: isSrcDir,
}) })
if err != nil { if err != nil {
return errors.ErrEmptyKey return fmt.Errorf("could not refresh index for source: %v", err)
} }
refreshConfig := FileOptions{Path: realdst, IsDir: true} refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir { if !isSrcDir {
@ -220,7 +220,7 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
} }
err = index.RefreshFileInfo(refreshConfig) err = index.RefreshFileInfo(refreshConfig)
if err != nil { if err != nil {
return errors.ErrEmptyKey return fmt.Errorf("could not refresh index for dest: %v", err)
} }
return nil return nil
} }

View File

@ -34,7 +34,7 @@ func (idx *Index) newScanner(origin string) {
} }
// Log and sleep before indexing // Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v\n", sleepTime)) logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime) time.Sleep(sleepTime)
idx.scannerMu.Lock() idx.scannerMu.Lock()

View File

@ -1,6 +1,7 @@
package fileutils package fileutils
import ( import (
"fmt"
"io" "io"
"os" "os"
"path" "path"
@ -11,15 +12,21 @@ import (
// By default, the rename system call is used. If src and dst point to different volumes, // By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback. // the file copy is used as a fallback.
func MoveFile(src, dst string) error { func MoveFile(src, dst string) error {
fmt.Println("moving", src, dst)
if os.Rename(src, dst) == nil { if os.Rename(src, dst) == nil {
return nil return nil
} }
fmt.Println("copyfile instead", src, dst)
// fallback // fallback
err := CopyFile(src, dst) err := CopyFile(src, dst)
if err != nil { if err != nil {
fmt.Println("ok it errored too", err)
_ = os.Remove(dst) _ = os.Remove(dst)
return err return err
} }
fmt.Println("removing", src)
if err := os.Remove(src); err != nil { if err := os.Remove(src); err != nil {
return err return err
} }

View File

@ -108,7 +108,6 @@ func withUserHelper(fn handleFunc) handleFunc {
} }
tokenString, err := extractToken(r) tokenString, err := extractToken(r)
if err != nil { if err != nil {
logger.Debug(fmt.Sprintf("error extracting from request %v", err))
return http.StatusUnauthorized, err return http.StatusUnauthorized, err
} }
data.token = tokenString data.token = tokenString

View File

@ -16,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/cache" "github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors" "github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files" "github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
) )
// resourceGetHandler retrieves information about a resource. // resourceGetHandler retrieves information about a resource.
@ -60,7 +61,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return renderJSON(w, r, fileInfo) return renderJSON(w, r, fileInfo)
} }
if algo := r.URL.Query().Get("checksum"); algo != "" { if algo := r.URL.Query().Get("checksum"); algo != "" {
checksums, err := files.GetChecksum(fileInfo.Path, algo) idx := files.GetIndex(source)
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
checksums, err := files.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption { if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil return http.StatusBadRequest, nil
} else if err != nil { } else if err != nil {
@ -115,10 +118,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
} }
// delete thumbnails // delete thumbnails
err = delThumbs(r.Context(), fileCache, fileInfo) delThumbs(r.Context(), fileCache, fileInfo)
if err != nil {
return errToStatus(err), err
}
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath)) err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil { if err != nil {
@ -184,10 +184,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, nil return http.StatusForbidden, nil
} }
err = delThumbs(r.Context(), fileCache, fileInfo) delThumbs(r.Context(), fileCache, fileInfo)
if err != nil {
return errToStatus(err), err
}
} }
err = files.WriteFile(fileOpts, r.Body) err = files.WriteFile(fileOpts, r.Body)
if err != nil { if err != nil {
@ -312,7 +309,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
err = d.RunHook(func() error { err = d.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source) return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
}, action, realSrc, realDest, d.user) }, action, realSrc, realDest, d.user)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
return errToStatus(err), err return errToStatus(err), err
} }
@ -332,11 +331,11 @@ func addVersionSuffix(source string) string {
return source return source
} }
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) error { func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
if err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime)); err != nil { err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
return err if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
} }
return nil
} }
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error { func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
@ -348,7 +347,6 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
err := files.CopyResource(index, src, dst, isSrcDir) err := files.CopyResource(index, src, dst, isSrcDir)
return err return err
case "rename", "move": case "rename", "move":
if !d.user.Perm.Rename { if !d.user.Perm.Rename {
return errors.ErrPermissionDenied return errors.ErrPermissionDenied
} }
@ -366,10 +364,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
} }
// delete thumbnails // delete thumbnails
err = delThumbs(ctx, fileCache, fileInfo) delThumbs(ctx, fileCache, fileInfo)
if err != nil {
return err
}
return files.MoveResource(index, src, dst, isSrcDir) return files.MoveResource(index, src, dst, isSrcDir)
default: default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams) return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)

1
backend/img/testdata Symbolic link
View File

@ -0,0 +1 @@
../../frontend/tests/playwright-files/myfolder/testdata/

View File

@ -16,6 +16,7 @@ type Logger struct {
apiLevels []LogLevel apiLevels []LogLevel
stdout bool stdout bool
disabled bool disabled bool
debugEnabled bool
disabledAPI bool disabledAPI bool
colors bool colors bool
} }
@ -34,7 +35,7 @@ func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*L
} }
fileWriter = file fileWriter = file
} }
flags := log.Ldate | log.Ltime var flags int
if slices.Contains(levels, DEBUG) { if slices.Contains(levels, DEBUG) {
flags |= log.Lshortfile flags |= log.Lshortfile
} }
@ -50,6 +51,7 @@ func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*L
levels: levels, levels: levels,
apiLevels: apiLevels, apiLevels: apiLevels,
disabled: slices.Contains(levels, DISABLED), disabled: slices.Contains(levels, DISABLED),
debugEnabled: slices.Contains(levels, DEBUG),
disabledAPI: slices.Contains(apiLevels, DISABLED), disabledAPI: slices.Contains(apiLevels, DISABLED),
colors: !noColors, colors: !noColors,
stdout: stdout, stdout: stdout,
@ -67,6 +69,9 @@ func SetupLogger(output, levels, apiLevels string, noColors bool) error {
if upperLevel == "WARNING" || upperLevel == "WARN" { if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN " upperLevel = "WARN "
} }
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel // Convert level strings to LogLevel
level, ok := stringToLevel[upperLevel] level, ok := stringToLevel[upperLevel]
if !ok { if !ok {
@ -87,6 +92,9 @@ func SetupLogger(output, levels, apiLevels string, noColors bool) error {
if upperLevel == "WARNING" || upperLevel == "WARN" { if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN " upperLevel = "WARN "
} }
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel // Convert level strings to LogLevel
level, ok := stringToLevel[strings.ToUpper(upperLevel)] level, ok := stringToLevel[strings.ToUpper(upperLevel)]
if !ok { if !ok {
@ -119,6 +127,7 @@ func SetupLogger(output, levels, apiLevels string, noColors bool) error {
loggers = append(loggers, logger) loggers = append(loggers, logger)
return nil return nil
} }
func SplitByMultiple(str string) []string { func SplitByMultiple(str string) []string {
delimiters := []rune{'|', ',', ' '} delimiters := []rune{'|', ',', ' '}
return strings.FieldsFunc(str, func(r rune) bool { return strings.FieldsFunc(str, func(r rune) bool {

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"slices" "slices"
"time"
) )
type LogLevel int type LogLevel int
@ -38,7 +39,7 @@ type levelConsts struct {
} }
var levels = levelConsts{ var levels = levelConsts{
INFO: "INFO", INFO: "INFO ", // with consistent space padding
FATAL: "FATAL", FATAL: "FATAL",
ERROR: "ERROR", ERROR: "ERROR",
WARNING: "WARN ", // with consistent space padding WARNING: "WARN ", // with consistent space padding
@ -50,7 +51,7 @@ var levels = levelConsts{
// stringToLevel maps string representation to LogLevel // stringToLevel maps string representation to LogLevel
var stringToLevel = map[string]LogLevel{ var stringToLevel = map[string]LogLevel{
"DEBUG": DEBUG, "DEBUG": DEBUG,
"INFO": INFO, "INFO ": INFO, // with consistent space padding
"ERROR": ERROR, "ERROR": ERROR,
"DISABLED": DISABLED, "DISABLED": DISABLED,
"WARN ": WARNING, // with consistent space padding "WARN ": WARNING, // with consistent space padding
@ -71,15 +72,21 @@ func Log(level string, msg string, prefix, api bool, color string) {
continue continue
} }
} }
if logger.stdout && LEVEL == FATAL { if logger.stdout && level == "FATAL" {
continue continue
} }
writeOut := msg writeOut := msg
if prefix { formattedTime := time.Now().Format("2006/01/02 15:04:05")
writeOut = fmt.Sprintf("[%s] ", level) + writeOut if logger.colors && color != "" {
formattedTime = formattedTime + color
}
if prefix || logger.debugEnabled {
logger.logger.SetPrefix(fmt.Sprintf("%s [%s] ", formattedTime, level))
} else {
logger.logger.SetPrefix(formattedTime + " ")
} }
if logger.colors && color != "" { if logger.colors && color != "" {
writeOut = color + writeOut + "\033[0m" writeOut = writeOut + "\033[0m"
} }
err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line
if err != nil { if err != nil {
@ -102,6 +109,8 @@ func Api(msg string, statusCode int) {
func Debug(msg string) { func Debug(msg string) {
if len(loggers) > 0 { if len(loggers) > 0 {
Log(levels.DEBUG, msg, true, false, GRAY) Log(levels.DEBUG, msg, true, false, GRAY)
} else {
log.Println("[DEBUG]: " + msg)
} }
} }

View File

@ -17,6 +17,9 @@ var Config Settings
func Initialize(configFile string) { func Initialize(configFile string) {
yamlData, err := loadConfigFile(configFile) yamlData, err := loadConfigFile(configFile)
if err != nil && configFile != "config.yaml" {
logger.Fatal("Could not load specified config file: " + err.Error())
}
if err != nil { if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err)) logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
} }
@ -35,21 +38,22 @@ func Initialize(configFile string) {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2)) logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
} }
source.Path = realPath source.Path = realPath
source.Name = "default" // Modify the local copy of the map value source.Name = "default"
Config.Server.Sources["default"] = source // Assign the modified value back to the map Config.Server.Sources = []Source{source} // temporary set only one source
} }
} else { } else {
realPath, err2 := filepath.Abs(Config.Server.Root) realPath, err2 := filepath.Abs(Config.Server.Root)
if err2 != nil { if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2)) logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
} }
Config.Server.Sources = map[string]Source{ Config.Server.Sources = []Source{
"default": { {
Name: "default", Name: "default",
Path: realPath, Path: realPath,
}, },
} }
} }
baseurl := strings.Trim(Config.Server.BaseURL, "/") baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" { if baseurl == "" {
Config.Server.BaseURL = "/" Config.Server.BaseURL = "/"
@ -57,10 +61,6 @@ func Initialize(configFile string) {
Config.Server.BaseURL = "/" + baseurl + "/" Config.Server.BaseURL = "/" + baseurl + "/"
} }
if !Config.Frontend.DisableDefaultLinks { if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: "FileBrowser Quantum",
Url: "https://github.com/gtsteffaniak/filebrowser",
})
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{ Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version), Text: fmt.Sprintf("(%v)", version.Version),
Title: version.CommitSHA, Title: version.CommitSHA,
@ -89,7 +89,6 @@ func Initialize(configFile string) {
log.Println("[ERROR] Failed to set up logger:", err) log.Println("[ERROR] Failed to set up logger:", err)
} }
} }
} }
func loadConfigFile(configFile string) ([]byte, error) { func loadConfigFile(configFile string) ([]byte, error) {

View File

@ -53,7 +53,7 @@ type Server struct {
Root string `json:"root"` Root string `json:"root"`
UserHomeBasePath string `json:"userHomeBasePath"` UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"` CreateUserDir bool `json:"createUserDir"`
Sources map[string]Source `json:"sources"` Sources []Source `json:"sources"`
ExternalUrl string `json:"externalUrl"` ExternalUrl string `json:"externalUrl"`
InternalUrl string `json:"internalUrl"` // used by integrations InternalUrl string `json:"internalUrl"` // used by integrations
} }
@ -79,7 +79,7 @@ type LogConfig struct {
type Source struct { type Source struct {
Path string `json:"path"` Path string `json:"path"`
Name string Name string `json:"name"`
Config IndexConfig `json:"config"` Config IndexConfig `json:"config"`
} }

View File

@ -12,7 +12,7 @@ async function globalSetup() {
await page.waitForURL("**/files/", { timeout: 100 }); await page.waitForURL("**/files/", { timeout: 100 });
let cookies = await context.cookies(); let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined(); expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files'); await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.context().storageState({ path: "./loginAuth.json" }); await page.context().storageState({ path: "./loginAuth.json" });
await browser.close(); await browser.close();
} }

View File

@ -158,10 +158,16 @@ export async function moveCopy(items, action = "copy", overwrite = false, rename
} }
export async function checksum(url, algo) { export async function checksum(path, algo) {
try { try {
const data = await resourceAction(`${url}?checksum=${algo}`, "GET"); const params = {
return (await data.json()).checksums[algo]; path: encodeURIComponent(removePrefix(path, "files")),
checksum: algo,
};
const apiPath = getApiPath("api/resources", params);
const res = await fetchURL(apiPath);
const data = await res.json();
return data.checksums[algo];
} catch (err) { } catch (err) {
notify.showError(err.message || "Error fetching checksum"); notify.showError(err.message || "Error fetching checksum");
throw err; throw err;

View File

@ -13,21 +13,20 @@
<div v-if="selectedCount > 0" class="button selected-count-header"> <div v-if="selectedCount > 0" class="button selected-count-header">
<span>{{ selectedCount }} selected</span> <span>{{ selectedCount }} selected</span>
</div> </div>
<action <action
v-if="!headerButtons.select" v-if="!isSearchActive"
icon="create_new_folder" icon="create_new_folder"
:label="$t('sidebar.newFolder')" :label="$t('sidebar.newFolder')"
@action="showHover('newDir')" @action="showHover('newDir')"
/> />
<action <action
v-if="!headerButtons.select" v-if="!headerButtons.select && !isSearchActive"
icon="note_add" icon="note_add"
:label="$t('sidebar.newFile')" :label="$t('sidebar.newFile')"
@action="showHover('newFile')" @action="showHover('newFile')"
/> />
<action <action
v-if="!headerButtons.select" v-if="!headerButtons.select && !isSearchActive"
icon="file_upload" icon="file_upload"
:label="$t('buttons.upload')" :label="$t('buttons.upload')"
@action="uploadFunc" @action="uploadFunc"
@ -40,7 +39,7 @@
show="info" show="info"
/> />
<action <action
v-if="!isMultiple" v-if="!isMultiple && !isSearchActive"
icon="check_circle" icon="check_circle"
:label="$t('buttons.selectMultiple')" :label="$t('buttons.selectMultiple')"
@action="toggleMultipleSelection" @action="toggleMultipleSelection"
@ -59,7 +58,7 @@
show="share" show="share"
/> />
<action <action
v-if="headerButtons.rename" v-if="headerButtons.rename && !isSearchActive"
icon="mode_edit" icon="mode_edit"
:label="$t('buttons.rename')" :label="$t('buttons.rename')"
show="rename" show="rename"
@ -102,6 +101,9 @@ export default {
}; };
}, },
computed: { computed: {
isSearchActive() {
return state.isSearchActive;
},
isMultiple() { isMultiple() {
return state.multiple; return state.multiple;
}, },

View File

@ -1,5 +1,9 @@
<template> <template>
<div id="search" :class="{ active, ongoing, 'dark-mode': isDarkMode }"> <div
id="search"
:class="{ active, ongoing, 'dark-mode': isDarkMode }"
@click="clearContext"
>
<!-- Search input section --> <!-- Search input section -->
<div id="input" @click="open"> <div id="input" @click="open">
<!-- Close button visible when search is active --> <!-- Close button visible when search is active -->
@ -115,11 +119,18 @@
</div> </div>
<!-- List of search results --> <!-- List of search results -->
<ul v-show="results.length > 0"> <ul v-show="results.length > 0">
<li v-for="(s, k) in results" :key="k" class="search-entry"> <li
<a :href="getRelative(s.path)"> v-for="(s, k) in results"
:key="k"
class="search-entry"
:class="{ active: activeStates[k] }"
:aria-label="baseName(s.path)"
>
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
<Icon :mimetype="s.type" /> <Icon :mimetype="s.type" />
<span class="text-container"> <span class="text-container">
{{ basePath(s.path, s.type === "directory")}}<b>{{ baseName(s.path) }}</b> {{ basePath(s.path, s.type === "directory")
}}<b>{{ baseName(s.path) }}</b>
</span> </span>
<div class="filesize">{{ humanSize(s.size) }}</div> <div class="filesize">{{ humanSize(s.size) }}</div>
</a> </a>
@ -136,6 +147,7 @@ import { search } from "@/api";
import { getters, mutations, state } from "@/store"; import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes"; import { getHumanReadableFilesize } from "@/utils/filesizes";
import { removeTrailingSlash, removeLeadingSlash } from "@/utils/url"; import { removeTrailingSlash, removeLeadingSlash } from "@/utils/url";
import { removePrefix } from "@/utils/url.js";
import Icon from "@/components/Icon.vue"; import Icon from "@/components/Icon.vue";
@ -226,7 +238,7 @@ export default {
return this.isTypeSelectDisabled; return this.isTypeSelectDisabled;
}, },
active() { active() {
return getters.currentPromptName() == "search"; return state.isSearchActive;
}, },
isDarkMode() { isDarkMode() {
return getters.isDarkMode(); return getters.isDarkMode();
@ -255,17 +267,22 @@ export default {
return this.showHelp; return this.showHelp;
}, },
getContext() { getContext() {
let path = state.route.path; return this.getRelativeContext();
path = path.slice(1); },
path = "./" + path.substring(path.indexOf("/") + 1); activeStates() {
path = path.replace(/\/+$/, "") + "/"; // Create a Set of combined `name` and `type` keys for efficient lookup
if (path == "./files/") { const selectedSet = new Set(
path = "./"; state.selected.map((item) => `${item.name}:${item.type}`)
} );
return path; const result = this.results.map((s) => selectedSet.has(`${s.name}:${s.type}`));
// Build a map of active states for the `results` array
return result;
}, },
}, },
methods: { methods: {
getRelativeContext() {
return removePrefix(decodeURIComponent(state.route.path), "files");
},
getRelative(path) { getRelative(path) {
return removeTrailingSlash(window.location.href) + "/" + removeLeadingSlash(path); return removeTrailingSlash(window.location.href) + "/" + removeLeadingSlash(path);
}, },
@ -304,15 +321,18 @@ export default {
return parts.pop(); return parts.pop();
}, },
open() { open() {
if (!this.active) { if (!state.isSearchActive) {
mutations.closeHovers();
mutations.closeSidebar();
mutations.resetSelected();
this.resetSearchFilters(); this.resetSearchFilters();
mutations.showHover("search"); mutations.setSearch(true);
} }
}, },
close(event) { close(event) {
this.value = ""; this.value = "";
event.stopPropagation(); event.stopPropagation();
mutations.closeHovers(); mutations.setSearch(false);
}, },
keyup(event) { keyup(event) {
if (event.keyCode === 27) { if (event.keyCode === 27) {
@ -381,6 +401,24 @@ export default {
toggleHelp() { toggleHelp() {
this.showHelp = !this.showHelp; this.showHelp = !this.showHelp;
}, },
clearContext() {
mutations.closeHovers();
},
addSelected(event, s) {
const pathParts = s.path.split("/");
const path = removePrefix(decodeURIComponent(state.route.path), "files") + s.path;
const modifiedItem = {
name: pathParts.pop(),
path: path,
size: s.size,
type: s.type,
source: "",
url: path,
fullPath: path,
};
mutations.resetSelected();
mutations.addSelected(modifiedItem);
},
}, },
}; };
</script> </script>
@ -511,6 +549,10 @@ export default {
border-radius: 0.25em; border-radius: 0.25em;
} }
.search-entry.active {
background-color: var(--surfacePrimary);
}
.search-entry:hover { .search-entry:hover {
background-color: var(--surfacePrimary); background-color: var(--surfacePrimary);
} }

View File

@ -52,7 +52,6 @@ import FileList from "./FileList.vue";
import { filesApi } from "@/api"; import { filesApi } from "@/api";
import buttons from "@/utils/buttons"; import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload"; import * as upload from "@/utils/upload";
import { removePrefix } from "@/utils/url";
import { notify } from "@/notify"; import { notify } from "@/notify";
export default { export default {
@ -74,6 +73,14 @@ export default {
}, },
}, },
mounted() { mounted() {
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) { for (let item of state.selected) {
this.items.push({ this.items.push({
from: state.req.items[item].url, from: state.req.items[item].url,
@ -81,6 +88,7 @@ export default {
name: state.req.items[item].name, name: state.req.items[item].name,
}); });
} }
}
}, },
methods: { methods: {
copy: async function (event) { copy: async function (event) {
@ -88,9 +96,8 @@ export default {
try { try {
// Define the action function // Define the action function
let action = async (overwrite, rename) => { let action = async (overwrite, rename) => {
const loc = removePrefix(this.dest, "files");
for (let item of this.items) { for (let item of this.items) {
item.to = loc + "/" + item.name; item.to = this.dest + item.name;
} }
buttons.loading("copy"); buttons.loading("copy");
await filesApi.moveCopy(this.items, "copy", overwrite, rename); await filesApi.moveCopy(this.items, "copy", overwrite, rename);
@ -123,12 +130,13 @@ export default {
await action(overwrite, rename); await action(overwrite, rename);
} }
mutations.closeHovers(); mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess("Successfully copied file/folder, redirecting..."); notify.showSuccess("Successfully copied file/folder, redirecting...");
setTimeout(() => { setTimeout(() => {
this.$router.push(this.dest); this.$router.push(this.dest);
}, 1000); }, 1000);
} catch (error) { } catch (error) {
notify.error(error); notify.showError(error);
} }
}, },
}, },

View File

@ -7,6 +7,9 @@
<p v-else> <p v-else>
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }} {{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
</p> </p>
<div style="display: grid" class="searchContext">
<span v-for="(item, index) in nav" :key="index"> {{ item }} </span>
</div>
</div> </div>
<div class="card-action"> <div class="card-action">
<button <button
@ -34,6 +37,7 @@ import { filesApi } from "@/api";
import buttons from "@/utils/buttons"; import buttons from "@/utils/buttons";
import { state, getters, mutations } from "@/store"; import { state, getters, mutations } from "@/store";
import { notify } from "@/notify"; import { notify } from "@/notify";
import { removePrefix } from "@/utils/url";
export default { export default {
name: "delete", name: "delete",
@ -47,6 +51,16 @@ export default {
currentPrompt() { currentPrompt() {
return getters.currentPrompt(); return getters.currentPrompt();
}, },
nav() {
if (state.isSearchActive) {
return [state.selected[0].path];
}
let paths = [];
for (let index of state.selected) {
paths.push(removePrefix(state.req.items[index].url, "files"));
}
return paths;
},
}, },
methods: { methods: {
closeHovers() { closeHovers() {
@ -56,17 +70,24 @@ export default {
buttons.loading("delete"); buttons.loading("delete");
try { try {
if (state.isSearchActive) {
await filesApi.remove(state.selected[0].url);
buttons.success("delete");
notify.showSuccess("Deleted item successfully");
mutations.closeHovers();
return;
}
if (!this.isListing) { if (!this.isListing) {
await filesApi.remove(state.route.path); await filesApi.remove(state.route.path);
buttons.success("delete"); buttons.success("delete");
notify.showSuccess("Deleted item successfully"); notify.showSuccess("Deleted item successfully");
this.currentPrompt?.confirm(); this.currentPrompt?.confirm();
this.closeHovers(); mutations.closeHovers();
return; return;
} }
this.closeHovers(); mutations.closeHovers();
if (getters.selectedCount() === 0) { if (getters.selectedCount() === 0) {
return; return;

View File

@ -99,6 +99,9 @@ export default {
return getters.isListing(); return getters.isListing();
}, },
humanSize() { humanSize() {
if (state.isSearchActive) {
return getHumanReadableFilesize(state.selected[0].size);
}
if (getters.selectedCount() === 0 || !this.isListing) { if (getters.selectedCount() === 0 || !this.isListing) {
return getHumanReadableFilesize(state.req.size); return getHumanReadableFilesize(state.req.size);
} }
@ -112,6 +115,9 @@ export default {
return getHumanReadableFilesize(sum); return getHumanReadableFilesize(sum);
}, },
humanTime() { humanTime() {
if (state.isSearchActive) {
return "unknown";
}
if (getters.selectedCount() === 0) { if (getters.selectedCount() === 0) {
return formatTimestamp(state.req.modified, state.user.locale); return formatTimestamp(state.req.modified, state.user.locale);
} }
@ -121,19 +127,31 @@ export default {
); );
}, },
modTime() { modTime() {
if (state.isSearchActive) {
return "";
}
return new Date(Date.parse(state.req.modified)).toLocaleString(); return new Date(Date.parse(state.req.modified)).toLocaleString();
}, },
name() { name() {
if (state.isSearchActive) {
return state.selected[0].name;
}
return getters.selectedCount() === 0 return getters.selectedCount() === 0
? state.req.name ? state.req.name
: state.req.items[this.selected[0]].name; : state.req.items[this.selected[0]].name;
}, },
type() { type() {
if (state.isSearchActive) {
return state.selected[0].type;
}
return getters.selectedCount() === 0 return getters.selectedCount() === 0
? state.req.type ? state.req.type
: state.req.items[this.selected[0]].type; : state.req.items[this.selected[0]].type;
}, },
dir() { dir() {
if (state.isSearchActive) {
return state.selected[0].type === "directory";
}
return ( return (
getters.selectedCount() > 1 || getters.selectedCount() > 1 ||
(getters.selectedCount() === 0 (getters.selectedCount() === 0
@ -145,9 +163,12 @@ export default {
methods: { methods: {
async checksum(event, algo) { async checksum(event, algo) {
event.preventDefault(); event.preventDefault();
let link; let link;
if (state.isSearchActive) {
const hash = await filesApi.checksum(state.selected[0].url, algo);
event.target.innerHTML = hash;
return;
}
if (getters.selectedCount()) { if (getters.selectedCount()) {
link = state.req.items[this.selected[0]].url; link = state.req.items[this.selected[0]].url;
} else { } else {

View File

@ -35,7 +35,6 @@
<button <button
class="button button--flat" class="button button--flat"
@click="move" @click="move"
:disabled="$route.path === dest"
:aria-label="$t('buttons.move')" :aria-label="$t('buttons.move')"
:title="$t('buttons.move')" :title="$t('buttons.move')"
> >
@ -53,7 +52,6 @@ import { filesApi } from "@/api";
import buttons from "@/utils/buttons"; import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload"; import * as upload from "@/utils/upload";
import { notify } from "@/notify"; import { notify } from "@/notify";
import { removePrefix } from "@/utils/url";
export default { export default {
name: "move", name: "move",
@ -74,6 +72,14 @@ export default {
}, },
}, },
mounted() { mounted() {
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) { for (let item of state.selected) {
this.items.push({ this.items.push({
from: state.req.items[item].url, from: state.req.items[item].url,
@ -81,6 +87,7 @@ export default {
name: state.req.items[item].name, name: state.req.items[item].name,
}); });
} }
}
}, },
methods: { methods: {
move: async function (event) { move: async function (event) {
@ -88,18 +95,15 @@ export default {
try { try {
// Define the action function // Define the action function
let action = async (overwrite, rename) => { let action = async (overwrite, rename) => {
const loc = removePrefix(this.dest, "files");
for (let item of this.items) { for (let item of this.items) {
item.to = loc + "/" + item.name; item.to = this.dest + item.name;
} }
buttons.loading("move"); buttons.loading("move");
await filesApi.moveCopy(this.items, "move", overwrite, rename); await filesApi.moveCopy(this.items, "move", overwrite, rename);
}; };
// Fetch destination files // Fetch destination files
let dstResp = await filesApi.fetchFiles(this.dest); let dstResp = await filesApi.fetchFiles(this.dest);
let conflict = upload.checkConflict(this.items, dstResp.items); let conflict = upload.checkConflict(this.items, dstResp.items);
let overwrite = false; let overwrite = false;
let rename = false; let rename = false;
@ -125,13 +129,13 @@ export default {
await action(overwrite, rename); await action(overwrite, rename);
} }
mutations.closeHovers(); mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess("Successfully moved file/folder, redirecting..."); notify.showSuccess("Successfully moved file/folder, redirecting...");
setTimeout(() => { setTimeout(() => {
this.$router.push(this.dest); this.$router.push(this.dest);
}, 1000); }, 1000);
} catch (e) { } catch (error) {
// Catch any errors from action or other parts of the flow notify.showError(error);
notify.showError(e);
} }
}, },
}, },

View File

@ -158,6 +158,9 @@ export default {
return getters.isListing(); // Access getter directly from the store return getters.isListing(); // Access getter directly from the store
}, },
url() { url() {
if (state.isSearchActive) {
return state.selected[0].url;
}
if (!this.isListing) { if (!this.isListing) {
return state.route.path; return state.route.path;
} }
@ -170,11 +173,15 @@ export default {
}, },
async beforeMount() { async beforeMount() {
try { try {
if (state.isSearchActive) {
this.subpath = state.selected[0].path;
} else {
let path = "." + getters.routePath("files"); let path = "." + getters.routePath("files");
if (getters.selectedCount() === 1) { if (getters.selectedCount() === 1) {
path = path + state.req.items[this.selected[0]].name; path = path + state.req.items[this.selected[0]].name;
} }
this.subpath = decodeURIComponent(path); this.subpath = decodeURIComponent(path);
}
// get last element of the path // get last element of the path
const links = await shareApi.get(this.subpath); const links = await shareApi.get(this.subpath);
this.links = links; this.links = links;
@ -238,6 +245,9 @@ export default {
return shareApi.getShareURL(share); return shareApi.getShareURL(share);
}, },
hasDownloadLink() { hasDownloadLink() {
if (state.isSearchActive) {
return state.selected[0].type != "directory";
}
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir; return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
}, },
buildDownloadLink(share) { buildDownloadLink(share) {

View File

@ -4,10 +4,10 @@
<div @click="navigateTo('/settings#profile-main')" class="inner-card"> <div @click="navigateTo('/settings#profile-main')" class="inner-card">
<i class="material-icons">person</i> <i class="material-icons">person</i>
{{ user.username }} {{ user.username }}
<i class="material-icons">settings</i> <i aria-label="settings" class="material-icons">settings</i>
</div> </div>
<div class="inner-card logout-button" @click="logout" > <div class="inner-card logout-button" @click="logout">
<i v-if="canLogout" class="material-icons">exit_to_app</i> <i v-if="canLogout" class="material-icons">exit_to_app</i>
</div> </div>
</div> </div>

View File

@ -1,5 +1,8 @@
<template> <template>
<nav id="sidebar" :class="{ active: active, 'dark-mode': isDarkMode }"> <nav
id="sidebar"
:class="{ active: active, 'dark-mode': isDarkMode, 'behind-overlay': behindOverlay }"
>
<SidebarSettings v-if="isSettings"></SidebarSettings> <SidebarSettings v-if="isSettings"></SidebarSettings>
<SidebarGeneral v-else-if="isLoggedIn"></SidebarGeneral> <SidebarGeneral v-else-if="isLoggedIn"></SidebarGeneral>
@ -8,14 +11,16 @@
<span v-for="item in externalLinks" :key="item.title"> <span v-for="item in externalLinks" :key="item.title">
<a :href="item.url" target="_blank" :title="item.title">{{ item.text }}</a> <a :href="item.url" target="_blank" :title="item.title">{{ item.text }}</a>
</span> </span>
<span v-if="name != ''"><h3>{{ name }}</h3></span> <span v-if="name != ''">
<h4>{{ name }}</h4>
</span>
</div> </div>
</nav> </nav>
</template> </template>
<script> <script>
import { externalLinks, name } from "@/utils/constants"; import { externalLinks, name } from "@/utils/constants";
import { getters, mutations } from "@/store"; // Import your custom store import { getters, mutations, state } from "@/store"; // Import your custom store
import SidebarGeneral from "./General.vue"; import SidebarGeneral from "./General.vue";
import SidebarSettings from "./Settings.vue"; import SidebarSettings from "./Settings.vue";
@ -36,6 +41,7 @@ export default {
isLoggedIn: () => getters.isLoggedIn(), isLoggedIn: () => getters.isLoggedIn(),
isSettings: () => getters.isSettings(), isSettings: () => getters.isSettings(),
active: () => getters.isSidebarVisible(), active: () => getters.isSidebarVisible(),
behindOverlay: () => state.isSearchActive,
}, },
methods: { methods: {
// Show the help overlay // Show the help overlay
@ -65,7 +71,11 @@ export default {
transition: 0.5s ease; transition: 0.5s ease;
top: 4em; top: 4em;
padding-bottom: 4em; padding-bottom: 4em;
background-color: #DDDDDD background-color: #dddddd;
}
#sidebar.behind-overlay {
z-index: 3;
} }
#sidebar.sticky { #sidebar.sticky {
@ -106,7 +116,7 @@ body.rtl .action {
text-align: right; text-align: right;
} }
#sidebar .action>* { #sidebar .action > * {
vertical-align: middle; vertical-align: middle;
} }
@ -121,7 +131,7 @@ body.rtl .action {
padding-bottom: 1em; padding-bottom: 1em;
} }
.credits>span { .credits > span {
display: block; display: block;
margin-top: 0.5em; margin-top: 0.5em;
margin-left: 0; margin-left: 0;

View File

@ -200,7 +200,7 @@ button:disabled {
} }
#popup-notification.success { #popup-notification.success {
background: var(--primaryColor); background: var(--blue);
} }
#popup-notification.error { #popup-notification.error {
background: var(--red); background: var(--red);

View File

@ -129,7 +129,6 @@ router.beforeResolve(async (to, from, next) => {
console.warn("Avoiding recursive navigation to the same route."); console.warn("Avoiding recursive navigation to the same route.");
return next(false); return next(false);
} }
// Set the page title using i18n // Set the page title using i18n
const title = i18n.global.t(titles[to.name as keyof typeof titles]); const title = i18n.global.t(titles[to.name as keyof typeof titles]);
document.title = name + " - " + title ; document.title = name + " - " + title ;

View File

@ -120,8 +120,8 @@ export const getters = {
}, },
showOverlay: () => { showOverlay: () => {
const hasPrompt = getters.currentPrompt() !== null && getters.currentPromptName() !== "more" const hasPrompt = getters.currentPrompt() !== null && getters.currentPromptName() !== "more"
const shouldOverlaySidebar = getters.isSidebarVisible() && !getters.isStickySidebar() const showForSidebar = getters.isSidebarVisible() && !getters.isStickySidebar()
return hasPrompt || shouldOverlaySidebar; return hasPrompt || showForSidebar || state.isSearchActive;
}, },
showBreadCrumbs: () => { showBreadCrumbs: () => {
return getters.currentView() == "listingView" ; return getters.currentView() == "listingView" ;

View File

@ -241,6 +241,10 @@ export const mutations = {
setSharePassword: (value) => { setSharePassword: (value) => {
state.sharePassword = value; state.sharePassword = value;
emitStateChanged(); emitStateChanged();
} },
setSearch: (value) => {
state.isSearchActive = value;
emitStateChanged();
},
}; };

View File

@ -5,6 +5,7 @@ export const state = reactive({
isSafari: /^((?!chrome|android).)*safari/i.test(navigator.userAgent), isSafari: /^((?!chrome|android).)*safari/i.test(navigator.userAgent),
activeSettingsView: "", activeSettingsView: "",
isMobile: window.innerWidth <= 800, isMobile: window.innerWidth <= 800,
isSearchActive: false,
showSidebar: false, showSidebar: false,
usage: { usage: {
used: "0 B", used: "0 B",

View File

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { removePrefix } from './url.js';
describe('testurl', () => {
it('url prefix', () => {
let tests = [
{input: "test",trimArg: "",expected:"/test"},
{input: "/test", trimArg: "test",expected:"/"},
{input: "/my/test/file", trimArg: "",expected:"/my/test/file"},
{input: "/my/test/file", trimArg: "my",expected:"/test/file"},
{input: "/files/my/test/file", trimArg: "files",expected:"/my/test/file"},
]
for (let test of tests) {
expect(removePrefix(test.input, test.trimArg)).toEqual(test.expected);
}
});
});

View File

@ -124,10 +124,7 @@ export default {
res = await filesApi.fetchFiles(getters.routePath(), content); res = await filesApi.fetchFiles(getters.routePath(), content);
} }
data = res; data = res;
// Verify if the fetched path matches the current route document.title = `${document.title} - ${res.name}`;
if (url.pathsMatch(res.path, `/${state.route.params.path}`)) {
document.title = `${res.name} - ${document.title}`;
}
} catch (e) { } catch (e) {
notify.showError(e); notify.showError(e);
this.error = e; this.error = e;

View File

@ -19,8 +19,13 @@
></editorBar> ></editorBar>
<defaultBar v-else :class="{ 'dark-mode-header': isDarkMode }"></defaultBar> <defaultBar v-else :class="{ 'dark-mode-header': isDarkMode }"></defaultBar>
<sidebar></sidebar> <sidebar></sidebar>
<search v-if="showSearch"></search> <main
<main :class="{ 'dark-mode': isDarkMode, moveWithSidebar: moveWithSidebar, 'main-padding': showPadding }"> :class="{
'dark-mode': isDarkMode,
moveWithSidebar: moveWithSidebar,
'main-padding': showPadding,
}"
>
<router-view></router-view> <router-view></router-view>
</main> </main>
<prompts :class="{ 'dark-mode': isDarkMode }"></prompts> <prompts :class="{ 'dark-mode': isDarkMode }"></prompts>
@ -38,7 +43,6 @@ import defaultBar from "./bars/Default.vue";
import listingBar from "./bars/ListingBar.vue"; import listingBar from "./bars/ListingBar.vue";
import Prompts from "@/components/prompts/Prompts.vue"; import Prompts from "@/components/prompts/Prompts.vue";
import Sidebar from "@/components/sidebar/Sidebar.vue"; import Sidebar from "@/components/sidebar/Sidebar.vue";
import Search from "@/components/Search.vue";
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import { notify } from "@/notify"; import { notify } from "@/notify";
@ -49,7 +53,6 @@ export default {
name: "layout", name: "layout",
components: { components: {
ContextMenu, ContextMenu,
Search,
defaultBar, defaultBar,
editorBar, editorBar,
listingBar, listingBar,
@ -74,9 +77,6 @@ export default {
showPadding() { showPadding() {
return getters.showBreadCrumbs(); return getters.showBreadCrumbs();
}, },
showSearch() {
return getters.isLoggedIn() && this.currentView == "listingView";
},
isLoggedIn() { isLoggedIn() {
return getters.isLoggedIn(); return getters.isLoggedIn();
}, },
@ -135,6 +135,7 @@ export default {
resetPrompts() { resetPrompts() {
mutations.closeSidebar(); mutations.closeSidebar();
mutations.closeHovers(); mutations.closeHovers();
mutations.setSearch(false);
}, },
}, },
}; };

View File

@ -72,6 +72,8 @@ export default {
}, },
}, },
mounted() { mounted() {
mutations.closeHovers();
mutations.setSearch(false);
mutations.setActiveSettingsView(getters.currentHash()); mutations.setActiveSettingsView(getters.currentHash());
}, },
methods: { methods: {

View File

@ -280,7 +280,7 @@ export default {
file.hash = this.hash; file.hash = this.hash;
this.token = file.token; this.token = file.token;
mutations.replaceRequest(file); mutations.replaceRequest(file);
document.title = `${file.name} - ${document.title}`; document.title = `${document.title} - ${file.name}`;
} catch (error) { } catch (error) {
this.error = error; this.error = error;
} }

View File

@ -31,18 +31,17 @@ export default {
}, },
methods: { methods: {
close() { close() {
mutations.closeHovers();
if (getters.isSettings()) { if (getters.isSettings()) {
// Use this.isSettings to access the computed property // Use this.isSettings to access the computed property
router.push({ path: "/files/", hash: "" }); router.push({ path: "/files/", hash: "" });
mutations.closeHovers();
return; return;
} }
mutations.closeHovers();
setTimeout(() => {
mutations.replaceRequest({}); mutations.replaceRequest({});
let uri = url.removeLastDir(state.route.path) + "/"; let uri = url.removeLastDir(state.route.path) + "/";
router.push({ path: uri }); router.push({ path: uri });
}, 50);
}, },
}, },
}; };

View File

@ -5,14 +5,15 @@
icon="menu" icon="menu"
:label="$t('buttons.toggleSidebar')" :label="$t('buttons.toggleSidebar')"
@action="toggleSidebar()" @action="toggleSidebar()"
:disabled="showOverlay" :disabled="isSearchActive"
/> />
<search v-if="showSearch"></search>
<action <action
class="menu-button" class="menu-button"
icon="grid_view" icon="grid_view"
:label="$t('buttons.switchView')" :label="$t('buttons.switchView')"
@action="switchView" @action="switchView"
:disabled="showOverlay" :disabled="isSearchActive"
/> />
</header> </header>
</template> </template>
@ -27,11 +28,13 @@
<script> <script>
import { state, mutations, getters } from "@/store"; import { state, mutations, getters } from "@/store";
import Action from "@/components/Action.vue"; import Action from "@/components/Action.vue";
import Search from "@/components/Search.vue";
export default { export default {
name: "listingView", name: "listingView",
components: { components: {
Action, Action,
Search,
}, },
data: function () { data: function () {
return { return {
@ -40,8 +43,11 @@ export default {
}; };
}, },
computed: { computed: {
showOverlay() { showSearch() {
return getters.currentPrompt() !== null && getters.currentPromptName() !== "more"; return getters.isLoggedIn() && getters.currentView() == "listingView";
},
isSearchActive() {
return state.isSearchActive;
}, },
viewIcon() { viewIcon() {
const icons = { const icons = {
@ -78,7 +84,7 @@ export default {
const currentIndex = this.viewModes.indexOf(state.user.viewMode); const currentIndex = this.viewModes.indexOf(state.user.viewMode);
const nextIndex = (currentIndex + 1) % this.viewModes.length; const nextIndex = (currentIndex + 1) % this.viewModes.length;
const newView = this.viewModes[nextIndex]; const newView = this.viewModes[nextIndex];
mutations.updateCurrentUser({ "viewMode": newView }); mutations.updateCurrentUser({ viewMode: newView });
}, },
}, },
}; };

View File

@ -280,6 +280,7 @@ export default {
}, },
}, },
mounted() { mounted() {
mutations.setSearch(false);
this.lastSelected = state.selected; this.lastSelected = state.selected;
// Check the columns size for the first time. // Check the columns size for the first time.
this.colunmsResize(); this.colunmsResize();
@ -504,6 +505,9 @@ export default {
} }
}, },
keyEvent(event) { keyEvent(event) {
if (state.isSearchActive) {
return;
}
const { key, ctrlKey, metaKey, which } = event; const { key, ctrlKey, metaKey, which } = event;
// Check if the key is alphanumeric // Check if the key is alphanumeric
const isAlphanumeric = /^[a-z0-9]$/i.test(key); const isAlphanumeric = /^[a-z0-9]$/i.test(key);
@ -518,10 +522,11 @@ export default {
// Handle the space bar key // Handle the space bar key
if (key === " ") { if (key === " ") {
event.preventDefault(); event.preventDefault();
if (getters.currentPromptName() == "search") { if (state.isSearchActive) {
mutations.setSearch(false);
mutations.closeHovers(); mutations.closeHovers();
} else { } else {
mutations.showHover("search"); mutations.setSearch(true);
} }
} }
if (getters.currentPromptName() != null) { if (getters.currentPromptName() != null) {

View File

@ -14,12 +14,12 @@ test("logout", async ({ page, context }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator("div.wrong")).toBeHidden(); await expect(page.locator("div.wrong")).toBeHidden();
await page.waitForURL("**/files/", { timeout: 100 }); await page.waitForURL("**/files/", { timeout: 100 });
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files'); await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
let cookies = await context.cookies(); let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined(); expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await page.locator('div.inner-card.logout-button').click(); await page.locator('div.inner-card.logout-button').click();
await page.waitForURL("**/login", { timeout: 100 }); await page.waitForURL("**/login", { timeout: 100 });
await expect(page).toHaveTitle('FileBrowser Quantum - Login'); await expect(page).toHaveTitle("Graham's Filebrowser - Login");
cookies = await context.cookies(); cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeUndefined(); expect(cookies.find((c) => c.name == "auth")?.value).toBeUndefined();
}); });

View File

@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
test("info from listing", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="file.tar.gz"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="file.tar.gz"]').click( { button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Info"]').click();
await expect(page.locator('.break-word')).toHaveText('Display Name: file.tar.gz');
});
test("info from search", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#main-input').fill('file.tar.gz');
await expect(page.locator('#result-list')).toHaveCount(1);
await page.locator('li[aria-label="file.tar.gz"]').click({ button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Info"]').click();
await expect(page.locator('.break-word')).toHaveText('Display Name: file.tar.gz');
})
test("copy from listing", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="copyme.txt"]').click( { button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Copy file"]').click();
//await expect(page.locator('.searchContext')).toHaveText('Path: /');
await page.locator('li[aria-label="myfolder"]').click();
await page.locator('button[aria-label="Copy"]').click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText("Successfully copied file/folder, redirecting...");
await page.waitForURL('**/myfolder/');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - myfolder");
// verify exists and copy again
await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="copyme.txt"]').click( { button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Copy file"]').click();
//await expect(page.locator('.searchContext')).toHaveText('Path: /');
await page.locator('li[aria-label=".."]').click();
await page.locator('button[aria-label="Copy"]').click();
const popup2 = page.locator('#popup-notification-content');
await popup2.waitFor({ state: 'visible' });
//await expect(popup2).toHaveText("Successfully copied file/folder, redirecting...");
//await page.waitForURL('**/testdata/');
//await expect(page).toHaveTitle("Graham's Filebrowser - Files - testdata");
})

View File

@ -0,0 +1 @@
test file for playwright

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,11 +1,55 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
test("file preview", async ({ page, context }) => { test("blob file preview", async ({ page, context }) => {
await page.goto("/files/"); await page.goto("/files/");
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files'); await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="file.tar.gz"]').waitFor({ state: 'visible' }); await page.locator('a[aria-label="file.tar.gz"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="file.tar.gz"]').dblclick(); await page.locator('a[aria-label="file.tar.gz"]').dblclick();
await expect(page).toHaveTitle('file.tar.gz - FileBrowser Quantum - Files'); await expect(page).toHaveTitle("Graham's Filebrowser - Files - file.tar.gz");
await page.locator('button[title="Close"]').click(); await page.locator('button[title="Close"]').click();
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files'); await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
});
test("text file editor", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="copyme.txt"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - copyme.txt");
const firstLineText = await page.locator('.ace_text-layer .ace_line').first().textContent();
expect(firstLineText).toBe('test file for playwright');
await page.locator('button[title="Close"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
});
test("navigate folders", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="myfolder"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="myfolder"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - myfolder");
await page.locator('a[aria-label="testdata"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="testdata"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - testdata");
await page.locator('a[aria-label="gray-sample.jpg"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="gray-sample.jpg"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - gray-sample.jpg");
});
test("navigating images", async ({ page, context }) => {
await page.goto("/files/myfolder/testdata/20130612_142406.jpg");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - 20130612_142406.jpg");
await page.locator('button[aria-label="Previous"]').waitFor({ state: 'hidden' });
await page.mouse.move(100, 100);
await page.locator('button[aria-label="Next"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="Next"]').click();
// went to next image
await expect(page).toHaveTitle("Graham's Filebrowser - Files - gray-sample.jpg");
await page.locator('button[aria-label="Previous"]').waitFor({ state: 'hidden' });
await page.locator('button[aria-label="Next"]').waitFor({ state: 'hidden' });
await page.mouse.move(100, 100);
await page.locator('button[aria-label="Next"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="Next"]').click();
// went to next image
await expect(page).toHaveTitle("Graham's Filebrowser - Files - IMG_2578.JPG");
}); });

View File

@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
test("sidebar links", async ({ page }) => {
await page.goto("/files/");
// Verify the page title
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
// Locate the credits container
const credits = page.locator('.credits'); // Fix the selector to match the HTML structure
// Assert that the <h3> contains the text 'FileBrowser Quantum'
await expect(credits.locator("h4")).toHaveText("Graham's Filebrowser");
// Assert that the <a> contains the text 'A playwright test'
await expect(credits.locator("span").locator("a")).toHaveText('A playwright test');
// Assert that the <a> does not contain the text 'Help'
await expect(credits.locator("span").locator("a")).not.toHaveText('Help');
});
test("adjusting theme colors", async ({ page }) => {
await page.goto("/files/");
const originalPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(originalPrimaryColor).toBe('#2196f3');
// Verify the page title
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('i[aria-label="settings"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
await page.locator('button', { hasText: 'violet' }).click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText('Settings updated!');
const newPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(newPrimaryColor).toBe('#9b59b6');
});