While I expect most of the file/photo operations to happen via a background sync between the web and archive server and a personal computer where the scanning of physical photographs is happening I also want the website to be used as an ongoing archive which means giving other family members not involved in the archiving process the ability to contribute. This means a simple web form where they can go and upload any digital photographs that they may have (e.g., from their phone). We’ll then process those photos and add them to the main archive (what that exact process looks like and how it will work is still TBD).
For the direct-upload form I’ve decided to use the excellent svelte-file-dropzone library to allow people to either drag-and-drop or click-to-upload their photos and then added some extra bits to show image previews and then handle the actual upload (with progress bars because it’s 2023!). It took me a while to get everything linked together because I couldn’t find anyone else who wrote about how to put it all together, though I’m sure there are others who have done so. Here’s how I got it all working together, I hope it can be useful to someone else in the future.
You can seem some screenshots of the final product under the demo header.
tl;dr
Here’s the svelte kit page altogether, a detailed explanation to follow:
<script lang="ts">
// src/routes/upload/+page.svelte
import Icon from 'svelte-awesome';
import Dropzone from 'svelte-file-dropzone/Dropzone.svelte';
import type DropzoneEvent from 'svelte-file-dropzone/Dropzone.svelte';
import { filesize } from 'filesize';
import PQueue from 'p-queue';
import nopreview from '$lib/assets/nopreview.png';
import {
faCircleCheck,
faCirclePause,
faCircleXmark,
} from '@fortawesome/free-solid-svg-icons';
type Upload = {
id: number;
raw: File;
preview: string | ArrayBuffer | null;
upload: {
started: boolean;
completed: boolean;
errored: boolean;
speed: null;
percent: number;
};
};
let id = 0;
let uploads: Upload[] = [];
let uploadStarted = false;
// currently browsers can only preview a limited number of image types
const previewMimeTypes = ['image/gif', 'image/jpeg', 'image/png'];
function handleFilesSelect(e: DropzoneEvent) {
const { acceptedFiles } = e.detail;
acceptedFiles.forEach((f: File) => {
let file: Upload = {
id: ++id, // this is just a svelte order tracker
raw: f,
preview: null,
upload: {
started: false,
completed: false,
errored: false,
speed: null,
percent: 0,
},
};
if (previewMimeTypes.include(f.type)) {
let reader = new FileReader();
reader.readAsDataURL(f);
reader.onload = (e) => {
if (e.target !== null) {
file.preview = e.target.result;
}
uploads = [...uploads, file];
};
} else {
uploads = [...uploads, file];
}
});
}
function removeUpload(event: MouseEvent, photoId: number) {
event.preventDefault();
uploads = uploads.filter((u) => u.id !== photoId);
}
async function doTheUpload(): Promise<void> {
uploadStarted = true;
const queue = new PQueue({ concurrency: 2 });
uploads.forEach(async (upload) => {
(async () => {
await queue.add(() =>
(async () => {
return new Promise((resolve, reject) => {
const form = new FormData();
form.append('photo', upload.raw);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
// must be _after_ xhr.open
xhr.setRequestHeader('Authorization', '...');
function progressHandler(event: ProgressEvent) {
upload.upload.percent = Math.round(
(event.loaded / event.total) * 100
);
uploads = uploads;
}
xhr.upload.addEventListener('progress', progressHandler, false);
xhr.onload = () => {
if (xhr.status === 200) {
upload.upload.completed = true;
uploads = uploads;
resolve(true);
} else {
upload.upload.errored = true;
uploads = uploads;
reject(xhr.status);
}
};
upload.upload.started = true;
uploads = uploads;
xhr.send(form);
});
})()
);
})();
});
}
</script>
<template>
<section class="section">
<div class="container">
<Dropzone accept="image/*", on:drop="{handleFilesSelect}" />
</div>
<div class="container">
<div class="columns is-multiline mt-4">
{#each uploads as upload (upload.id)}
<div class="column is-3">
<div class="box">
<div class="columns">
<div class="column has-text-centered upload-preview">
{#if upload.preview !== null}
<img src={upload.preview} alt={upload.raw.name} />
{:else}
<img src={nopreview} alt="no preview available" />
{/if}
</div>
<div class="columns is-vcentered">
<div class="column is-three-quarters">
<p class="upload-title"><b>{upload.raw.name}</b></p>
<p class="upload-filesize">
<i class="is-size-7">{filesize(upload.raw.size, {round: 0})}</i>
</div>
<div class="column has-text-centered">
{#if !uploadStarted}
<button class="delete is-large" on:click={e=>removeUpload(e, upload.id)></button>
{:else}
{#if upload.upload.started && !upload.upload.completed}
<span class="bulma-loader-mixin"></span>
{:else if upload.upload.started && !upload.upload.errored}
<span class="icon is-large">
<Icon data={faCirclePause} scale=2 />
</span>
{:else if upload.upload.completed}
<span class="icon is-large has-text-success">
<Icon data={faCircleCheck} scale=2 />
</span>
{:else}
<span class="icon is-large has-text-danger">
<Icon data={faCircleXmark} scale=2 />
</span>
{/if}
{/if}
</div>
{#if uploadStarted}
<div class="columns">
<div class="column">
<progress class="progress" value={upload.upload.percent} max=100>
{upload.upload.percent}%
</progress>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
<div class="columns mt-4">
<div class="column is-half is-offset-one-quarter has-text-centered">
<button class="button" on:click={doTheUpload}>Start Upload</button>
</div>
</div>
</section>
</template>
<style lang="scss">
@import 'bulma/sass/utilities/_all';
div.upload-preview {
max-height: 200px;
img {
max-height: 100%;
max-width: 100%;
}
}
p.upload-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@mixin delete-is-large-size {
// size taken from the bulma delete button is-large
height: 32px;
max-height: 32px;
max-width: 32px;
min-height: 32px;
min-width: 32px;
width: 32px;
}
span.bulma-loader-mixin {
@include loader;
@include delete-is-large-size;
}
</style>
Here’s the necessary backend code to handle the upload:
package routes
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func HandleUpload(c *gin.Context) {
file, err := c.FormFile("photo")
if err != nil {
// log or notify error handling service
c.String(http.StatusInternalServiceError, "")
return
}
// now you can do whatever you need to with the file like save it to
// disk or a cloud storage
log.Printf("received a file with name %s", file.Filename)
// or c.JSON, etc...
c.String(http.StatusOK, "ok")
}
Finally, later on where you mount your routes:
package main
import "github.com/gin-gonic/gin"
import "example.com/routes"
func main() {
r := gin.Default()
r.POST("/api/upload", routes.HandleUpload)
r.Run()
}
detailed explanation
You’ll have noticed right away that I’m using Typescript for the frontend code so there will be type annotations. If you’re not using Typescript then you can just remove them.
To start, we import our dependencies. As mentioned previously I’m using svelte-file-dropzone to show and handle the actual drag-and-drop functionality. I’m also using svelte-awesome to easily show and inline font awesome icons. I’m using filesize to convert the filesize into human-readable bytes. And, finally, to handle concurrent uploads gracefully the p-queue package.
I also need to import the image that I’m using to show as a preview when we can’t compute a preview (this is because browsers can only handle data URLs for certain image types like GIF, JPEG, and PNG (probably among others like the newer WebP) but we accept all image types including, for example, TIFF. When the user attempts to upload an image in a format that we can’t accept then instead of an actual preview we’ll show them this image instead.
Finally we need to import the font awesome icons that we’ll be using.
import Icon from 'svelte-awesome';
import Dropzone from 'svelte-file-dropzone/Dropzone.svelte';
import type DropzoneEvent from 'svelte-file-dropzone/Dropzone.svelte';
import { filesize } from 'filesize';
import PQueue from 'p-queue';
import nopreview from '$lib/assets/nopreview.png';
import {
faCircleCheck,
faCirclePause,
faCircleXmark,
} from '@fortawesome/free-solid-svg-icons';
Up next is the definition of the Upload
type which we’ll be using to keep
track of all of the files that the user is trying to upload. We keep an internal
id
which we can use to let svelte match the for each with the item from the
array (more info here). We keep
the raw
File
object that gets passed when selecting files to upload. We have
an optional preview
to let the user know (when possible as described above)
what it is that they’re uploading. And, finally, we keep track of some
information that we can use when actually processing the upload: if it’s
started
yet, it if has completed
or errored
, a computed upload speed
(at
the initial time of writing I haven’t implemented this yet, but I’ll update this
post when I do), and a whole-number percent
of the upload that has completed
based on uploaded and total bytes.
type Upload = {
id: number;
raw: File;
preview: string | ArrayBuffer | null;
upload: {
started: boolean;
completed: boolean;
errored: boolean;
speed: null;
percent: number;
};
};
Now, we set some initial variables that we’ll be using to track state:
let id = 0;
let uploads: Upload[] = [];
let uploadStarted = false;
// currently browsers can only preview a limited number of image types
const previewMimeTypes = ['image/gif', 'image/jpeg', 'image/png'];
We use the id
as mentioned before to calculate an internal ID to make use of
keyed for each loops in svelte. The uploads
array is used to keep a running
total of all of the files that we’ll be uploading. The uploadStarted
is used
as a global marker for when we’ve started the upload process (so that we can
show an empty progress bar on all of the uploads at once, for example).
Next we need to actually do something when the user selects or drops files on the dropzone:
function handleFilesSelect(e: DropzoneEvent) {
const { acceptedFiles } = e.detail;
acceptedFiles.forEach((f: File) => {
let file: Upload = {
id: ++id, // this is just a svelte order tracker
raw: f,
preview: null,
upload: {
started: false,
completed: false,
errored: false,
speed: null,
percent: 0,
},
};
if (previewMimeTypes.include(f.type)) {
let reader = new FileReader();
reader.readAsDataURL(f);
reader.onload = (e) => {
if (e.target !== null) {
file.preview = e.target.result;
}
uploads = [...uploads, file];
};
} else {
uploads = [...uploads, file];
}
});
}
First, we get the acceptedFiles
, the e.detail
also returns a rejectedFiles
if necessary, but I’m not exactly sure how to get it to populate or why it might
be necessary. For my use-case it’s enough to deal only with accepted files.
We then need to loop over all of those accepted files creating a new Upload
entry for each one and initializing all of the upload
fields to be
not-yet-started. If we’re dealing with an acceptable preview MIME type then we
can calculate the preview and update the preview
field with the data URL,
otherwise we’ll leave the preview field empty which will show our “no preview”
image instead. In any case (whether we support a preview or not) we then add the
new upload to the uploads array: uploads = [...uploads, file]
.
Removing files once they’ve been selected is straight forward:
function removeUpload(event: MouseEvent, photoId: number) {
event.preventDefault();
uploads = uploads.filter((u) => u.id !== photoId);
}
Here’s the relevant template:
{#if !uploadStarted}
<button class="delete is-large" on:click={e=>removeUpload(e, upload.id)></button>
{:else}
<!-- we'll show some different buttons -->
{/if}
Finally, the most complicated part is to actually send the files to the server!
We can’t use fetch
because it doesn’t support upload progress (yet) so we need to stick with a
classic Ajax request
(XMLHttpRequest
).
The code starts to get a little ugly so that we can handle concurrent uploads:
we need to promise-ify the XMLHttpRequest
so that we can async
/await
add
it to the p-queue
that we’re using to handle concurrency.
If you wanted to just send everything all at once you could do this instead,
ignoring the async
/await
bits:
async function doTheUpload(): Promise<void> {
uploads.forEach((upload) => {
const form = new FormData();
form.append('photo', upload.raw);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.onload = () => {
if (xhr.status === 200) {
upload.upload.completed = true;
uploads = uploads;
} else {
upload.upload.errored = true;
uploads = uploads;
}
};
upload.upload.started = true;
uploads = uploads;
xhr.send(form);
});
}
Instead as you can see we wrap that like so to make the concurrency work:
async function doTheUpload(): Promise<void> {
const queue = new PQueue({ concurrency: 2 });
uploads.forEach(async (upload) => {
(async () => {
await queue.add(() =>
(async () => {
return new Promise((resolve, reject) => {
// the XMLHttpRequest happens in here
// resolve() if it's successful, reject() if it's not
});
})()
);
})();
});
}
Inside the loop we create a new
FormData and then
add the raw File
that we got from the dropzone to it.
// "upload" comes from the forEach loop above
const form = new FormData();
form.append('photo', upload.raw);
We then need to create the actual XMLHTTPRequest
(we’re doing one file per
request, because it makes reporting progress easier, but you could also just
send everything all at once).
After “opening” the request (but no data gets sent until you call send
) we set
an Authorization
header (which needs to be done after opening the request
but before sending it).
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
// must be _after_ xhr.open
xhr.setRequestHeader('Authorization', '...');
To enable progress reporting to the user we need to add an event listener for
progress
. We just calculate the percent that we’ve uploaded and then when we
call uploads = uploads
svelte will update the view (and the progress bar) for
us.
function progressHandler(event: ProgressEvent) {
upload.upload.percent = Math.round((event.loaded / event.total) * 100);
uploads = uploads;
}
xhr.upload.addEventListener('progress', progressHandler, false);
Finally, we need to let the user know what happened once the upload is done. If
we get back a 200
response code then we set the upload as completed, otherwise
as errored. The frontend will then show the appropriate icon.
xhr.onload = () => {
if (xhr.status === 200) {
upload.upload.completed = true;
uploads = uploads;
resolve(true);
} else {
upload.upload.errored = true;
uploads = uploads;
reject(xhr.status);
}
};
The last bit to do for each loop is to mark each file as upload started so that
we show the upload spinner and then send
off the request. Again,
uploads = uploads
is a bit strange but it’s necessary for the way that svelte
reactivity works to get it to update the frontend for us.
I won’t spend any time on the template code as I hope it’s self-explanatory enough as the logic is fairly simple and only has a few conditions.
A few brief notes about the styling. In order avoid vertical images from
enlarging the box undesirably we set a maximum height on the box itself and then
we also need to set the maximum height on the img
within.
div.upload-preview {
max-height: 200px;
img {
max-height: 100%;
max-width: 100%;
}
}
To prevent long filenames from expanding the box or breaking onto a second line:
p.upload-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
And finally, to create a loading spinner we can use the builtin mixin from the
bulma library as well
as our own mixin to make it mimic the is-large
size that we’re using
everywhere else:
// provides the loader mixin
@import 'bulma/sass/utilities/_all';
@mixin delete-is-large-size {
// size taken from the bulma delete button is-large
height: 32px;
max-height: 32px;
max-width: 32px;
min-height: 32px;
min-width: 32px;
width: 32px;
}
span.bulma-loader-mixin {
@include loader;
@include delete-is-large-size;
}
Finally, on the backend you can access the uploaded file like you normally would
as an enctype="multipart/form-data"
form. Using the
gin-gonic library for example:
import "github.com/gin-gonic/gin"
func HandleUpload(c *gin.Context) {
file, _ := c.FormFile("photo")
// do something with the file
c.String(200, file.Filename)
}
demo
It’s not a real demo, just a couple of screenshots to show what it looks like.
After selecting some images to upload (courtesy of unsplash):
Once the upload starts and some images start uploading:
After the upload is complete:
Links to the images found in the screenshots: