Get file upload working
Even though the UI doesn't do anything with it yet.
This commit is contained in:
parent
0d1bd752a4
commit
747544bb58
8 changed files with 354 additions and 77 deletions
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/Gleipnir-Technology/nidus-sync/html"
|
||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
|
||||
"github.com/gorilla/schema"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
|
@ -124,23 +125,28 @@ func handlerJSONPost[ReqType any](f handlerFunctionPost[ReqType]) http.HandlerFu
|
|||
}
|
||||
}
|
||||
|
||||
func authenticatedHandlerPostMultipart[RequestType any](f handlerFunctionPostAuthenticated[RequestType]) http.Handler {
|
||||
func authenticatedHandlerPostMultipart(f handlerFunctionPostAuthenticated[[]file.Upload], collection file.Collection) http.Handler {
|
||||
return auth.NewEnsureAuth(func(w http.ResponseWriter, r *http.Request, u platform.User) {
|
||||
err := r.ParseMultipartForm(32 << 10) // 32 MB buffer
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to parse form: %w ", err)
|
||||
return
|
||||
}
|
||||
uploads, err := file.SaveFileUploads(r, collection)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save uploads: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
var content RequestType
|
||||
|
||||
/*
|
||||
err = decoder.Decode(&content, r.PostForm)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to decode form: %w", err)
|
||||
return
|
||||
}
|
||||
*/
|
||||
ctx := r.Context()
|
||||
path, e := f(ctx, r, u, content)
|
||||
path, e := f(ctx, r, u, uploads)
|
||||
if e != nil {
|
||||
http.Error(w, e.Error(), e.Status)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"github.com/go-chi/render"
|
||||
|
||||
"github.com/Gleipnir-Technology/nidus-sync/auth"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
|
||||
)
|
||||
|
||||
func AddRoutes(r chi.Router) {
|
||||
|
|
@ -18,10 +19,6 @@ func AddRoutes(r chi.Router) {
|
|||
r.Method("GET", "/client/ios", auth.NewEnsureAuth(handleClientIos))
|
||||
r.Method("GET", "/communication", authenticatedHandlerJSON(listCommunication))
|
||||
r.Method("POST", "/configuration/integration/arcgis", authenticatedHandlerJSONPost(postConfigurationIntegrationArcgis))
|
||||
r.Method("POST", "/configuration/upload/pool/flyover", authenticatedHandlerPostMultipart(postUploadPoolFlyoverCreate))
|
||||
r.Method("POST", "/configuration/upload/pool/custom", authenticatedHandlerPostMultipart(postUploadPoolCustomCreate))
|
||||
r.Method("POST", "/configuration/upload/{id}/commit", authenticatedHandlerJSONPost(postUploadCommit))
|
||||
r.Method("POST", "/configuration/upload/{id}/discard", authenticatedHandlerJSONPost(postUploadDiscard))
|
||||
r.Method("GET", "/events", auth.NewEnsureAuth(streamEvents))
|
||||
r.Method("POST", "/image/{uuid}", auth.NewEnsureAuth(apiImagePost))
|
||||
r.Method("GET", "/image/{uuid}/content", auth.NewEnsureAuth(apiImageContentGet))
|
||||
|
|
@ -41,7 +38,11 @@ func AddRoutes(r chi.Router) {
|
|||
r.Method("POST", "/sudo/sse", authenticatedHandlerJSONPost(postSudoSSE))
|
||||
r.Method("GET", "/trap-data", auth.NewEnsureAuth(apiTrapData))
|
||||
r.Method("GET", "/tile/{z}/{y}/{x}", auth.NewEnsureAuth(getTile))
|
||||
r.Method("POST", "/upload/pool/flyover", authenticatedHandlerPostMultipart(postUploadPoolFlyoverCreate, file.CollectionCSV))
|
||||
r.Method("POST", "/upload/pool/custom", authenticatedHandlerPostMultipart(postUploadPoolCustomCreate, file.CollectionCSV))
|
||||
r.Method("GET", "/upload/{id}", authenticatedHandlerJSON(getUploadByID))
|
||||
r.Method("POST", "/upload/{id}/commit", authenticatedHandlerJSONPost(postUploadCommit))
|
||||
r.Method("POST", "/upload/{id}/discard", authenticatedHandlerJSONPost(postUploadDiscard))
|
||||
r.Method("GET", "/user/self", authenticatedHandlerJSON(getUserSelf))
|
||||
r.Method("GET", "/user/suggestion", authenticatedHandlerJSON(listUserSuggestion))
|
||||
r.Method("GET", "/user", authenticatedHandlerJSON(listUser))
|
||||
|
|
|
|||
|
|
@ -99,17 +99,11 @@ func postUploadDiscard(ctx context.Context, r *http.Request, u platform.User, f
|
|||
return "/configuration/upload", nil
|
||||
}
|
||||
|
||||
type FormUploadPool struct{}
|
||||
|
||||
func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platform.User, f FormUploadPool) (string, *nhttp.ErrorWithStatus) {
|
||||
func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
|
||||
// If the organization we're uploading to doesn't have a service area, we can't process the upload correctly
|
||||
if !(u.Organization.HasServiceArea() || u.Organization.IsCatchall()) {
|
||||
return "", nhttp.NewErrorStatus(http.StatusConflict, "Your organization does not yet have a service area")
|
||||
}
|
||||
uploads, err := file.SaveFileUpload(r, "csvfile", file.CollectionCSV)
|
||||
if err != nil {
|
||||
return "", nhttp.NewError("Failed to extract image uploads: %s", err)
|
||||
}
|
||||
if len(uploads) == 0 {
|
||||
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "No upload found")
|
||||
}
|
||||
|
|
@ -123,11 +117,7 @@ func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platfor
|
|||
}
|
||||
return fmt.Sprintf("/configuration/upload/%d", saved_upload.ID), nil
|
||||
}
|
||||
func postUploadPoolCustomCreate(ctx context.Context, r *http.Request, u platform.User, f FormUploadPool) (string, *nhttp.ErrorWithStatus) {
|
||||
uploads, err := file.SaveFileUpload(r, "csvfile", file.CollectionCSV)
|
||||
if err != nil {
|
||||
return "", nhttp.NewError("Failed to extract image uploads: %s", err)
|
||||
}
|
||||
func postUploadPoolCustomCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
|
||||
if len(uploads) == 0 {
|
||||
return "", nhttp.NewErrorStatus(http.StatusBadRequest, "No upload found")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,20 +11,17 @@ import (
|
|||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type FileUpload struct {
|
||||
type Upload struct {
|
||||
ContentType string
|
||||
Name string
|
||||
SizeBytes int
|
||||
UUID uuid.UUID
|
||||
}
|
||||
|
||||
func SaveFileUpload(r *http.Request, name string, collection Collection) ([]FileUpload, error) {
|
||||
results := make([]FileUpload, 0)
|
||||
func SaveFileUploads(r *http.Request, collection Collection) ([]Upload, error) {
|
||||
results := make([]Upload, 0)
|
||||
for n, fheaders := range r.MultipartForm.File {
|
||||
log.Debug().Str("n", n).Msg("looking at header")
|
||||
if n != name {
|
||||
continue
|
||||
}
|
||||
for _, headers := range fheaders {
|
||||
f, err := saveFileUpload(headers, collection)
|
||||
if err != nil {
|
||||
|
|
@ -35,8 +32,8 @@ func SaveFileUpload(r *http.Request, name string, collection Collection) ([]File
|
|||
}
|
||||
return results, nil
|
||||
}
|
||||
func saveFileUploads(r *http.Request, collection Collection) ([]FileUpload, error) {
|
||||
results := make([]FileUpload, 0)
|
||||
func saveFileUploads(r *http.Request, collection Collection) ([]Upload, error) {
|
||||
results := make([]Upload, 0)
|
||||
for name, fheaders := range r.MultipartForm.File {
|
||||
for _, headers := range fheaders {
|
||||
upload, err := saveFileUpload(headers, collection)
|
||||
|
|
@ -48,7 +45,7 @@ func saveFileUploads(r *http.Request, collection Collection) ([]FileUpload, erro
|
|||
}
|
||||
return results, nil
|
||||
}
|
||||
func saveFileUpload(headers *multipart.FileHeader, collection Collection) (upload FileUpload, err error) {
|
||||
func saveFileUpload(headers *multipart.FileHeader, collection Collection) (upload Upload, err error) {
|
||||
file, err := headers.Open()
|
||||
if err != nil {
|
||||
return upload, fmt.Errorf("Failed to open header: %w", err)
|
||||
|
|
@ -67,7 +64,7 @@ func saveFileUpload(headers *multipart.FileHeader, collection Collection) (uploa
|
|||
return upload, fmt.Errorf("Failed to write file to disk: %w", err)
|
||||
}
|
||||
log.Info().Int("size", len(file_bytes)).Str("uploaded_filename", headers.Filename).Str("content-type", content_type).Str("uuid", u.String()).Msg("Saved an uploaded file to disk")
|
||||
return FileUpload{
|
||||
return Upload{
|
||||
ContentType: content_type,
|
||||
Name: headers.Filename,
|
||||
SizeBytes: len(file_bytes),
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func GetUploadDetail(ctx context.Context, organization_id int32, file_id int32)
|
|||
return nil, errors.New("No idea what to do with upload type")
|
||||
}
|
||||
|
||||
func NewUpload(ctx context.Context, u User, upload file.FileUpload, t enums.FileuploadCsvtype) (*Upload, error) {
|
||||
func NewUpload(ctx context.Context, u User, upload file.Upload, t enums.FileuploadCsvtype) (*Upload, error) {
|
||||
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to begin transaction: %w", err)
|
||||
|
|
|
|||
304
ts/components/CSVUpload.vue
Normal file
304
ts/components/CSVUpload.vue
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
<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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...props.headers,
|
||||
// Don't set Content-Type - let browser set it with boundary
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
uploadSuccess.value = true;
|
||||
emit('doSuccess', data);
|
||||
|
||||
// Reset after successful upload
|
||||
setTimeout(() => {
|
||||
resetUpload();
|
||||
}, 2000);
|
||||
|
||||
} 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>
|
||||
|
||||
|
|
@ -85,12 +85,14 @@
|
|||
<i class="bi bi-filetype-csv me-1"></i> CSV Format
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
class="btn btn-success btn-lg w-100"
|
||||
href="/configuration/upload/pool/custom"
|
||||
<RouterLink
|
||||
to="/configuration/upload/pool/custom"
|
||||
>
|
||||
<i class="bi bi-upload me-2"></i>Pick me
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-success btn-lg w-100">
|
||||
<i class="bi bi-upload me-2"></i>Pick me
|
||||
</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -81,47 +81,11 @@
|
|||
<h5 class="mb-0">Upload Data</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form
|
||||
action="/configuration/upload/pool/flyover"
|
||||
method="POST"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<div class="upload-area">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="48"
|
||||
height="48"
|
||||
fill="currentColor"
|
||||
class="bi bi-cloud-arrow-up text-primary mb-3"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708l2-2z"
|
||||
/>
|
||||
<path
|
||||
d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383zm.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"
|
||||
/>
|
||||
</svg>
|
||||
<h5>Select your CSV file</h5>
|
||||
<p class="text-muted">
|
||||
Drag and drop a file here or click to browse
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
class="form-control"
|
||||
id="csvFile"
|
||||
name="csvfile"
|
||||
accept=".csv"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 text-center">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
Upload and Continue
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<CSVUpload
|
||||
upload-url="/api/upload/pool/flyover"
|
||||
@doError="onError"
|
||||
@doFileSelected="onFileSelected"
|
||||
@doSuccess="onUploadSuccess" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -133,3 +97,16 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import CSVUpload from "@/components/CSVUpload.vue";
|
||||
|
||||
function onError(err) {
|
||||
console.error("CSV upload error", err);
|
||||
}
|
||||
function onFileSelected(file) {
|
||||
console.log("file selected", file);
|
||||
}
|
||||
function onUploadSuccess(resp) {
|
||||
console.log("upload success", resp);
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue