This commit is contained in:
Anaïs Saraiva
2026-08-25 12:20:31 +02:00
parent ccc29212cd
commit abf8736fdd
59 changed files with 4228 additions and 3008 deletions
+132
View File
@@ -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} />
+88
View File
@@ -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;
}
}
+91
View File
@@ -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 &amp; drop a file here</p>
<p class="mt-1 text-xs text-muted">or click to browse — up to 10&nbsp;GB</p>
{/if}
</div>
+65
View File
@@ -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>
+80
View File
@@ -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}
+41
View File
@@ -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}
+55
View File
@@ -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();
}
+101
View File
@@ -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 = `![${name}](${url})`;
} 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();
}
+50
View File
@@ -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 */
}
}
+8
View File
@@ -0,0 +1,8 @@
import './app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')
});
export default app;