Rewrote the server in cpp with the frontend in svelte
This commit is contained in:
53
frontend/src/components/DeleteModal.svelte
Normal file
53
frontend/src/components/DeleteModal.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import {rpc, show_working, state, token, type UploadFile} from '../store';
|
||||
import {Button, ButtonGroup, Modal} from 'flowbite-svelte';
|
||||
import {afterUpdate} from 'svelte';
|
||||
|
||||
let show_confirm = false;
|
||||
let show_modal = false;
|
||||
let pre_element: HTMLElement|null = null;
|
||||
let text = '';
|
||||
let nodes: number[] = [];
|
||||
|
||||
async function real_delete() {
|
||||
show_confirm = false;
|
||||
show_modal = true;
|
||||
text = '';
|
||||
show_working.set(true);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
rpc.FS_delete_nodes($token ?? '', nodes, (v) => {
|
||||
if (v == null)
|
||||
resolve();
|
||||
else {
|
||||
text += v;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
show_working.set(false);
|
||||
show_modal = false;
|
||||
state.update(v => v);
|
||||
}
|
||||
|
||||
export const del = async (n: number[]) => {
|
||||
nodes = n;
|
||||
show_confirm = true;
|
||||
};
|
||||
|
||||
afterUpdate(() => {
|
||||
if (pre_element)
|
||||
pre_element.scroll({ top: pre_element.scrollHeight, behavior: 'instant' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal bind:open={show_confirm} dismissable={false} title="Do you really want to delete these files?">
|
||||
<ButtonGroup class="w-full flex flex-nowrap">
|
||||
<Button class="flex-1" color="green" on:click={() => show_confirm = false}>No</Button>
|
||||
<Button class="flex-1" color="red" on:click={real_delete}>Yes</Button>
|
||||
</ButtonGroup>
|
||||
</Modal>
|
||||
|
||||
<Modal bind:open={show_modal} dismissable={false} size="xl" title="Deleting" bodyClass="h-full p-0 space-y-0" class="h-screen-90">
|
||||
<pre bind:this={pre_element} class="bg-gray-200 text-gray-600 px-4 py-2 h-full overflow-y-auto overscroll-contain rounded-b">{text}</pre>
|
||||
</Modal>
|
||||
146
frontend/src/components/DirViewer.svelte
Normal file
146
frontend/src/components/DirViewer.svelte
Normal file
@@ -0,0 +1,146 @@
|
||||
<script context="module" lang="ts">
|
||||
import {writable} from 'svelte/store';
|
||||
const show_preview = writable<boolean>(false);
|
||||
</script>
|
||||
<script lang="ts">
|
||||
import {Checkbox, Dropdown, DropdownItem, Spinner, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell, Tooltip} from 'flowbite-svelte';
|
||||
import {Folder, FolderParent, DocumentBlank, ChevronSortDown} from 'carbon-icons-svelte';
|
||||
import {filesize} from 'filesize';
|
||||
import {api, changeStateFunction, download, StateE, token, rpc} from '../store';
|
||||
import LinkButton from './LinkButton.svelte';
|
||||
import DeleteModal from './DeleteModal.svelte';
|
||||
|
||||
export let node: api.Node;
|
||||
|
||||
let selected: number[] = [];
|
||||
let nodes: api.Node[], dirs: api.Node[], files: api.Node[], previews: {[key: number]: string|null} = {};
|
||||
let total_size: number;
|
||||
$: { nodes = node.children!; selectNone(); }
|
||||
$: dirs = nodes.filter(v => !v.file).sort((a, b) => a.name.localeCompare(b.name));
|
||||
$: files = nodes.filter(v => v.file).sort((a, b) => a.name.localeCompare(b.name));
|
||||
$: total_size = files.map(v => v.size).reduce<number>((a, b) => (a + b!), 0);
|
||||
$: {
|
||||
if ($show_preview) {
|
||||
for (const file of files) {
|
||||
if (!file.preview) continue;
|
||||
getPreview(file.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getPreview(node: number) {
|
||||
const resp = await rpc.FS_download_preview($token ?? '', node);
|
||||
if (resp.o == null)
|
||||
return;
|
||||
previews[node] = 'data:image/png;base64,' + resp.o;
|
||||
previews = previews;
|
||||
}
|
||||
|
||||
let ctx_node: api.Node;
|
||||
let ctx_hidden = true;
|
||||
let ctx_x = 0, ctx_y = 0;
|
||||
let ctx_style: string;
|
||||
$: ctx_style = `top: ${ctx_y}px; left: ${ctx_x}px; position: fixed;`;
|
||||
|
||||
function onCtxMenu(node: api.Node, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
if (!ctx_hidden)
|
||||
return ctx_hidden = true;
|
||||
ctx_x = e.pageX;
|
||||
ctx_y = e.pageY;
|
||||
ctx_node = node;
|
||||
ctx_hidden = false;
|
||||
}
|
||||
|
||||
const selectAll = () => selected = nodes.map(v => v.id);
|
||||
const selectFolders = () => selected = dirs.map(v => v.id);
|
||||
const selectFiles = () => selected = files.map(v => v.id);
|
||||
const selectNone = () => selected = [];
|
||||
const downloadSelected = () => download($token ?? '', nodes.filter(v => selected.includes(v.id)));
|
||||
const deleteSelected = () => del(selected);
|
||||
|
||||
|
||||
const onCtxDownload = () => download($token ?? '', [ctx_node]);
|
||||
|
||||
let del: (nodes: number[]) => Promise<void>;
|
||||
const onCtxDelete = () => del([ctx_node.id]);
|
||||
|
||||
const onShowPreview = (e: Event) => { show_preview.set((e.target as HTMLInputElement).checked); }
|
||||
</script>
|
||||
|
||||
<svelte:body on:click={() => (ctx_hidden = true)} />
|
||||
|
||||
<DeleteModal bind:del={del} />
|
||||
|
||||
<Table hoverable>
|
||||
<TableHead theadClass="text-xs">
|
||||
<TableHeadCell class="p-2 pl-4 w-0 h-0">
|
||||
<ChevronSortDown id="dropdown-button" />
|
||||
</TableHeadCell>
|
||||
<TableHeadCell class="p-2 w-0"><Checkbox checked={$show_preview} on:change={onShowPreview} /><Tooltip>Show image previews</Tooltip></TableHeadCell>
|
||||
<TableHeadCell>Name</TableHeadCell>
|
||||
<TableHeadCell>Size</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{#if node.parent !== null}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell class="!p-4"></TableBodyCell>
|
||||
<TableBodyCell class="px-2 w-0"><FolderParent /></TableBodyCell>
|
||||
<TableBodyCell class="pl-0"><LinkButton on:click={changeStateFunction(StateE.VIEW, node.parent ?? 0)}>..</LinkButton></TableBodyCell>
|
||||
<TableBodyCell></TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/if}
|
||||
{#each dirs as node}
|
||||
<TableBodyRow on:contextmenu={onCtxMenu.bind(null, node)}>
|
||||
<TableBodyCell class="p-2 pl-4 w-0 h-0"><Checkbox bind:group={selected} value={node.id}/></TableBodyCell>
|
||||
<TableBodyCell class="px-2 w-0"><Folder /></TableBodyCell>
|
||||
<TableBodyCell class="pl-0"><LinkButton on:click={changeStateFunction(StateE.VIEW, node.id)}>{node.name}</LinkButton></TableBodyCell>
|
||||
<TableBodyCell></TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
{#each files as node}
|
||||
<TableBodyRow on:contextmenu={onCtxMenu.bind(null, node)}>
|
||||
<TableBodyCell class="p-2 pl-4 w-0 h-0"><Checkbox bind:group={selected} value={node.id}/></TableBodyCell>
|
||||
<TableBodyCell class="px-2 min-w-0">
|
||||
{#if $show_preview && node.preview}
|
||||
{#if previews[node.id] !== undefined}
|
||||
<img class="w-screen max-w-xs" alt="preview" src={previews[node.id]} />
|
||||
{:else}
|
||||
<Spinner size="4"/>
|
||||
{/if}
|
||||
{:else}
|
||||
<DocumentBlank />
|
||||
{/if}
|
||||
</TableBodyCell>
|
||||
<TableBodyCell class="pl-0"><LinkButton on:click={changeStateFunction(StateE.VIEW, node.id)}>{node.name}</LinkButton></TableBodyCell>
|
||||
<TableBodyCell>{filesize(node.size ?? 0, {base: 2, standard: 'jedec'})}</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
<tfoot class="text-gray-700 bg-gray-50">
|
||||
<tr>
|
||||
<td class="px-6 py-3" colspan="3">
|
||||
{#if selected.length > 0}
|
||||
<LinkButton on:click={downloadSelected}>Download</LinkButton>
|
||||
<LinkButton color="red" on:click={deleteSelected}>Delete</LinkButton>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-6 py-3">{filesize(total_size, {base: 2, standard: 'jedec'})}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
|
||||
<Dropdown triggeredBy="#dropdown-button" trigger="hover" placement="left">
|
||||
<DropdownItem on:click={selectAll}>Select all</DropdownItem>
|
||||
<DropdownItem on:click={selectFolders}>Select folders</DropdownItem>
|
||||
<DropdownItem on:click={selectFiles}>Select files</DropdownItem>
|
||||
<DropdownItem on:click={selectNone}>Select none</DropdownItem>
|
||||
</Dropdown>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div style={ctx_style} hidden={ctx_hidden} class="z-50 shadow-md rounded-lg border-gray-100 bg-white" on:contextmenu={() => (ctx_hidden = true)}>
|
||||
<ul class="py-1">
|
||||
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 w-full text-left" on:click={onCtxDownload}>Download</button></li>
|
||||
<li><button class="font-medium py-2 px-4 text-sm hover:bg-gray-100 text-red-400 w-full text-left" on:click={onCtxDelete}>Delete</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
57
frontend/src/components/FileViewer.svelte
Normal file
57
frontend/src/components/FileViewer.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import {Button, Spinner} from 'flowbite-svelte';
|
||||
import {Download} from 'carbon-icons-svelte';
|
||||
import {api, rpc, token, workingWrapperR} from '../store';
|
||||
import {onDestroy} from 'svelte';
|
||||
|
||||
export let node: api.Node;
|
||||
|
||||
let src = '';
|
||||
let loading = false;
|
||||
|
||||
let mime: string|null;
|
||||
let image: boolean, video: boolean, audio: boolean, pdf: boolean, can_display: boolean;
|
||||
$: workingWrapperR<string>(() => rpc.FS_get_mime($token ?? '', node.id)).then(v => mime = v);
|
||||
$: image = mime?.startsWith('image/') ?? false;
|
||||
$: video = mime?.startsWith('video/') ?? false;
|
||||
$: audio = mime?.startsWith('audio/') ?? false;
|
||||
$: pdf = mime === 'application/pdf';
|
||||
$: can_display = image || video || audio || pdf;
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
if (src.startsWith('blob'))
|
||||
URL.revokeObjectURL(src);
|
||||
const resp = await fetch('/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `token=${$token ?? ''}&node=${node.id}`
|
||||
});
|
||||
if (resp.status != 200)
|
||||
return;
|
||||
src = URL.createObjectURL(await resp.blob());
|
||||
loading = false;
|
||||
}
|
||||
|
||||
$: if (image || pdf) load();
|
||||
|
||||
onDestroy(() => { if (src.startsWith('blob')) URL.revokeObjectURL(src); });
|
||||
</script>
|
||||
|
||||
<Button class="w-full mb-6"><Download />Download</Button>
|
||||
{#if can_display && !loading && src === ''}
|
||||
<Button class="w-full" outline on:click={load}>Load</Button>
|
||||
{:else if loading}
|
||||
<Spinner class="w-full" />
|
||||
{:else if can_display && src !== ''}
|
||||
{#if image}
|
||||
<img class="w-full" alt="img" src={src} />
|
||||
{:else if video}
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video class="w-full" src={src} controls autoplay />
|
||||
{:else if audio}
|
||||
<audio class="w-full" src={src} controls autoplay />
|
||||
{:else if pdf}
|
||||
<embed class="w-full" style="height: 75vh" src={src} type="application/pdf" />
|
||||
{/if}
|
||||
{/if}
|
||||
22
frontend/src/components/LinkButton.svelte
Normal file
22
frontend/src/components/LinkButton.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import {twMerge} from "tailwind-merge";
|
||||
|
||||
export let color = 'primary';
|
||||
</script>
|
||||
|
||||
<button class={twMerge('link-button transition-colors', `text-${color}-600`, `hover:text-${color}-400`, $$props.class)} on:click>
|
||||
<slot>
|
||||
<span class="text-primary-600 hover:text-primary-400"></span>
|
||||
<span class="text-amber-600 hover:text-amber-400"></span>
|
||||
<span class="text-red-600 hover:text-red-400"></span>
|
||||
</slot>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 0.25em;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
145
frontend/src/components/UploadModal.svelte
Normal file
145
frontend/src/components/UploadModal.svelte
Normal file
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import {state, token, type UploadFile} from '../store';
|
||||
import {Button, ButtonGroup, Modal, Progressbar} from 'flowbite-svelte';
|
||||
import {filesize} from 'filesize';
|
||||
|
||||
interface MyFile extends UploadFile {
|
||||
waiting: boolean,
|
||||
done: boolean,
|
||||
current: number,
|
||||
total: number,
|
||||
progress: number
|
||||
}
|
||||
|
||||
let aborting = false;
|
||||
let done = false;
|
||||
let show_modal = false;
|
||||
let files: MyFile[] = [];
|
||||
let current = 0, total = 0;
|
||||
let progress: number;
|
||||
$: progress = total == 0 ? 0 : (current / total * 100);
|
||||
let not_done_files: MyFile[], done_files: MyFile[];
|
||||
$: not_done_files = files.filter(f => !f.done);
|
||||
$: done_files = files.filter(f => f.done);
|
||||
|
||||
function close() {
|
||||
show_modal = false;
|
||||
state.update(v => v);
|
||||
}
|
||||
|
||||
async function realUpload(file: MyFile) {
|
||||
if (file.done)
|
||||
return;
|
||||
file.waiting = false;
|
||||
let load_progress = 0;
|
||||
await new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onloadend = resolve;
|
||||
xhr.onerror = resolve;
|
||||
xhr.upload.onprogress = ev => {
|
||||
current += ev.loaded - load_progress;
|
||||
load_progress = ev.loaded;
|
||||
file.current = ev.loaded;
|
||||
file.progress = file.current / file.total * 100;
|
||||
files = files;
|
||||
if (file.current == file.total)
|
||||
resolve(null);
|
||||
};
|
||||
xhr.open('POST', '/upload', true);
|
||||
xhr.setRequestHeader('X-Node', file.id.toString());
|
||||
xhr.setRequestHeader('X-Token', $token ?? '');
|
||||
xhr.send(file.file);
|
||||
});
|
||||
current += file.total - load_progress;
|
||||
file.done = true;
|
||||
files = files;
|
||||
}
|
||||
|
||||
export const upload = async (fs: UploadFile[]) => {
|
||||
aborting = false;
|
||||
done = false;
|
||||
current = 0;
|
||||
total = 0;
|
||||
show_modal = true;
|
||||
|
||||
fs.forEach(f => (total += f.file.size));
|
||||
files = fs.map(f => {
|
||||
return {
|
||||
...f,
|
||||
waiting: f.file.size != 0,
|
||||
done: f.file.size == 0,
|
||||
current: 0,
|
||||
total: f.file.size,
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
const in_flight = new Set();
|
||||
for (const file of files) {
|
||||
if (in_flight.size >= 15) {
|
||||
await Promise.race(in_flight);
|
||||
}
|
||||
if (aborting)
|
||||
break;
|
||||
const p = realUpload(file);
|
||||
in_flight.add(p);
|
||||
const _ = (async () => { await p; in_flight.delete(p); })();
|
||||
}
|
||||
|
||||
await Promise.all(in_flight);
|
||||
|
||||
done = true;
|
||||
if (!aborting && !files.some(v => v.overwrite))
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:open={show_modal} dismissable={false} size="xl" title="Uploading">
|
||||
<div>
|
||||
<div class="mb-1 flex justify-between">
|
||||
<span>{filesize(current, {base: 2, standard: 'jedec'})} / {filesize(total, {base: 2, standard: 'jedec'})}</span>
|
||||
<span>{progress.toFixed(2)}%</span>
|
||||
</div>
|
||||
<Progressbar class="mt-0" size="h-4" bind:progress={progress} />
|
||||
</div>
|
||||
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each not_done_files as file (file.id)}
|
||||
<tr>
|
||||
<td>{file.full_name}</td>
|
||||
<td>
|
||||
{#if file.waiting}
|
||||
Waiting
|
||||
{:else}
|
||||
<div class="flex flex-nowrap flex-row">
|
||||
<span class="mr-4">{filesize(file.current, {base: 2, standard: 'jedec'})} / {filesize(file.total, {base: 2, standard: 'jedec'})}</span>
|
||||
<Progressbar class="flex-1" size="h-4" bind:progress={file.progress} labelInside />
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#each done_files as file (file.id)}
|
||||
<tr>
|
||||
<td>{file.full_name}</td>
|
||||
<td>Done {#if file.overwrite}(Overwrote old file){/if}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span slot="footer">
|
||||
{#if done}
|
||||
<Button class="w-full" on:click={close}>Close</Button>
|
||||
{:else if !aborting}
|
||||
<Button class="w-full" color="red" on:click={() => (aborting = true)}>Abort</Button>
|
||||
{/if}
|
||||
</span>
|
||||
</Modal>
|
||||
Reference in New Issue
Block a user