Files
ds-galo/frontend/src/lib/components/PhotoGrid.svelte
T
iu ebae341c9a feat(ui): open photo on tap for touch and fix narrow-screen panel
- 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
2026-10-11 17:58:04 +03:00

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}