Files
ds-galo/frontend/src/lib/components/PhotoGrid.svelte
T
iu 0548b1ab5e feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
2026-10-20 23:06:24 +03:00

175 lines
5.3 KiB
Svelte

<script lang="ts">
import Icon from './Icon.svelte';
import { grid } from '$lib/stores/grid.svelte';
import { photoMediumUrl, photoPreviewUrl, photoThumbUrl } from '$lib/edit';
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();
// gap-2 in the grid template is 0.5rem.
const GAP_PX = 8;
let gridWidth = $state(0);
$effect(() => {
grid.width = gridWidth;
});
// Offer every variant the photo actually has; the backend generates the
// medium variant asynchronously, so an incomplete set must still render.
// When the photo is edited, the edited variants are shown.
function srcsetFor(photo: Photo): string {
const parts: string[] = [];
const thumb = photoThumbUrl(photo);
const medium = photoMediumUrl(photo);
const preview = photoPreviewUrl(photo);
if (thumb) parts.push(`${thumb} 200w`);
if (medium) parts.push(`${medium} 800w`);
if (preview) parts.push(`${preview} 1200w`);
return parts.join(', ');
}
const sizes = $derived(
gridWidth > 0
? `${Math.max(1, Math.round((gridWidth - (grid.cols - 1) * GAP_PX) / grid.cols))}px`
: undefined
);
const isSelected = (id: string) => selectedIds.includes(id);
const isCursor = (i: number) => cursorIndex === i;
function formatDuration(ms?: number): string {
if (!ms || ms <= 0) return '';
const total = Math.round(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
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"
bind:clientWidth={gridWidth}
style="grid-template-columns: repeat({grid.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 photoThumbUrl(photo)}
<img
src={photoThumbUrl(photo)}
srcset={srcsetFor(photo)}
{sizes}
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}
{#if photo.kind === 'video'}
<span
class="absolute top-1 left-1 inline-flex items-center justify-center rounded bg-black/60 p-1 text-white"
aria-label="Видео"
>
<Icon name="play_arrow" class="size-4" />
</span>
{#if formatDuration(photo.duration_ms)}
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white"
>
{formatDuration(photo.duration_ms)}
</span>
{/if}
{/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}