Converted view/Share.vue to composition api

This commit is contained in:
Joep 2023-09-09 13:06:04 +02:00
parent 22854bf699
commit ef5c8b0d8e
13 changed files with 251 additions and 306 deletions

View File

@ -3,10 +3,14 @@
"env": { "env": {
"node": true "node": true
}, },
"parser": "@typescript-eslint/parser",
"extends": [ "extends": [
"plugin:vue/vue3-essential",
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
"@vue/eslint-config-prettier" "@vue/eslint-config-prettier"
], ],
"rules": { "rules": {
"vue/multi-word-component-names": "off", "vue/multi-word-component-names": "off",

View File

@ -1,7 +1,7 @@
import { fetchURL, removePrefix, createURL } from "./utils"; import { fetchURL, removePrefix, createURL } from "./utils";
import { baseURL } from "@/utils/constants"; import { baseURL } from "@/utils/constants";
export async function fetch(url: apiUrl, password: string = "") { export async function fetch(url: ApiUrl, password: string = "") {
url = removePrefix(url); url = removePrefix(url);
const res = await fetchURL( const res = await fetchURL(
@ -33,35 +33,35 @@ export async function fetch(url: apiUrl, password: string = "") {
} }
// Is this redundant code? // Is this redundant code?
// export function download(format, hash, token, ...files) { export function download(format: any, hash: string, token: string, ...files: any) {
// let url = `${baseURL}/api/public/dl/${hash}`; let url = `${baseURL}/api/public/dl/${hash}`;
// if (files.length === 1) { if (files.length === 1) {
// url += encodeURIComponent(files[0]) + "?"; url += encodeURIComponent(files[0]) + "?";
// } else { } else {
// let arg = ""; let arg = "";
// for (let file of files) { for (let file of files) {
// arg += encodeURIComponent(file) + ","; arg += encodeURIComponent(file) + ",";
// } }
// arg = arg.substring(0, arg.length - 1); arg = arg.substring(0, arg.length - 1);
// arg = encodeURIComponent(arg); arg = encodeURIComponent(arg);
// url += `/?files=${arg}&`; url += `/?files=${arg}&`;
// } }
// if (format) { if (format) {
// url += `algo=${format}&`; url += `algo=${format}&`;
// } }
// if (token) { if (token) {
// url += `token=${token}&`; url += `token=${token}&`;
// } }
// window.open(url); window.open(url);
// } }
export function getDownloadURL(share: share, inline = false) { export function getDownloadURL(share: IFile, inline = false) {
const params = { const params = {
...(inline && { inline: "true" }), ...(inline && { inline: "true" }),
...(share.token && { token: share.token }), ...(share.token && { token: share.token }),

View File

@ -3,7 +3,7 @@ import { renew, logout } from "@/utils/auth";
import { baseURL } from "@/utils/constants"; import { baseURL } from "@/utils/constants";
import { encodePath } from "@/utils/url"; import { encodePath } from "@/utils/url";
export async function fetchURL(url: apiUrl, opts: apiOpts, auth = true) { export async function fetchURL(url: ApiUrl, opts: ApiOpts, auth = true) {
const authStore = useAuthStore(); const authStore = useAuthStore();
opts = opts || {}; opts = opts || {};
@ -46,7 +46,7 @@ export async function fetchURL(url: apiUrl, opts: apiOpts, auth = true) {
return res; return res;
} }
export async function fetchJSON(url: apiUrl, opts?: any) { export async function fetchJSON(url: ApiUrl, opts?: any) {
const res = await fetchURL(url, opts); const res = await fetchURL(url, opts);
if (res.status === 200) { if (res.status === 200) {
@ -56,7 +56,7 @@ export async function fetchJSON(url: apiUrl, opts?: any) {
} }
} }
export function removePrefix(url: apiUrl) { export function removePrefix(url: ApiUrl) {
url = url.split("/").splice(2).join("/"); url = url.split("/").splice(2).join("/");
if (url === "") url = "/"; if (url === "") url = "/";
@ -64,7 +64,7 @@ export function removePrefix(url: apiUrl) {
return url; return url;
} }
export function createURL(endpoint: apiUrl, params = {}, auth = true) { export function createURL(endpoint: ApiUrl, params = {}, auth = true) {
const authStore = useAuthStore(); const authStore = useAuthStore();
let prefix = baseURL; let prefix = baseURL;

View File

@ -13,6 +13,8 @@ import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat"; import localizedFormat from "dayjs/plugin/localizedFormat";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import duration from "dayjs/plugin/duration"; import duration from "dayjs/plugin/duration";
import "./css/styles.css";
// register dayjs plugins globally // register dayjs plugins globally
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
dayjs.extend(relativeTime); dayjs.extend(relativeTime);

View File

@ -3,15 +3,15 @@ import { defineStore } from "pinia";
export const useFileStore = defineStore("file", { export const useFileStore = defineStore("file", {
// convert to a function // convert to a function
state: (): { state: (): {
req: req, req: IFile | null,
oldReq: req, oldReq: IFile | null,
reload: boolean, reload: boolean,
selected: any[], selected: any[],
multiple: boolean, multiple: boolean,
isFiles: boolean isFiles: boolean
} => ({ } => ({
req: {}, req: null,
oldReq: {}, oldReq: null,
reload: false, reload: false,
selected: [], selected: [],
multiple: false, multiple: false,
@ -28,7 +28,7 @@ export const useFileStore = defineStore("file", {
// return !layoutStore.loading && state.route._value.name === "Files"; // return !layoutStore.loading && state.route._value.name === "Files";
// }, // },
isListing: (state) => { isListing: (state) => {
return state.isFiles && state.req.isDir; return state.isFiles && state?.req?.isDir;
}, },
}, },
actions: { actions: {
@ -36,7 +36,7 @@ export const useFileStore = defineStore("file", {
toggleMultiple() { toggleMultiple() {
this.multiple = !this.multiple; this.multiple = !this.multiple;
}, },
updateRequest(value: req) { updateRequest(value: IFile) {
this.oldReq = this.req; this.oldReq = this.req;
this.req = value; this.req = value;
}, },

View File

@ -7,7 +7,7 @@ export const useLayoutStore = defineStore("layout", {
state: (): { state: (): {
loading: boolean, loading: boolean,
show: string | null | boolean, show: string | null | boolean,
showConfirm: boolean | null, showConfirm: Function | null,
showAction: boolean | null, showAction: boolean | null,
showShell: boolean | null showShell: boolean | null
} => ({ } => ({

View File

@ -1,14 +1,13 @@
type apiUrl = string // Can also be set as a path eg: "path1" | "path2" type ApiUrl = string // Can also be set as a path eg: "path1" | "path2"
type resourcePath = string type resourcePath = string
type apiMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" type ApiMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
// type apiContent = string | Blob | File type ApiContent = Blob | File | Pick<ReadableStreamDefaultReader<any>, "read"> | ""
type apiContent = Blob | File | Pick<ReadableStreamDefaultReader<any>, "read"> | ""
interface apiOpts { interface ApiOpts {
method?: apiMethod, method?: ApiMethod,
headers?: object, headers?: object,
body?: any body?: any
} }

View File

@ -1,5 +1,5 @@
interface IFile {
interface file { index?: number
name: string, name: string,
modified: string, modified: string,
path: string, path: string,
@ -7,27 +7,32 @@ interface file {
isDir: boolean, isDir: boolean,
size: number, size: number,
fullPath: string, fullPath: string,
type: uploadType type: uploadType,
items: IFile[]
token?: string,
hash: string,
url?: string
} }
interface item {
id: number,
path: string,
file: file,
url?: string,
dir?: boolean,
from?: string,
to?: string,
name?: string,
type?: uploadType
overwrite: boolean
}
type uploadType = "video" | "audio" | "image" | "pdf" | "text" | "blob" type uploadType = "video" | "audio" | "image" | "pdf" | "text" | "blob"
interface req { type req = {
isDir?: boolean path: string
} name: string
size: number
extension: string
modified: string
mode: number
isDir: boolean
isSymlink: boolean
type: string
url: string
hash: string
}
interface uploads { interface uploads {
[key: string]: upload [key: string]: upload

View File

@ -5,4 +5,7 @@ declare global {
FileBrowser: any; FileBrowser: any;
grecaptcha: any grecaptcha: any
} }
interface HTMLAttributes extends HTMLAttributes {
title: any
}
} }

View File

@ -1,5 +1,5 @@
interface LayoutValue { interface LayoutValue {
prompt: boolean, prompt: string,
confirm: boolean, confirm: Function,
action: boolean, action?: boolean,
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<header-bar v-if="error || req.type == null" showMenu showLogo /> <header-bar v-if="error || req?.type === null" showMenu showLogo />
<breadcrumbs base="/files" /> <breadcrumbs base="/files" />
@ -68,7 +68,7 @@ export default {
uploadError: "error", uploadError: "error",
}), }),
currentView() { currentView() {
if (this.req.type == undefined) { if (this.req?.type == undefined) {
return null; return null;
} }
@ -129,7 +129,6 @@ export default {
let url = this.$route.path; let url = this.$route.path;
if (url === "") url = "/"; if (url === "") url = "/";
if (url[0] !== "/") url = "/" + url; if (url[0] !== "/") url = "/" + url;
try { try {
const res = await api.fetch(url); const res = await api.fetch(url);

View File

@ -3,32 +3,18 @@
<header-bar showMenu showLogo> <header-bar showMenu showLogo>
<title /> <title />
<action <action v-if="fileStore.selectedCount" icon="file_download" :label="$t('buttons.download')" @action="download"
v-if="selectedCount" :counter="fileStore.selectedCount" />
icon="file_download" <button v-if="isSingleFile()" class="action copy-clipboard" :data-clipboard-text="linkSelected()"
:label="$t('buttons.download')" :aria-label="$t('buttons.copyDownloadLinkToClipboard')" :data-title="$t('buttons.copyDownloadLinkToClipboard')">
@action="download"
:counter="selectedCount"
/>
<button
v-if="isSingleFile()"
class="action copy-clipboard"
:data-clipboard-text="linkSelected()"
:aria-label="$t('buttons.copyDownloadLinkToClipboard')"
:title="$t('buttons.copyDownloadLinkToClipboard')"
>
<i class="material-icons">content_paste</i> <i class="material-icons">content_paste</i>
</button> </button>
<action <action icon="check_circle" :label="$t('buttons.selectMultiple')" @action="toggleMultipleSelection" />
icon="check_circle"
:label="$t('buttons.selectMultiple')"
@action="toggleMultipleSelection"
/>
</header-bar> </header-bar>
<breadcrumbs :base="'/share/' + hash" /> <breadcrumbs :base="'/share/' + hash" />
<div v-if="loading"> <div v-if="layoutStore.loading">
<h2 class="message delayed"> <h2 class="message delayed">
<div class="spinner"> <div class="spinner">
<div class="bounce1"></div> <div class="bounce1"></div>
@ -49,21 +35,12 @@
</div> </div>
<div class="card-content"> <div class="card-content">
<input <input v-focus type="password" :placeholder="$t('login.password')" v-model="password"
v-focus @keyup.enter="fetchData" />
type="password"
:placeholder="$t('login.password')"
v-model="password"
@keyup.enter="fetchData"
/>
</div> </div>
<div class="card-action"> <div class="card-action">
<button <button class="button button--flat" @click="fetchData" :aria-label="$t('buttons.submit')"
class="button button--flat" :data-title="$t('buttons.submit')">
@click="fetchData"
:aria-label="$t('buttons.submit')"
:title="$t('buttons.submit')"
>
{{ $t("buttons.submit") }} {{ $t("buttons.submit") }}
</button> </button>
</div> </div>
@ -71,14 +48,14 @@
</div> </div>
<errors v-else :errorCode="error.status" /> <errors v-else :errorCode="error.status" />
</div> </div>
<div v-else> <div v-else-if="req !== null">
<div class="share"> <div class="share">
<div class="share__box share__box__info"> <div class="share__box share__box__info">
<div class="share__box__header"> <div class="share__box__header">
{{ {{
req.isDir req.isDir
? $t("download.downloadFolder") ? $t("download.downloadFolder")
: $t("download.downloadFile") : $t("download.downloadFile")
}} }}
</div> </div>
<div class="share__box__element share__box__center share__box__icon"> <div class="share__box__element share__box__center share__box__icon">
@ -87,7 +64,7 @@
<div class="share__box__element"> <div class="share__box__element">
<strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }} <strong>{{ $t("prompts.displayName") }}</strong> {{ req.name }}
</div> </div>
<div class="share__box__element" :title="modTime"> <div class="share__box__element" :data-title="modTime">
<strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }} <strong>{{ $t("prompts.lastModified") }}:</strong> {{ humanTime }}
</div> </div>
<div class="share__box__element"> <div class="share__box__element">
@ -96,19 +73,12 @@
<div class="share__box__element share__box__center"> <div class="share__box__element share__box__center">
<a target="_blank" :href="link" class="button button--flat"> <a target="_blank" :href="link" class="button button--flat">
<div> <div>
<i class="material-icons">file_download</i <i class="material-icons">file_download</i>{{ $t("buttons.download") }}
>{{ $t("buttons.download") }}
</div> </div>
</a> </a>
<a <a target="_blank" :href="inlineLink" class="button button--flat" v-if="!req.isDir">
target="_blank"
:href="inlineLink"
class="button button--flat"
v-if="!req.isDir"
>
<div> <div>
<i class="material-icons">open_in_new</i <i class="material-icons">open_in_new</i>{{ $t("buttons.openFile") }}
>{{ $t("buttons.openFile") }}
</div> </div>
</a> </a>
</div> </div>
@ -116,56 +86,31 @@
<qrcode-vue :value="link" :size="200" level="M"></qrcode-vue> <qrcode-vue :value="link" :size="200" level="M"></qrcode-vue>
</div> </div>
</div> </div>
<div <div v-if="req.isDir && req.items.length > 0" class="share__box share__box__items">
v-if="req.isDir && req.items.length > 0"
class="share__box share__box__items"
>
<div class="share__box__header" v-if="req.isDir"> <div class="share__box__header" v-if="req.isDir">
{{ $t("files.files") }} {{ $t("files.files") }}
</div> </div>
<div id="listing" class="list file-icons"> <div id="listing" class="list file-icons">
<item <item v-for="item in req.items.slice(0, showLimit)" :key="base64(item.name)" v-bind:index="item.index"
v-for="item in req.items.slice(0, this.showLimit)" v-bind:name="item.name" v-bind:isDir="item.isDir" v-bind:url="item.url" v-bind:modified="item.modified"
:key="base64(item.name)" v-bind:type="item.type" v-bind:size="item.size" readOnly>
v-bind:index="item.index"
v-bind:name="item.name"
v-bind:isDir="item.isDir"
v-bind:url="item.url"
v-bind:modified="item.modified"
v-bind:type="item.type"
v-bind:size="item.size"
readOnly
>
</item> </item>
<div <div v-if="req.items.length > showLimit" class="item" @click="showLimit += 100">
v-if="req.items.length > showLimit"
class="item"
@click="showLimit += 100"
>
<div> <div>
<p class="name">+ {{ req.items.length - showLimit }}</p> <p class="name">+ {{ req.items.length - showLimit }}</p>
</div> </div>
</div> </div>
<div :class="{ active: multiple }" id="multiple-selection"> <div :class="{ active: fileStore.multiple }" id="multiple-selection">
<p>{{ $t("files.multipleSelectionEnabled") }}</p> <p>{{ $t("files.multipleSelectionEnabled") }}</p>
<div <div @click="() => (fileStore.multiple = false)" tabindex="0" role="button" :data-title="$t('files.clear')"
@click="() => (multiple = false)" :aria-label="$t('files.clear')" class="action">
tabindex="0"
role="button"
:title="$t('files.clear')"
:aria-label="$t('files.clear')"
class="action"
>
<i class="material-icons">clear</i> <i class="material-icons">clear</i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div <div v-else-if="req.isDir && req.items.length === 0" class="share__box share__box__items">
v-else-if="req.isDir && req.items.length === 0"
class="share__box share__box__items"
>
<h2 class="message"> <h2 class="message">
<i class="material-icons">sentiment_dissatisfied</i> <i class="material-icons">sentiment_dissatisfied</i>
<span>{{ $t("files.lonely") }}</span> <span>{{ $t("files.lonely") }}</span>
@ -176,9 +121,8 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import { mapState, mapActions, mapWritableState } from "pinia"; import { pub as api, files } from "@/api";
import { pub as api } from "@/api";
import { filesize } from "filesize"; import { filesize } from "filesize";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Base64 } from "js-base64"; import { Base64 } from "js-base64";
@ -192,171 +136,160 @@ import Item from "@/components/files/ListingItem.vue";
import Clipboard from "clipboard"; import Clipboard from "clipboard";
import { useFileStore } from "@/stores/file"; import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout"; import { useLayoutStore } from "@/stores/layout";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
export default { const error = ref<null | any>(null)
name: "share", const showLimit = ref<number>(100)
components: { const password = ref<string>("")
HeaderBar, const attemptedPasswordLogin = ref<boolean>(false)
Action, const hash = ref<any>(null)
Breadcrumbs, const token = ref<any>(null)
Item, const clip = ref<any>(null)
QrcodeVue,
Errors,
},
data: () => ({
error: null,
showLimit: 100,
password: "",
attemptedPasswordLogin: false,
hash: null,
token: null,
clip: null,
}),
inject: ["$showSuccess"],
watch: {
$route: function () {
this.showLimit = 100;
this.fetchData(); const route = useRoute()
}, const fileStore = useFileStore()
}, const layoutStore = useLayoutStore()
created: async function () {
const hash = this.$route.params.path[0]; watch(route, (newValue, oldValue) => {
this.hash = hash; showLimit.value = 100
await this.fetchData(); fetchData();
}, })
mounted() {
window.addEventListener("keydown", this.keyEvent); const req = computed(() => fileStore.req)
this.clip = new Clipboard(".copy-clipboard");
this.clip.on("success", () => { // inject: ["$showSuccess"],
this.$showSuccess(this.$t("success.linkCopied"));
});
}, // Define computes
beforeUnmount() {
window.removeEventListener("keydown", this.keyEvent); const icon = computed(() => {
this.clip.destroy(); if (req.value === null) return "insert_drive_file"
}, if (req.value.isDir) return "folder";
computed: { if (req.value.type === "image") return "insert_photo";
...mapState(useFileStore, ["req", "selectedCount"]), if (req.value.type === "audio") return "volume_up";
...mapWritableState(useFileStore, ["reload", "multiple", "selected"]), if (req.value.type === "video") return "movie";
...mapWritableState(useLayoutStore, ["loading"]), return "insert_drive_file";
icon: function () { })
if (this.req.isDir) return "folder";
if (this.req.type === "image") return "insert_photo"; const link = computed(() => (req.value ? api.getDownloadURL(req.value) : ""))
if (this.req.type === "audio") return "volume_up"; const inlineLink = computed(() => (req.value ? api.getDownloadURL(req.value, true) : ""))
if (this.req.type === "video") return "movie"; const humanSize = computed(() => {
return "insert_drive_file"; if (req.value) {
}, return (req.value.isDir ? req.value.items.length : filesize(req.value.size ?? 0))
link: function () { } else {
return api.getDownloadURL(this.req); return ""
}, }
inlineLink: function () { })
return api.getDownloadURL(this.req, true); const humanTime = computed(() => dayjs(req.value?.modified).fromNow())
}, const modTime = computed(() => (req.value ? new Date(Date.parse(req.value.modified)).toLocaleString() : new Date()))
humanSize: function () {
if (this.req.isDir) { // Functions
return this.req.items.length; const base64 = (name: any) => Base64.encodeURI(name);
const fetchData = async () => {
fileStore.reload = false;
fileStore.selected = [];
fileStore.multiple = false;
// fileStore.closeHovers();
// Set loading to true and reset the error.
layoutStore.loading = true;
error.value = null;
if (password.value !== "") {
attemptedPasswordLogin.value = true;
}
let url = route.path;
if (url === "") url = "/";
if (url[0] !== "/") url = "/" + url;
try {
let file = await api.fetch(url, password.value);
file.hash = hash.value;
token.value = file.token || "";
fileStore.updateRequest(file);
document.title = `${file.name} - ${document.title}`;
} catch (e) {
error.value = e;
} finally {
layoutStore.loading = false;
}
}
const keyEvent = (event: KeyboardEvent) => {
if (event.key === "Escape") {
// If we're on a listing, unselect all
// files and folders.
if (fileStore.selectedCount > 0) {
fileStore.selected = [];
}
}
}
const toggleMultipleSelection = () => {
// toggle
}
const isSingleFile = () => fileStore.selectedCount === 1 && !req.value?.items[fileStore.selected[0]].isDir
const download = () => {
if (isSingleFile()) {
api.download(
null,
hash.value,
token.value,
req.value?.items[fileStore.selected[0]].path
);
return;
}
layoutStore.showHover({
prompt: "download",
confirm: (format: any) => {
if (req.value === null) return false
layoutStore.closeHovers();
let files: string[] = [];
for (let i of fileStore.selected) {
files.push(req.value.items[i].path);
} }
return filesize(this.req.size); // @ts-ignore
api.download(format, hash.value, token.value, ...files);
}, },
humanTime: function () { });
return dayjs(this.req.modified).fromNow(); }
},
modTime: function () {
return new Date(Date.parse(this.req.modified)).toLocaleString();
},
},
methods: {
...mapActions(useFileStore, ["updateRequest", "toggleMultiple"]),
...mapActions(useLayoutStore, ["showHover", "closeHovers"]),
base64: function (name) {
return Base64.encodeURI(name);
},
fetchData: async function () {
// Reset view information.
this.reload = false;
this.selected = [];
this.multiple = false;
this.closeHovers();
// Set loading to true and reset the error. const linkSelected = () => {
this.loading = true; return isSingleFile() && req.value
this.error = null; // @ts-ignore
? api.getDownloadURL({
hash: hash.value,
path: req.value.items[fileStore.selected[0]].path,
})
: "";
}
if (this.password !== "") {
this.attemptedPasswordLogin = true;
}
let url = this.$route.path; onMounted(async () => {
if (url === "") url = "/"; // Created
if (url[0] !== "/") url = "/" + url; hash.value = route.params.path[0];
await fetchData();
try {
let file = await api.fetch(url, this.password);
file.hash = this.hash;
this.token = file.token || ""; // window.addEventListener("keydown", this.keyEvent);
// this.clip = new Clipboard(".copy-clipboard");
// this.clip.on("success", () => {
// this.$showSuccess(this.$t("success.linkCopied"));
// });
})
this.updateRequest(file); onBeforeUnmount(() => {
document.title = `${file.name} - ${document.title}`; // window.removeEventListener("keydown", this.keyEvent);
} catch (e) { // this.clip.destroy();
this.error = e; })
} finally {
this.loading = false;
}
},
keyEvent(event) {
if (event.key === "Escape") {
// If we're on a listing, unselect all
// files and folders.
if (this.selectedCount > 0) {
this.selected = [];
}
}
},
toggleMultipleSelection() {
this.toggleMultiple();
},
isSingleFile: function () {
return (
this.selectedCount === 1 && !this.req.items[this.selected[0]].isDir
);
},
download() {
if (this.isSingleFile()) {
api.download(
null,
this.hash,
this.token,
this.req.items[this.selected[0]].path
);
return;
}
this.showHover({ </script>
prompt: "download",
confirm: (format) => {
this.closeHovers();
let files = [];
for (let i of this.selected) {
files.push(this.req.items[i].path);
}
api.download(format, this.hash, this.token, ...files);
},
});
},
linkSelected: function () {
return this.isSingleFile()
? api.getDownloadURL({
hash: this.hash,
path: this.req.items[this.selected[0]].path,
})
: "";
},
},
};
</script>

View File

@ -616,7 +616,7 @@ export default {
if (!items) return; if (!items) return;
let columns = Math.floor( let columns = Math.floor(
document.querySelector("main").offsetWidth / this.columnWidth document.querySelector("main")?.offsetWidth / this.columnWidth
); );
if (columns === 0) columns = 1; if (columns === 0) columns = 1;
items.style.width = `calc(${100 / columns}% - 1em)`; items.style.width = `calc(${100 / columns}% - 1em)`;