feat(frontend): add tile view mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:49:54 +03:00
parent be3f7da565
commit d153e1c251
2 changed files with 325 additions and 27 deletions

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import type { FileRecord } from '$lib/types';
import { formatName } from '$lib/format';
let {
file,
selected,
onselect,
onpreview,
ondownload,
ondelete
}: {
file: FileRecord;
selected: boolean;
onselect: () => void;
onpreview: () => void;
ondownload: () => void;
ondelete: () => void;
} = $props();
const isImage = $derived(file.fileType.includes('image'));
const isVideo = $derived(file.fileType.includes('video'));
let confirming = $state(false);
</script>
<div
role="button"
tabindex="0"
onclick={onselect}
ondblclick={onpreview}
onkeydown={(e) => {
if (e.key === 'Enter') {
onpreview();
} else if (e.key === ' ') {
e.preventDefault();
onselect();
}
}}
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50
{selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}"
>
{#if isImage}
<img
src={`/api/files/${file.id}/download`}
alt={file.name}
class="h-full w-full object-cover"
/>
{:else}
<div class="flex h-full w-full items-center justify-center bg-white/5 text-white/30">
{#if isVideo}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="2" y="2" width="20" height="20" rx="2.18" />
<line x1="7" y1="2" x2="7" y2="22" />
<line x1="17" y1="2" x2="17" y2="22" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="2" y1="7" x2="7" y2="7" />
<line x1="2" y1="17" x2="7" y2="17" />
<line x1="17" y1="17" x2="22" y2="17" />
<line x1="17" y1="7" x2="22" y2="7" />
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
<polyline points="13 2 13 9 20 9" />
</svg>
{/if}
</div>
{/if}
{#if isVideo}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-black/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
</div>
{/if}
<div
class="pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 to-transparent px-2 py-1.5"
>
<span class="block truncate text-xs font-medium text-white">{formatName(file.name)}</span>
</div>
<div
class="absolute top-1 right-1 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
{#if confirming}
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-emerald-400"
title="Confirm delete"
aria-label="Confirm delete"
onclick={(e) => {
e.stopPropagation();
ondelete();
confirming = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-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="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-white"
title="Cancel"
aria-label="Cancel"
onclick={(e) => {
e.stopPropagation();
confirming = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-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>
{:else}
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-emerald-400"
title="Download"
aria-label="Download"
onclick={(e) => {
e.stopPropagation();
ondownload();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-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>
<button
class="cursor-pointer rounded bg-black/60 p-1 text-white/70 transition-colors hover:text-red-400"
title="Delete"
aria-label="Delete"
onclick={(e) => {
e.stopPropagation();
confirming = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-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}
</div>
</div>

View File

@@ -5,6 +5,7 @@
import type { FileRecord } from '$lib/types';
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
import FileRow from '$lib/components/FileRow.svelte';
import TileCard from '$lib/components/TileCard.svelte';
import FilePreview from '$lib/components/FilePreview.svelte';
let fileRecords = $state<FileRecord[]>([]);
@@ -12,6 +13,12 @@
let previewFile = $state<FileRecord | null>(null);
let search = $state('');
let loading = $state(true);
let viewMode = $state<'rows' | 'tiles'>('rows');
function setViewMode(mode: 'rows' | 'tiles') {
viewMode = mode;
localStorage.setItem('viewMode', mode);
}
async function refresh() {
fileRecords = await loadFiles();
@@ -54,7 +61,11 @@
window.location.href = '/auth';
}
onMount(refresh);
onMount(() => {
const saved = localStorage.getItem('viewMode');
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
refresh();
});
</script>
<div class="w-full px-4 py-8">
@@ -92,17 +103,69 @@
</div>
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
<div class="mb-1">
<div class="mb-1 flex items-center justify-between">
<button
class="cursor-pointer rounded-sm border border-sky-200/20 px-2 py-1 text-sm text-white/60 transition-colors hover:border-sky-200/40"
onclick={() => fileInput.click()}
>
Import
</button>
<div class="flex gap-0.5 rounded-sm border border-sky-200/20 p-0.5">
<button
class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'rows'
? 'bg-white/10 text-white'
: 'text-white/40 hover:text-white/70'}"
title="List view"
aria-label="List view"
onclick={() => setViewMode('rows')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" />
<line x1="3" y1="12" x2="3.01" y2="12" />
<line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
<button
class="cursor-pointer rounded-sm px-1.5 py-1 transition-colors {viewMode === 'tiles'
? 'bg-white/10 text-white'
: 'text-white/40 hover:text-white/70'}"
title="Tile view"
aria-label="Tile view"
onclick={() => setViewMode('tiles')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</button>
</div>
</div>
{#if viewMode === 'rows'}
<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="flex-1 py-2 pl-6 text-sm font-medium text-white/40">Name</span>
<span class="w-40 py-2 text-sm text-white/40">Size</span>
@@ -123,9 +186,31 @@
/>
</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>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each filteredFileRecords as fileRecord (fileRecord.id)}
<div transition:fade={{ duration: 200 }} animate:flip={{ duration: 200 }}>
<TileCard
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="col-span-full px-4 py-2 text-sm text-white/30">No files yet.</p>
{/if}
{/each}
</div>
{/if}
</div>
{#if previewFile}