|
|
|
|
@@ -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>
|
|
|
|
|
|