Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 40 additions & 40 deletions apps/server/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,41 @@
export default defineAppConfig({
ui: {
primary: 'white',
gray: 'zinc',
button: {
color: {
gray: {
ghost: 'text-gray-400 bg-gray-900 ring-1 ring-gray-400 rounded-full hover:ring-gray-200 hover:text-gray-200 hover:bg-gray-900 transition-hover duration-200'
},
green: {
ghost: 'text-green-400 bg-gray-900 ring-1 ring-green-400 rounded-full hover:ring-green-300 hover:text-green-300 hover:bg-gray-900 transition-hover duration-200'
},
red: {
ghost: 'text-red-400 bg-gray-900 ring-1 ring-red-400 rounded-full hover:ring-red-300 hover:text-red-300 hover:bg-gray-900 transition-hover duration-200'
}
}
},
tooltip: {
background: 'bg-gray-900',
color: 'text-gray-200',
ring: 'ring-1 ring-gray-800'
},
slideover: {
background: '',
base: 'flex-1 flex flex-col w-full focus:outline-none',
overlay: {
background: 'bg-gray-200/75 dark:bg-gray-800/50 backdrop-blur-md'
}
},
range: {
thumb: {
color: 'dark:text-gray-100'
},
progress: {
color: 'dark:text-gray-100',
rounded: 'rounded-s-lg',
background: 'bg-gray-500 dark:bg-gray-100'
}
}
}
})
// ui: {
// primary: 'white',
// gray: 'zinc',
// button: {
// color: {
// gray: {
// ghost: 'text-gray-400 bg-gray-900 ring-1 ring-gray-400 rounded-full hover:ring-gray-200 hover:text-gray-200 hover:bg-gray-900 transition-hover duration-200'
// },
// green: {
// ghost: 'text-green-400 bg-gray-900 ring-1 ring-green-400 rounded-full hover:ring-green-300 hover:text-green-300 hover:bg-gray-900 transition-hover duration-200'
// },
// red: {
// ghost: 'text-red-400 bg-gray-900 ring-1 ring-red-400 rounded-full hover:ring-red-300 hover:text-red-300 hover:bg-gray-900 transition-hover duration-200'
// }
// }
// },
// tooltip: {
// background: 'bg-gray-900',
// color: 'text-gray-200',
// ring: 'ring-1 ring-gray-800'
// },
// slideover: {
// background: '',
// base: 'flex-1 flex flex-col w-full focus:outline-none',
// overlay: {
// background: 'bg-gray-200/75 dark:bg-gray-800/50 backdrop-blur-md'
// }
// },
// range: {
// thumb: {
// color: 'dark:text-gray-100'
// },
// progress: {
// color: 'dark:text-gray-100',
// rounded: 'rounded-s-lg',
// background: 'bg-gray-500 dark:bg-gray-100'
// }
// }
// }
});
141 changes: 141 additions & 0 deletions apps/server/app/components/FileUploader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<template>
<div class="w-full">
<!-- Upload Zone -->
<div
class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-8 text-center"
@drop.prevent="onDrop"
@dragover.prevent
@click="triggerFileInput"
>
<input
type="file"
ref="fileInputRef"
@change="onFileSelect"
class="hidden"
/>
<div class="cursor-pointer">
<div class="text-gray-500 mb-2">
<i class="fas fa-cloud-upload-alt text-3xl"></i>
</div>
<p class="text-gray-600 dark:text-gray-400">
Click to select a file or drag and drop it here
</p>
</div>
</div>

<!-- File Preview -->
<div v-if="file" class="mt-4">
<div class="flex items-center justify-between p-2 border-b">
<div>
<p class="font-medium">{{ file.name }}</p>
<p class="text-sm text-gray-500">{{ formatFileSize(file.size) }}</p>
</div>
<UButton
@click="clearFile"
color="red"
size="xs"
variant="ghost"
icon="i-heroicons-trash"
/>
</div>

<!-- Progress Bar -->
<div v-if="uploadProgress > 0 || uploadedBytes > 0" class="mt-2">
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div
class="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
/>
</div>
<p class="text-sm text-gray-500 mt-1">
{{ uploadProgress.toFixed(1) }}% uploaded
</p>
</div>
</div>

<!-- Upload Button -->
<div class="flex justify-end mt-4">
<UButton
label="Upload File"
color="green"
:loading="isUploading"
:disabled="!file"
@click="startUpload"
/>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: "upload-complete"): void;
(e: "upload-error", error: Error): void;
}>();

const fileInputRef = ref<HTMLInputElement | null>(null);
const file = ref<File | null>(null);
const isUploading = ref(false);
const uploadProgress = ref<number>(0);
const uploadedBytes = ref(0);

const upload = useMultipartUpload("/api/files/multipart");

const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};

const triggerFileInput = (): void => {
fileInputRef.value?.click();
};

const onFileSelect = (event: Event): void => {
const input = event.target as HTMLInputElement;
const selectedFile = input.files?.[0];
if (selectedFile && selectedFile instanceof File) {
file.value = selectedFile;
uploadProgress.value = 0;
uploadedBytes.value = 0;
}
};

const onDrop = (event: DragEvent): void => {
const droppedFile = event.dataTransfer?.files[0];
if (droppedFile && droppedFile instanceof File) {
file.value = droppedFile;
uploadProgress.value = 0;
uploadedBytes.value = 0;
}
};

const clearFile = (): void => {
file.value = null;
uploadProgress.value = 0;
uploadedBytes.value = 0;
if (fileInputRef.value) {
fileInputRef.value.value = "";
}
};

const startUpload = async (): Promise<void> => {
if (!file.value || isUploading.value) return;

isUploading.value = true;
const { progress: uploadProgressRef, completed } = upload(file.value);

watch(uploadProgressRef, (newProgress) => {
uploadProgress.value = newProgress;
});

try {
await completed;
emit("upload-complete");
} catch (error) {
emit("upload-error", error as Error);
} finally {
clearFile();
isUploading.value = false;
}
};
</script>
139 changes: 107 additions & 32 deletions apps/server/app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -1,41 +1,81 @@
<template>
<div class="p-5">
<div class="flex px-3 py-3.5 border-b border-gray-200 dark:border-gray-700">
<UInput v-model="q" placeholder="Filter files..." />
<div class="flex justify-between items-center">
<div class="ml-auto">
<UButton label="Upload File" color="green" size="sm" to="/upload" />
</div>
</div>
<div class="h-full w-full pt-5">
<div
class="flex items-center justify-between gap-5 py-3.5 border-b border-gray-200 dark:border-gray-700"
>
<UInput v-model="q" placeholder="Filter files..." class="max-w-md" />

<UTable :rows="filteredRows" :columns="columns">
<template #size-data="{ row }">
{{ formatFileSize(row.size) }}
</template>

<template #uploadedAt-data="{ row }">
{{ formatDate(row.uploadedAt) }}
</template>

<template #actions-data="{ row }">
<UButton
label="Open in new Tab"
color="gray"
size="xs"
variant="ghost"
:to="'/api/fileupload/' + row.pathname"
target="_blank"
/>
</template>
</UTable>

<UPagination
v-model="page"
:total="filteredRows.length"
:per-page="perPage"
class="mt-4"
/>
<p>
<span class="font-bold">Total Files:</span>
<span class="pl-2 text-red-600">
{{ data?.length }}
</span>
</p>
</div>

<div class="w-full">
<UTable :rows="filteredRows" :columns="columns">
<template #pathname-data="{ row }">
<div class="max-w-[200px] truncate" :title="row.pathname">
{{ row.pathname }}
</div>
</template>

<template #size-data="{ row }">
{{ formatFileSize(row.size) }}
</template>

<template #uploadedAt-data="{ row }">
{{ formatDate(row.uploadedAt) }}
</template>

<template #actions-data="{ row }">
<div class="flex space-x-2">
<UButton
label="View"
color="green"
size="xs"
variant="ghost"
:to="'/api/files/' + row.pathname"
target="_blank"
/>
<UButton
label="Download"
color="blue"
size="xs"
variant="ghost"
@click="downloadFile(row.pathname)"
/>
<UButton
label="Delete"
color="red"
size="xs"
variant="ghost"
@click="deleteFile(row.pathname)"
/>
</div>
</template>
</UTable>
</div>

<UPagination
v-model="page"
:total="filteredRows.length"
:per-page="perPage"
class="mt-4 px-4"
/>
</div>
</div>
</template>

<script setup lang="ts">
const { data } = await useFetch("/api/images/");
const { data, refresh } = await useFetch("/api/files");

const columns = [
{
Expand All @@ -62,7 +102,7 @@ const columns = [

const q = ref("");
const page = ref(1);
const perPage = ref(5);
const perPage = ref(25);

const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
Expand All @@ -80,11 +120,46 @@ const formatDate = (date: string) => {
});
};

const deleteFile = async (pathname: string) => {
try {
await $fetch(`/api/files/${pathname}`, { method: "DELETE" });
await refresh(); // Refresh the file list after deletion
} catch (error) {
console.error("Failed to delete file:", error);
// Optionally show an error toast/notification
}
};

function downloadFile(path: string): void {
const link = document.createElement("a");
link.href = window.location.origin + "/api/files/" + path;
link.download = getFilenameFromUrl(path);

document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

// Helper function to extract filename from URL
function getFilenameFromUrl(url: string): string {
try {
// Try to get filename from URL path
const pathname = new URL(url).pathname;
const filename = pathname.split("/").pop();

// If no filename found, generate a default name based on timestamp
return filename || `download-${Date.now()}`;
} catch {
// Fallback if URL parsing fails
return `download-${Date.now()}`;
}
}

const filteredRows = computed(() => {
let rows = data.value || [];

if (q.value) {
rows = rows.filter((file) => {
rows = rows.filter((file: { pathname: string; contentType?: string }) => {
return (
file.pathname.toLowerCase().includes(q.value.toLowerCase()) ||
file?.contentType?.toLowerCase().includes(q.value.toLowerCase())
Expand Down
Loading