client refactor: use pagination in drive component
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/lint-backend Pipeline was successful
ci/woodpecker/push/lint-client Pipeline was successful
ci/woodpecker/push/lint-foundkey-js Pipeline was successful
ci/woodpecker/push/test Pipeline was successful

Squashed commit of the following:

commit 8636adab6455bea29659a6799a7f3aad9e7cc10d
Author: Johann150 <johann.galle@protonmail.com>
Date:   Mon Oct 17 22:53:24 2022 +0200

    fix: remove comment

commit 7ff8d45bfa2ed5c07c9a053e817604ef2eb115ad
Author: Johann150 <johann.galle@protonmail.com>
Date:   Mon Oct 17 21:55:48 2022 +0200

    fix paginations reloading

    The Pagination type actually specifies that just the params property
    should be a Ref.

commit 55fe9210c15785611603e3a7a2535ebf8008ea64
Author: Johann150 <johann.galle@protonmail.com>
Date:   Mon Oct 17 18:55:54 2022 +0200

    fix variable name

commit a464d1363bc8c62606a4d2acc148ce269973bede
Author: Johann150 <johann.galle@protonmail.com>
Date:   Sun Oct 16 22:36:11 2022 +0200

    fix: don't display empty drive message while loading

commit 52905b398f683ff3c71c2d5592851b2d2a428550
Author: Johann150 <johann.galle@protonmail.com>
Date:   Fri Oct 14 22:19:13 2022 +0200

    remove unavailable i18n strings

commit d491a71cbec05f991864a06b8e0001d40da006a3
Author: Johann150 <johann.galle@protonmail.com>
Date:   Fri Oct 14 22:18:42 2022 +0200

    client refactor: use pagination in drive component

    This majorly refactors the drive component to use the proper pagination
    component instead of reimplementing pagination.

    The drive component is also refactored to use ref sugar (i.e. $ref).
This commit is contained in:
Johann150 2022-10-17 22:58:12 +02:00
parent 04d4dd323f
commit f4ee8b321e
Signed by: Johann150
GPG key ID: 9EE6577A2A06F8F1
2 changed files with 184 additions and 250 deletions

View file

@ -28,7 +28,7 @@
</nav>
<div
ref="main" class="main"
:class="{ uploading: uploadings.length > 0, fetching }"
:class="{ uploading: uploadings.length > 0 }"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@ -36,51 +36,77 @@
@contextmenu.stop="onContextmenu"
>
<div ref="contents" class="contents">
<div v-show="folders.length > 0" ref="foldersContainer" class="folders">
<XFolder
v-for="(f, i) in folders"
:key="f.id"
v-anim="i"
class="folder"
:folder="f"
:select-mode="select === 'folder'"
:is-selected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder"
@move="move"
@upload="upload"
@removeFile="removeFile"
@removeFolder="removeFolder"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
</div>
<div v-show="files.length > 0" ref="filesContainer" class="files">
<XFile
v-for="(file, i) in files"
:key="file.id"
v-anim="i"
class="file"
:file="file"
:select-mode="select === 'file'"
:is-selected="selectedFiles.some(x => x.id === file.id)"
@chosen="chooseFile"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
</div>
<div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty">
<p v-if="draghover">{{ i18n.t('empty-draghover') }}</p>
<p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p>
<p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p>
<MkPagination
ref="foldersPaginationElem"
:pagination="foldersPagination"
class="folders"
@loaded="foldersLoading = false"
>
<template #empty>
<!--
Don't display anything here if there are no folders,
there is a separate check if both paginations are empty.
-->
{{ null }}
</template>
<template #default="{ items: folders }">
<XFolder
v-for="(f, i) in folders"
:key="f.id"
v-anim="i"
class="folder"
:folder="f"
:select-mode="select === 'folder'"
:is-selected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder"
@move="move"
@upload="upload"
@removeFile="removeFile"
@removeFolder="removeFolder"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
</template>
</MkPagination>
<MkPagination
ref="filesPaginationElem"
:pagination="filesPagination"
class="files"
@loaded="filesLoading = false"
>
<template #empty>
<!--
Don't display anything here if there are no files,
there is a separate check if both paginations are empty.
-->
{{ null }}
</template>
<template #default="{ items: files }">
<XFile
v-for="(file, i) in files"
:key="file.id"
v-anim="i"
class="file"
:file="file"
:select-mode="select === 'file'"
:is-selected="selectedFiles.some(x => x.id === file.id)"
@chosen="chooseFile"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
</template>
</MkPagination>
<div v-if="empty" class="empty">
<p v-if="folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong></p>
<p v-else>{{ i18n.ts.emptyFolder }}</p>
</div>
</div>
<MkLoading v-if="fetching"/>
</div>
<div v-if="draghover" class="dropzone"></div>
<input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
@ -88,12 +114,13 @@
</template>
<script lang="ts" setup>
import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, watch } from 'vue';
import * as foundkey from 'foundkey-js';
import XNavFolder from './drive.nav-folder.vue';
import XFolder from './drive.folder.vue';
import XFile from './drive.file.vue';
import MkButton from './ui/button.vue';
import MkPagination from './ui/pagination.vue';
import * as os from '@/os';
import { stream } from '@/stream';
import { defaultStore } from '@/store';
@ -118,42 +145,40 @@ const emit = defineEmits<{
(ev: 'open-folder', v: foundkey.entities.DriveFolder): void;
}>();
const loadMoreFiles = ref<InstanceType<typeof MkButton>>();
const fileInput = ref<HTMLInputElement>();
let foldersPaginationElem = $ref<InstanceType<typeof MkPagination>>();
let filesPaginationElem = $ref<InstanceType<typeof MkPagination>>();
let foldersLoading = $ref<boolean>(true);
let filesLoading = $ref<boolean>(true);
const empty = $computed(() => !foldersLoading && !filesLoading
&& foldersPaginationElem?.items.length === 0 && filesPaginationElem?.items.length === 0);
let fileInput = $ref<HTMLInputElement>();
const folder = ref<foundkey.entities.DriveFolder | null>(null);
const files = ref<foundkey.entities.DriveFile[]>([]);
const folders = ref<foundkey.entities.DriveFolder[]>([]);
const moreFiles = ref(false);
const moreFolders = ref(false);
const hierarchyFolders = ref<foundkey.entities.DriveFolder[]>([]);
const selectedFiles = ref<foundkey.entities.DriveFile[]>([]);
const selectedFolders = ref<foundkey.entities.DriveFolder[]>([]);
const uploadings = uploads;
const connection = stream.useChannel('drive');
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // $ref使
let folder = $ref<foundkey.entities.DriveFolder | null>(null);
let hierarchyFolders = $ref<foundkey.entities.DriveFolder[]>([]);
let selectedFiles = $ref<foundkey.entities.DriveFile[]>([]);
let selectedFolders = $ref<foundkey.entities.DriveFolder[]>([]);
let keepOriginal = $ref<boolean>(defaultStore.state.keepOriginalUploading);
//
const draghover = ref(false);
let draghover = $ref(false);
//
// ()
const isDragSource = ref(false);
let isDragSource = $ref(false);
const fetching = ref(true);
const ilFilesObserver = new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
);
watch(folder, () => emit('cd', folder.value));
watch($$(folder), () => emit('cd', folder));
function onStreamDriveFileCreated(file: foundkey.entities.DriveFile) {
addFile(file, true);
}
function onStreamDriveFileUpdated(file: foundkey.entities.DriveFile) {
const current = folder.value ? folder.value.id : null;
const current = folder?.id ?? null;
if (current !== file.folderId) {
removeFile(file);
} else {
@ -170,7 +195,7 @@ function onStreamDriveFolderCreated(createdFolder: foundkey.entities.DriveFolder
}
function onStreamDriveFolderUpdated(updatedFolder: foundkey.entities.DriveFolder) {
const current = folder.value ? folder.value.id : null;
const current = folder?.id ?? null;
if (current !== updatedFolder.parentId) {
removeFolder(updatedFolder);
} else {
@ -186,7 +211,7 @@ function onDragover(ev: DragEvent): any {
if (!ev.dataTransfer) return;
//
if (isDragSource.value) {
if (isDragSource) {
//
ev.dataTransfer.dropEffect = 'none';
return;
@ -205,22 +230,22 @@ function onDragover(ev: DragEvent): any {
}
function onDragenter() {
if (!isDragSource.value) draghover.value = true;
if (!isDragSource) draghover = true;
}
function onDragleave() {
draghover.value = false;
draghover = false;
}
function onDrop(ev: DragEvent): any {
draghover.value = false;
draghover = false;
if (!ev.dataTransfer) return;
//
if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) {
upload(file, folder.value);
upload(file, folder);
}
return;
}
@ -229,11 +254,14 @@ function onDrop(ev: DragEvent): any {
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
const file = JSON.parse(driveFile);
if (files.value.some(f => f.id === file.id)) return;
// cannot move file within parent folder
if (folder.id === file.folderId) return;
removeFile(file.id);
os.api('drive/files/update', {
fileId: file.id,
folderId: folder.value ? folder.value.id : null,
folderId: folder?.id ?? null,
});
}
//#endregion
@ -243,15 +271,15 @@ function onDrop(ev: DragEvent): any {
if (driveFolder != null && driveFolder !== '') {
const droppedFolder = JSON.parse(driveFolder);
// reject
if (folder.value && droppedFolder.id === folder.value.id) return false;
if (folders.value.some(f => f.id === droppedFolder.id)) return false;
// cannot move folder into itself
if (droppedFolder.id === folder?.id) return false;
// cannot move folder within parent folder
if (foldersPaginationElem.items.some(f => f.id === droppedFolder.id)) return false;
removeFolder(droppedFolder.id);
os.api('drive/folders/update', {
folderId: droppedFolder.id,
parentId: folder.value ? folder.value.id : null,
}).then(() => {
// noop
parentId: folder?.id ?? null,
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
@ -272,7 +300,7 @@ function onDrop(ev: DragEvent): any {
}
function selectLocalFile() {
fileInput.value?.click();
fileInput?.click();
}
function urlUpload() {
@ -284,7 +312,7 @@ function urlUpload() {
if (canceled || !url) return;
os.api('drive/files/upload-from-url', {
url,
folderId: folder.value ? folder.value.id : undefined,
folderId: folder?.id ?? undefined,
});
os.alert({
@ -302,7 +330,7 @@ function createFolder() {
if (canceled) return;
os.api('drive/folders/create', {
name,
parentId: folder.value ? folder.value.id : undefined,
parentId: folder?.id ?? undefined,
}).then(createdFolder => {
addFolder(createdFolder, true);
});
@ -351,57 +379,61 @@ function deleteFolder(folderToDelete: foundkey.entities.DriveFolder) {
}
function onChangeFileInput() {
if (!fileInput.value?.files) return;
for (const file of Array.from(fileInput.value.files)) {
upload(file, folder.value);
if (!fileInput?.files) return;
for (const file of Array.from(fileInput.files)) {
upload(file, folder);
}
}
function upload(file: File, folderToUpload?: foundkey.entities.DriveFolder | null) {
uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => {
uploadFile(file, (typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal).then(res => {
addFile(res, true);
});
}
function chooseFile(file: foundkey.entities.DriveFile) {
const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id);
const isAlreadySelected = selectedFiles.some(f => f.id === file.id);
if (props.multiple) {
if (isAlreadySelected) {
selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
selectedFiles = selectedFiles.filter(f => f.id !== file.id);
} else {
selectedFiles.value.push(file);
selectedFiles.push(file);
}
emit('change-selection', selectedFiles.value);
emit('change-selection', selectedFiles);
} else {
if (isAlreadySelected) {
emit('selected', file);
} else {
selectedFiles.value = [file];
selectedFiles = [file];
emit('change-selection', [file]);
}
}
}
function chooseFolder(folderToChoose: foundkey.entities.DriveFolder) {
const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id);
const isAlreadySelected = selectedFolders.some(f => f.id === folderToChoose.id);
if (props.multiple) {
if (isAlreadySelected) {
selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id);
selectedFolders = selectedFolders.filter(f => f.id !== folderToChoose.id);
} else {
selectedFolders.value.push(folderToChoose);
selectedFolders.push(folderToChoose);
}
emit('change-selection', selectedFolders.value);
emit('change-selection', selectedFolders);
} else {
if (isAlreadySelected) {
emit('selected', folderToChoose);
} else {
selectedFolders.value = [folderToChoose];
selectedFolders = [folderToChoose];
emit('change-selection', [folderToChoose]);
}
}
}
function move(target?: string | foundkey.entities.DriveFolder) {
// reset loading state
foldersLoading = true;
filesLoading = true;
if (!target) {
goRoot();
return;
@ -409,165 +441,92 @@ function move(target?: string | foundkey.entities.DriveFolder) {
const targetId = typeof target === 'string' ? target : target.id;
fetching.value = true;
os.api('drive/folders/show', {
folderId: targetId,
}).then(folderToMove => {
folder.value = folderToMove;
hierarchyFolders.value = [];
folder = folderToMove;
const dive = folderToDive => {
hierarchyFolders.value.unshift(folderToDive);
if (folderToDive.parent) dive(folderToDive.parent);
};
if (folderToMove.parent) dive(folderToMove.parent);
// display new folder hierarchy appropriately
hierarchyFolders = [];
let parent = folderToMove.parent;
while (parent) {
hierarchyFolders.unshift(parent);
parent = parent.parent;
}
emit('open-folder', folderToMove);
fetch();
});
}
function addFolder(folderToAdd: foundkey.entities.DriveFolder, unshift = false) {
const current = folder.value ? folder.value.id : null;
const current = folder?.id ?? null;
if (current !== folderToAdd.parentId) return;
if (folders.value.some(f => f.id === folderToAdd.id)) {
const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id);
folders.value[exist] = folderToAdd;
return;
}
if (unshift) {
folders.value.unshift(folderToAdd);
const exist = foldersPaginationElem.items.some(f => f.id === folderToAdd.id);
if (exist) {
foldersPaginationElem.updateItem(folderToAdd.id, () => folderToAdd);
} else if (unshift) {
foldersPaginationElem.prepend(folderToAdd);
} else {
folders.value.push(folderToAdd);
foldersPaginationElem.append(folderToAdd);
}
}
function addFile(fileToAdd: foundkey.entities.DriveFile, unshift = false) {
const current = folder.value ? folder.value.id : null;
const current = folder?.id ?? null;
if (current !== fileToAdd.folderId) return;
if (files.value.some(f => f.id === fileToAdd.id)) {
const exist = files.value.map(f => f.id).indexOf(fileToAdd.id);
files.value[exist] = fileToAdd;
return;
}
if (unshift) {
files.value.unshift(fileToAdd);
const exist = filesPaginationElem.items.some(f => f.id === fileToAdd.id);
if (exist) {
filesPaginationElem.updateItem(fileToAdd.id, () => fileToAdd);
} else if (unshift) {
filesPaginationElem.prepend(fileToAdd);
} else {
files.value.push(fileToAdd);
filesPaginationElem.append(fileToAdd);
}
}
function removeFolder(folderToRemove: foundkey.entities.DriveFolder | string) {
function removeFolder(folderToRemove: foundkey.entities.DriveFolder | string): void {
const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove;
folders.value = folders.value.filter(f => f.id !== folderIdToRemove);
foldersPaginationElem.removeItem(item => item.id === folderIdToRemove);
}
function removeFile(file: foundkey.entities.DriveFile | string) {
const fileId = typeof file === 'object' ? file.id : file;
files.value = files.value.filter(f => f.id !== fileId);
function removeFile(fileToRemove: foundkey.entities.DriveFile | string): void {
const fileIdToRemove = typeof fileToRemove === 'object' ? fileToRemove.id : fileToRemove;
filesPaginationElem.removeItem(item => item.id === fileIdToRemove);
}
function appendFile(file: foundkey.entities.DriveFile) {
addFile(file);
}
function appendFolder(folderToAppend: foundkey.entities.DriveFolder) {
addFolder(folderToAppend);
}
/*
function prependFile(file: foundkey.entities.DriveFile) {
addFile(file, true);
}
function prependFolder(folderToPrepend: foundkey.entities.DriveFolder) {
addFolder(folderToPrepend, true);
}
*/
function goRoot() {
// root
if (folder.value == null) return;
// do nothing if already at root
if (folder == null) return;
folder.value = null;
hierarchyFolders.value = [];
folder = null;
hierarchyFolders = [];
emit('move-root');
fetch();
}
async function fetch() {
folders.value = [];
files.value = [];
moreFolders.value = false;
moreFiles.value = false;
fetching.value = true;
const foldersPagination = {
endpoint: 'drive/folders' as const,
limit: 30,
params: computed(() => ({
folderId: folder?.id ?? null,
})),
};
const foldersMax = 30;
const filesMax = 30;
const foldersPromise = os.api('drive/folders', {
folderId: folder.value ? folder.value.id : null,
limit: foldersMax + 1,
}).then(fetchedFolders => {
if (fetchedFolders.length === foldersMax + 1) {
moreFolders.value = true;
fetchedFolders.pop();
}
return fetchedFolders;
});
const filesPromise = os.api('drive/files', {
folderId: folder.value ? folder.value.id : null,
const filesPagination = {
endpoint: 'drive/files' as const,
limit: 30,
params: computed(() => ({
folderId: folder?.id ?? null,
type: props.type,
limit: filesMax + 1,
}).then(fetchedFiles => {
if (fetchedFiles.length === filesMax + 1) {
moreFiles.value = true;
fetchedFiles.pop();
}
return fetchedFiles;
});
const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]);
for (const x of fetchedFolders) appendFolder(x);
for (const x of fetchedFiles) appendFile(x);
fetching.value = false;
}
function fetchMoreFiles() {
fetching.value = true;
const max = 30;
//
os.api('drive/files', {
folderId: folder.value ? folder.value.id : null,
type: props.type,
untilId: files.value[files.value.length - 1].id,
limit: max + 1,
}).then(files => {
if (files.length === max + 1) {
moreFiles.value = true;
files.pop();
} else {
moreFiles.value = false;
}
for (const x of files) appendFile(x);
fetching.value = false;
});
}
})),
};
function getMenu() {
return [{
type: 'switch',
text: i18n.ts.keepOriginalUploading,
ref: keepOriginal,
ref: $$(keepOriginal),
}, null, {
text: i18n.ts.addFile,
type: 'label',
@ -580,16 +539,16 @@ function getMenu() {
icon: 'fas fa-link',
action: () => { urlUpload(); },
}, null, {
text: folder.value ? folder.value.name : i18n.ts.drive,
text: folder?.name ?? i18n.ts.drive,
type: 'label',
}, folder.value ? {
}, folder != null ? {
text: i18n.ts.renameFolder,
icon: 'fas fa-i-cursor',
action: () => { renameFolder(folder.value); },
} : undefined, folder.value ? {
action: () => { renameFolder(folder); },
} : undefined, folder != null ? {
text: i18n.ts.deleteFolder,
icon: 'fas fa-trash-alt',
action: () => { deleteFolder(folder.value as foundkey.entities.DriveFolder); },
action: () => { deleteFolder(folder as foundkey.entities.DriveFolder); },
} : undefined, {
text: i18n.ts.createFolder,
icon: 'fas fa-folder-plus',
@ -606,12 +565,6 @@ function onContextmenu(ev: MouseEvent) {
}
onMounted(() => {
if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
nextTick(() => {
ilFilesObserver.observe(loadMoreFiles.value?.$el);
});
}
connection.on('fileCreated', onStreamDriveFileCreated);
connection.on('fileUpdated', onStreamDriveFileUpdated);
connection.on('fileDeleted', onStreamDriveFileDeleted);
@ -621,22 +574,11 @@ onMounted(() => {
if (props.initialFolder) {
move(props.initialFolder);
} else {
fetch();
}
});
onActivated(() => {
if (defaultStore.state.enableInfiniteScroll) {
nextTick(() => {
ilFilesObserver.observe(loadMoreFiles.value?.$el);
});
}
});
onBeforeUnmount(() => {
connection.dispose();
ilFilesObserver.disconnect();
});
</script>
@ -718,18 +660,6 @@ onBeforeUnmount(() => {
user-select: none;
}
&.fetching {
cursor: wait !important;
* {
pointer-events: none;
}
> .contents {
opacity: 0.5;
}
}
&.uploading {
height: calc(100% - 38px - 100px);
}

View file

@ -70,6 +70,8 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'queue', count: number): void;
(ev: 'loaded'): void;
(ev: 'error'): void;
}>();
type Item = { id: string; [another: string]: unknown; };
@ -105,9 +107,11 @@ const init = async (): Promise<void> => {
offset.value = res.length;
error.value = false;
fetching.value = false;
emit('loaded');
}, () => {
error.value = true;
fetching.value = false;
emit('error');
});
};