refactor(frontend): extract types, API, helpers, and preview from +page.svelte

This commit is contained in:
2026-06-01 19:12:21 +03:00
parent b561389175
commit c1bceae5e4
8 changed files with 264 additions and 174 deletions

74
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { FileRecord } from "$lib/types";
export async function loadFiles(): Promise<FileRecord[]> {
const res = await fetch('/api/files', {
credentials: 'include'
});
if (res.status === 401) {
window.location.href = '/auth';
return [];
}
if (!res.ok) {
throw new Error(`Failed to load files: ${res.status}`);
}
return await res.json()
}
export async function uploadFile(file: File): Promise<FileRecord> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/files', {
method: 'POST',
credentials: 'include',
body: formData
});
if (!res.ok) {
throw new Error(`Upload failed: ${res.status}`)
}
return await res.json()
}
export async function downloadFile(fileId: number): Promise<Blob> {
const res = await fetch(`/api/files/${fileId}/download`, {
credentials: 'include'
});
if (!res.ok) {
throw new Error(`Download failed: ${res.status}`)
}
return await res.blob()
}
export async function deleteFile(fileId: number): Promise<void> {
const res = await fetch(`/api/files/${fileId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) {
throw new Error(`Delete failed: ${res.status}`);
}
}
export async function submit(activeTab: string, username: string, password: string): Promise<void> {
const endpoint = activeTab === 'login' ? '/api/auth/login' : '/api/auth/register';
const res = await fetch(`${endpoint}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
throw new Error('Invalid credentials');
}
}
export async function logout(): Promise<void> {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
}

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import type { FileRecord } from "$lib/types";
import { fade } from "svelte/transition";
let { file, onclose }: { file: FileRecord; onclose: () => void } = $props();
</script>
<svelte:window onkeydown={(e) => { if (e.key === 'Escape') onclose(); }} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={onclose}
transition:fade={{ duration: 150 }}
>
<div
class="bg-[#0f1117] border border-sky-200/20 rounded-sm w-2/3 h-2/3 flex flex-col p-6"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between mb-4">
<span class="text-white text-sm font-medium">{file.name}</span>
<button class="text-white/40 hover:text-white transition-colors cursor-pointer" onclick={onclose} aria-label="Close">
<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">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="flex-1 flex items-center justify-center text-white/20 text-sm">
preview coming soon
</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import type { FileRecord } from '$lib/types';
import { formatName, formatSize } from '$lib/format';
let {
file,
selected,
onselect,
onpreview,
ondownload,
ondelete,
}: {
file: FileRecord;
selected: boolean;
onselect: () => void;
onpreview: () => void;
ondownload: () => void;
ondelete: () => void;
} = $props();
let confirming = $state(false);
</script>
<div
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter') {
onpreview();
} else if (e.key === ' ') {
e.preventDefault(); onselect();
}
}
}
onclick={onselect}
ondblclick={onpreview}
class="flex items-stretch border-l hover:bg-white/2 transition-all {selected ? 'border-sky-400/50 bg-white/2 ring-1 ring-inset ring-sky-400/50' : 'border-sky-200/20 border-l-transparent hover:border-l-sky-400/50'}"
>
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(file.name)}</span>
<span class="text-sm text-white/40 w-40 py-2">{formatSize(file.size)}</span>
<span class="text-sm text-white/40 w-48 py-2">{new Date(file.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={ondownload}>
<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">
<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={() => confirming = true}>
<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 confirming}
<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={() => { ondelete(); confirming = false; }}>
<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={() => confirming = false}>
<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>

View File

@@ -0,0 +1,13 @@
export function formatName(name: string): string {
if (name.length > 65) {
name = name.slice(0, 65).concat("...");
}
return name;
}
export 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';
}

View File

@@ -0,0 +1,6 @@
export type FileRecord = {
id: number;
name: string;
size: number;
uploadedAt: string;
};

View File

@@ -2,69 +2,40 @@
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate'
type FileRecord = {
id: number;
name: string;
size: number;
uploadedAt: string;
};
import type { FileRecord } from '$lib/types';
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
import FileRow from '$lib/components/FileRow.svelte';
import FilePreview from '$lib/components/FilePreview.svelte';
let fileRecords = $state<FileRecord[]>([]);
let confirmDeleteId = $state<number | null>(null);
let selectedFileId = $state<number | null>(null);
let previewFile = $state<FileRecord | null>(null);
let search = $state('');
let loading = $state(true);
async function refresh() {
fileRecords = await loadFiles();
loading = false;
}
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'
});
if (res.status === 401) {
window.location.href = '/auth';
return;
}
fileRecords = await res.json();
loading = false;
}
onMount(loadFiles);
async function uploadFile(event: Event) {
async function handleUpload(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
});
await uploadFile(file)
input.value = '';
await loadFiles();
await refresh()
}
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();
async function handleDownload(fileId: number, fileName: string) {
const blob = await downloadFile(fileId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -73,40 +44,17 @@
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 > 65) {
name = name.slice(0, 65).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';
async function handleDelete(fileId: number) {
await deleteFile(fileId);
await refresh();
}
async function handleLogout() {
await fetch('http://localhost:3000/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
await logout();
window.location.href = '/auth';
}
onMount(refresh)
</script>
<div class="w-full px-4 py-8">
@@ -133,9 +81,9 @@
/>
</div>
<input type="file" bind:this={fileInput} onchange={uploadFile} class="hidden" />
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
<div class="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}>
<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={() => fileInput.click()}>
Import
</button>
</div>
@@ -151,96 +99,22 @@
</div>
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div
transition:fade={{ duration: 200 }}
animate:flip={{ duration: 200 }}
onclick={() => selectedFileId = fileRecord.id}
ondblclick={() => previewFile = fileRecord}
class="flex items-stretch border-l hover:bg-white/2 transition-all {selectedFileId === fileRecord.id ? 'border-sky-400/50 bg-white/2 ring-1 ring-inset ring-sky-400/50' : 'border-sky-200/20 border-l-transparent hover:border-l-sky-400/50'}"
>
<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 transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
<FileRow
file={fileRecord}
selected={selectedFileId === fileRecord.id}
onselect={() => selectedFileId = fileRecord.id}
onpreview={() => previewFile = fileRecord}
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
ondelete={() => handleDelete(fileRecord.id)}
/>
</div>
{:else}
{#if !loading}
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
{/if}
{#if !loading}<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>{/if}
{/each}
</div>
</div>
{#if previewFile}
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
onclick={() => previewFile = null}
transition:fade={{ duration: 150 }}
>
<div
class="bg-[#0f1117] border border-sky-200/20 rounded-sm w-2/3 h-2/3 flex flex-col p-6"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between mb-4">
<span class="text-white text-sm font-medium">{previewFile.name}</span>
<button class="text-white/40 hover:text-white transition-colors cursor-pointer" onclick={() => previewFile = 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" 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 class="flex-1 flex items-center justify-center text-white/20 text-sm">
preview coming soon
</div>
</div>
</div>
<FilePreview file={previewFile} onclose={() => previewFile = null} />
{/if}

View File

@@ -1,26 +1,19 @@
<script lang="ts">
import { submit } from "$lib/api";
let activeTab = $state<'login' | 'register'>('login');
let username = $state('');
let password = $state('');
let error = $state('');
async function handleSubmit() {
const endpoint = activeTab === 'login' ? '/api/auth/login' : '/api/auth/register';
const res = await fetch(`http://localhost:3000${endpoint}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (res.ok) {
try {
await submit(activeTab, username, password);
window.location.href = '/';
} else {
} catch {
error = 'Invalid credentials';
}
}
</script>
<div class="flex-1 flex items-center justify-center -mt-16">
@@ -50,7 +43,7 @@
bind:value={username}
type="text"
autocomplete="username"
class="px-4 py-2 rounded-sm !bg-[#0f1117] border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
class="px-4 py-2 rounded-sm bg-[#0f1117]! border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
/>
</div>
<div class="flex flex-col gap-1">
@@ -60,7 +53,7 @@
bind:value={password}
type="password"
autocomplete="current-password"
class="px-4 py-2 rounded-sm !bg-[#0f1117] border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
class="px-4 py-2 rounded-sm bg-[#0f1117]! border border-sky-200/15 transition-colors hover:bg-black/20 hover:border-sky-200/30 text-white/80 text-sm focus:outline-none focus:border-sky-200/40"
/>
</div>
{#if error}<p class="text-red-400/60 text-xs">{error}</p>{/if}

View File

@@ -5,6 +5,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:3000'
}
},
test: {
expect: { requireAssertions: true },
projects: [