upend/webui/src/lib/components/AddModal.svelte

320 lines
6.3 KiB
Svelte

<script context="module" lang="ts">
import mitt from 'mitt';
export type AddEvents = {
choose: void;
files: File[];
urls: string[];
};
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;
});
});
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
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>