327 lines
6.5 KiB
Svelte
327 lines
6.5 KiB
Svelte
<script context="module" lang="ts">
|
|
import mitt from 'mitt';
|
|
import type { Address } from '@upnd/upend/types';
|
|
|
|
export type AddEvents = {
|
|
choose: void;
|
|
files: File[];
|
|
urls: string[];
|
|
destination: Address;
|
|
};
|
|
export const addEmitter = mitt<AddEvents>();
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import Icon from './utils/Icon.svelte';
|
|
import IconButton from './utils/IconButton.svelte';
|
|
import ProgressBar from './utils/ProgressBar.svelte';
|
|
import api from '$lib/api';
|
|
import { goto } from '$app/navigation';
|
|
import { i18n } from '$lib/i18n';
|
|
import { selected } from '$lib/components/EntitySelect.svelte';
|
|
import Modal from '$lib/components/layout/Modal.svelte';
|
|
import Selector, { type SelectorValue } from '$lib/components/utils/Selector.svelte';
|
|
import { ATTR_IN } from '@upnd/upend/constants';
|
|
|
|
let files: File[] = [];
|
|
let URLs: string[] = [];
|
|
let uploading = false;
|
|
let abortController: AbortController | undefined;
|
|
|
|
let destination: Address | undefined;
|
|
|
|
let progress: Record<string, number> = {};
|
|
let totalProgress: number | undefined;
|
|
|
|
let filesElement: HTMLDivElement;
|
|
|
|
$: visible = files.length + URLs.length > 0 || destination;
|
|
|
|
addEmitter.on('files', (ev) => {
|
|
ev.forEach((file) => {
|
|
if (!files.map((f) => `${f.name}${f.size}`).includes(`${file.name}${file.size}`)) {
|
|
files.push(file);
|
|
}
|
|
files = files;
|
|
});
|
|
});
|
|
|
|
addEmitter.on('destination', (ev) => {
|
|
destination = ev;
|
|
});
|
|
|
|
function onDestinationSelected(ev: CustomEvent<SelectorValue | undefined>) {
|
|
if (ev.detail?.t === 'Address') {
|
|
destination = ev.detail.c;
|
|
} else {
|
|
destination = undefined;
|
|
}
|
|
}
|
|
|
|
async function upload() {
|
|
uploading = true;
|
|
|
|
try {
|
|
abortController = new AbortController();
|
|
const addresses: string[] = [];
|
|
for (const [idx, file] of files.entries()) {
|
|
filesElement
|
|
?.querySelectorAll('.entry')
|
|
[idx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
const address = await api.putBlob(file, {
|
|
abortController,
|
|
onProgress: (p) => {
|
|
progress[file.name] = (p.loaded / p.total) * 100;
|
|
totalProgress = Object.values(progress).reduce((a, b) => a + b, 0) / files.length;
|
|
},
|
|
timeout: -1
|
|
});
|
|
if (destination) {
|
|
await api.putEntry({
|
|
entity: address,
|
|
attribute: ATTR_IN,
|
|
value: {
|
|
t: 'Address',
|
|
c: destination
|
|
}
|
|
});
|
|
}
|
|
addresses.push(address);
|
|
|
|
if (!uploading) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (addresses.length == 1) {
|
|
goto(`/browse/${addresses[0]}`);
|
|
} else {
|
|
$selected = addresses;
|
|
goto(`/browse/selected`);
|
|
}
|
|
} catch (error) {
|
|
alert(error);
|
|
}
|
|
|
|
uploading = false;
|
|
reset();
|
|
}
|
|
|
|
function reset() {
|
|
if (uploading) {
|
|
const msg = $i18n.t('Are you sure you want to cancel the upload?');
|
|
if (!confirm(msg)) return;
|
|
}
|
|
abortController?.abort();
|
|
|
|
files = [];
|
|
URLs = [];
|
|
progress = {};
|
|
uploading = false;
|
|
destination = undefined;
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (!files.length) return;
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
upload();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
reset();
|
|
}
|
|
}
|
|
|
|
function onBeforeUnload(ev: BeforeUnloadEvent) {
|
|
if (files.length || uploading) {
|
|
ev.preventDefault();
|
|
ev.returnValue = true;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:beforeunload={onBeforeUnload} />
|
|
<svelte:body on:keydown={onKeydown} />
|
|
|
|
{#if visible}
|
|
<Modal on:close={reset}>
|
|
<div class="files" bind:this={filesElement}>
|
|
{#each files as file}
|
|
<div class="entry">
|
|
<div class="row">
|
|
<div class="file">
|
|
<div class="icon">
|
|
<Icon name="file" />
|
|
</div>
|
|
<div class="label">{file.name}</div>
|
|
{#if file.type.startsWith('image')}
|
|
<img src={URL.createObjectURL(file)} alt="To be uploaded." />
|
|
{/if}
|
|
</div>
|
|
{#if !uploading}
|
|
<IconButton
|
|
small
|
|
subdued
|
|
color="#dc322f"
|
|
name="x-circle"
|
|
on:click={() => (files = files.filter((f) => f !== file))}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{#if uploading && files.length > 1}
|
|
<div class="progress">
|
|
<ProgressBar value={progress[file.name] || 0} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{#if !uploading}
|
|
<div class="entry add">
|
|
<IconButton outline name="plus-circle" on:click={() => addEmitter.emit('choose')} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="controls">
|
|
<div class="controls-destination">
|
|
<div class="label"><Icon plain name="download" /> {$i18n.t('Destination')}</div>
|
|
<Selector
|
|
initial={destination ? { t: 'Address', c: destination } : undefined}
|
|
types={['Address', 'NewAddress']}
|
|
placeholder={$i18n.t('Choose automatically') || ''}
|
|
on:input={onDestinationSelected}
|
|
/>
|
|
</div>
|
|
<div class="controls-submit">
|
|
<IconButton small disabled={uploading} name="upload" on:click={upload}>
|
|
{$i18n.t('Upload')}
|
|
</IconButton>
|
|
</div>
|
|
</div>
|
|
{#if uploading}
|
|
<div class="progress">
|
|
<ProgressBar value={totalProgress} />
|
|
</div>
|
|
{/if}
|
|
</Modal>
|
|
{/if}
|
|
|
|
<style lang="scss">
|
|
.addmodal-container {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
color: var(--foreground);
|
|
|
|
display: none;
|
|
|
|
&.visible {
|
|
display: unset;
|
|
}
|
|
|
|
&.uploading {
|
|
cursor: progress;
|
|
|
|
.addmodal {
|
|
filter: brightness(0.85);
|
|
}
|
|
}
|
|
|
|
z-index: 99;
|
|
}
|
|
|
|
.addmodal {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
|
|
background: var(--background);
|
|
|
|
color: var(--foreground);
|
|
border: solid 2px var(--foreground);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
min-width: 33vw;
|
|
}
|
|
|
|
.files {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1em;
|
|
|
|
padding: 0.5em;
|
|
|
|
overflow-y: auto;
|
|
max-height: 66vh;
|
|
width: 80vw;
|
|
}
|
|
|
|
.entry .row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.file {
|
|
flex-grow: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
|
|
border: 1px solid var(--foreground);
|
|
border-radius: 4px;
|
|
background: var(--background-lighter);
|
|
padding: 0.5em;
|
|
|
|
img {
|
|
max-height: 12em;
|
|
max-width: 12em;
|
|
}
|
|
|
|
.icon {
|
|
font-size: 24px;
|
|
}
|
|
|
|
.label {
|
|
flex-grow: 1;
|
|
}
|
|
}
|
|
|
|
.entry.add {
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
font-size: 3em;
|
|
margin-top: 0.5rem;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.controls-destination {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
|
|
font-size: 1rem;
|
|
flex-grow: 3;
|
|
}
|
|
|
|
.controls-submit {
|
|
margin: 0 1rem;
|
|
}
|
|
|
|
.progress {
|
|
margin-top: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
</style>
|