- Touch/pen tap opens the viewer; mouse click still selects, dblclick opens - Panel and rail emit a single transform utility so the panel actually slides in below 1280px (Tailwind v4 utility ordering made the base translate-x-full win over the conditional translate-x-0) - Add touch-action: manipulation to grid cells
120 lines
3.5 KiB
Svelte
120 lines
3.5 KiB
Svelte
<script lang="ts">
|
|
import Icon from './Icon.svelte';
|
|
import { density } from '$lib/stores/density.svelte';
|
|
import { isPanoTag } from '$lib/pano';
|
|
import type { Photo } from '$lib/types';
|
|
|
|
interface Props {
|
|
photos: Photo[];
|
|
selectedIds?: string[];
|
|
cursorIndex?: number | null;
|
|
cursorVisible?: boolean;
|
|
onSelect?: (index: number, event: MouseEvent) => void;
|
|
onOpen?: (index: number) => void;
|
|
onDragStart?: (index: number, event: DragEvent) => void;
|
|
onContextMenu?: (index: number, event: MouseEvent) => void;
|
|
onCursorFocus?: (index: number) => void;
|
|
}
|
|
|
|
let {
|
|
photos,
|
|
selectedIds = [],
|
|
cursorIndex = null,
|
|
cursorVisible = false,
|
|
onSelect,
|
|
onOpen,
|
|
onDragStart,
|
|
onContextMenu,
|
|
onCursorFocus
|
|
}: Props = $props();
|
|
|
|
const isSelected = (id: string) => selectedIds.includes(id);
|
|
const isCursor = (i: number) => cursorIndex === i;
|
|
const tabIndexFor = (i: number) =>
|
|
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
|
|
|
let lastPointer = '';
|
|
function activate(i: number, e: MouseEvent) {
|
|
const touch = lastPointer === 'touch' || lastPointer === 'pen';
|
|
lastPointer = '';
|
|
if (touch) onOpen?.(i);
|
|
else onSelect?.(i, e);
|
|
}
|
|
</script>
|
|
|
|
{#if photos.length === 0}
|
|
<p class="text-text-muted p-8 text-center">Пока нет фотографий.</p>
|
|
{:else}
|
|
<ul
|
|
class="grid gap-2"
|
|
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
|
|
>
|
|
{#each photos as photo, i (photo.id)}
|
|
<li>
|
|
<button
|
|
type="button"
|
|
data-photo-cell
|
|
tabindex={tabIndexFor(i)}
|
|
draggable={isSelected(photo.id)}
|
|
class="border-border bg-bg-muted group relative block aspect-square w-full touch-manipulation overflow-hidden rounded-lg border {isSelected(
|
|
photo.id
|
|
)
|
|
? 'ring-primary ring-2'
|
|
: ''} {isCursor(i) && cursorVisible
|
|
? 'outline outline-2 outline-offset-2 outline-text'
|
|
: ''}"
|
|
aria-pressed={isSelected(photo.id)}
|
|
onpointerdown={(e) => (lastPointer = e.pointerType)}
|
|
onfocus={() => {
|
|
if (lastPointer !== 'touch' && lastPointer !== 'pen') onCursorFocus?.(i);
|
|
}}
|
|
onclick={(e) => activate(i, e)}
|
|
ondblclick={() => onOpen?.(i)}
|
|
ondragstart={(e) => onDragStart?.(i, e)}
|
|
oncontextmenu={(e) => onContextMenu?.(i, e)}
|
|
>
|
|
{#if photo.thumb_url}
|
|
<img
|
|
src={photo.thumb_url}
|
|
alt={photo.original_filename}
|
|
loading="lazy"
|
|
decoding="async"
|
|
class="pointer-events-none h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
/>
|
|
{:else}
|
|
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
|
обработка…
|
|
</span>
|
|
{/if}
|
|
{#if isPanoTag(photo)}
|
|
<span
|
|
class="bg-bg/70 text-text absolute top-1 left-1 rounded px-1.5 py-0.5 text-[10px] font-semibold"
|
|
aria-label="360-панорама"
|
|
>
|
|
360
|
|
</span>
|
|
{/if}
|
|
<span
|
|
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
|
|
>
|
|
<span class="inline-flex items-center gap-0.5" aria-label="{photo.likes_count} лайков">
|
|
<Icon
|
|
name={photo.liked_by_me ? 'favorite' : 'favorite_outline'}
|
|
class="size-3.5"
|
|
/>
|
|
{photo.likes_count}
|
|
</span>
|
|
<span
|
|
class="inline-flex items-center gap-0.5"
|
|
aria-label="{photo.comments_count} комментариев"
|
|
>
|
|
<Icon name="mode_comment" class="size-3.5" />
|
|
{photo.comments_count}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|