refactor(frontend): extract types, API, helpers, and preview from +page.svelte
This commit is contained in:
74
frontend/src/lib/api.ts
Normal file
74
frontend/src/lib/api.ts
Normal 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' });
|
||||||
|
}
|
||||||
34
frontend/src/lib/components/FilePreview.svelte
Normal file
34
frontend/src/lib/components/FilePreview.svelte
Normal 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>
|
||||||
91
frontend/src/lib/components/FileRow.svelte
Normal file
91
frontend/src/lib/components/FileRow.svelte
Normal 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>
|
||||||
13
frontend/src/lib/format.ts
Normal file
13
frontend/src/lib/format.ts
Normal 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';
|
||||||
|
}
|
||||||
6
frontend/src/lib/types.ts
Normal file
6
frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type FileRecord = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
uploadedAt: string;
|
||||||
|
};
|
||||||
@@ -2,69 +2,40 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { flip } from 'svelte/animate'
|
import { flip } from 'svelte/animate'
|
||||||
|
import type { FileRecord } from '$lib/types';
|
||||||
type FileRecord = {
|
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
|
||||||
id: number;
|
import FileRow from '$lib/components/FileRow.svelte';
|
||||||
name: string;
|
import FilePreview from '$lib/components/FilePreview.svelte';
|
||||||
size: number;
|
|
||||||
uploadedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let fileRecords = $state<FileRecord[]>([]);
|
let fileRecords = $state<FileRecord[]>([]);
|
||||||
let confirmDeleteId = $state<number | null>(null);
|
|
||||||
let selectedFileId = $state<number | null>(null);
|
let selectedFileId = $state<number | null>(null);
|
||||||
let previewFile = $state<FileRecord | null>(null);
|
let previewFile = $state<FileRecord | null>(null);
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
fileRecords = await loadFiles();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
let filteredFileRecords = $derived(fileRecords.filter(f =>
|
let filteredFileRecords = $derived(fileRecords.filter(f =>
|
||||||
f.name.toLowerCase().includes(search.toLowerCase())
|
f.name.toLowerCase().includes(search.toLowerCase())
|
||||||
));
|
));
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
|
|
||||||
function openFilePicker() {
|
async function handleUpload(event: Event) {
|
||||||
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) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
await uploadFile(file)
|
||||||
formData.append('file', file);
|
|
||||||
await fetch('http://localhost:3000/api/files', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
await loadFiles();
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(fileId: number, fileName: string) {
|
async function handleDownload(fileId: number, fileName: string) {
|
||||||
const res = await fetch(`http://localhost:3000/api/files/${fileId}/download`, {
|
const blob = await downloadFile(fileId);
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -73,40 +44,17 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyLink() {
|
async function handleDelete(fileId: number) {
|
||||||
await loadFiles();
|
await deleteFile(fileId);
|
||||||
//TODO: Generate sharable link with expiration date, add param fileId: number
|
await refresh();
|
||||||
}
|
|
||||||
|
|
||||||
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 handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch('http://localhost:3000/api/auth/logout', {
|
await logout();
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
window.location.href = '/auth';
|
window.location.href = '/auth';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(refresh)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full px-4 py-8">
|
<div class="w-full px-4 py-8">
|
||||||
@@ -133,9 +81,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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
|
Import
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,96 +99,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||||
<div
|
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
|
||||||
transition:fade={{ duration: 200 }}
|
<FileRow
|
||||||
animate:flip={{ duration: 200 }}
|
file={fileRecord}
|
||||||
onclick={() => selectedFileId = fileRecord.id}
|
selected={selectedFileId === fileRecord.id}
|
||||||
ondblclick={() => previewFile = fileRecord}
|
onselect={() => selectedFileId = fileRecord.id}
|
||||||
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'}"
|
onpreview={() => previewFile = fileRecord}
|
||||||
>
|
ondownload={() => handleDownload(fileRecord.id, fileRecord.name)}
|
||||||
<span class="font-medium text-white text-sm flex-1 truncate py-2 pl-6">{formatName(fileRecord.name)}</span>
|
ondelete={() => handleDelete(fileRecord.id)}
|
||||||
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if !loading}
|
{#if !loading}<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>{/if}
|
||||||
<p class="py-2 px-4 text-white/30 text-sm">No files yet.</p>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if previewFile}
|
{#if previewFile}
|
||||||
<div
|
<FilePreview file={previewFile} onclose={() => previewFile = null} />
|
||||||
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>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { submit } from "$lib/api";
|
||||||
|
|
||||||
let activeTab = $state<'login' | 'register'>('login');
|
let activeTab = $state<'login' | 'register'>('login');
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const endpoint = activeTab === 'login' ? '/api/auth/login' : '/api/auth/register';
|
try {
|
||||||
const res = await fetch(`http://localhost:3000${endpoint}`, {
|
await submit(activeTab, username, password);
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} else {
|
} catch {
|
||||||
error = 'Invalid credentials';
|
error = 'Invalid credentials';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 flex items-center justify-center -mt-16">
|
<div class="flex-1 flex items-center justify-center -mt-16">
|
||||||
@@ -50,7 +43,7 @@
|
|||||||
bind:value={username}
|
bind:value={username}
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="username"
|
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>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -60,7 +53,7 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-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>
|
</div>
|
||||||
{#if error}<p class="text-red-400/60 text-xs">{error}</p>{/if}
|
{#if error}<p class="text-red-400/60 text-xs">{error}</p>{/if}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000'
|
||||||
|
}
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
projects: [
|
projects: [
|
||||||
|
|||||||
Reference in New Issue
Block a user