Compare commits

...
Author SHA1 Message Date
Henrique Dias 83492a4dfb feat(frontend): migrate Vue to Composition API
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:23:20 +01:00
Henrique Dias bf3ba65782 chore: bump @vue/tsconfig
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:21:30 +01:00
Henrique Dias f35b7c9d9d chore: remove unused tests
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:21:11 +01:00
Henrique Dias e9506c3eae chore: remove unused dependencies
Signed-off-by: Henrique Dias <mail@hacdias.com>
2025-11-13 14:19:08 +01:00
Henrique Dias bb4465548b chore: use 'chore' instead of 'fix' in renovate 2025-11-13 14:10:06 +01:00
Henrique Dias cf8b5ca768 docs: fix duplicated changelog entry 2025-11-13 14:05:50 +01:00
42 changed files with 1382 additions and 2684 deletions
-5
View File
@@ -35,10 +35,5 @@ build/
/frontend/dist/*
!/frontend/dist/.gitkeep
# Playwright files
/frontend/test-results/
/frontend/playwright-report/
/frontend/playwright/.cache/
default.nix
Dockerfile.dev
-8
View File
@@ -5,14 +5,6 @@ All notable changes to this project will be documented in this file. See [commit
## [2.45.2](https://github.com/filebrowser/filebrowser/compare/v2.45.1...v2.45.2) (2025-11-13)
### Bug Fixes
* **deps:** update module github.com/shirou/gopsutil/v3 to v4 ([#5536](https://github.com/filebrowser/filebrowser/issues/5536)) ([fdff7a3](https://github.com/filebrowser/filebrowser/commit/fdff7a38f4711f2b58dfdd60bebbb057bd3a478d))
* **deps:** update module gopkg.in/yaml.v2 to v3 ([#5537](https://github.com/filebrowser/filebrowser/issues/5537)) ([f26a685](https://github.com/filebrowser/filebrowser/commit/f26a68587d8432b536453093f42dc255d19d10fa))
## [2.45.2](https://github.com/filebrowser/filebrowser/compare/v2.45.1...v2.45.2) (2025-11-13)
### Bug Fixes
* **deps:** update module github.com/shirou/gopsutil/v3 to v4 ([#5536](https://github.com/filebrowser/filebrowser/issues/5536)) ([fdff7a3](https://github.com/filebrowser/filebrowser/commit/fdff7a38f4711f2b58dfdd60bebbb057bd3a478d))
+5 -1
View File
@@ -12,7 +12,11 @@
<link rel="icon" type="image/svg+xml" href="/img/icons/favicon.svg" />
<link rel="shortcut icon" href="/img/icons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/icons/apple-touch-icon.png" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/img/icons/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers -->
+4 -8
View File
@@ -11,18 +11,16 @@
"dev": "vite dev",
"build": "pnpm run typecheck && vite build",
"clean": "find ./dist -maxdepth 1 -mindepth 1 ! -name '.gitkeep' -exec rm -r {} +",
"typecheck": "vue-tsc -p ./tsconfig.tsc.json --noEmit",
"typecheck": "vue-tsc -p ./tsconfig.app.json --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint --fix src/",
"format": "prettier --write .",
"test": "playwright test"
"format": "prettier --write ."
},
"dependencies": {
"@chenfengyuan/vue-number-input": "^2.0.1",
"@vueuse/core": "^14.0.0",
"@vueuse/integrations": "^14.0.0",
"ace-builds": "^1.43.2",
"core-js": "^3.44.0",
"dayjs": "^1.11.13",
"dompurify": "^3.2.6",
"epubjs": "^0.3.93",
@@ -51,7 +49,6 @@
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^11.0.1",
"@playwright/test": "^1.54.1",
"@tsconfig/node24": "^24.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1",
@@ -60,17 +57,16 @@
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.0",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-vue": "^10.5.1",
"jsdom": "^27.2.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-plugin-compression2": "^2.3.1",
"vue-tsc": "^3.1.3"
-80
View File
@@ -1,80 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
// {
// name: "webkit",
// use: { ...devices["Desktop Safari"] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
});
+144 -677
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -18,9 +18,17 @@
<meta name="robots" content="noindex,nofollow" />
<link rel="icon" type="image/svg+xml" href="[{[ .StaticURL ]}]/img/icons/favicon.svg" />
<link
rel="icon"
type="image/svg+xml"
href="[{[ .StaticURL ]}]/img/icons/favicon.svg"
/>
<link rel="shortcut icon" href="[{[ .StaticURL ]}]/img/icons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="[{[ .StaticURL ]}]/img/icons/apple-touch-icon.png"
/>
<meta name="apple-mobile-web-app-title" content="File Browser" />
<!-- Add to home screen for Android and modern mobile browsers -->
+147 -168
View File
@@ -1,6 +1,3 @@
<!-- This component taken directly from vue-simple-progress
since it didnt support Vue 3 but the component itself does
https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/components/Progress.vue -->
<template>
<div>
<div
@@ -44,182 +41,164 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
</div>
</template>
<script>
// We're leaving this untouched as you can read in the beginning
const isNumber = function (n) {
return !isNaN(parseFloat(n)) && isFinite(n);
<script setup lang="ts">
import { computed } from "vue";
const isNumber = (n: number | string): boolean => {
return !isNaN(parseFloat(n as string)) && isFinite(n as number);
};
export default {
name: "progress-bar",
props: {
val: {
default: 0,
},
max: {
default: 100,
},
size: {
// either a number (pixel width/height) or 'tiny', 'small',
// 'medium', 'large', 'huge', 'massive' for common sizes
default: 3,
},
"bg-color": {
type: String,
default: "#eee",
},
"bar-color": {
type: String,
default: "#2196f3", // match .blue color to Material Design's 'Blue 500' color
},
"bar-transition": {
type: String,
default: "all 0.5s ease",
},
"bar-border-radius": {
type: Number,
default: 0,
},
spacing: {
type: Number,
default: 4,
},
text: {
type: String,
default: "",
},
"text-align": {
type: String,
default: "center", // 'left', 'right'
},
"text-position": {
type: String,
default: "bottom", // 'bottom', 'top', 'middle', 'inside'
},
"font-size": {
type: Number,
default: 13,
},
"text-fg-color": {
type: String,
default: "#222",
},
},
computed: {
pct() {
let pct = (this.val / this.max) * 100;
pct = pct.toFixed(2);
return Math.min(pct, this.max);
},
size_px() {
switch (this.size) {
case "tiny":
return 2;
case "small":
return 4;
case "medium":
return 8;
case "large":
return 12;
case "big":
return 16;
case "huge":
return 32;
case "massive":
return 64;
}
const props = withDefaults(
defineProps<{
val?: number;
max?: number;
size?: number | string;
bgColor?: string;
barColor?: string;
barTransition?: string;
barBorderRadius?: number;
spacing?: number;
text?: string;
textAlign?: string;
textPosition?: string;
fontSize?: number;
textFgColor?: string;
}>(),
{
val: 0,
max: 100,
size: 3,
bgColor: "#eee",
barColor: "#2196f3",
barTransition: "all 0.5s ease",
barBorderRadius: 0,
spacing: 4,
text: "",
textAlign: "center",
textPosition: "bottom",
fontSize: 13,
textFgColor: "#222",
}
);
return isNumber(this.size) ? this.size : 32;
},
text_padding() {
switch (this.size) {
case "tiny":
case "small":
case "medium":
case "large":
case "big":
case "huge":
case "massive":
return Math.min(Math.max(Math.ceil(this.size_px / 8), 3), 12);
}
const pct = computed(() => {
const pct = (props.val / props.max) * 100;
const pctFixed = pct.toFixed(2);
return Math.min(parseFloat(pctFixed), props.max);
});
return isNumber(this.spacing) ? this.spacing : 4;
},
text_font_size() {
switch (this.size) {
case "tiny":
case "small":
case "medium":
case "large":
case "big":
case "huge":
case "massive":
return Math.min(Math.max(Math.ceil(this.size_px * 1.4), 11), 32);
}
const size_px = computed(() => {
switch (props.size) {
case "tiny":
return 2;
case "small":
return 4;
case "medium":
return 8;
case "large":
return 12;
case "big":
return 16;
case "huge":
return 32;
case "massive":
return 64;
}
return isNumber(this.fontSize) ? this.fontSize : 13;
},
progress_style() {
const style = {
background: this.bgColor,
};
return isNumber(props.size) ? (props.size as number) : 32;
});
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["position"] = "relative";
style["min-height"] = this.size_px + "px";
style["z-index"] = "-2";
}
const text_padding = computed(() => {
switch (props.size) {
case "tiny":
case "small":
case "medium":
case "large":
case "big":
case "huge":
case "massive":
return Math.min(Math.max(Math.ceil(size_px.value / 8), 3), 12);
}
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
}
return isNumber(props.spacing) ? props.spacing : 4;
});
return style;
},
bar_style() {
const style = {
background: this.barColor,
width: this.pct + "%",
height: this.size_px + "px",
transition: this.barTransition,
};
const text_font_size = computed(() => {
switch (props.size) {
case "tiny":
case "small":
case "medium":
case "large":
case "big":
case "huge":
case "massive":
return Math.min(Math.max(Math.ceil(size_px.value * 1.4), 11), 32);
}
if (this.barBorderRadius > 0) {
style["border-radius"] = this.barBorderRadius + "px";
}
return isNumber(props.fontSize) ? props.fontSize : 13;
});
if (this.textPosition == "middle" || this.textPosition == "inside") {
style["position"] = "absolute";
style["top"] = "0";
style["height"] = "100%";
((style["min-height"] = this.size_px + "px"),
(style["z-index"] = "-1"));
}
const progress_style = computed(() => {
const style: Record<string, string> = {
background: props.bgColor,
};
return style;
},
text_style() {
const style = {
color: this.textFgColor,
"font-size": this.text_font_size + "px",
"text-align": this.textAlign,
};
if (props.textPosition == "middle" || props.textPosition == "inside") {
style["position"] = "relative";
style["min-height"] = size_px.value + "px";
style["z-index"] = "-2";
}
if (
this.textPosition == "top" ||
this.textPosition == "middle" ||
this.textPosition == "inside"
)
style["padding-bottom"] = this.text_padding + "px";
if (
this.textPosition == "bottom" ||
this.textPosition == "middle" ||
this.textPosition == "inside"
)
style["padding-top"] = this.text_padding + "px";
if (props.barBorderRadius > 0) {
style["border-radius"] = props.barBorderRadius + "px";
}
return style;
},
},
};
return style;
});
const bar_style = computed(() => {
const style: Record<string, string> = {
background: props.barColor,
width: pct.value + "%",
height: size_px.value + "px",
transition: props.barTransition,
};
if (props.barBorderRadius > 0) {
style["border-radius"] = props.barBorderRadius + "px";
}
if (props.textPosition == "middle" || props.textPosition == "inside") {
style["position"] = "absolute";
style["top"] = "0";
style["height"] = "100%";
style["min-height"] = size_px.value + "px";
style["z-index"] = "-1";
}
return style;
});
const text_style = computed(() => {
const style: Record<string, string> = {
color: props.textFgColor,
"font-size": text_font_size.value + "px",
"text-align": props.textAlign,
};
if (
props.textPosition == "top" ||
props.textPosition == "middle" ||
props.textPosition == "inside"
)
style["padding-bottom"] = text_padding.value + "px";
if (
props.textPosition == "bottom" ||
props.textPosition == "middle" ||
props.textPosition == "inside"
)
style["padding-top"] = text_padding.value + "px";
return style;
});
</script>
+160 -136
View File
@@ -2,13 +2,13 @@
<div
class="shell"
:class="{ ['shell--hidden']: !showShell }"
:style="{ height: `${this.shellHeight}em`, direction: 'ltr' }"
:style="{ height: `${shellHeight}em`, direction: 'ltr' }"
>
<div
@pointerdown="startDrag()"
@pointerup="stopDrag()"
class="shell__divider"
:style="this.shellDrag ? { background: `${checkTheme()}` } : ''"
:style="shellDrag ? { background: `${checkTheme()}` } : ''"
></div>
<div @click="focus" class="shell__content" ref="scrollable">
<div v-for="(c, index) in content" :key="index" class="shell__result">
@@ -39,13 +39,15 @@
<div
@pointerup="stopDrag()"
class="shell__overlay"
v-show="this.shellDrag"
v-show="shellDrag"
></div>
</div>
</template>
<script>
import { mapState, mapActions } from "pinia";
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { storeToRefs } from "pinia";
import { useRoute } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
@@ -53,142 +55,164 @@ import { commands } from "@/api";
import { throttle } from "lodash-es";
import { theme } from "@/utils/constants";
export default {
name: "shell",
computed: {
...mapState(useLayoutStore, ["showShell"]),
...mapState(useFileStore, ["isFiles"]),
path: function () {
if (this.isFiles) {
return this.$route.path;
}
const route = useRoute();
return "";
},
},
data: () => ({
content: [],
history: [],
historyPos: 0,
canInput: true,
shellDrag: false,
shellHeight: 25,
fontsize: parseFloat(getComputedStyle(document.documentElement).fontSize),
}),
mounted() {
window.addEventListener("resize", this.resize);
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
},
methods: {
...mapActions(useLayoutStore, ["toggleShell"]),
checkTheme() {
if (theme == "dark") {
return "rgba(255, 255, 255, 0.4)";
}
return "rgba(127, 127, 127, 0.4)";
},
startDrag() {
document.addEventListener("pointermove", this.handleDrag);
this.shellDrag = true;
},
stopDrag() {
document.removeEventListener("pointermove", this.handleDrag);
this.shellDrag = false;
},
handleDrag: throttle(function (event) {
const top = window.innerHeight / this.fontsize - 4;
const userPos = (window.innerHeight - event.clientY) / this.fontsize;
const bottom =
2.25 +
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
if (userPos <= top && userPos >= bottom) {
this.shellHeight = userPos.toFixed(2);
}
}, 32),
resize: throttle(function () {
const top = window.innerHeight / this.fontsize - 4;
const bottom =
2.25 +
document.querySelector(".shell__divider").offsetHeight / this.fontsize;
const { showShell } = storeToRefs(layoutStore);
const { isFiles } = storeToRefs(fileStore);
const { toggleShell } = layoutStore;
if (this.shellHeight > top) {
this.shellHeight = top;
} else if (this.shellHeight < bottom) {
this.shellHeight = bottom;
}
}, 32),
scroll: function () {
this.$refs.scrollable.scrollTop = this.$refs.scrollable.scrollHeight;
},
focus: function () {
this.$refs.input.focus();
},
historyUp() {
if (this.historyPos > 0) {
this.$refs.input.innerText = this.history[--this.historyPos];
this.focus();
}
},
historyDown() {
if (this.historyPos >= 0 && this.historyPos < this.history.length - 1) {
this.$refs.input.innerText = this.history[++this.historyPos];
this.focus();
} else {
this.historyPos = this.history.length;
this.$refs.input.innerText = "";
}
},
submit: function (event) {
const cmd = event.target.innerText.trim();
const scrollable = ref<HTMLElement | null>(null);
const input = ref<HTMLElement | null>(null);
if (cmd === "") {
return;
}
const content = ref<Array<{ text: string }>>([]);
const history = ref<string[]>([]);
const historyPos = ref(0);
const canInput = ref(true);
const shellDrag = ref(false);
const shellHeight = ref(25);
const fontsize = ref(
parseFloat(getComputedStyle(document.documentElement).fontSize)
);
if (cmd === "clear") {
this.content = [];
event.target.innerHTML = "";
return;
}
const path = computed(() => {
if (isFiles.value) {
return route.path;
}
return "";
});
if (cmd === "exit") {
event.target.innerHTML = "";
this.toggleShell();
return;
}
this.canInput = false;
event.target.innerHTML = "";
const results = {
text: `${cmd}\n\n`,
};
this.history.push(cmd);
this.historyPos = this.history.length;
this.content.push(results);
commands(
this.path,
cmd,
(event) => {
results.text += `${event.data}\n`;
this.scroll();
},
() => {
results.text = results.text
.replace(/\u001b\[[0-9;]+m/g, "") // Filter ANSI color for now
.trimEnd();
this.canInput = true;
this.$refs.input.focus();
this.scroll();
}
);
},
},
const checkTheme = () => {
if (theme == "dark") {
return "rgba(255, 255, 255, 0.4)";
}
return "rgba(127, 127, 127, 0.4)";
};
const scroll = () => {
if (scrollable.value) {
scrollable.value.scrollTop = scrollable.value.scrollHeight;
}
};
const focus = () => {
input.value?.focus();
};
const handleDrag = throttle((event: PointerEvent) => {
const top = window.innerHeight / fontsize.value - 4;
const userPos = (window.innerHeight - event.clientY) / fontsize.value;
const divider = document.querySelector(".shell__divider") as HTMLElement;
const bottom = 2.25 + (divider?.offsetHeight ?? 0) / fontsize.value;
if (userPos <= top && userPos >= bottom) {
shellHeight.value = parseFloat(userPos.toFixed(2));
}
}, 32);
const resize = throttle(() => {
const top = window.innerHeight / fontsize.value - 4;
const divider = document.querySelector(".shell__divider") as HTMLElement;
const bottom = 2.25 + (divider?.offsetHeight ?? 0) / fontsize.value;
if (shellHeight.value > top) {
shellHeight.value = top;
} else if (shellHeight.value < bottom) {
shellHeight.value = bottom;
}
}, 32);
const startDrag = () => {
document.addEventListener("pointermove", handleDrag as any);
shellDrag.value = true;
};
const stopDrag = () => {
document.removeEventListener("pointermove", handleDrag as any);
shellDrag.value = false;
};
const historyUp = () => {
if (historyPos.value > 0 && input.value) {
historyPos.value--;
input.value.innerText = history.value[historyPos.value];
focus();
}
};
const historyDown = () => {
if (
historyPos.value >= 0 &&
historyPos.value < history.value.length - 1 &&
input.value
) {
historyPos.value++;
input.value.innerText = history.value[historyPos.value];
focus();
} else {
historyPos.value = history.value.length;
if (input.value) {
input.value.innerText = "";
}
}
};
const submit = (event: Event) => {
const target = event.target as HTMLElement;
const cmd = target.innerText.trim();
if (cmd === "") {
return;
}
if (cmd === "clear") {
content.value = [];
target.innerHTML = "";
return;
}
if (cmd === "exit") {
target.innerHTML = "";
toggleShell();
return;
}
canInput.value = false;
target.innerHTML = "";
const results = {
text: `${cmd}\n\n`,
};
history.value.push(cmd);
historyPos.value = history.value.length;
content.value.push(results);
commands(
path.value,
cmd,
(event: MessageEvent) => {
results.text += `${event.data}\n`;
scroll();
},
() => {
results.text = results.text
.replace(/\u001b\[[0-9;]+m/g, "") // Filter ANSI color for now
.trimEnd();
canInput.value = true;
input.value?.focus();
scroll();
}
);
};
onMounted(() => {
window.addEventListener("resize", resize as any);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resize as any);
});
</script>
+87 -85
View File
@@ -4,7 +4,7 @@
<template v-if="isLoggedIn">
<button @click="toAccountSettings" class="action">
<i class="material-icons">person</i>
<span>{{ user.username }}</span>
<span>{{ user?.username }}</span>
</button>
<button
class="action"
@@ -16,7 +16,7 @@
<span>{{ $t("sidebar.myFiles") }}</span>
</button>
<div v-if="user.perm.create">
<div v-if="user?.perm.create">
<button
@click="showHover('newDir')"
class="action"
@@ -38,7 +38,7 @@
</button>
</div>
<div v-if="user.perm.admin">
<div v-if="user?.perm.admin">
<button
class="action"
@click="toGlobalSettings"
@@ -113,9 +113,10 @@
</nav>
</template>
<script>
import { reactive } from "vue";
import { mapActions, mapState } from "pinia";
<script setup lang="ts">
import { reactive, ref, computed, watch, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
@@ -135,84 +136,85 @@ import prettyBytes from "pretty-bytes";
const USAGE_DEFAULT = { used: "0 B", total: "0 B", usedPercentage: 0 };
export default {
name: "sidebar",
setup() {
const usage = reactive(USAGE_DEFAULT);
return { usage, usageAbortController: new AbortController() };
},
components: {
ProgressBar,
},
inject: ["$showError"],
computed: {
...mapState(useAuthStore, ["user", "isLoggedIn"]),
...mapState(useFileStore, ["isFiles", "reload"]),
...mapState(useLayoutStore, ["currentPromptName"]),
active() {
return this.currentPromptName === "sidebar";
},
signup: () => signup,
version: () => version,
disableExternal: () => disableExternal,
disableUsedPercentage: () => disableUsedPercentage,
canLogout: () => !noAuth && loginPage,
},
methods: {
...mapActions(useLayoutStore, ["closeHovers", "showHover"]),
abortOngoingFetchUsage() {
this.usageAbortController.abort();
},
async fetchUsage() {
const path = this.$route.path.endsWith("/")
? this.$route.path
: this.$route.path + "/";
let usageStats = USAGE_DEFAULT;
if (this.disableUsedPercentage) {
return Object.assign(this.usage, usageStats);
}
try {
this.abortOngoingFetchUsage();
this.usageAbortController = new AbortController();
const usage = await api.usage(path, this.usageAbortController.signal);
usageStats = {
used: prettyBytes(usage.used, { binary: true }),
total: prettyBytes(usage.total, { binary: true }),
usedPercentage: Math.round((usage.used / usage.total) * 100),
};
} finally {
return Object.assign(this.usage, usageStats);
}
},
toRoot() {
this.$router.push({ path: "/files" });
this.closeHovers();
},
toAccountSettings() {
this.$router.push({ path: "/settings/profile" });
this.closeHovers();
},
toGlobalSettings() {
this.$router.push({ path: "/settings/global" });
this.closeHovers();
},
help() {
this.showHover("help");
},
logout: auth.logout,
},
watch: {
$route: {
handler(to) {
if (to.path.includes("/files")) {
this.fetchUsage();
}
},
immediate: true,
},
},
unmounted() {
this.abortOngoingFetchUsage();
},
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const { user, isLoggedIn } = storeToRefs(authStore);
const { isFiles } = storeToRefs(fileStore);
const { currentPromptName } = storeToRefs(layoutStore);
const { closeHovers, showHover } = layoutStore;
const usage = reactive(USAGE_DEFAULT);
const usageAbortController = ref(new AbortController());
const active = computed(() => {
return currentPromptName.value === "sidebar";
});
const canLogout = !noAuth && loginPage;
const abortOngoingFetchUsage = () => {
usageAbortController.value.abort();
};
const fetchUsage = async () => {
const path = route.path.endsWith("/") ? route.path : route.path + "/";
let usageStats = USAGE_DEFAULT;
if (disableUsedPercentage) {
return Object.assign(usage, usageStats);
}
try {
abortOngoingFetchUsage();
usageAbortController.value = new AbortController();
const usageData = await api.usage(path, usageAbortController.value.signal);
usageStats = {
used: prettyBytes(usageData.used, { binary: true }),
total: prettyBytes(usageData.total, { binary: true }),
usedPercentage: Math.round((usageData.used / usageData.total) * 100),
};
} finally {
return Object.assign(usage, usageStats);
}
};
const toRoot = () => {
router.push({ path: "/files" });
closeHovers();
};
const toAccountSettings = () => {
router.push({ path: "/settings/profile" });
closeHovers();
};
const toGlobalSettings = () => {
router.push({ path: "/settings/global" });
closeHovers();
};
const help = () => {
showHover("help");
};
const logout = () => {
auth.logout();
};
watch(
() => route.path,
(newPath) => {
if (newPath.includes("/files")) {
fetchUsage();
}
},
{ immediate: true }
);
onUnmounted(() => {
abortOngoingFetchUsage();
});
</script>
+72 -76
View File
@@ -8,7 +8,7 @@
<p>{{ $t("prompts.copyMessage") }}</p>
<file-list
ref="fileList"
@update:selected="(val) => (dest = val)"
@update:selected="(val: string) => (dest = val)"
tabindex="1"
/>
</div>
@@ -17,10 +17,10 @@
class="card-action"
style="display: flex; align-items: center; justify-content: space-between"
>
<template v-if="user.perm.create">
<template v-if="user?.perm.create">
<button
class="button button--flat"
@click="$refs.fileList.createDir()"
@click="fileList?.createDir()"
:aria-label="$t('sidebar.newFolder')"
:title="$t('sidebar.newFolder')"
style="justify-self: left"
@@ -53,8 +53,10 @@
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
<script setup lang="ts">
import { ref, inject } from "vue";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { useAuthStore } from "@/stores/auth";
@@ -64,90 +66,84 @@ import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { removePrefix } from "@/api/utils";
export default {
name: "copy",
components: { FileList },
data: function () {
return {
current: window.location.pathname,
dest: null,
};
},
inject: ["$showError"],
computed: {
...mapState(useFileStore, ["req", "selected"]),
...mapState(useAuthStore, ["user"]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
},
methods: {
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
copy: async function (event) {
event.preventDefault();
const items = [];
const route = useRoute();
const router = useRouter();
const $showError = inject<(error: unknown) => void>("$showError");
// Create a new promise for each file.
for (const item of this.selected) {
items.push({
from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name,
});
}
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const authStore = useAuthStore();
const action = async (overwrite, rename) => {
buttons.loading("copy");
const { req, selected } = storeToRefs(fileStore);
const { user } = storeToRefs(authStore);
const { showHover, closeHovers } = layoutStore;
await api
.copy(items, overwrite, rename)
.then(() => {
buttons.success("copy");
this.preselect = removePrefix(items[0].to);
const fileList = ref<InstanceType<typeof FileList> | null>(null);
const dest = ref<string | null>(null);
if (this.$route.path === this.dest) {
this.reload = true;
const copy = async (event: Event) => {
event.preventDefault();
const items: Array<{ from: string; to: string; name: string }> = [];
return;
}
// Create a new promise for each file.
for (const item of selected.value) {
items.push({
from: req.value!.items[item].url,
to: dest.value! + encodeURIComponent(req.value!.items[item].name),
name: req.value!.items[item].name,
});
}
this.$router.push({ path: this.dest });
})
.catch((e) => {
buttons.done("copy");
this.$showError(e);
});
};
const action = async (overwrite: boolean, rename: boolean) => {
buttons.loading("copy");
if (this.$route.path === this.dest) {
this.closeHovers();
action(false, true);
await api
.copy(items, overwrite, rename)
.then(() => {
buttons.success("copy");
fileStore.preselect = removePrefix(items[0].to);
return;
}
if (route.path === dest.value) {
fileStore.reload = true;
return;
}
const dstItems = (await api.fetch(this.dest)).items;
const conflict = upload.checkConflict(items, dstItems);
router.push({ path: dest.value! });
})
.catch((e) => {
buttons.done("copy");
$showError?.(e);
});
};
let overwrite = false;
let rename = false;
if (route.path === dest.value) {
closeHovers();
action(false, true);
return;
}
if (conflict) {
this.showHover({
prompt: "replace-rename",
confirm: (event, option) => {
overwrite = option == "overwrite";
rename = option == "rename";
const dstItems = (await api.fetch(dest.value!)).items;
const conflict = upload.checkConflict(items as any, dstItems);
event.preventDefault();
this.closeHovers();
action(overwrite, rename);
},
});
let overwrite = false;
let rename = false;
return;
}
if (conflict) {
showHover({
prompt: "replace-rename",
confirm: (event: Event, option: string) => {
overwrite = option == "overwrite";
rename = option == "rename";
action(overwrite, rename);
},
},
event.preventDefault();
closeHovers();
action(overwrite, rename);
},
});
return;
}
action(overwrite, rename);
};
</script>
+44 -49
View File
@@ -1,7 +1,7 @@
<template>
<div class="card floating">
<div class="card-content">
<p v-if="!this.isListing || selectedCount === 1">
<p v-if="!isListing || selectedCount === 1">
{{ $t("prompts.deleteMessageSingle") }}
</p>
<p v-else>
@@ -32,67 +32,62 @@
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
<script setup lang="ts">
import { inject } from "vue";
import { storeToRefs } from "pinia";
import { useRoute } from "vue-router";
import { files as api } from "@/api";
import buttons from "@/utils/buttons";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
export default {
name: "delete",
inject: ["$showError"],
computed: {
...mapState(useFileStore, [
"isListing",
"selectedCount",
"req",
"selected",
]),
...mapState(useLayoutStore, ["currentPrompt"]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
submit: async function () {
buttons.loading("delete");
const route = useRoute();
const $showError = inject<(error: unknown) => void>("$showError");
try {
if (!this.isListing) {
await api.remove(this.$route.path);
buttons.success("delete");
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
this.currentPrompt?.confirm();
this.closeHovers();
return;
}
const { isListing, selectedCount, req, selected } = storeToRefs(fileStore);
const { currentPrompt } = storeToRefs(layoutStore);
const { closeHovers } = layoutStore;
this.closeHovers();
const submit = async () => {
buttons.loading("delete");
if (this.selectedCount === 0) {
return;
}
try {
if (!isListing.value) {
await api.remove(route.path);
buttons.success("delete");
const promises = [];
for (const index of this.selected) {
promises.push(api.remove(this.req.items[index].url));
}
currentPrompt.value?.confirm();
closeHovers();
return;
}
await Promise.all(promises);
buttons.success("delete");
closeHovers();
const nearbyItem =
this.req.items[Math.max(0, Math.min(this.selected) - 1)];
if (selectedCount.value === 0) {
return;
}
this.preselect = nearbyItem?.path;
const promises = [];
for (const index of selected.value) {
promises.push(api.remove(req.value!.items[index].url));
}
this.reload = true;
} catch (e) {
buttons.done("delete");
this.$showError(e);
if (this.isListing) this.reload = true;
}
},
},
await Promise.all(promises);
buttons.success("delete");
const nearbyItem =
req.value!.items[Math.max(0, Math.min(...selected.value) - 1)];
fileStore.preselect = nearbyItem?.path;
fileStore.reload = true;
} catch (e) {
buttons.done("delete");
$showError?.(e);
if (isListing.value) fileStore.reload = true;
}
};
</script>
@@ -33,8 +33,4 @@ import { useI18n } from "vue-i18n";
const layoutStore = useLayoutStore();
const { t } = useI18n();
// const emit = defineEmits<{
// (e: "confirm"): void;
// }>();
</script>
@@ -17,7 +17,7 @@
</button>
<button
class="button button--flat button--blue"
@click="currentPrompt.saveAction"
@click="currentPrompt?.saveAction"
:aria-label="$t('buttons.saveChanges')"
:title="$t('buttons.saveChanges')"
tabindex="1"
@@ -26,7 +26,7 @@
</button>
<button
id="focus-prompt"
@click="currentPrompt.confirm"
@click="currentPrompt?.confirm"
class="button button--flat button--red"
:aria-label="$t('buttons.discardChanges')"
:title="$t('buttons.discardChanges')"
@@ -38,17 +38,11 @@
</div>
</template>
<script>
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useLayoutStore } from "@/stores/layout";
import { mapActions, mapState } from "pinia";
export default {
name: "discardEditorChanges",
computed: {
...mapState(useLayoutStore, ["currentPrompt"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
},
};
const layoutStore = useLayoutStore();
const { currentPrompt } = storeToRefs(layoutStore);
const { closeHovers } = layoutStore;
</script>
+154 -137
View File
@@ -24,8 +24,10 @@
</div>
</template>
<script>
import { mapState, mapActions } from "pinia";
<script setup lang="ts">
import { ref, computed, inject, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import { useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
@@ -34,147 +36,162 @@ import url from "@/utils/url";
import { files } from "@/api";
import { StatusError } from "@/api/utils.js";
export default {
name: "file-list",
props: {
exclude: {
type: Array,
default: () => [],
},
},
data: function () {
return {
items: [],
touches: {
id: "",
count: 0,
},
selected: null,
current: window.location.pathname,
nextAbortController: new AbortController(),
};
},
inject: ["$showError"],
computed: {
...mapState(useAuthStore, ["user"]),
...mapState(useFileStore, ["req"]),
nav() {
return decodeURIComponent(this.current);
},
},
mounted() {
this.fillOptions(this.req);
},
unmounted() {
this.abortOngoingNext();
},
methods: {
...mapActions(useLayoutStore, ["showHover"]),
abortOngoingNext() {
this.nextAbortController.abort();
},
fillOptions(req) {
// Sets the current path and resets
// the current items.
this.current = req.url;
this.items = [];
const props = defineProps<{
exclude?: string[];
}>();
this.$emit("update:selected", this.current);
const emit = defineEmits<{
"update:selected": [value: string];
}>();
// If the path isn't the root path,
// show a button to navigate to the previous
// directory.
if (req.url !== "/files/") {
this.items.push({
name: "..",
url: url.removeLastDir(req.url) + "/",
});
}
const route = useRoute();
const $showError = inject<(error: unknown) => void>("$showError");
// If this folder is empty, finish here.
if (req.items === null) return;
const authStore = useAuthStore();
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
// Otherwise we add every directory to the
// move options.
for (const item of req.items) {
if (!item.isDir) continue;
if (this.exclude?.includes(item.url)) continue;
const { user } = storeToRefs(authStore);
const { req } = storeToRefs(fileStore);
const { showHover } = layoutStore;
this.items.push({
name: item.name,
url: item.url,
});
}
},
next: function (event) {
// Retrieves the URL of the directory the user
// just clicked in and fill the options with its
// content.
const uri = event.currentTarget.dataset.url;
this.abortOngoingNext();
this.nextAbortController = new AbortController();
files
.fetch(uri, this.nextAbortController.signal)
.then(this.fillOptions)
.catch((e) => {
if (e instanceof StatusError && e.is_canceled) {
return;
}
this.$showError(e);
});
},
touchstart(event) {
const url = event.currentTarget.dataset.url;
const items = ref<Array<{ name: string; url: string }>>([]);
const touches = ref({
id: "",
count: 0,
});
const selected = ref<string | null>(null);
const current = ref(window.location.pathname);
const nextAbortController = ref(new AbortController());
// In 300 milliseconds, we shall reset the count.
setTimeout(() => {
this.touches.count = 0;
}, 300);
const nav = computed(() => {
return decodeURIComponent(current.value);
});
// If the element the user is touching
// is different from the last one he touched,
// reset the count.
if (this.touches.id !== url) {
this.touches.id = url;
this.touches.count = 1;
return;
}
this.touches.count++;
// If there is more than one touch already,
// open the next screen.
if (this.touches.count > 1) {
this.next(event);
}
},
itemClick: function (event) {
if (this.user.singleClick) this.next(event);
else this.select(event);
},
select: function (event) {
// If the element is already selected, unselect it.
if (this.selected === event.currentTarget.dataset.url) {
this.selected = null;
this.$emit("update:selected", this.current);
return;
}
// Otherwise select the element.
this.selected = event.currentTarget.dataset.url;
this.$emit("update:selected", this.selected);
},
createDir: async function () {
this.showHover({
prompt: "newDir",
action: null,
confirm: null,
props: {
redirect: false,
base: this.current === this.$route.path ? null : this.current,
},
});
},
},
const abortOngoingNext = () => {
nextAbortController.value.abort();
};
const fillOptions = (reqData: any) => {
// Sets the current path and resets
// the current items.
current.value = reqData.url;
items.value = [];
emit("update:selected", current.value);
// If the path isn't the root path,
// show a button to navigate to the previous
// directory.
if (reqData.url !== "/files/") {
items.value.push({
name: "..",
url: url.removeLastDir(reqData.url) + "/",
});
}
// If this folder is empty, finish here.
if (reqData.items === null) return;
// Otherwise we add every directory to the
// move options.
for (const item of reqData.items) {
if (!item.isDir) continue;
if (props.exclude?.includes(item.url)) continue;
items.value.push({
name: item.name,
url: item.url,
});
}
};
const next = (event: Event) => {
// Retrieves the URL of the directory the user
// just clicked in and fill the options with its
// content.
const uri = (event.currentTarget as HTMLElement).dataset.url!;
abortOngoingNext();
nextAbortController.value = new AbortController();
files
.fetch(uri, nextAbortController.value.signal)
.then(fillOptions)
.catch((e) => {
if (e instanceof StatusError && e.is_canceled) {
return;
}
$showError?.(e);
});
};
const touchstart = (event: Event) => {
const urlValue = (event.currentTarget as HTMLElement).dataset.url!;
// In 300 milliseconds, we shall reset the count.
setTimeout(() => {
touches.value.count = 0;
}, 300);
// If the element the user is touching
// is different from the last one he touched,
// reset the count.
if (touches.value.id !== urlValue) {
touches.value.id = urlValue;
touches.value.count = 1;
return;
}
touches.value.count++;
// If there is more than one touch already,
// open the next screen.
if (touches.value.count > 1) {
next(event);
}
};
const itemClick = (event: Event) => {
if (user.value?.singleClick) next(event);
else select(event);
};
const select = (event: Event) => {
const urlValue = (event.currentTarget as HTMLElement).dataset.url!;
// If the element is already selected, unselect it.
if (selected.value === urlValue) {
selected.value = null;
emit("update:selected", current.value);
return;
}
// Otherwise select the element.
selected.value = urlValue;
emit("update:selected", selected.value);
};
const createDir = async () => {
showHover({
prompt: "newDir",
action: undefined,
confirm: undefined,
props: {
redirect: false,
base: current.value === route.path ? null : current.value,
},
});
};
onMounted(() => {
if (req.value) {
fillOptions(req.value);
}
});
onUnmounted(() => {
abortOngoingNext();
});
defineExpose({
createDir,
});
</script>
+2 -8
View File
@@ -34,14 +34,8 @@
</div>
</template>
<script>
import { mapActions } from "pinia";
<script setup lang="ts">
import { useLayoutStore } from "@/stores/layout";
export default {
name: "help",
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
},
};
const { closeHovers } = useLayoutStore();
</script>
+83 -81
View File
@@ -29,10 +29,10 @@
<template v-if="dir && selected.length === 0">
<p>
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req.numFiles }}
<strong>{{ $t("prompts.numberFiles") }}:</strong> {{ req?.numFiles }}
</p>
<p>
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req.numDirs }}
<strong>{{ $t("prompts.numberDirs") }}:</strong> {{ req?.numDirs }}
</p>
</template>
@@ -99,98 +99,100 @@
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
<script setup lang="ts">
import { computed, inject } from "vue";
import { storeToRefs } from "pinia";
import { useRoute } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { filesize } from "@/utils";
import dayjs from "dayjs";
import { files as api } from "@/api";
export default {
name: "info",
inject: ["$showError"],
computed: {
...mapState(useFileStore, [
"req",
"selected",
"selectedCount",
"isListing",
]),
humanSize: function () {
if (this.selectedCount === 0 || !this.isListing) {
return filesize(this.req.size);
}
const route = useRoute();
const $showError = inject<(error: unknown) => void>("$showError");
let sum = 0;
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
for (const selected of this.selected) {
sum += this.req.items[selected].size;
}
const { req, selected, selectedCount, isListing } = storeToRefs(fileStore);
const { closeHovers } = layoutStore;
return filesize(sum);
},
humanTime: function () {
if (this.selectedCount === 0) {
return dayjs(this.req.modified).fromNow();
}
const humanSize = computed(() => {
if (selectedCount.value === 0 || !isListing.value) {
return filesize(req.value?.size ?? 0);
}
return dayjs(this.req.items[this.selected[0]].modified).fromNow();
},
modTime: function () {
if (this.selectedCount === 0) {
return new Date(Date.parse(this.req.modified)).toLocaleString();
}
let sum = 0;
return new Date(
Date.parse(this.req.items[this.selected[0]].modified)
).toLocaleString();
},
name: function () {
return this.selectedCount === 0
? this.req.name
: this.req.items[this.selected[0]].name;
},
dir: function () {
return (
this.selectedCount > 1 ||
(this.selectedCount === 0
? this.req.isDir
: this.req.items[this.selected[0]].isDir)
);
},
resolution: function () {
if (this.selectedCount === 1) {
const selectedItem = this.req.items[this.selected[0]];
if (selectedItem && selectedItem.type === "image") {
return selectedItem.resolution;
}
} else if (this.req && this.req.type === "image") {
return this.req.resolution;
}
return null;
},
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
checksum: async function (event, algo) {
event.preventDefault();
for (const selectedIdx of selected.value) {
sum += req.value?.items[selectedIdx]?.size ?? 0;
}
let link;
return filesize(sum);
});
if (this.selectedCount) {
link = this.req.items[this.selected[0]].url;
} else {
link = this.$route.path;
}
const humanTime = computed(() => {
if (selectedCount.value === 0) {
return dayjs(req.value?.modified).fromNow();
}
try {
const hash = await api.checksum(link, algo);
event.target.textContent = hash;
} catch (e) {
this.$showError(e);
}
},
},
return dayjs(req.value?.items[selected.value[0]]?.modified).fromNow();
});
const modTime = computed(() => {
if (selectedCount.value === 0) {
return new Date(Date.parse(req.value?.modified ?? "")).toLocaleString();
}
return new Date(
Date.parse(req.value?.items[selected.value[0]]?.modified ?? "")
).toLocaleString();
});
const name = computed(() => {
return selectedCount.value === 0
? (req.value?.name ?? "")
: (req.value?.items[selected.value[0]]?.name ?? "");
});
const dir = computed(() => {
return (
selectedCount.value > 1 ||
(selectedCount.value === 0
? (req.value?.isDir ?? false)
: (req.value?.items[selected.value[0]]?.isDir ?? false))
);
});
const resolution = computed(() => {
if (selectedCount.value === 1) {
const selectedItem = req.value?.items[selected.value[0]];
if (selectedItem && selectedItem.type === "image") {
return (selectedItem as any).resolution;
}
} else if (req.value && req.value.type === "image") {
return (req.value as any).resolution;
}
return null;
});
const checksum = async (event: Event, algo: string) => {
event.preventDefault();
let link;
if (selectedCount.value) {
link = req.value?.items[selected.value[0]]?.url ?? "";
} else {
link = route.path;
}
try {
const hash = await api.checksum(link, algo as any);
(event.target as HTMLElement).textContent = hash;
} catch (e) {
$showError?.(e);
}
};
</script>
+69 -71
View File
@@ -7,7 +7,7 @@
<div class="card-content">
<file-list
ref="fileList"
@update:selected="(val) => (dest = val)"
@update:selected="(val: string) => (dest = val)"
:exclude="excludedFolders"
tabindex="1"
/>
@@ -17,10 +17,10 @@
class="card-action"
style="display: flex; align-items: center; justify-content: space-between"
>
<template v-if="user.perm.create">
<template v-if="user?.perm.create">
<button
class="button button--flat"
@click="$refs.fileList.createDir()"
@click="fileList?.createDir()"
:aria-label="$t('sidebar.newFolder')"
:title="$t('sidebar.newFolder')"
style="justify-self: left"
@@ -54,8 +54,10 @@
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
<script setup lang="ts">
import { ref, computed, inject } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { useAuthStore } from "@/stores/auth";
@@ -65,80 +67,76 @@ import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { removePrefix } from "@/api/utils";
export default {
name: "move",
components: { FileList },
data: function () {
return {
current: window.location.pathname,
dest: null,
};
},
inject: ["$showError"],
computed: {
...mapState(useFileStore, ["req", "selected"]),
...mapState(useAuthStore, ["user"]),
...mapWritableState(useFileStore, ["preselect"]),
excludedFolders() {
return this.selected
.filter((idx) => this.req.items[idx].isDir)
.map((idx) => this.req.items[idx].url);
},
},
methods: {
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
move: async function (event) {
event.preventDefault();
const items = [];
const router = useRouter();
const $showError = inject<(error: unknown) => void>("$showError");
for (const item of this.selected) {
items.push({
from: this.req.items[item].url,
to: this.dest + encodeURIComponent(this.req.items[item].name),
name: this.req.items[item].name,
});
}
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const authStore = useAuthStore();
const action = async (overwrite, rename) => {
buttons.loading("move");
const { req, selected } = storeToRefs(fileStore);
const { user } = storeToRefs(authStore);
const { showHover, closeHovers } = layoutStore;
await api
.move(items, overwrite, rename)
.then(() => {
buttons.success("move");
this.preselect = removePrefix(items[0].to);
this.$router.push({ path: this.dest });
})
.catch((e) => {
buttons.done("move");
this.$showError(e);
});
};
const fileList = ref<InstanceType<typeof FileList> | null>(null);
const dest = ref<string | null>(null);
const dstItems = (await api.fetch(this.dest)).items;
const conflict = upload.checkConflict(items, dstItems);
const excludedFolders = computed(() => {
return selected.value
.filter((idx) => req.value!.items[idx].isDir)
.map((idx) => req.value!.items[idx].url);
});
let overwrite = false;
let rename = false;
const move = async (event: Event) => {
event.preventDefault();
const items: Array<{ from: string; to: string; name: string }> = [];
if (conflict) {
this.showHover({
prompt: "replace-rename",
confirm: (event, option) => {
overwrite = option == "overwrite";
rename = option == "rename";
for (const item of selected.value) {
items.push({
from: req.value!.items[item].url,
to: dest.value! + encodeURIComponent(req.value!.items[item].name),
name: req.value!.items[item].name,
});
}
event.preventDefault();
this.closeHovers();
action(overwrite, rename);
},
});
const action = async (overwrite: boolean, rename: boolean) => {
buttons.loading("move");
return;
}
await api
.move(items, overwrite, rename)
.then(() => {
buttons.success("move");
fileStore.preselect = removePrefix(items[0].to);
router.push({ path: dest.value! });
})
.catch((e) => {
buttons.done("move");
$showError?.(e);
});
};
action(overwrite, rename);
},
},
const dstItems = (await api.fetch(dest.value!)).items;
const conflict = upload.checkConflict(items as any, dstItems);
let overwrite = false;
let rename = false;
if (conflict) {
showHover({
prompt: "replace-rename",
confirm: (event: Event, option: string) => {
overwrite = option == "overwrite";
rename = option == "rename";
event.preventDefault();
closeHovers();
action(overwrite, rename);
},
});
return;
}
action(overwrite, rename);
};
</script>
+55 -61
View File
@@ -40,80 +40,74 @@
</div>
</template>
<script>
import { mapActions, mapState, mapWritableState } from "pinia";
<script setup lang="ts">
import { ref, onMounted, inject } from "vue";
import { storeToRefs } from "pinia";
import { useRouter } from "vue-router";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import url from "@/utils/url";
import { files as api } from "@/api";
import { removePrefix } from "@/api/utils";
export default {
name: "rename",
data: function () {
return {
name: "",
};
},
created() {
this.name = this.oldName();
},
inject: ["$showError"],
computed: {
...mapState(useFileStore, [
"req",
"selected",
"selectedCount",
"isListing",
]),
...mapWritableState(useFileStore, ["reload", "preselect"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
cancel: function () {
this.closeHovers();
},
oldName: function () {
if (!this.isListing) {
return this.req.name;
}
const router = useRouter();
const $showError = inject<(error: unknown) => void>("$showError");
if (this.selectedCount === 0 || this.selectedCount > 1) {
// This shouldn't happen.
return;
}
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
return this.req.items[this.selected[0]].name;
},
submit: async function () {
let oldLink = "";
let newLink = "";
const { req, selected, selectedCount, isListing } = storeToRefs(fileStore);
const { closeHovers } = layoutStore;
if (!this.isListing) {
oldLink = this.req.url;
} else {
oldLink = this.req.items[this.selected[0]].url;
}
const name = ref("");
newLink =
url.removeLastDir(oldLink) + "/" + encodeURIComponent(this.name);
const oldName = (): string => {
if (!isListing.value) {
return req.value?.name ?? "";
}
try {
await api.move([{ from: oldLink, to: newLink }]);
if (!this.isListing) {
this.$router.push({ path: newLink });
return;
}
if (selectedCount.value === 0 || selectedCount.value > 1) {
// This shouldn't happen.
return "";
}
this.preselect = removePrefix(newLink);
return req.value?.items[selected.value[0]].name ?? "";
};
this.reload = true;
} catch (e) {
this.$showError(e);
}
onMounted(() => {
name.value = oldName();
});
this.closeHovers();
},
},
const submit = async () => {
let oldLink = "";
let newLink = "";
if (!req.value) {
return;
}
if (!isListing.value) {
oldLink = req.value.url;
} else {
oldLink = req.value.items[selected.value[0]].url;
}
newLink = url.removeLastDir(oldLink) + "/" + encodeURIComponent(name.value);
try {
await api.move([{ from: oldLink, to: newLink }]);
if (!isListing.value) {
router.push({ path: newLink });
return;
}
fileStore.preselect = removePrefix(newLink);
fileStore.reload = true;
} catch (e) {
$showError?.(e);
}
closeHovers();
};
</script>
+7 -13
View File
@@ -20,7 +20,7 @@
</button>
<button
class="button button--flat button--blue"
@click="currentPrompt.action"
@click="currentPrompt?.action"
:aria-label="$t('buttons.continue')"
:title="$t('buttons.continue')"
tabindex="2"
@@ -30,7 +30,7 @@
<button
id="focus-prompt"
class="button button--flat button--red"
@click="currentPrompt.confirm"
@click="currentPrompt?.confirm"
:aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')"
tabindex="1"
@@ -41,17 +41,11 @@
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useLayoutStore } from "@/stores/layout";
export default {
name: "replace",
computed: {
...mapState(useLayoutStore, ["currentPrompt"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
},
};
const layoutStore = useLayoutStore();
const { currentPrompt } = storeToRefs(layoutStore);
const { closeHovers } = layoutStore;
</script>
@@ -20,7 +20,7 @@
</button>
<button
class="button button--flat button--blue"
@click="(event) => currentPrompt.confirm(event, 'rename')"
@click="(event) => currentPrompt?.confirm(event, 'rename')"
:aria-label="$t('buttons.rename')"
:title="$t('buttons.rename')"
tabindex="2"
@@ -30,7 +30,7 @@
<button
id="focus-prompt"
class="button button--flat button--red"
@click="(event) => currentPrompt.confirm(event, 'overwrite')"
@click="(event) => currentPrompt?.confirm(event, 'overwrite')"
:aria-label="$t('buttons.replace')"
:title="$t('buttons.replace')"
tabindex="1"
@@ -41,17 +41,11 @@
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useLayoutStore } from "@/stores/layout";
export default {
name: "replace-rename",
computed: {
...mapState(useLayoutStore, ["currentPrompt"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
},
};
const layoutStore = useLayoutStore();
const { currentPrompt } = storeToRefs(layoutStore);
const { closeHovers } = layoutStore;
</script>
+126 -118
View File
@@ -129,138 +129,146 @@
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
<script setup lang="ts">
import { ref, computed, inject, onBeforeMount } from "vue";
import { storeToRefs } from "pinia";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { useFileStore } from "@/stores/file";
import { share as api } from "@/api";
import dayjs from "dayjs";
import { useLayoutStore } from "@/stores/layout";
import { copy } from "@/utils/clipboard";
export default {
name: "share",
data: function () {
return {
time: 0,
unit: "hours",
links: [],
clip: null,
password: "",
listing: true,
};
},
inject: ["$showError", "$showSuccess"],
computed: {
...mapState(useFileStore, [
"req",
"selected",
"selectedCount",
"isListing",
]),
url() {
if (!this.isListing) {
return this.$route.path;
}
const route = useRoute();
const { t } = useI18n();
const $showError = inject<(error: unknown) => void>("$showError");
const $showSuccess = inject<(message: string) => void>("$showSuccess");
if (this.selectedCount === 0 || this.selectedCount > 1) {
// This shouldn't happen.
return;
}
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
return this.req.items[this.selected[0]].url;
const { req, selected, selectedCount, isListing } = storeToRefs(fileStore);
const { closeHovers } = layoutStore;
const time = ref(0);
const unit = ref("hours");
const links = ref<any[]>([]);
const password = ref("");
const listing = ref(true);
const url = computed(() => {
if (!isListing.value) {
return route.path;
}
if (selectedCount.value === 0 || selectedCount.value > 1) {
// This shouldn't happen.
return "";
}
return req.value?.items[selected.value[0]].url ?? "";
});
const copyToClipboard = (text: string) => {
copy({ text }).then(
() => {
// clipboard successfully set
$showSuccess?.(t("success.linkCopied"));
},
},
async beforeMount() {
try {
const links = await api.get(this.url);
this.links = links;
this.sort();
if (this.links.length == 0) {
this.listing = false;
}
} catch (e) {
this.$showError(e);
}
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
copyToClipboard: function (text) {
copy({ text }).then(
() => {
// clipboard write failed
copy({ text }, { permission: true }).then(
() => {
// clipboard successfully set
this.$showSuccess(this.$t("success.linkCopied"));
$showSuccess?.(t("success.linkCopied"));
},
() => {
(e) => {
// clipboard write failed
copy({ text }, { permission: true }).then(
() => {
// clipboard successfully set
this.$showSuccess(this.$t("success.linkCopied"));
},
(e) => {
// clipboard write failed
this.$showError(e);
}
);
$showError?.(e);
}
);
},
submit: async function () {
try {
let res = null;
if (!this.time) {
res = await api.create(this.url, this.password);
} else {
res = await api.create(this.url, this.password, this.time, this.unit);
}
this.links.push(res);
this.sort();
this.time = 0;
this.unit = "hours";
this.password = "";
this.listing = true;
} catch (e) {
this.$showError(e);
}
},
deleteLink: async function (event, link) {
event.preventDefault();
try {
await api.remove(link.hash);
this.links = this.links.filter((item) => item.hash !== link.hash);
if (this.links.length == 0) {
this.listing = false;
}
} catch (e) {
this.$showError(e);
}
},
humanTime(time) {
return dayjs(time * 1000).fromNow();
},
buildLink(share) {
return api.getShareURL(share);
},
sort() {
this.links = this.links.sort((a, b) => {
if (a.expire === 0) return -1;
if (b.expire === 0) return 1;
return new Date(a.expire) - new Date(b.expire);
});
},
switchListing() {
if (this.links.length == 0 && !this.listing) {
this.closeHovers();
}
this.listing = !this.listing;
},
},
}
);
};
const submit = async () => {
try {
let res = null;
if (!time.value) {
res = await api.create(url.value, password.value);
} else {
res = await api.create(
url.value,
password.value,
String(time.value),
unit.value
);
}
links.value.push(res);
sort();
time.value = 0;
unit.value = "hours";
password.value = "";
listing.value = true;
} catch (e) {
$showError?.(e);
}
};
const deleteLink = async (event: Event, link: any) => {
event.preventDefault();
try {
await api.remove(link.hash);
links.value = links.value.filter((item) => item.hash !== link.hash);
if (links.value.length == 0) {
listing.value = false;
}
} catch (e) {
$showError?.(e);
}
};
const humanTime = (time: number) => {
return dayjs(time * 1000).fromNow();
};
const buildLink = (share: any) => {
return api.getShareURL(share);
};
const sort = () => {
links.value = links.value.sort((a, b) => {
if (a.expire === 0) return -1;
if (b.expire === 0) return 1;
return new Date(a.expire).getTime() - new Date(b.expire).getTime();
});
};
const switchListing = () => {
if (links.value.length == 0 && !listing.value) {
closeHovers();
}
listing.value = !listing.value;
};
onBeforeMount(async () => {
try {
const fetchedLinks = await api.get(url.value);
links.value = Array.isArray(fetchedLinks) ? fetchedLinks : [fetchedLinks];
sort();
if (links.value.length == 0) {
listing.value = false;
}
} catch (e) {
$showError?.(e);
}
});
</script>
@@ -27,20 +27,15 @@
</div>
</template>
<script>
import { mapActions, mapState } from "pinia";
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useLayoutStore } from "@/stores/layout";
export default {
name: "share-delete",
computed: {
...mapState(useLayoutStore, ["currentPrompt"]),
},
methods: {
...mapActions(useLayoutStore, ["closeHovers"]),
submit: function () {
this.currentPrompt?.confirm();
},
},
const layoutStore = useLayoutStore();
const { currentPrompt } = storeToRefs(layoutStore);
const { closeHovers } = layoutStore;
const submit = () => {
currentPrompt.value?.confirm();
};
</script>
+22 -18
View File
@@ -8,23 +8,27 @@
</div>
</template>
<script>
export default {
name: "permissions",
props: ["commands"],
computed: {
raw: {
get() {
return this.commands.join(" ");
},
set(value) {
if (value !== "") {
this.$emit("update:commands", value.split(" "));
} else {
this.$emit("update:commands", []);
}
},
},
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
commands: string[];
}>();
const emit = defineEmits<{
"update:commands": [commands: string[]];
}>();
const raw = computed({
get() {
return props.commands.join(" ");
},
};
set(value: string) {
if (value !== "") {
emit("update:commands", value.split(" "));
} else {
emit("update:commands", []);
}
},
});
</script>
+41 -52
View File
@@ -6,61 +6,50 @@
</select>
</template>
<script>
<script setup lang="ts">
import { markRaw } from "vue";
export default {
name: "languages",
props: ["locale"],
data() {
const dataObj = {};
const locales = {
he: "עברית",
hr: "Hrvatski",
hu: "Magyar",
ar: "العربية",
ca: "Català",
cs: "Čeština",
de: "Deutsch",
el: "Ελληνικά",
en: "English",
es: "Español",
fr: "Français",
is: "Icelandic",
it: "Italiano",
ja: "日本語",
ko: "한국어",
"nl-be": "Dutch (Belgium)",
no: "Norsk",
pl: "Polski",
"pt-br": "Português",
pt: "Português (Brasil)",
ro: "Romanian",
ru: "Русский",
sk: "Slovenčina",
"sv-se": "Swedish (Sweden)",
tr: "Türkçe",
uk: "Українська",
vi: "Tiếng Việt",
"zh-cn": "中文 (简体)",
"zh-tw": "中文 (繁體)",
};
defineProps<{
locale: string;
}>();
// Vue3 reactivity breaks with this configuration
// so we need to use markRaw as a workaround
// https://github.com/vuejs/core/issues/3024
Object.defineProperty(dataObj, "locales", {
value: markRaw(locales),
configurable: false,
writable: false,
});
const emit = defineEmits<{
"update:locale": [locale: string];
}>();
return dataObj;
},
methods: {
change(event) {
this.$emit("update:locale", event.target.value);
},
},
const locales = markRaw({
he: "עברית",
hr: "Hrvatski",
hu: "Magyar",
ar: "العربية",
ca: "Català",
cs: "Čeština",
de: "Deutsch",
el: "Ελληνικά",
en: "English",
es: "Español",
fr: "Français",
is: "Icelandic",
it: "Italiano",
ja: "日本語",
ko: "한국어",
"nl-be": "Dutch (Belgium)",
no: "Norsk",
pl: "Polski",
"pt-br": "Português",
pt: "Português (Brasil)",
ro: "Romanian",
ru: "Русский",
sk: "Slovenčina",
"sv-se": "Swedish (Sweden)",
tr: "Türkçe",
uk: "Українська",
vi: "Tiếng Việt",
"zh-cn": "中文 (简体)",
"zh-tw": "中文 (繁體)",
});
const change = (event: Event) => {
emit("update:locale", (event.target as HTMLSelectElement).value);
};
</script>
@@ -39,27 +39,28 @@
</div>
</template>
<script>
<script setup lang="ts">
import { computed } from "vue";
import { enableExec } from "@/utils/constants";
export default {
name: "permissions",
props: ["perm"],
computed: {
admin: {
get() {
return this.perm.admin;
},
set(value) {
if (value) {
for (const key in this.perm) {
this.perm[key] = true;
}
}
this.perm.admin = value;
},
},
isExecEnabled: () => enableExec,
const props = defineProps<{
perm: UserPermissions;
}>();
const admin = computed({
get() {
return props.perm.admin;
},
};
set(value: boolean) {
if (value) {
for (const key in props.perm) {
props.perm[key as keyof UserPermissions] = true;
}
}
props.perm.admin = value;
},
});
const isExecEnabled = enableExec;
</script>
+37 -25
View File
@@ -32,32 +32,44 @@
</form>
</template>
<script>
export default {
name: "rules-textarea",
props: ["rules"],
methods: {
remove(event, index) {
event.preventDefault();
const rules = [...this.rules];
rules.splice(index, 1);
this.$emit("update:rules", [...rules]);
},
create(event) {
event.preventDefault();
<script setup lang="ts">
interface Rule {
allow: boolean;
path: string;
regex: boolean;
regexp: {
raw: string;
};
}
this.$emit("update:rules", [
...this.rules,
{
allow: true,
path: "",
regex: false,
regexp: {
raw: "",
},
},
]);
const props = defineProps<{
rules: Rule[];
}>();
const emit = defineEmits<{
"update:rules": [rules: Rule[]];
}>();
const remove = (event: Event, index: number) => {
event.preventDefault();
const rules = [...props.rules];
rules.splice(index, 1);
emit("update:rules", [...rules]);
};
const create = (event: Event) => {
event.preventDefault();
emit("update:rules", [
...props.rules,
{
allow: true,
path: "",
regex: false,
regexp: {
raw: "",
},
},
},
]);
};
</script>
+15 -6
View File
@@ -80,12 +80,20 @@ const { t } = useI18n();
const createUserDirData = ref<boolean | null>(null);
const originalUserScope = ref<string | null>(null);
const props = defineProps<{
user: IUserForm;
isNew: boolean;
isDefault: boolean;
createUserDir?: boolean;
}>();
const props = defineProps<
| {
user: IUserForm;
isNew: boolean;
isDefault: false;
createUserDir?: boolean;
}
| {
user: SettingsDefaults;
isNew: boolean;
isDefault: true;
createUserDir?: boolean;
}
>();
onMounted(() => {
if (props.user.scope) {
@@ -108,6 +116,7 @@ watch(
() => props.user,
() => {
if (!props.user?.perm?.admin) return;
if (props.isDefault) return;
props.user.lockPassword = false;
}
);
+15 -12
View File
@@ -63,8 +63,8 @@
local("Roboto"),
local("Roboto-Regular"),
url(../assets/fonts/roboto/normal-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF,
U+2C60-2C7F, U+A720-A7FF;
unicode-range:
U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
@@ -75,8 +75,9 @@
local("Roboto"),
local("Roboto-Regular"),
url(../assets/fonts/roboto/normal-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
@font-face {
@@ -142,8 +143,8 @@
local("Roboto Medium"),
local("Roboto-Medium"),
url(../assets/fonts/roboto/medium-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF,
U+2C60-2C7F, U+A720-A7FF;
unicode-range:
U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
@@ -154,8 +155,9 @@
local("Roboto Medium"),
local("Roboto-Medium"),
url(../assets/fonts/roboto/medium-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
@font-face {
@@ -221,8 +223,8 @@
local("Roboto Bold"),
local("Roboto-Bold"),
url(../assets/fonts/roboto/bold-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF,
U+2C60-2C7F, U+A720-A7FF;
unicode-range:
U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
@@ -233,8 +235,9 @@
local("Roboto Bold"),
local("Roboto-Bold"),
url(../assets/fonts/roboto/bold-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC,
U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F,
U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
.material-icons {
+1 -1
View File
@@ -17,7 +17,7 @@ interface SettingsDefaults {
viewMode: ViewModeType;
singleClick: boolean;
sorting: Sorting;
perm: Permissions;
perm: UserPermissions;
commands: any[];
hideDotfiles: boolean;
dateFormat: boolean;
+13 -13
View File
@@ -4,7 +4,7 @@ interface IUser {
password: string;
scope: string;
locale: string;
perm: Permissions;
perm: UserPermissions;
commands: string[];
rules: IRule[];
lockPassword: boolean;
@@ -20,20 +20,20 @@ type ViewModeType = "list" | "mosaic" | "mosaic gallery";
interface IUserForm {
id?: number;
username?: string;
password?: string;
scope?: string;
locale?: string;
perm?: Permissions;
commands?: string[];
rules?: IRule[];
lockPassword?: boolean;
hideDotfiles?: boolean;
singleClick?: boolean;
dateFormat?: boolean;
username: string;
password: string;
scope: string;
locale: string;
perm: UserPermissions;
commands: string[];
rules: IRule[];
lockPassword: boolean;
hideDotfiles: boolean;
singleClick: boolean;
dateFormat: boolean;
}
interface Permissions {
interface UserPermissions {
admin: boolean;
copy: boolean;
create: boolean;
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
@@ -1,489 +0,0 @@
import { test, expect, type Page } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("https://demo.playwright.dev/todomvc");
});
const TODO_ITEMS = [
"buy some cheese",
"feed the cat",
"book a doctors appointment",
];
test.describe("New Todo", () => {
test("should allow me to add todo items", async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
// Create 1st todo.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press("Enter");
// Make sure the list only has one todo item.
await expect(page.getByTestId("todo-title")).toHaveText([TODO_ITEMS[0]]);
// Create 2nd todo.
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press("Enter");
// Make sure the list now has two todo items.
await expect(page.getByTestId("todo-title")).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[1],
]);
await checkNumberOfTodosInLocalStorage(page, 2);
});
test("should clear text input field when an item is added", async ({
page,
}) => {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
// Create one todo item.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press("Enter");
// Check that input is empty.
await expect(newTodo).toBeEmpty();
await checkNumberOfTodosInLocalStorage(page, 1);
});
test("should append new items to the bottom of the list", async ({
page,
}) => {
// Create 3 items.
await createDefaultTodos(page);
// create a todo count locator
const todoCount = page.getByTestId("todo-count");
// Check test using different methods.
await expect(page.getByText("3 items left")).toBeVisible();
await expect(todoCount).toHaveText("3 items left");
await expect(todoCount).toContainText("3");
await expect(todoCount).toHaveText(/3/);
// Check all items in one call.
await expect(page.getByTestId("todo-title")).toHaveText(TODO_ITEMS);
await checkNumberOfTodosInLocalStorage(page, 3);
});
});
test.describe("Mark all as completed", () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test.afterEach(async ({ page }) => {
await checkNumberOfTodosInLocalStorage(page, 3);
});
test("should allow me to mark all items as completed", async ({ page }) => {
// Complete all todos.
await page.getByLabel("Mark all as complete").check();
// Ensure all todos have 'completed' class.
await expect(page.getByTestId("todo-item")).toHaveClass([
"completed",
"completed",
"completed",
]);
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
});
test("should allow me to clear the complete state of all items", async ({
page,
}) => {
const toggleAll = page.getByLabel("Mark all as complete");
// Check and then immediately uncheck.
await toggleAll.check();
await toggleAll.uncheck();
// Should be no completed classes.
await expect(page.getByTestId("todo-item")).toHaveClass(["", "", ""]);
});
test("complete all checkbox should update state when items are completed / cleared", async ({
page,
}) => {
const toggleAll = page.getByLabel("Mark all as complete");
await toggleAll.check();
await expect(toggleAll).toBeChecked();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Uncheck first todo.
const firstTodo = page.getByTestId("todo-item").nth(0);
await firstTodo.getByRole("checkbox").uncheck();
// Reuse toggleAll locator and make sure its not checked.
await expect(toggleAll).not.toBeChecked();
await firstTodo.getByRole("checkbox").check();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Assert the toggle all is checked again.
await expect(toggleAll).toBeChecked();
});
});
test.describe("Item", () => {
test("should allow me to mark items as complete", async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press("Enter");
}
// Check first item.
const firstTodo = page.getByTestId("todo-item").nth(0);
await firstTodo.getByRole("checkbox").check();
await expect(firstTodo).toHaveClass("completed");
// Check second item.
const secondTodo = page.getByTestId("todo-item").nth(1);
await expect(secondTodo).not.toHaveClass("completed");
await secondTodo.getByRole("checkbox").check();
// Assert completed class.
await expect(firstTodo).toHaveClass("completed");
await expect(secondTodo).toHaveClass("completed");
});
test("should allow me to un-mark items as complete", async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press("Enter");
}
const firstTodo = page.getByTestId("todo-item").nth(0);
const secondTodo = page.getByTestId("todo-item").nth(1);
const firstTodoCheckbox = firstTodo.getByRole("checkbox");
await firstTodoCheckbox.check();
await expect(firstTodo).toHaveClass("completed");
await expect(secondTodo).not.toHaveClass("completed");
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await firstTodoCheckbox.uncheck();
await expect(firstTodo).not.toHaveClass("completed");
await expect(secondTodo).not.toHaveClass("completed");
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
});
test("should allow me to edit an item", async ({ page }) => {
await createDefaultTodos(page);
const todoItems = page.getByTestId("todo-item");
const secondTodo = todoItems.nth(1);
await secondTodo.dblclick();
await expect(secondTodo.getByRole("textbox", { name: "Edit" })).toHaveValue(
TODO_ITEMS[1]
);
await secondTodo
.getByRole("textbox", { name: "Edit" })
.fill("buy some sausages");
await secondTodo.getByRole("textbox", { name: "Edit" }).press("Enter");
// Explicitly assert the new text value.
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
"buy some sausages",
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, "buy some sausages");
});
});
test.describe("Editing", () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test("should hide other controls when editing", async ({ page }) => {
const todoItem = page.getByTestId("todo-item").nth(1);
await todoItem.dblclick();
await expect(todoItem.getByRole("checkbox")).not.toBeVisible();
await expect(
todoItem.locator("label", {
hasText: TODO_ITEMS[1],
})
).not.toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 3);
});
test("should save edits on blur", async ({ page }) => {
const todoItems = page.getByTestId("todo-item");
await todoItems.nth(1).dblclick();
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.fill("buy some sausages");
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.dispatchEvent("blur");
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
"buy some sausages",
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, "buy some sausages");
});
test("should trim entered text", async ({ page }) => {
const todoItems = page.getByTestId("todo-item");
await todoItems.nth(1).dblclick();
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.fill(" buy some sausages ");
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.press("Enter");
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
"buy some sausages",
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, "buy some sausages");
});
test("should remove the item if an empty text string was entered", async ({
page,
}) => {
const todoItems = page.getByTestId("todo-item");
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole("textbox", { name: "Edit" }).fill("");
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.press("Enter");
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test("should cancel edits on escape", async ({ page }) => {
const todoItems = page.getByTestId("todo-item");
await todoItems.nth(1).dblclick();
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.fill("buy some sausages");
await todoItems
.nth(1)
.getByRole("textbox", { name: "Edit" })
.press("Escape");
await expect(todoItems).toHaveText(TODO_ITEMS);
});
});
test.describe("Counter", () => {
test("should display the current number of todo items", async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
// create a todo count locator
const todoCount = page.getByTestId("todo-count");
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press("Enter");
await expect(todoCount).toContainText("1");
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press("Enter");
await expect(todoCount).toContainText("2");
await checkNumberOfTodosInLocalStorage(page, 2);
});
});
test.describe("Clear completed button", () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
});
test("should display the correct text", async ({ page }) => {
await page.locator(".todo-list li .toggle").first().check();
await expect(
page.getByRole("button", { name: "Clear completed" })
).toBeVisible();
});
test("should remove completed items when clicked", async ({ page }) => {
const todoItems = page.getByTestId("todo-item");
await todoItems.nth(1).getByRole("checkbox").check();
await page.getByRole("button", { name: "Clear completed" }).click();
await expect(todoItems).toHaveCount(2);
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test("should be hidden when there are no items that are completed", async ({
page,
}) => {
await page.locator(".todo-list li .toggle").first().check();
await page.getByRole("button", { name: "Clear completed" }).click();
await expect(
page.getByRole("button", { name: "Clear completed" })
).toBeHidden();
});
});
test.describe("Persistence", () => {
test("should persist its data", async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press("Enter");
}
const todoItems = page.getByTestId("todo-item");
const firstTodoCheck = todoItems.nth(0).getByRole("checkbox");
await firstTodoCheck.check();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(["completed", ""]);
// Ensure there is 1 completed item.
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// Now reload.
await page.reload();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(["completed", ""]);
});
});
test.describe("Routing", () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
// make sure the app had a chance to save updated todos in storage
// before navigating to a new view, otherwise the items can get lost :(
// in some frameworks like Durandal
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
});
test("should allow me to display active items", async ({ page }) => {
const todoItem = page.getByTestId("todo-item");
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole("link", { name: "Active" }).click();
await expect(todoItem).toHaveCount(2);
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test("should respect the back button", async ({ page }) => {
const todoItem = page.getByTestId("todo-item");
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await test.step("Showing all items", async () => {
await page.getByRole("link", { name: "All" }).click();
await expect(todoItem).toHaveCount(3);
});
await test.step("Showing active items", async () => {
await page.getByRole("link", { name: "Active" }).click();
});
await test.step("Showing completed items", async () => {
await page.getByRole("link", { name: "Completed" }).click();
});
await expect(todoItem).toHaveCount(1);
await page.goBack();
await expect(todoItem).toHaveCount(2);
await page.goBack();
await expect(todoItem).toHaveCount(3);
});
test("should allow me to display completed items", async ({ page }) => {
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole("link", { name: "Completed" }).click();
await expect(page.getByTestId("todo-item")).toHaveCount(1);
});
test("should allow me to display all items", async ({ page }) => {
await page.getByTestId("todo-item").nth(1).getByRole("checkbox").check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole("link", { name: "Active" }).click();
await page.getByRole("link", { name: "Completed" }).click();
await page.getByRole("link", { name: "All" }).click();
await expect(page.getByTestId("todo-item")).toHaveCount(3);
});
test("should highlight the currently applied filter", async ({ page }) => {
await expect(page.getByRole("link", { name: "All" })).toHaveClass(
"selected"
);
//create locators for active and completed links
const activeLink = page.getByRole("link", { name: "Active" });
const completedLink = page.getByRole("link", { name: "Completed" });
await activeLink.click();
// Page change - active items.
await expect(activeLink).toHaveClass("selected");
await completedLink.click();
// Page change - completed items.
await expect(completedLink).toHaveClass("selected");
});
});
async function createDefaultTodos(page: Page) {
// create a new todo locator
const newTodo = page.getByPlaceholder("What needs to be done?");
for (const item of TODO_ITEMS) {
await newTodo.fill(item);
await newTodo.press("Enter");
}
}
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction((e) => {
return JSON.parse(localStorage["react-todos"]).length === e;
}, expected);
}
async function checkNumberOfCompletedTodosInLocalStorage(
page: Page,
expected: number
) {
return await page.waitForFunction((e) => {
return (
JSON.parse(localStorage["react-todos"]).filter(
(todo: any) => todo.completed
).length === e
);
}, expected);
}
async function checkTodosInLocalStorage(page: Page, title: string) {
return await page.waitForFunction((t) => {
return JSON.parse(localStorage["react-todos"])
.map((todo: any) => todo.title)
.includes(t);
}, title);
}
-32
View File
@@ -1,32 +0,0 @@
import { test, expect } from "./fixtures/auth";
test("redirect to login", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
await page.goto("/files/");
await expect(page).toHaveURL(/\/login\?redirect=\/files\//);
});
test("login and logout", async ({ authPage, page, context }) => {
await authPage.goto();
await expect(page).toHaveTitle(/Login - File Browser$/);
await authPage.loginAs("fake", "fake");
await expect(authPage.wrongCredentials).toBeVisible();
await authPage.loginAs();
await expect(authPage.wrongCredentials).toBeHidden();
// await page.waitForURL("**/files/", { timeout: 5000 });
await expect(page).toHaveTitle(/.*Files - File Browser$/);
let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await authPage.logout();
// await page.waitForURL("**/login", { timeout: 5000 });
await expect(page).toHaveTitle(/Login - File Browser$/);
cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeUndefined();
});
-40
View File
@@ -1,40 +0,0 @@
import {
type Page,
type Locator,
test as base,
expect,
} from "@playwright/test";
export class AuthPage {
public readonly wrongCredentials: Locator;
constructor(public readonly page: Page) {
this.wrongCredentials = this.page.locator("div.wrong");
}
async goto() {
await this.page.goto("/login");
}
async loginAs(username = "admin", password = "admin") {
await this.page.getByPlaceholder("Username").fill(username);
await this.page.getByPlaceholder("Password").fill(password);
await this.page.getByRole("button", { name: "Login" }).click();
}
async logout() {
await this.page.getByRole("button", { name: "Logout" }).click();
}
}
const test = base.extend<{ authPage: AuthPage }>({
authPage: async ({ page }, use) => {
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.loginAs();
await use(authPage);
// await authPage.logout();
},
});
export { test, expect };
-61
View File
@@ -1,61 +0,0 @@
import {
type Locator,
type Page,
test as base,
expect,
} from "@playwright/test";
import { AuthPage } from "./auth";
type SettingsType = "profile" | "shares" | "global" | "users";
export class SettingsPage {
public readonly hideDotfiles: Locator; // checkbox
public readonly singleClick: Locator; // checkbox
public readonly dateFormat: Locator; // checkbox
private readonly languages: Locator; // selection
private readonly submitProfile: Locator; // submit
private readonly submitPassword: Locator; // submit
constructor(public readonly page: Page) {
this.hideDotfiles = this.page.locator('input[name="hideDotfiles"]');
this.singleClick = this.page.locator('input[name="singleClick"]');
this.dateFormat = this.page.locator('input[name="dateFormat"]');
this.languages = this.page.locator('select[name="selectLanguage"]');
this.submitProfile = this.page.locator('input[name="submitProfile"]');
this.submitPassword = this.page.locator('input[name="submitPassword"]');
}
async goto(type: SettingsType = "profile") {
await this.page.goto(`/settings/${type}`);
}
async setLanguage(locale: string = "en") {
await this.languages.selectOption(locale);
}
async saveProfile() {
await this.submitProfile.click();
}
async savePassword() {
await this.submitPassword.click();
}
}
const test = base.extend<{ settingsPage: SettingsPage }>({
page: async ({ page }, use) => {
// Sign in with our account.
const authPage = new AuthPage(page);
await authPage.goto();
await authPage.loginAs();
await expect(page).toHaveTitle(/.*Files - File Browser$/);
// Use signed-in page in the test.
await use(page);
},
settingsPage: async ({ page }, use) => {
const settingsPage = new SettingsPage(page);
await use(settingsPage);
},
});
export { test, expect };
-20
View File
@@ -1,20 +0,0 @@
//classes: Vue-Toastification__toast Vue-Toastification__toast--success bottom-center
import { type Page, type Locator, expect } from "@playwright/test";
export class Toast {
private readonly success: Locator;
private readonly error: Locator;
constructor(public readonly page: Page) {
this.success = this.page.locator("div.Vue-Toastification__toast--success");
this.error = this.page.locator("div.Vue-Toastification__toast--error");
}
async isSuccess() {
await expect(this.success).toBeVisible();
}
async isError() {
await expect(this.error).toBeVisible();
}
}
-46
View File
@@ -1,46 +0,0 @@
import { test, expect } from "./fixtures/settings";
import { Toast } from "./fixtures/toast";
// test.describe("profile settings", () => {
test("settings button", async ({ page }) => {
const button = page.getByLabel("Settings", { exact: true });
await expect(button).toBeVisible();
await button.click();
await expect(page).toHaveTitle(/^Profile Settings/);
await expect(
page.getByRole("heading", { name: "Profile Settings" })
).toBeVisible();
});
test("set locale", async ({ settingsPage, page }) => {
const toast = new Toast(page);
await settingsPage.goto("profile");
await expect(page).toHaveTitle(/^Profile Settings/);
// await settingsPage.saveProfile();
// await toast.isSuccess();
// await expect(
// page.getByText("Settings updated!", { exact: true })
// ).toBeVisible();
await settingsPage.setLanguage("hu");
await settingsPage.saveProfile();
await toast.isSuccess();
await expect(
page.getByText("Beállítások frissítve!", { exact: true })
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Profilbeállítások" })
).toBeVisible();
await settingsPage.setLanguage("en");
await settingsPage.saveProfile();
await toast.isSuccess();
await expect(
page.getByText("Settings updated!", { exact: true })
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Profile Settings" })
).toBeVisible();
});
// });
+18 -2
View File
@@ -1,13 +1,29 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"exclude": [
"src/**/__tests__/*",
// Excluding non-TS Vue files which use the old Options API.
// This can be removed once those files are properly migrated to
// the new Composition API with TS support.
"src/components/Shell.vue",
"src/components/prompts/Copy.vue",
"src/components/prompts/Move.vue",
"src/components/prompts/Delete.vue",
"src/components/prompts/FileList.vue",
"src/components/prompts/Rename.vue",
"src/components/prompts/Share.vue"
],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client", "@intlify/unplugin-vue-i18n/messages"],
"paths": {
"@/*": ["./src/*"]
}
},
// Version 0.8.0 of @vue/tsconfig enabled this automatically.
// Disabling for now since it's causing quite a lot of errors.
// Should be revisited.
"noUncheckedIndexedAccess": false
}
}
+2 -5
View File
@@ -4,8 +4,7 @@
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
"nightwatch.conf.*"
],
"compilerOptions": {
"composite": true,
@@ -13,8 +12,6 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": [
"node"
]
"types": ["node"]
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"extends": "./tsconfig.app.json",
// vue-tsc wont shut up about error TS9005
// in non-TS vue files so exclude them
"exclude": [
"src/components/Shell.vue",
"src/components/prompts/Copy.vue",
"src/components/prompts/Move.vue",
"src/components/prompts/Delete.vue",
"src/components/prompts/FileList.vue",
"src/components/prompts/Rename.vue",
"src/components/prompts/Share.vue"
]
}
+2 -1
View File
@@ -4,7 +4,8 @@
"config:recommended",
"group:allNonMajor",
"group:allDigest",
":disableDependencyDashboard"
":disableDependencyDashboard",
":semanticCommitTypeAll(chore)"
],
"postUpdateOptions": [
"gomodUpdateImportPaths",