file uploads

This commit is contained in:
2025-07-13 07:17:40 +02:00
parent 11105c074f
commit 548dd7e9ef
14 changed files with 451 additions and 13 deletions

View File

@@ -12,6 +12,7 @@
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"byte-size": "^9.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.0.0",
@@ -30,6 +31,7 @@
"devDependencies": {
"@iconify-json/lucide": "^1.2.57",
"@nuxtjs/color-mode": "^3.5.2",
"@types/byte-size": "^8.1.2",
"eslint-config-prettier": "^10.1.5",
"prettier": "^3.6.2",
},
@@ -490,6 +492,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
"@types/byte-size": ["@types/byte-size@8.1.2", "", {}, "sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -676,6 +680,8 @@
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"byte-size": ["byte-size@9.0.1", "", { "peerDependencies": { "@75lb/nature": "latest" }, "optionalPeers": ["@75lb/nature"] }, "sha512-YLe9x3rabBrcI0cueCdLS2l5ONUKywcRpTs02B8KP9/Cimhj7o3ZccGrPnRvcbyHMbb7W79/3MUJl7iGgTXKEw=="],
"c12": ["c12@3.0.4", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.5.0", "exsolve": "^1.0.5", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.1.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-t5FaZTYbbCtvxuZq9xxIruYydrAGsJ+8UdP0pZzMiK2xl/gNiSOy0OxhLzHUEEb0m1QXYqfzfvyIFEmz/g9lqg=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import byteSize from 'byte-size';
import { uploadToWarren } from '~/lib/api/warrens';
const warrenRoute = useWarrenRoute();
const fileInputElement = ref<HTMLInputElement>(
null as unknown as HTMLInputElement
);
const uploading = ref(false);
const open = ref(false);
const files = ref<Array<{ uploaded: boolean; data: File }>>([]);
const presentPaths = new Set<string>();
function onFilesChanged(event: Event) {
if (event.target == null) {
return;
}
const target = event.target as HTMLInputElement;
if (target.files == null) {
return;
}
for (const file of target.files) {
if (presentPaths.has(file.name)) {
continue;
}
files.value.push({ uploaded: false, data: file });
presentPaths.add(file.name);
}
fileInputElement.value.value = '';
}
async function submit() {
if (files.value.length < 1) {
return;
}
uploading.value = true;
const dt = new DataTransfer();
for (const file of files.value) {
if (!file.uploaded) {
dt.items.add(file.data);
}
}
const { success } = await uploadToWarren(warrenRoute.value, dt.files);
uploading.value = false;
if (success) {
for (const file of files.value) {
file.uploaded = true;
}
}
}
function removeFile(index: number) {
if (files.value.length <= index) {
return;
}
const [file] = files.value.splice(index, 1);
presentPaths.delete(file.data.name);
}
function clearCompletedFiles() {
files.value = files.value.filter((f) => {
if (f.uploaded) {
presentPaths.delete(f.data.name);
return false;
}
return true;
});
}
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger as-child>
<slot />
</DialogTrigger>
<DialogContent class="!min-w-[min(50%,98vw)] sm:!min-h-[unset]">
<DialogHeader>
<DialogTitle>Upload files</DialogTitle>
<DialogDescription
>Upload files to the current directory</DialogDescription
>
</DialogHeader>
<input
ref="fileInputElement"
class="hidden"
type="file"
multiple="true"
@change="onFilesChanged"
/>
<div
class="flex min-h-[280px] sm:min-h-[unset] sm:aspect-video w-full items-center justify-center rounded-xl border overflow-hidden"
>
<ScrollArea v-if="files.length > 0" class="h-full w-full">
<div
class="flex h-full w-full flex-col items-stretch gap-1 text-left p-2"
>
<div
v-for="(file, i) in files"
:key="file.data.name"
class="flex flex-row items-center justify-between gap-4 rounded-lg border px-4 py-2"
>
<div class="rounded-sm border p-3">
<Icon
v-if="!file.uploaded"
class="h-5 w-5"
:name="getFileIcon(file.data.type)"
/>
<Icon
v-else
class="h-5 w-5 text-green-300"
name="lucide:circle-check"
/>
</div>
<div class="flex flex-col grow overflow-hidden">
<span class="font-medium truncate">{{
file.data.name
}}</span>
<span class="text-muted-foreground">{{
byteSize(file.data.size)
}}</span>
</div>
<div class="flex flex-row gap-2 items-center">
<Button
variant="ghost"
size="icon"
:disabled="uploading"
@click="() => removeFile(i)"
>
<Icon name="lucide:x" />
</Button>
</div>
</div>
</div>
</ScrollArea>
<div
v-else
class="flex h-full w-full items-center justify-center"
:disabled="fileInputElement == null"
@click="() => files.length < 1 && fileInputElement?.click()"
>
<Icon
class="h-[25%] w-[25%] opacity-25"
name="lucide:upload"
/>
</div>
</div>
<DialogFooter>
<Button
class="sm:mr-auto"
variant="outline"
:disabled="files.every((f) => !f.uploaded)"
@click="clearCompletedFiles"
>
Clear completed
</Button>
<Button
variant="outline"
:disabled="fileInputElement == null || uploading"
@click="() => fileInputElement?.click()"
>
Select files
</Button>
<Button
:disabled="uploading || !files.some((f) => !f.uploaded)"
@click="submit"
>Upload</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -8,6 +8,9 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
import UploadDialog from '~/components/actions/UploadDialog.vue';
const route = useRoute();
const breadcrumbs = computed(() => getBreadcrumbs(route.path));
@@ -53,13 +56,18 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
<div
v-if="route.path.startsWith('/warrens/')"
class="ml-auto"
class="ml-auto flex flex-row-reverse items-center gap-2"
>
<CreateDirectoryDialog>
<Button variant="outline" size="icon">
<Icon name="lucide:folder-plus" />
</Button>
</CreateDirectoryDialog>
<UploadDialog>
<Button variant="outline" size="icon">
<Icon name="lucide:upload" />
</Button>
</UploadDialog>
</div>
</div>
</header>

View File

@@ -26,8 +26,17 @@ export async function getWarrens(): Promise<Record<string, Warren>> {
export async function getWarrenDirectory(
path: string
): Promise<DirectoryEntry[]> {
// eslint-disable-next-line prefer-const
let [warrenId, rest] = splitOnce(path, '/');
if (rest == null) {
rest = '';
} else {
rest = '/' + rest;
}
const { data: entries, error } = await useFetch<DirectoryEntry[]>(
getApiUrl(`warrens/${path}`),
getApiUrl(`warrens/${warrenId}/get${rest}`),
{
method: 'GET',
}
@@ -44,8 +53,17 @@ export async function createDirectory(
path: string,
directoryName: string
): Promise<{ success: boolean }> {
// eslint-disable-next-line prefer-const
let [warrenId, rest] = splitOnce(path, '/');
if (rest == null) {
rest = '';
} else {
rest += '/';
}
const { status } = await useFetch(
getApiUrl(`warrens/${path}/${directoryName}`),
getApiUrl(`warrens/${warrenId}/create/${rest}${directoryName}`),
{
method: 'POST',
}
@@ -74,8 +92,19 @@ export async function deleteWarrenEntry(
directoryName: string,
fileType: FileType
): Promise<{ success: boolean }> {
// eslint-disable-next-line prefer-const
let [warrenId, rest] = splitOnce(path, '/');
if (rest == null) {
rest = '';
} else {
rest = '/' + rest;
}
const { status } = await useFetch(
getApiUrl(`warrens/${path}/${directoryName}?fileType=${fileType}`),
getApiUrl(
`warrens/${warrenId}/delete${rest}/${directoryName}?fileType=${fileType}`
),
{
method: 'DELETE',
}
@@ -99,3 +128,48 @@ export async function deleteWarrenEntry(
});
return { success: true };
}
export async function uploadToWarren(
path: string,
files: FileList
): Promise<{ success: boolean }> {
const body = new FormData();
for (const file of files) {
body.append('files', file);
}
// eslint-disable-next-line prefer-const
let [warrenId, rest] = splitOnce(path, '/');
if (rest == null) {
rest = '';
} else {
rest = '/' + rest;
}
const { status } = await useFetch(
getApiUrl(`warrens/${warrenId}/upload${rest}`),
{
method: 'POST',
body,
key: 'upload-' + new Date().getTime().toString(),
}
);
if (status.value !== 'success') {
toast.error('Upload', {
id: 'UPLOAD_FILE_TOAST',
description: `Failed to upload`,
});
return { success: false };
}
await refreshNuxtData('current-directory');
toast.success('Upload', {
id: 'UPLOAD_FILE_TOAST',
description: `Successfully uploaded ${files.length} file${files.length !== 1 ? 's' : ''}`,
});
return { success: true };
}

View File

@@ -19,6 +19,7 @@
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"byte-size": "^9.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9.0.0",
@@ -37,6 +38,7 @@
"devDependencies": {
"@iconify-json/lucide": "^1.2.57",
"@nuxtjs/color-mode": "^3.5.2",
"@types/byte-size": "^8.1.2",
"eslint-config-prettier": "^10.1.5",
"prettier": "^3.6.2"
}

30
frontend/utils/icons.ts Normal file
View File

@@ -0,0 +1,30 @@
export function getFileIcon(fileType: string) {
if (fileType.startsWith('image/')) {
return 'lucide:file-image';
}
if (fileType.startsWith('video/')) {
return 'lucide:file-video-2';
}
if (fileType.startsWith('audio/')) {
return 'lucide:file-audio-2';
}
if (fileType.startsWith('application/')) {
if (fileType === 'application/x-msdownload') {
return 'lucide:file-box';
}
if (fileType === 'application/json') {
return 'lucide:file-json';
}
if (fileType === 'application/pdf') {
return 'lucide:file-text';
}
}
if (fileType === 'text/html') {
return 'lucide:file-code';
}
return 'lucide:file';
}

View File

@@ -47,3 +47,16 @@ export function joinPaths(base: string, other: string): string {
return base + other;
}
export function splitOnce(
str: string,
search: string
): [string, string | null] {
const index = str.indexOf(search);
if (index === -1) {
return [str, null];
}
return [str.slice(0, index), str.slice(index + 1)];
}