feat(frontend): migrate Vue to Composition API

Signed-off-by: Henrique Dias <mail@hacdias.com>
This commit is contained in:
Henrique Dias 2025-11-13 14:23:20 +01:00
parent bf3ba65782
commit 83492a4dfb
No known key found for this signature in database
23 changed files with 1178 additions and 1181 deletions

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>

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>

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>

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>

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>

View file

@ -33,8 +33,4 @@ import { useI18n } from "vue-i18n";
const layoutStore = useLayoutStore();
const { t } = useI18n();
// const emit = defineEmits<{
// (e: "confirm"): void;
// }>();
</script>

View file

@ -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>

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>

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>

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>

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>

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>

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>

View file

@ -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>

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>

View file

@ -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>

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>

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>

View file

@ -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>

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>

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;
}
);

View file

@ -17,7 +17,7 @@ interface SettingsDefaults {
viewMode: ViewModeType;
singleClick: boolean;
sorting: Sorting;
perm: Permissions;
perm: UserPermissions;
commands: any[];
hideDotfiles: boolean;
dateFormat: boolean;

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;