Part O
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
import Dropzone from './components/Dropzone.svelte';
|
||||
import ResultPanel from './components/ResultPanel.svelte';
|
||||
import FileList from './components/FileList.svelte';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import { uploadFile } from './lib/api.js';
|
||||
import { addUpload } from './lib/store.js';
|
||||
|
||||
let currentFile = null;
|
||||
let uploading = false;
|
||||
let progress = 0;
|
||||
let result = null;
|
||||
let password = '';
|
||||
let toast = { message: '', kind: 'info', id: 0 };
|
||||
|
||||
function notify(message, kind = 'info') {
|
||||
toast = { message, kind, id: Date.now() };
|
||||
}
|
||||
|
||||
function onFile(e) {
|
||||
const file = e.detail;
|
||||
if (!file) return;
|
||||
if (file.size === 0) {
|
||||
notify('Empty files are not supported.', 'error');
|
||||
return;
|
||||
}
|
||||
currentFile = file;
|
||||
notify('', 'info');
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!currentFile || uploading) return;
|
||||
uploading = true;
|
||||
progress = 0;
|
||||
result = null;
|
||||
try {
|
||||
const data = await uploadFile(currentFile, password, (loaded, total) => {
|
||||
progress = total > 0 ? Math.round((loaded / total) * 100) : 0;
|
||||
});
|
||||
result = data;
|
||||
addUpload({
|
||||
id: data.id,
|
||||
key: data.key,
|
||||
url: data.url,
|
||||
originalName: data.originalName,
|
||||
downloadName: data.downloadName || data.originalName,
|
||||
mimeType: data.mimeType || '',
|
||||
size: data.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
protected: !!data.protected
|
||||
});
|
||||
notify('Upload complete.', 'success');
|
||||
currentFile = null;
|
||||
password = '';
|
||||
} catch (e) {
|
||||
notify(e.message || 'Upload failed.', 'error');
|
||||
} finally {
|
||||
uploading = false;
|
||||
progress = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
|
||||
<header class="flex items-center justify-between gap-4 border-b border-edge/60 py-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/logo.svg" alt="File Drop" class="h-9 w-9" />
|
||||
<div>
|
||||
<h1 class="text-lg font-bold leading-tight text-ink">File Drop</h1>
|
||||
<p class="text-xs text-muted">Private file hosting</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/" class="btn btn-ghost px-4 py-2 text-xs">New upload</a>
|
||||
</header>
|
||||
|
||||
<main class="mt-6 grid grid-cols-1 items-start gap-5 lg:grid-cols-[360px_1fr]">
|
||||
<aside class="card p-5 lg:sticky lg:top-6">
|
||||
<h2 class="mb-4 text-base font-semibold text-ink">Upload a file</h2>
|
||||
|
||||
<Dropzone bind:file={currentFile} disabled={uploading} on:file={onFile} />
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="pw" class="mb-1.5 block text-xs font-semibold text-muted">
|
||||
Password <span class="font-normal opacity-70">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="pw"
|
||||
type="password"
|
||||
class="field"
|
||||
bind:value={password}
|
||||
placeholder="Protect the download"
|
||||
autocomplete="new-password"
|
||||
on:keydown={(e) => {
|
||||
if (e.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if uploading}
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span class="truncate">{currentFile ? currentFile.name : 'Uploading…'}</span>
|
||||
<span class="tabular-nums">{progress}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-surface">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-accent to-[#a78bfa] transition-all duration-150"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-4 w-full py-3"
|
||||
disabled={!currentFile || uploading}
|
||||
on:click={submit}
|
||||
>
|
||||
{uploading ? 'Uploading…' : 'Upload'}
|
||||
</button>
|
||||
|
||||
<div class="mt-4">
|
||||
<ResultPanel {result} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<FileList on:notify={(e) => notify(e.detail.message, e.detail.kind)} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toast {...toast} />
|
||||
@@ -0,0 +1,88 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-base text-ink font-sans;
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(1200px 600px at 15% -10%, rgba(91, 140, 255, 0.14), transparent 60%),
|
||||
radial-gradient(1000px 500px at 100% 0%, rgba(91, 140, 255, 0.08), transparent 55%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 140, 255, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #232936;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #2f3746;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-2xl border border-edge bg-surface/80 backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold
|
||||
transition-all duration-150 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-accent text-white hover:bg-accent-strong
|
||||
shadow-[0_8px_24px_-8px_rgba(91,140,255,0.7)];
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent text-muted border border-edge hover:text-ink hover:border-muted/60;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-transparent text-danger border border-danger/40 hover:bg-danger/10;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply w-full rounded-xl border border-edge bg-base/70 px-3.5 py-3 text-sm text-ink
|
||||
placeholder:text-muted/60 outline-none transition focus:border-accent focus:ring-2
|
||||
focus:ring-accent/20;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-gradient {
|
||||
@apply bg-gradient-to-r from-accent to-[#a78bfa] bg-clip-text text-transparent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let disabled = false;
|
||||
export let file = null;
|
||||
|
||||
let dragging = false;
|
||||
let depth = 0;
|
||||
let input;
|
||||
|
||||
function handleFiles(files) {
|
||||
const f = files && files.length ? files[0] : null;
|
||||
if (f) dispatch('file', f);
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
if (!disabled) input.click();
|
||||
}
|
||||
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
dragging = false;
|
||||
depth = 0;
|
||||
if (disabled) return;
|
||||
handleFiles(e.dataTransfer && e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function onDragEnter(e) {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
depth += 1;
|
||||
dragging = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave(e) {
|
||||
e.preventDefault();
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (depth === 0) dragging = false;
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
|
||||
e.preventDefault();
|
||||
input.click();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-edge bg-surface/40 px-6 py-10 text-center transition-all duration-200 hover:border-accent/60 hover:bg-surface/70 {dragging ? 'border-accent bg-accent/10' : ''} {disabled ? 'cursor-not-allowed opacity-60' : ''}"
|
||||
class:dragging
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Choose a file"
|
||||
on:click={onClick}
|
||||
on:keydown={onKeydown}
|
||||
on:dragover={(e) => e.preventDefault()}
|
||||
on:dragenter={onDragEnter}
|
||||
on:dragleave={onDragLeave}
|
||||
on:drop={onDrop}
|
||||
>
|
||||
<input
|
||||
bind:this={input}
|
||||
type="file"
|
||||
class="hidden"
|
||||
on:change={(e) => {
|
||||
handleFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-[#a78bfa]/20 text-accent transition-transform duration-200 {dragging ? 'scale-110' : 'group-hover:scale-105'}"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" class="h-7 w-7" stroke="currentColor" stroke-width="1.8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16V4m0 0L7 9m5-5l5 5" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if file}
|
||||
<p class="max-w-full truncate text-sm font-semibold text-ink">{file.name}</p>
|
||||
<p class="mt-1 text-xs text-muted">Drop another file to replace it</p>
|
||||
{:else}
|
||||
<p class="text-sm font-semibold text-ink">Drag & drop a file here</p>
|
||||
<p class="mt-1 text-xs text-muted">or click to browse — up to 10 GB</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { uploads, removeUpload, clearUploads } from '../lib/store.js';
|
||||
import { deleteFile } from '../lib/api.js';
|
||||
import FileRow from './FileRow.svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function onDelete(item) {
|
||||
const ok = window.confirm(
|
||||
`Delete "${item.originalName || item.downloadName}" permanently? This cannot be undone.`
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteFile(item);
|
||||
removeUpload(item.id);
|
||||
dispatch('notify', { message: 'File deleted.', kind: 'success' });
|
||||
} catch (e) {
|
||||
dispatch('notify', { message: e.message || 'Deletion failed.', kind: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
if ($uploads.length === 0) return;
|
||||
const ok = window.confirm(
|
||||
'Clear this list? This only removes entries from THIS browser. Files on the server are not touched.'
|
||||
);
|
||||
if (!ok) return;
|
||||
clearUploads();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card p-5">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h2 class="text-base font-semibold text-ink">Your files</h2>
|
||||
{#if $uploads.length}
|
||||
<span class="text-xs text-muted">{$uploads.length} file{$uploads.length === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if $uploads.length}
|
||||
<button type="button" class="text-xs font-semibold text-muted transition hover:text-danger" on:click={onClear}>
|
||||
Clear list
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $uploads.length}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each $uploads as item (item.id)}
|
||||
<FileRow {item} on:delete={(e) => onDelete(e.detail)} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-14 text-center">
|
||||
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-surface">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="h-6 w-6 text-muted/60" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2M4 7h16M4 7v10a2 2 0 002 2h12a2 2 0 002-2V7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm text-muted">No files yet.</p>
|
||||
<p class="mt-1 text-xs text-muted/70">Upload your first file to see it here.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import {
|
||||
forcedDownloadUrl,
|
||||
downloadUrl,
|
||||
displayName,
|
||||
fileExtension,
|
||||
formatBytes,
|
||||
formatDate,
|
||||
copyText
|
||||
} from '../lib/formats.js';
|
||||
import LinkFormats from './LinkFormats.svelte';
|
||||
|
||||
export let item;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let copied = false;
|
||||
|
||||
$: name = displayName(item);
|
||||
$: ext = fileExtension(item);
|
||||
|
||||
async function copy() {
|
||||
await copyText(downloadUrl(item));
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent/20 to-[#a78bfa]/20"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-accent" stroke="currentColor" stroke-width="1.7">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 3h7l4 4v11a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 3v4h4" />
|
||||
</svg>
|
||||
{#if ext}
|
||||
<span
|
||||
class="absolute -bottom-1.5 -right-1.5 max-w-[40px] truncate rounded-md border border-edge bg-raised px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-muted"
|
||||
>
|
||||
{ext}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate text-sm font-semibold text-ink" title={name}>{name}</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full border px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider {item.protected ? 'border-accent/40 bg-accent/10 text-accent' : 'border-ok/40 bg-ok/10 text-ok'}"
|
||||
>
|
||||
{item.protected ? 'Protected' : 'Public'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-muted">{formatBytes(item.size)} · {formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 md:items-end">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href={forcedDownloadUrl(item)} class="btn btn-primary px-3 py-2 text-xs">Download</a>
|
||||
<button type="button" class="btn btn-ghost px-3 py-2 text-xs" on:click={copy}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger px-3 py-2 text-xs" on:click={() => dispatch('delete', item)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="cursor-pointer select-none text-xs font-semibold text-muted transition hover:text-ink">
|
||||
Links ▾
|
||||
</summary>
|
||||
<div class="mt-2 md:flex md:justify-end">
|
||||
<LinkFormats {item} />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import { buildLinkFormats, copyText } from '../lib/formats.js';
|
||||
|
||||
export let item;
|
||||
export let size = 'sm'; // 'sm' | 'md'
|
||||
|
||||
let copied = null;
|
||||
$: formats = buildLinkFormats(item);
|
||||
|
||||
async function copy(fmt) {
|
||||
await copyText(fmt.value);
|
||||
copied = fmt.label;
|
||||
setTimeout(() => {
|
||||
if (copied === fmt.label) copied = null;
|
||||
}, 1400);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each formats as fmt}
|
||||
<button
|
||||
type="button"
|
||||
title={fmt.value}
|
||||
class="rounded-lg border border-edge bg-surface px-2.5 font-semibold text-muted transition hover:border-muted/60 hover:text-ink {size === 'sm' ? 'py-1.5 text-[11px]' : 'py-2 text-xs'}"
|
||||
on:click={() => copy(fmt)}
|
||||
>
|
||||
{copied === fmt.label ? 'Copied' : fmt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script>
|
||||
import { fade } from 'svelte/transition';
|
||||
import { downloadUrl, displayName, copyText } from '../lib/formats.js';
|
||||
import LinkFormats from './LinkFormats.svelte';
|
||||
|
||||
export let result;
|
||||
|
||||
let copied = false;
|
||||
|
||||
$: url = result ? downloadUrl(result) : '';
|
||||
$: name = result ? displayName(result) : '';
|
||||
|
||||
async function copy() {
|
||||
await copyText(url);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
<div class="card p-4" transition:fade={{ duration: 180 }}>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<span class="truncate text-sm font-semibold text-ink" title={name}>{name}</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider {result.protected ? 'border-accent/40 bg-accent/10 text-accent' : 'border-ok/40 bg-ok/10 text-ok'}"
|
||||
>
|
||||
{result.protected ? 'Protected' : 'Public'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input class="field !py-2.5 text-xs" type="text" readonly value={url} aria-label="Download URL" />
|
||||
<button type="button" class="btn btn-primary px-4" on:click={copy}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<p class="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted">Share links</p>
|
||||
<LinkFormats item={result} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { fly } from 'svelte/transition';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
export let message = '';
|
||||
export let kind = 'info';
|
||||
export let id = 0;
|
||||
|
||||
let visible = false;
|
||||
let timer = null;
|
||||
|
||||
function reset() {
|
||||
if (timer) clearTimeout(timer);
|
||||
visible = !!message;
|
||||
if (message) {
|
||||
timer = setTimeout(() => {
|
||||
visible = false;
|
||||
}, 4200);
|
||||
}
|
||||
}
|
||||
|
||||
$: message, id, reset();
|
||||
|
||||
onDestroy(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div class="fixed bottom-5 left-1/2 z-50 -translate-x-1/2 px-4" transition:fly={{ y: 12, duration: 180 }}>
|
||||
<div
|
||||
class="rounded-xl border px-4 py-3 text-sm shadow-2xl backdrop-blur-md {kind === 'error'
|
||||
? 'border-danger/40 bg-danger/15 text-danger'
|
||||
: kind === 'success'
|
||||
? 'border-ok/40 bg-ok/15 text-ok'
|
||||
: 'border-edge bg-raised/95 text-ink'}"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,55 @@
|
||||
export function uploadFile(file, password, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const form = new FormData();
|
||||
if (password) form.append('password', password);
|
||||
form.append('file', file);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
reject(new Error('Invalid server response.'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let message = `Upload failed (HTTP ${xhr.status}).`;
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
if (data && data.error) message = data.error;
|
||||
} catch {
|
||||
/* keep fallback message */
|
||||
}
|
||||
reject(new Error(message));
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload.')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Upload cancelled.')));
|
||||
|
||||
xhr.open('POST', '/api/upload');
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFile(item) {
|
||||
const res = await fetch(`/api/files/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Delete-Key': item.key }
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = 'Deletion failed.';
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && data.error) message = data.error;
|
||||
} catch {
|
||||
/* keep fallback message */
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export function downloadUrl(item) {
|
||||
return new URL(item.url, window.location.origin).href;
|
||||
}
|
||||
|
||||
export function forcedDownloadUrl(item) {
|
||||
const url = new URL(downloadUrl(item));
|
||||
url.searchParams.set('dl', '1');
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function displayName(item) {
|
||||
return item.downloadName || item.originalName || 'file';
|
||||
}
|
||||
|
||||
export function fileExtension(item) {
|
||||
const name = displayName(item);
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot > 0 && dot < name.length - 1) return name.slice(dot + 1).toUpperCase();
|
||||
return '';
|
||||
}
|
||||
|
||||
export function linkLabel(item) {
|
||||
return item.originalName || item.downloadName || 'file';
|
||||
}
|
||||
|
||||
export function buildLinkFormats(item) {
|
||||
const url = downloadUrl(item);
|
||||
const name = linkLabel(item);
|
||||
const mime = item.mimeType || '';
|
||||
const isImage = mime.startsWith('image/') && mime !== 'image/svg+xml';
|
||||
|
||||
let html;
|
||||
let bbcode;
|
||||
let markdown;
|
||||
if (isImage) {
|
||||
html = `<img src="${url}" alt="${name}">`;
|
||||
bbcode = `[img]${url}[/img]`;
|
||||
markdown = ``;
|
||||
} else {
|
||||
html = `<a href="${url}" target="_blank" rel="noopener">${name}</a>`;
|
||||
bbcode = `[url=${url}]${name}[/url]`;
|
||||
markdown = `[${name}](${url})`;
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'Direct', value: url },
|
||||
{ label: 'HTML', value: html },
|
||||
{ label: 'BBCode', value: bbcode },
|
||||
{ label: 'Markdown', value: markdown }
|
||||
];
|
||||
}
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let i = -1;
|
||||
do {
|
||||
value /= 1024;
|
||||
i += 1;
|
||||
} while (value >= 1024 && i < units.length - 1);
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyText(text) {
|
||||
const fallback = () => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
let ok = false;
|
||||
try {
|
||||
ok = document.execCommand('copy');
|
||||
} catch {
|
||||
ok = false;
|
||||
}
|
||||
ta.remove();
|
||||
return ok;
|
||||
};
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
return fallback();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const STORAGE_KEY = 'filehost.uploads';
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist(list) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||
} catch {
|
||||
/* storage unavailable — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export const uploads = writable(load());
|
||||
|
||||
export function addUpload(record) {
|
||||
uploads.update((list) => {
|
||||
const idx = list.findIndex((item) => item.id === record.id);
|
||||
const next = [...list];
|
||||
if (idx !== -1) next[idx] = record;
|
||||
else next.unshift(record);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function removeUpload(id) {
|
||||
uploads.update((list) => {
|
||||
const next = list.filter((item) => item.id !== id);
|
||||
persist(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearUploads() {
|
||||
uploads.set([]);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
});
|
||||
|
||||
export default app;
|
||||
Reference in New Issue
Block a user