313 lines
6 KiB
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>
|