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 { 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,40 +103,114 @@
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span>
|
||||
<div class="w-px self-stretch bg-sky-200/20"></div>
|
||||
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span>
|
||||
<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>
|
||||
|
||||
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||
<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}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if viewMode === 'rows'}
|
||||
<div class="rounded-sm border border-sky-200/20">
|
||||
<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>
|
||||
<span class="w-48 py-2 text-sm text-white/40">Uploaded at</span>
|
||||
<div class="w-px self-stretch bg-sky-200/20"></div>
|
||||
<span class="w-32 py-2 text-center text-sm text-white/40">Actions</span>
|
||||
</div>
|
||||
|
||||
{#each filteredFileRecords as fileRecord (fileRecord.id)}
|
||||
<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}
|
||||
{/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}
|
||||
|
||||
Reference in New Issue
Block a user