basic selection + download multiple files with selection

This commit is contained in:
2025-09-02 18:08:13 +02:00
parent be46f92ddf
commit e2085c1baa
22 changed files with 516 additions and 156 deletions

View File

@@ -8,19 +8,23 @@ import {
} from '@/components/ui/context-menu';
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
import type { DirectoryEntry } from '#shared/types';
import { toast } from 'vue-sonner';
const warrenStore = useWarrenStore();
const copyStore = useCopyStore();
const renameDialog = useRenameDirectoryDialog();
const { entry, disabled } = defineProps<{
const {
entry,
disabled,
draggable = true,
} = defineProps<{
entry: DirectoryEntry;
disabled: boolean;
draggable?: boolean;
}>();
const emit = defineEmits<{
'entry-click': [entry: DirectoryEntry];
'entry-click': [entry: DirectoryEntry, event: MouseEvent];
'entry-download': [entry: DirectoryEntry];
}>();
@@ -33,6 +37,7 @@ const isCopied = computed(
warrenStore.current.path === copyStore.file.path &&
entry.name === copyStore.file.name
);
const isSelected = computed(() => warrenStore.isSelected(entry));
async function submitDelete(force: boolean = false) {
if (warrenStore.current == null) {
@@ -63,8 +68,8 @@ async function openRenameDialog() {
renameDialog.openDialog(entry);
}
async function onClick() {
emit('entry-click', entry);
function onClick(event: MouseEvent) {
emit('entry-click', entry, event);
}
function onDragStart(e: DragEvent) {
@@ -97,6 +102,10 @@ function onShare() {
function onDownload() {
emit('entry-download', entry);
}
function onClearCopy() {
copyStore.clearFile();
}
</script>
<template>
@@ -105,10 +114,11 @@ function onDownload() {
<button
:disabled="warrenStore.loading || disabled"
:class="[
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 outline-0 select-none',
isCopied && 'border-primary/50 border',
isSelected && 'bg-primary/20',
]"
draggable="true"
:draggable
@dragstart="onDragStart"
@drop="onDrop"
@click="onClick"
@@ -136,13 +146,25 @@ function onDownload() {
<Icon name="lucide:pencil" />
Rename
</ContextMenuItem>
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="onCopy"
>
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
<template v-if="warrenStore.current != null">
<ContextMenuItem
v-if="
copyStore.file == null ||
copyStore.file.warrenId !==
warrenStore.current.warrenId ||
copyStore.file.path !== warrenStore.current.path ||
copyStore.file.name !== entry.name
"
@select="onCopy"
>
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
<ContextMenuItem v-else @select="onClearCopy">
<Icon name="lucide:copy-x" />
Clear clipboard
</ContextMenuItem>
</template>
<ContextMenuItem @select="onDownload">
<Icon name="lucide:download" />
Download

View File

@@ -33,10 +33,10 @@ const warrenStore = useWarrenStore();
:data="
route.meta.layout === 'share'
? getApiUrl(
`warrens/files/cat_share?shareId=${route.query.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
`warrens/files/cat_share?shareId=${route.query.id}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
)
: getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
)
"
>

View File

@@ -7,15 +7,17 @@ const {
parent,
isOverDropZone,
disableEntries = false,
entriesDraggable = true,
} = defineProps<{
entries: DirectoryEntry[];
parent: DirectoryEntry | null;
isOverDropZone?: boolean;
disableEntries?: boolean;
entriesDraggable?: boolean;
}>();
const emit = defineEmits<{
'entry-click': [entry: DirectoryEntry];
'entry-click': [entry: DirectoryEntry, event: MouseEvent];
'entry-download': [entry: DirectoryEntry];
back: [];
}>();
@@ -26,8 +28,8 @@ const sortedEntries = computed(() =>
entries.toSorted((a, b) => a.name.localeCompare(b.name))
);
function onEntryClicked(entry: DirectoryEntry) {
emit('entry-click', entry);
function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
emit('entry-click', entry, event);
}
function onEntryDownload(entry: DirectoryEntry) {
@@ -56,6 +58,7 @@ function onEntryDownload(entry: DirectoryEntry) {
:key="entry.name"
:entry="entry"
:disabled="isLoading || disableEntries"
:draggable="entriesDraggable"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
/>

View File

@@ -27,12 +27,16 @@ async function onPaste() {
pasting.value = true;
await pasteFile(copyStore.file!, {
const success = await pasteFile(copyStore.file!, {
warrenId: warrenStore.current!.warrenId,
name: copyStore.file!.name,
path: warrenStore.current!.path,
});
if (success) {
copyStore.clearFile();
}
pasting.value = false;
}
</script>

View File

@@ -31,6 +31,34 @@ await useAsyncData('warrens', async () => {
</div>
<div class="ml-auto flex flex-row items-center gap-2">
<Button
variant="outline"
size="icon"
:disabled="
store.current != null &&
store.current.dir != null &&
store.selection.size >=
store.current.dir.entries.length
"
@click="
() =>
store.current != null &&
store.current.dir != null &&
store.addMultipleToSelection(
store.current.dir.entries
)
"
>
<Icon name="lucide:list" />
</Button>
<Button
variant="outline"
size="icon"
:disabled="store.selection.size < 1"
@click="() => store.clearSelection()"
>
<Icon name="lucide:list-x" />
</Button>
<Separator
orientation="vertical"
class="mr-2 hidden !h-4 md:block"

View File

@@ -149,7 +149,7 @@ export async function fetchShareFile(
password: string | null
): Promise<{ success: true; data: Blob } | { success: false }> {
const { data } = await useFetch<Blob>(
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&path=${path}`),
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&paths=${path}`),
{
method: 'GET',
headers:

View File

@@ -273,14 +273,27 @@ export async function fetchFile(
warrenId: string,
path: string,
fileName: string
): Promise<{ success: true; data: Blob } | { success: false }> {
return fetchFiles(warrenId, path, [fileName]);
}
export async function fetchFiles(
warrenId: string,
path: string,
fileNames: string[]
): Promise<{ success: true; data: Blob } | { success: false }> {
if (!path.endsWith('/')) {
path += '/';
}
path += fileName;
const paths = [];
for (const fileName of fileNames) {
paths.push(path + fileName);
}
const { data, error } = await useFetch<Blob>(
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&path=${path}`),
getApiUrl(
`warrens/files/cat?warrenId=${warrenId}&paths=${paths.join(':')}`
),
{
method: 'GET',
headers: getApiHeaders(),
@@ -314,7 +327,7 @@ export async function fetchFileStream(
path += fileName;
const { data, error } = await useFetch<ReadableStream<Uint8Array>>(
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&path=${path}`),
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&paths=${path}`),
{
method: 'GET',
headers: getApiHeaders(),

View File

@@ -10,11 +10,23 @@ definePageMeta({
const warrenStore = useWarrenStore();
const route = useRoute();
const entries = computed(() =>
warrenStore.current != null && warrenStore.current.dir != null
? warrenStore.current.dir.entries
: null
);
const parent = computed(() =>
warrenStore.current != null && warrenStore.current.dir != null
? warrenStore.current.dir.parent
: null
);
const share = await getShareFromQuery();
const entries = ref<DirectoryEntry[] | null>(null);
const parent = ref<DirectoryEntry | null>(null);
const password = ref<string>('');
const loading = ref<boolean>(false);
const passwordValid = ref<boolean>(
share == null ? false : !share.data.password
);
if (share != null) {
warrenStore.setCurrentWarren(share.data.warrenId, '/');
@@ -65,6 +77,7 @@ async function loadFiles() {
);
if (result.success) {
passwordValid.value = true;
let cookie = `X-Share-Password=${password.value}; Path=/; SameSite=Lax; Secure;`;
if (share.data.expiresAt != null) {
@@ -76,18 +89,22 @@ async function loadFiles() {
document.cookie = cookie;
entries.value = result.files;
parent.value = result.parent;
warrenStore.setCurrentWarrenEntries(result.files, result.parent);
}
loading.value = false;
}
async function onEntryClicked(entry: DirectoryEntry) {
async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
if (warrenStore.current == null) {
return;
}
if (event.ctrlKey) {
warrenStore.toggleSelection(entry);
return;
}
const entryPath = joinPaths(warrenStore.current.path, entry.name);
if (entry.fileType === 'directory') {
@@ -130,7 +147,7 @@ function onDowloadClicked() {
? `${share.file.name}.zip`
: share.file.name;
const downloadApiUrl = getApiUrl(
`warrens/files/cat_share?shareId=${share.data.id}&path=/`
`warrens/files/cat_share?shareId=${share.data.id}&paths=/`
);
downloadFile(downloadName, downloadApiUrl);
@@ -141,11 +158,27 @@ function onEntryDownload(entry: DirectoryEntry) {
return;
}
const downloadName =
entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name;
const downloadApiUrl = getApiUrl(
`warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
);
let downloadName: string;
let downloadApiUrl: string;
const selectionSize = warrenStore.selection.size;
if (selectionSize === 0 || !warrenStore.isSelected(entry)) {
downloadName =
entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name;
downloadApiUrl = getApiUrl(
`warrens/files/cat_share?shareId=${share.data.id}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
);
} else {
downloadName = 'download.zip';
const paths = Array.from(warrenStore.selection).map((entry) =>
joinPaths(warrenStore.current!.path, entry.name)
);
downloadApiUrl = getApiUrl(
`warrens/files/cat_share?shareId=${share.data.id}&paths=${paths.join(':')}`
);
}
downloadFile(downloadName, downloadApiUrl);
}
@@ -158,8 +191,8 @@ function onEntryDownload(entry: DirectoryEntry) {
>
<div
:class="[
'w-full rounded-lg border transition-all',
entries == null ? 'max-w-lg' : 'max-w-screen-xl',
'h-[min(98vh,600px)] w-full max-w-screen-xl rounded-lg border transition-all',
passwordValid ? 'max-w-screen-xl' : 'max-w-lg',
]"
>
<div
@@ -205,6 +238,7 @@ function onEntryDownload(entry: DirectoryEntry) {
:entries
:parent
:disable-entries="loading"
:entries-draggable="false"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
@back="onBack"

View File

@@ -28,7 +28,7 @@ if (warrenStore.current == null) {
});
}
const dirData = useAsyncData(
useAsyncData(
'current-directory',
async () => {
if (warrenStore.current == null) {
@@ -49,10 +49,10 @@ const dirData = useAsyncData(
warrenStore.loading = false;
loadingIndicator.finish();
return { files, parent };
warrenStore.setCurrentWarrenEntries(files, parent);
},
{ watch: [warrenPath] }
).data;
);
function onDrop(files: File[] | null, e: DragEvent) {
if (files == null) {
@@ -79,11 +79,16 @@ function onDrop(files: File[] | null, e: DragEvent) {
}
}
async function onEntryClicked(entry: DirectoryEntry) {
async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
if (warrenStore.loading || warrenStore.current == null) {
return;
}
if (event.ctrlKey) {
warrenStore.toggleSelection(entry);
return;
}
if (entry.fileType === 'directory') {
warrenStore.addToCurrentWarrenPath(entry.name);
return;
@@ -111,11 +116,27 @@ function onEntryDownload(entry: DirectoryEntry) {
return;
}
const downloadName =
entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name;
const downloadApiUrl = getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
);
let downloadName: string;
let downloadApiUrl: string;
const selectionSize = warrenStore.selection.size;
if (selectionSize === 0 || !warrenStore.isSelected(entry)) {
downloadName =
entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name;
downloadApiUrl = getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
);
} else {
downloadName = 'download.zip';
const paths = Array.from(warrenStore.selection).map((entry) =>
joinPaths(warrenStore.current!.path, entry.name)
);
downloadApiUrl = getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&paths=${paths.join(':')}`
);
}
downloadFile(downloadName, downloadApiUrl);
}
@@ -129,13 +150,16 @@ function onBack() {
<div ref="dropZoneRef" class="grow">
<DirectoryListContextMenu class="w-full grow">
<DirectoryList
v-if="dirData != null"
v-if="
warrenStore.current != null &&
warrenStore.current.dir != null
"
:is-over-drop-zone="
dropZone.isOverDropZone.value &&
dropZone.files.value != null
"
:entries="dirData.files"
:parent="dirData.parent"
:entries="warrenStore.current.dir.entries"
:parent="warrenStore.current.dir.parent"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
@back="onBack"

View File

@@ -9,7 +9,15 @@ export const useWarrenStore = defineStore('warrens', {
imageViewer: {
src: null as string | null,
},
current: null as { warrenId: string; path: string } | null,
current: null as {
warrenId: string;
path: string;
dir: {
parent: DirectoryEntry | null;
entries: DirectoryEntry[];
} | null;
} | null,
selection: new Set() as Set<DirectoryEntry>,
loading: false,
}),
actions: {
@@ -17,6 +25,21 @@ export const useWarrenStore = defineStore('warrens', {
this.current = {
warrenId,
path,
dir: null,
};
this.clearSelection();
},
setCurrentWarrenEntries(
entries: DirectoryEntry[],
parent: DirectoryEntry | null
) {
if (this.current == null) {
return;
}
this.current.dir = {
entries,
parent,
};
},
addToCurrentWarrenPath(path: string) {
@@ -28,14 +51,14 @@ export const useWarrenStore = defineStore('warrens', {
path = '/' + path;
}
this.current.path += path;
this.setCurrentWarrenPath(this.current.path + path);
},
backCurrentPath(): boolean {
if (this.current == null || this.current.path === '/') {
return false;
}
this.current.path = getParentPath(this.current.path);
this.setCurrentWarrenPath(getParentPath(this.current.path));
return true;
},
@@ -44,15 +67,50 @@ export const useWarrenStore = defineStore('warrens', {
return;
}
const previous = this.current.path;
if (!path.startsWith('/')) {
path = '/' + path;
}
this.current.path = path;
if (previous !== path) {
this.clearSelection();
this.current.dir = null;
}
},
clearCurrentWarren() {
this.current = null;
},
addToSelection(entry: DirectoryEntry) {
this.selection.add(entry);
},
addMultipleToSelection(entries: DirectoryEntry[]) {
for (const entry of entries) {
this.selection.add(entry);
}
},
removeFromSelection(entry: DirectoryEntry): boolean {
return this.selection.delete(entry);
},
toggleSelection(entry: DirectoryEntry) {
if (this.selection.has(entry)) {
this.removeFromSelection(entry);
} else {
this.addToSelection(entry);
}
},
isSelected(entry: DirectoryEntry): boolean {
if (this.current == null) {
return false;
}
return this.selection.has(entry);
},
clearSelection() {
this.selection.clear();
},
},
});