В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
175 lines
5.3 KiB
Svelte
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}
|