file uploads
This commit is contained in:
27
backend/Cargo.lock
generated
27
backend/Cargo.lock
generated
@@ -116,6 +116,7 @@ dependencies = [
|
|||||||
"matchit",
|
"matchit",
|
||||||
"memchr",
|
"memchr",
|
||||||
"mime",
|
"mime",
|
||||||
|
"multer",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
@@ -345,6 +346,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_filter"
|
name = "env_filter"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -958,6 +968,23 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multer"
|
||||||
|
version = "3.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"spin",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint-dig"
|
name = "num-bigint-dig"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { version = "0.8.4", features = ["query"] }
|
axum = { version = "0.8.4", features = ["multipart", "query"] }
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
log = "0.4.27"
|
log = "0.4.27"
|
||||||
|
|||||||
@@ -2,16 +2,33 @@ mod create_directory;
|
|||||||
mod delete_directory;
|
mod delete_directory;
|
||||||
mod get_warren_path;
|
mod get_warren_path;
|
||||||
mod list_warrens;
|
mod list_warrens;
|
||||||
|
mod upload_files;
|
||||||
|
|
||||||
use axum::routing::{delete, get, post};
|
use axum::{
|
||||||
|
extract::DefaultBodyLimit,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::server::Router;
|
use crate::server::Router;
|
||||||
|
|
||||||
pub(super) fn router() -> Router {
|
pub(super) fn router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_warrens::route))
|
.route("/", get(list_warrens::route))
|
||||||
.route("/{warren_id}", get(get_warren_path::route))
|
.route("/{warren_id}/get", get(get_warren_path::route))
|
||||||
.route("/{warren_id}/{*rest}", get(get_warren_path::route))
|
.route("/{warren_id}/get/{*rest}", get(get_warren_path::route))
|
||||||
.route("/{warren_id}/{*rest}", post(create_directory::route))
|
.route("/{warren_id}/create/{*rest}", post(create_directory::route))
|
||||||
.route("/{warren_id}/{*rest}", delete(delete_directory::route))
|
.route(
|
||||||
|
"/{warren_id}/delete/{*rest}",
|
||||||
|
delete(delete_directory::route),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{warren_id}/upload",
|
||||||
|
// 536870912 bytes = 0.5GB
|
||||||
|
post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/{warren_id}/upload/{*rest}",
|
||||||
|
// 536870912 bytes = 0.5GB
|
||||||
|
post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
34
backend/src/api/warrens/upload_files.rs
Normal file
34
backend/src/api/warrens/upload_files.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use axum::extract::{Multipart, Path, State};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{Result, api::AppState, warrens::Warren};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct UploadPath {
|
||||||
|
warren_id: Uuid,
|
||||||
|
rest: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn route(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(UploadPath { warren_id, rest }): Path<UploadPath>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<()> {
|
||||||
|
let warren = Warren::get(state.pool(), &warren_id).await?;
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
if field.name().is_none_or(|name| name != "files") {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_name = field.file_name().map(str::to_owned).unwrap();
|
||||||
|
let data = field.bytes().await.unwrap();
|
||||||
|
|
||||||
|
warren
|
||||||
|
.upload(state.serve_dir(), rest.as_deref(), &file_name, &data)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use tokio::fs;
|
use tokio::{fs, io::AsyncWriteExt};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
|
pub async fn write_file<P>(path: P, data: &[u8]) -> Result<()>
|
||||||
|
where
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.open(path)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
file.write_all(data).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_file<P>(path: P) -> Result<()>
|
pub async fn delete_file<P>(path: P) -> Result<()>
|
||||||
where
|
where
|
||||||
P: AsRef<Path>,
|
P: AsRef<Path>,
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Result,
|
Result,
|
||||||
fs::{DirectoryEntry, FileType, create_dir, delete_dir, delete_file, get_dir_entries},
|
fs::{
|
||||||
|
DirectoryEntry, FileType, create_dir, delete_dir, delete_file, get_dir_entries, write_file,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, FromRow)]
|
#[derive(Debug, Clone, Serialize, FromRow)]
|
||||||
@@ -64,6 +66,20 @@ impl Warren {
|
|||||||
FileType::Directory => delete_dir(path).await,
|
FileType::Directory => delete_dir(path).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn upload(
|
||||||
|
&self,
|
||||||
|
serve_path: &str,
|
||||||
|
rest_path: Option<&str>,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let rest = format!("{}/{file_name}", rest_path.unwrap_or(""));
|
||||||
|
|
||||||
|
let path = build_path(serve_path, &self.path, Some(&rest));
|
||||||
|
|
||||||
|
write_file(path, data).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> PathBuf {
|
fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> PathBuf {
|
||||||
@@ -72,7 +88,7 @@ fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> P
|
|||||||
final_path.push(warren_path.strip_prefix("/").unwrap_or(warren_path));
|
final_path.push(warren_path.strip_prefix("/").unwrap_or(warren_path));
|
||||||
|
|
||||||
if let Some(ref rest) = rest_path {
|
if let Some(ref rest) = rest_path {
|
||||||
final_path.push(rest);
|
final_path.push(rest.strip_prefix("/").unwrap_or(rest));
|
||||||
}
|
}
|
||||||
|
|
||||||
final_path
|
final_path
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.1",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
|
"byte-size": "^9.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.57",
|
"@iconify-json/lucide": "^1.2.57",
|
||||||
"@nuxtjs/color-mode": "^3.5.2",
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
|
"@types/byte-size": "^8.1.2",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"prettier": "^3.6.2",
|
"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=="],
|
"@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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|||||||
196
frontend/components/actions/UploadDialog.vue
Normal file
196
frontend/components/actions/UploadDialog.vue
Normal 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>
|
||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from '@/components/ui/breadcrumb';
|
} from '@/components/ui/breadcrumb';
|
||||||
|
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
|
||||||
|
import UploadDialog from '~/components/actions/UploadDialog.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
||||||
@@ -53,13 +56,18 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="route.path.startsWith('/warrens/')"
|
v-if="route.path.startsWith('/warrens/')"
|
||||||
class="ml-auto"
|
class="ml-auto flex flex-row-reverse items-center gap-2"
|
||||||
>
|
>
|
||||||
<CreateDirectoryDialog>
|
<CreateDirectoryDialog>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<Icon name="lucide:folder-plus" />
|
<Icon name="lucide:folder-plus" />
|
||||||
</Button>
|
</Button>
|
||||||
</CreateDirectoryDialog>
|
</CreateDirectoryDialog>
|
||||||
|
<UploadDialog>
|
||||||
|
<Button variant="outline" size="icon">
|
||||||
|
<Icon name="lucide:upload" />
|
||||||
|
</Button>
|
||||||
|
</UploadDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -26,8 +26,17 @@ export async function getWarrens(): Promise<Record<string, Warren>> {
|
|||||||
export async function getWarrenDirectory(
|
export async function getWarrenDirectory(
|
||||||
path: string
|
path: string
|
||||||
): Promise<DirectoryEntry[]> {
|
): 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[]>(
|
const { data: entries, error } = await useFetch<DirectoryEntry[]>(
|
||||||
getApiUrl(`warrens/${path}`),
|
getApiUrl(`warrens/${warrenId}/get${rest}`),
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
}
|
}
|
||||||
@@ -44,8 +53,17 @@ export async function createDirectory(
|
|||||||
path: string,
|
path: string,
|
||||||
directoryName: string
|
directoryName: string
|
||||||
): Promise<{ success: boolean }> {
|
): Promise<{ success: boolean }> {
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let [warrenId, rest] = splitOnce(path, '/');
|
||||||
|
|
||||||
|
if (rest == null) {
|
||||||
|
rest = '';
|
||||||
|
} else {
|
||||||
|
rest += '/';
|
||||||
|
}
|
||||||
|
|
||||||
const { status } = await useFetch(
|
const { status } = await useFetch(
|
||||||
getApiUrl(`warrens/${path}/${directoryName}`),
|
getApiUrl(`warrens/${warrenId}/create/${rest}${directoryName}`),
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}
|
}
|
||||||
@@ -74,8 +92,19 @@ export async function deleteWarrenEntry(
|
|||||||
directoryName: string,
|
directoryName: string,
|
||||||
fileType: FileType
|
fileType: FileType
|
||||||
): Promise<{ success: boolean }> {
|
): 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(
|
const { status } = await useFetch(
|
||||||
getApiUrl(`warrens/${path}/${directoryName}?fileType=${fileType}`),
|
getApiUrl(
|
||||||
|
`warrens/${warrenId}/delete${rest}/${directoryName}?fileType=${fileType}`
|
||||||
|
),
|
||||||
{
|
{
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}
|
}
|
||||||
@@ -99,3 +128,48 @@ export async function deleteWarrenEntry(
|
|||||||
});
|
});
|
||||||
return { success: true };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@pinia/nuxt": "^0.11.1",
|
"@pinia/nuxt": "^0.11.1",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
|
"byte-size": "^9.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.57",
|
"@iconify-json/lucide": "^1.2.57",
|
||||||
"@nuxtjs/color-mode": "^3.5.2",
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
|
"@types/byte-size": "^8.1.2",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"prettier": "^3.6.2"
|
"prettier": "^3.6.2"
|
||||||
}
|
}
|
||||||
|
|||||||
30
frontend/utils/icons.ts
Normal file
30
frontend/utils/icons.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -47,3 +47,16 @@ export function joinPaths(base: string, other: string): string {
|
|||||||
|
|
||||||
return base + other;
|
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)];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user