feat(frontend): add tile view mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
213
frontend/src/lib/components/TileCard.svelte
Normal file
213
frontend/src/lib/components/TileCard.svelte
Normal 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>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { FileRecord } from '$lib/types';
|
import type { FileRecord } from '$lib/types';
|
||||||
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
|
import { deleteFile, downloadFile, loadFiles, logout, uploadFile } from '$lib/api';
|
||||||
import FileRow from '$lib/components/FileRow.svelte';
|
import FileRow from '$lib/components/FileRow.svelte';
|
||||||
|
import TileCard from '$lib/components/TileCard.svelte';
|
||||||
import FilePreview from '$lib/components/FilePreview.svelte';
|
import FilePreview from '$lib/components/FilePreview.svelte';
|
||||||
|
|
||||||
let fileRecords = $state<FileRecord[]>([]);
|
let fileRecords = $state<FileRecord[]>([]);
|
||||||
@@ -12,6 +13,12 @@
|
|||||||
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);
|
||||||
|
let viewMode = $state<'rows' | 'tiles'>('rows');
|
||||||
|
|
||||||
|
function setViewMode(mode: 'rows' | 'tiles') {
|
||||||
|
viewMode = mode;
|
||||||
|
localStorage.setItem('viewMode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
fileRecords = await loadFiles();
|
fileRecords = await loadFiles();
|
||||||
@@ -54,7 +61,11 @@
|
|||||||
window.location.href = '/auth';
|
window.location.href = '/auth';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(refresh);
|
onMount(() => {
|
||||||
|
const saved = localStorage.getItem('viewMode');
|
||||||
|
if (saved === 'rows' || saved === 'tiles') viewMode = saved;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="w-full px-4 py-8">
|
<div class="w-full px-4 py-8">
|
||||||
@@ -92,17 +103,69 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
|
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
|
||||||
<div class="mb-1">
|
<div class="mb-1 flex items-center justify-between">
|
||||||
<button
|
<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"
|
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()}
|
onclick={() => fileInput.click()}
|
||||||
>
|
>
|
||||||
Import
|
Import
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
{#if viewMode === 'rows'}
|
||||||
<div class="rounded-sm border border-sky-200/20">
|
<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">
|
<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="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>
|
<span class="w-40 py-2 text-sm text-white/40">Size</span>
|
||||||
@@ -123,9 +186,31 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/each}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if previewFile}
|
{#if previewFile}
|
||||||
|
|||||||
Reference in New Issue
Block a user