nidus-sync/ts/components/CSVUpload.vue

313 lines
6 KiB
Vue

<style scoped>
.upload-widget {
max-width: 400px;
font-family:
system-ui,
-apple-system,
sans-serif;
}
.upload-area {
position: relative;
border: 2px dashed #cbd5e0;
border-radius: 8px;
padding: 2rem;
text-align: center;
background-color: #f7fafc;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-area:hover {
border-color: #4299e1;
background-color: #ebf8ff;
}
.file-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-input:disabled {
cursor: not-allowed;
}
.upload-info {
pointer-events: none;
}
.placeholder {
color: #718096;
}
.placeholder svg {
margin: 0 auto 1rem;
color: #a0aec0;
}
.placeholder p {
margin: 0;
font-size: 0.875rem;
}
.file-selected {
color: #2d3748;
}
.file-name {
margin: 0 0 0.5rem;
font-weight: 600;
word-break: break-all;
}
.file-size {
margin: 0;
font-size: 0.875rem;
color: #718096;
}
.upload-button {
width: 100%;
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background-color: #4299e1;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.upload-button:hover {
background-color: #3182ce;
}
.upload-button:disabled {
background-color: #cbd5e0;
cursor: not-allowed;
}
.upload-status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
padding: 0.75rem;
background-color: #ebf8ff;
border-radius: 6px;
color: #2c5282;
}
.spinner {
width: 20px;
height: 20px;
border: 3px solid #bee3f8;
border-top-color: #4299e1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.success-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #c6f6d5;
color: #22543d;
border-radius: 6px;
font-weight: 500;
}
.error-message {
margin-top: 1rem;
padding: 0.75rem;
background-color: #fed7d7;
color: #742a2a;
border-radius: 6px;
font-weight: 500;
}
</style>
<template>
<div class="upload-widget">
<div class="upload-area">
<input
ref="fileInput"
type="file"
accept=".csv"
@change="handleFileSelect"
:disabled="isUploading"
class="file-input"
/>
<div class="upload-info">
<div v-if="!selectedFile" class="placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>Select a CSV file to upload</p>
</div>
<div v-else class="file-selected">
<p class="file-name">{{ selectedFile.name }}</p>
<p class="file-size">{{ formatFileSize(selectedFile.size) }}</p>
</div>
</div>
</div>
<button
v-if="selectedFile && !isUploading"
@click="uploadFile"
class="upload-button"
:disabled="isUploading"
>
Upload File
</button>
<div v-if="isUploading" class="upload-status">
<div class="spinner"></div>
<p>Uploading...</p>
</div>
<div v-if="uploadSuccess" class="success-message">
File uploaded successfully!
</div>
<div v-if="errorMessage" class="error-message"> {{ errorMessage }}</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
interface Props {
uploadUrl: string;
headers?: Record<string, string>;
additionalData?: Record<string, string>;
}
interface Emits {
(e: "doError", error: Error): void;
(e: "doFileSelected", file: File): void;
(e: "doSuccess", response: any): void;
}
const props = withDefaults(defineProps<Props>(), {
headers: () => ({}),
additionalData: () => ({}),
});
const emit = defineEmits<Emits>();
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const isUploading = ref(false);
const uploadSuccess = ref(false);
const errorMessage = ref("");
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Validate it's a CSV file
if (!file.name.toLowerCase().endsWith(".csv")) {
errorMessage.value = "Please select a valid CSV file";
selectedFile.value = null;
return;
}
selectedFile.value = file;
errorMessage.value = "";
uploadSuccess.value = false;
emit("doFileSelected", file);
}
};
const uploadFile = async () => {
if (!selectedFile.value) return;
isUploading.value = true;
errorMessage.value = "";
uploadSuccess.value = false;
try {
const formData = new FormData();
formData.append("file", selectedFile.value);
// Add any additional data to the form
Object.entries(props.additionalData).forEach(([key, value]) => {
formData.append(key, value);
});
const response = await fetch(props.uploadUrl, {
body: formData,
headers: {
...props.headers,
// Don't set Content-Type - let browser set it with boundary
},
method: "POST",
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
const data = await response.json();
uploadSuccess.value = true;
// This is a hack, I should really fix it later
const path = "/_" + data;
emit("doSuccess", path);
resetUpload();
} catch (error) {
errorMessage.value =
error instanceof Error ? error.message : "Upload failed";
emit("doError", error as Error);
} finally {
isUploading.value = false;
}
};
const resetUpload = () => {
selectedFile.value = null;
uploadSuccess.value = false;
errorMessage.value = "";
if (fileInput.value) {
fileInput.value.value = "";
}
};
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 Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
// Expose methods for parent component
defineExpose({
resetUpload,
});
</script>