feat(frontend): add file manager UI for view/upload/download/search of files

This commit is contained in:
2026-05-21 00:31:41 +03:00
parent 58ba8954c5
commit 880468282a
4 changed files with 185 additions and 75 deletions

View File

@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@700&display=swap" rel="stylesheet" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>

View File

@@ -1,20 +1,13 @@
<script lang="ts">
import Header from './Header.svelte';
import './layout.css';
let { children } = $props();
</script>
<div class="app">
<Header />
<main>{@render children()}</main>
<footer>
<p>
visit
<a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a>
to learn about SvelteKit
</p>
</footer>
</div>
@@ -44,10 +37,6 @@
padding: 12px;
}
footer a {
font-weight: bold;
}
@media (min-width: 480px) {
footer {
padding: 12px 0;

View File

@@ -1,60 +1,190 @@
<script lang="ts">
import welcomeFallback from '$lib/images/svelte-welcome.png';
import welcome from '$lib/images/svelte-welcome.webp';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate'
import Counter from './Counter.svelte';
type FileRecord = {
id: number;
name: string;
size: number;
uploadedAt: string;
};
let fileRecords = $state<FileRecord[]>([]);
let confirmDeleteId = $state<number | null>(null);
let search = $state('');
let loading = $state(true);
let filteredFileRecords = $derived(fileRecords.filter(f =>
f.name.toLowerCase().includes(search.toLowerCase())
));
let fileInput: HTMLInputElement;
function openFilePicker() {
fileInput.click();
}
async function loadFiles() {
const res = await fetch('http://localhost:3000/api/files', {
credentials: 'include'
});
fileRecords = await res.json();
loading = false;
}
onMount(loadFiles);
async function uploadFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
await fetch('http://localhost:3000/api/files', {
method: 'POST',
credentials: 'include',
body: formData
});
input.value = '';
await loadFiles();
}
async function downloadFile(fileId: number, fileName: string) {
const res = await fetch(`http://localhost:3000/api/files/${fileId}/download`, {
credentials: 'include'
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
async function copyLink() {
await loadFiles();
//TODO: Generate sharable link with expiration date, add param fileId: number
}
async function deleteFile(fileId: number) {
await fetch(`http://localhost:3000/api/files/${fileId}`, {
method: 'DELETE',
credentials: 'include'
});
await loadFiles();
}
function formatName(name: string): string {
if (name.length > 50) {
name = name.slice(0, 50).concat("...");
}
return name;
}
function formatSize(bytes: number): string {
if (bytes < 1000) return bytes + 'B';
if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + 'KB';
if (bytes < 1000 * 1000 * 1000) return (bytes / 1000 / 1000).toFixed(1) + 'MB';
return (bytes / 1000 / 1000 / 1000).toFixed(1) + 'GB';
}
</script>
<svelte:head>
<title>Home</title>
<meta name="description" content="Svelte demo app" />
</svelte:head>
<div class="w-full px-16 py-8">
<h1 class="text-4xl text-white mb-6" style="font-family: 'Caveat', cursive;">rafi</h1>
<section>
<h1>
<span class="welcome">
<picture>
<source srcset={welcome} type="image/webp" />
<img src={welcomeFallback} alt="Welcome" />
</picture>
</span>
<div class="flex justify-center mb-4">
<input
type="text"
placeholder="Search files..."
bind:value={search}
class="w-96 px-4 py-1.5 rounded-xl bg-white/5 border border-sky-200/20 transition-colors hover:border-sky-200/40 text-white placeholder-white/30 text-sm focus:outline-none focus:ring-1 focus:ring-white/10"
/>
</div>
to your new<br />SvelteKit app
</h1>
<input type="file" bind:this={fileInput} onchange={uploadFile} class="hidden" />
<div class="flex justify-start mb-1">
<button class="px-2 py-1 border border-sky-200/20 text-white/60 hover:border-sky-200/40 text-sm transition-colors rounded-sm cursor-pointer" onclick={openFilePicker}>
Import
</button>
</div>
<h2>
try editing <strong>src/routes/+page.svelte</strong>
</h2>
<div class="rounded-sm border border-sky-200/20">
<!-- Header row -->
<div class="flex items-stretch border-b border-sky-200/20 bg-white/5">
<span class="font-medium text-white/40 text-sm flex-1 py-2 pl-6">Name</span>
<span class="text-sm text-white/40 w-40 py-2">Size</span>
<span class="text-sm text-white/40 w-48 py-2">Uploaded at</span>
<div class="w-px bg-sky-200/20 self-stretch"></div>
<span class="text-sm text-white/40 w-32 text-center py-2">Actions</span>
</div>
<Counter />
</section>
<style>
section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
flex: 0.6;
}
h1 {
width: 100%;
}
.welcome {
display: block;
position: relative;
width: 100%;
height: 0;
padding: 0 0 calc(100% * 495 / 2048) 0;
}
.welcome img {
position: absolute;
width: 100%;
height: 100%;
top: 0;
display: block;
}
</style>
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div
transition:fade={{ duration: 200 }}
animate:flip={{ duration: 200 }}
class="flex items-stretch border-b border-sky-200/20 border-l-2 border-l-transparent last:border-b-0 hover:bg-white/2 hover:border-l-sky-400/50 transition-all"
>
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(fileRecord.name)}</span>
<span class="text-sm text-white/40 w-40 py-2">{formatSize(fileRecord.size)}</span>
<span class="text-sm text-white/40 w-48 py-2">{new Date(fileRecord.uploadedAt).toLocaleString()}</span>
<div class="w-px bg-sky-200/20 self-stretch"></div>
<div class="flex items-center gap-4 w-32 justify-center relative">
<!-- Download -->
<button class="text-white/40 hover:text-emerald-400 hover:scale-110 transition-all cursor-pointer" title="Download" onclick={() => downloadFile(fileRecord.id, fileRecord.name)}>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<!-- TODO: Copy -->
<button class="text-white/40 hover:text-blue-400 hover:scale-110 transition-all cursor-pointer" title="Copy link" onclick={() => copyLink()}>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>
<!-- Delete -->
<div class="relative">
<button class="text-white/40 hover:text-red-400 hover:scale-110 transition-all cursor-pointer" title="Delete" onclick={() => confirmDeleteId = fileRecord.id}>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
{#if confirmDeleteId === fileRecord.id}
<div class="absolute left-6 top-1/2 -translate-y-1/2 flex items-center z-10">
<div class="w-0 h-0 border-t-4 border-b-4 border-r-4 border-t-transparent border-b-transparent border-r-sky-200/20"></div>
<div class="bg-[#0f1117] border border-sky-200/20 rounded px-3 py-2 flex items-center gap-2 whitespace-nowrap">
<span class="text-white/40 text-xs">Confirm action</span>
<button class="text-white/60 hover:text-white transition-colors cursor-pointer" title="Confirm" onclick={() => { deleteFile(fileRecord.id); confirmDeleteId = null; }}>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
<button class="text-white/60 hover:text-white transition-colors cursor-pointer" title="Cancel" onclick={() => confirmDeleteId = null}>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
{:else}
{#if !loading}
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
{/if}
{/each}
</div>
</div>

View File

@@ -8,12 +8,7 @@
Arial, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Fira Mono', monospace;
--color-bg-0: rgb(202, 216, 228);
--color-bg-1: hsl(209, 36%, 86%);
--color-bg-2: hsl(224, 44%, 95%);
--color-theme-1: #ff3e00;
--color-theme-2: #4075a6;
--color-text: rgba(0, 0, 0, 0.7);
--color-text: rgba(255, 255, 255, 0.87);
--column-width: 42rem;
--column-margin-top: 4rem;
font-family: var(--font-body);
@@ -23,12 +18,7 @@
body {
min-height: 100vh;
margin: 0;
background-attachment: fixed;
background-color: var(--color-bg-1);
background-size: 100vw 100vh;
background-image:
radial-gradient(50% 50% at 50% 50%, rgba(255, 255, 255, 0.75) 0%, rgba(255, 255, 255, 0) 100%),
linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 15%, var(--color-bg-2) 50%);
background-color: #0f1117;
}
h1,