feat: Lightroom-style workspace UI #2
@@ -8,6 +8,7 @@
|
|||||||
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
|
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
|
||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
|
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
|
||||||
import type { Album, Photo, Preferences } from '$lib/types';
|
import type { Album, Photo, Preferences } from '$lib/types';
|
||||||
|
|
||||||
@@ -25,10 +26,15 @@
|
|||||||
let savingName = $state(false);
|
let savingName = $state(false);
|
||||||
let nameError = $state('');
|
let nameError = $state('');
|
||||||
|
|
||||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
const selectedPhoto = $derived(selection.albumId === albumId ? selection.photo : null);
|
||||||
const canRename = $derived(!!photo && (canEdit || photo.uploader_id === auth.user?.id));
|
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
|
||||||
|
|
||||||
const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>);
|
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||||
|
const canRename = $derived(
|
||||||
|
!!panelPhoto && (canEdit || panelPhoto.uploader_id === auth.user?.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const exif = $derived((panelPhoto?.exif ?? {}) as Record<string, unknown>);
|
||||||
|
|
||||||
const prefs = $derived(auth.preferences);
|
const prefs = $derived(auth.preferences);
|
||||||
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
|
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
|
||||||
@@ -97,24 +103,33 @@
|
|||||||
.catch((e) => (error = (e as Error).message));
|
.catch((e) => (error = (e as Error).message));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// сброс временного состояния панели при выборе другого фото в сетке
|
||||||
|
selectedPhoto?.id;
|
||||||
|
editingName = false;
|
||||||
|
nameError = '';
|
||||||
|
showAll = false;
|
||||||
|
});
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
if (!photo) return;
|
if (!panelPhoto) return;
|
||||||
nameDraft = photo.original_filename;
|
nameDraft = panelPhoto.original_filename;
|
||||||
nameError = '';
|
nameError = '';
|
||||||
editingName = true;
|
editingName = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveName(ev: Event) {
|
async function saveName(ev: Event) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (!photo || !nameDraft.trim()) return;
|
if (!panelPhoto || !nameDraft.trim()) return;
|
||||||
savingName = true;
|
savingName = true;
|
||||||
nameError = '';
|
nameError = '';
|
||||||
try {
|
try {
|
||||||
const data = await api<{ photo: Photo }>(`/photos/${photo.id}`, {
|
const data = await api<{ photo: Photo }>(`/photos/${panelPhoto.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { original_filename: nameDraft.trim() }
|
body: { original_filename: nameDraft.trim() }
|
||||||
});
|
});
|
||||||
photo = data.photo;
|
photo = data.photo;
|
||||||
|
if (selectedPhoto?.id === data.photo.id) selection.select(albumId, data.photo);
|
||||||
editingName = false;
|
editingName = false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
nameError = (e as Error).message;
|
nameError = (e as Error).message;
|
||||||
@@ -154,8 +169,8 @@
|
|||||||
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
|
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if photoId}
|
{#if panelPhoto}
|
||||||
{#if photo}
|
{@const p = panelPhoto}
|
||||||
<div>
|
<div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
{#if editingName}
|
{#if editingName}
|
||||||
@@ -195,10 +210,10 @@
|
|||||||
title="Переименовать"
|
title="Переименовать"
|
||||||
onclick={startEdit}
|
onclick={startEdit}
|
||||||
>
|
>
|
||||||
{photo.original_filename}
|
{p.original_filename}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<h2 class="text-text truncate text-sm font-semibold">{photo.original_filename}</h2>
|
<h2 class="text-text truncate text-sm font-semibold">{p.original_filename}</h2>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,12 +233,12 @@
|
|||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<dl class="space-y-1">
|
<dl class="space-y-1">
|
||||||
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
|
<div class={metaRow}><span>Автор</span><span class="text-text">{p.uploader.name}</span></div>
|
||||||
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
|
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(p.size)}</span></div>
|
||||||
<div class={metaRow}><span>Тип</span><span class="text-text">{photo.mime_type}</span></div>
|
<div class={metaRow}><span>Тип</span><span class="text-text">{p.mime_type}</span></div>
|
||||||
<div class={metaRow}>
|
<div class={metaRow}>
|
||||||
<span>Разрешение</span>
|
<span>Разрешение</span>
|
||||||
<span class="text-text">{photo.width}×{photo.height}</span>
|
<span class="text-text">{p.width}×{p.height}</span>
|
||||||
</div>
|
</div>
|
||||||
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
||||||
<div class={metaRow}>
|
<div class={metaRow}>
|
||||||
@@ -242,27 +257,27 @@
|
|||||||
…
|
…
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if photo.taken_at}
|
{#if p.taken_at}
|
||||||
<div class={metaRow}>
|
<div class={metaRow}>
|
||||||
<span>Снято</span>
|
<span>Снято</span>
|
||||||
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
|
<span class="text-text">{new Date(p.taken_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if photo.latitude != null && photo.longitude != null}
|
{#if p.latitude != null && p.longitude != null}
|
||||||
<div class={metaRow}>
|
<div class={metaRow}>
|
||||||
<span>Координаты</span>
|
<span>Координаты</span>
|
||||||
<span class="text-text">{photo.latitude.toFixed(5)}, {photo.longitude.toFixed(5)}</span>
|
<span class="text-text">{p.latitude.toFixed(5)}, {p.longitude.toFixed(5)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class={metaRow}>
|
<div class={metaRow}>
|
||||||
<span>Загружено</span>
|
<span>Загружено</span>
|
||||||
<span class="text-text">{new Date(photo.created_at).toLocaleString()}</span>
|
<span class="text-text">{new Date(p.created_at).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection id="photo-comments" title="Комментарии">
|
<PanelSection id="photo-comments" title="Комментарии">
|
||||||
<Comments photoId={photo.id} />
|
<Comments photoId={p.id} />
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,8 +293,7 @@
|
|||||||
onClose={() => (settingsOpen = false)}
|
onClose={() => (settingsOpen = false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{:else if album && !photoId}
|
||||||
{:else if album}
|
|
||||||
<div>
|
<div>
|
||||||
<PanelSection id="album-cover" title="Обложка">
|
<PanelSection id="album-cover" title="Обложка">
|
||||||
<div class="bg-bg-muted border-border aspect-video overflow-hidden rounded-lg border">
|
<div class="bg-bg-muted border-border aspect-video overflow-hidden rounded-lg border">
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
|
selectedId?: string | null;
|
||||||
|
onSelect?: (index: number) => void;
|
||||||
onOpen?: (index: number) => void;
|
onOpen?: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { photos, onOpen }: Props = $props();
|
let { photos, selectedId = null, onSelect, onOpen }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if photos.length === 0}
|
{#if photos.length === 0}
|
||||||
@@ -22,8 +24,13 @@
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border"
|
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border {photo.id ===
|
||||||
onclick={() => onOpen?.(i)}
|
selectedId
|
||||||
|
? 'ring-primary ring-2'
|
||||||
|
: ''}"
|
||||||
|
aria-pressed={photo.id === selectedId}
|
||||||
|
onclick={() => onSelect?.(i)}
|
||||||
|
ondblclick={() => onOpen?.(i)}
|
||||||
>
|
>
|
||||||
{#if photo.thumb_url}
|
{#if photo.thumb_url}
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
|
class SelectionStore {
|
||||||
|
albumId = $state('');
|
||||||
|
photo = $state<Photo | null>(null);
|
||||||
|
|
||||||
|
select(albumId: string, photo: Photo) {
|
||||||
|
this.albumId = albumId;
|
||||||
|
this.photo = photo;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.albumId = '';
|
||||||
|
this.photo = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selection = new SelectionStore();
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { albums } from '$lib/stores/albums.svelte';
|
import { albums } from '$lib/stores/albums.svelte';
|
||||||
|
import { selection } from '$lib/stores/selection.svelte';
|
||||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = albumId;
|
const id = albumId;
|
||||||
|
selection.clear();
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
Promise.all([loadAlbum(), loadPhotos()])
|
Promise.all([loadAlbum(), loadPhotos()])
|
||||||
@@ -200,6 +202,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<PhotoGrid
|
<PhotoGrid
|
||||||
{photos}
|
{photos}
|
||||||
|
selectedId={selection.albumId === albumId ? (selection.photo?.id ?? null) : null}
|
||||||
|
onSelect={(i) => {
|
||||||
|
const p = photos[i];
|
||||||
|
if (p) selection.select(albumId, p);
|
||||||
|
}}
|
||||||
onOpen={(i) => {
|
onOpen={(i) => {
|
||||||
const p = photos[i];
|
const p = photos[i];
|
||||||
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
||||||
|
|||||||
@@ -104,10 +104,13 @@ filmstrip, панель метаданных/комментариев, тулб
|
|||||||
|
|
||||||
### 13. Просмотр: полный экран и зум
|
### 13. Просмотр: полный экран и зум
|
||||||
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
|
Полный экран — `requestFullscreen` на корне `Viewer` (кнопка + `f`), состояние — по
|
||||||
`fullscreenchange`. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
|
`fullscreenchange`. `f`/кнопка переключают именно полноэкранный режим вьюера: если активен
|
||||||
скрыты и всплывают у соответствующих кромок (панели — по касанию края, скрытие ~1 с после
|
полноэкранный режим приложения, `f` входит в просмотр фото вложенным режимом и не выключает
|
||||||
ухода; стрелки — при курсоре в 1/6 левой/правой области, скрытие ~300 мс; в зуме стрелки
|
режим приложения. В полноэкранном режиме верхняя/нижняя панели и стрелки соседних фото
|
||||||
скрыты полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
|
всплывают у соответствующих кромок без таймеров — показываются и скрываются сразу по позиции
|
||||||
|
курсора (панели — по касанию края; стрелки — при курсоре в 1/6 левой/правой области, только
|
||||||
|
внутри границ вьюера, поэтому при уходе в боковые панели они скрыты; в зуме стрелки скрыты
|
||||||
|
полностью). Зум — состояние `zoomed`/`scale` с масштабом от истинных размеров фото:
|
||||||
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
|
колесо зумит с привязкой к указателю (без прокрутки, `overscroll-contain`), предел — 200%;
|
||||||
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
|
`Space` возвращает из любого зума во вписанный вид, а из вписанного — в 100%; вход в зум
|
||||||
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
|
догружает оригинал и подменяет его по факту загрузки (до этого — превью); панорамирование —
|
||||||
@@ -162,6 +165,15 @@ EXIF извлекается при загрузке и хранится в `phot
|
|||||||
`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена:
|
`exif`/`taken_at`/координаты. Альтернатива — фиксированный набор полей — отклонена:
|
||||||
пользователю нужны все атрибуты.
|
пользователю нужны все атрибуты.
|
||||||
|
|
||||||
|
### 19. Выбор фото в сетке через общий стор
|
||||||
|
Выбор фото в сетке и контекстная панель живут в разных местах дерева (страница маршрута и слот
|
||||||
|
`AppShell`), поэтому выбранное фото хранится в общем сторе `selection` (`albumId` + `photo`).
|
||||||
|
Одиночный клик в `PhotoGrid` записывает выбор (рамка `ring` + `aria-pressed`), и панель
|
||||||
|
показывает сведения о выбранном фото; двойной клик открывает маршрут просмотра. Панель
|
||||||
|
использует `panelPhoto = photoId ? routePhoto : selectedPhoto`, поэтому в просмотре приоритет у
|
||||||
|
маршрута, а в альбоме — у выбора; выбор сбрасывается при смене альбома. Альтернатива — хранить
|
||||||
|
выбор в URL (query) — избыточно для локального выделения.
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
- [Много альбомов → много presign в списке] → presign это локальная крипто-операция без сети;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
удаление текущего фото с модальным подтверждением; возврат к обзору.
|
удаление текущего фото с модальным подтверждением; возврат к обзору.
|
||||||
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
|
||||||
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
|
||||||
|
Выбор фото в сетке показывается в панели (одиночный клик), двойной клик открывает просмотр.
|
||||||
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
|
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
|
||||||
модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять
|
модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять
|
||||||
все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…»
|
все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…»
|
||||||
|
|||||||
@@ -161,12 +161,16 @@ SHALL отображаться вне каркаса, но в общей тем
|
|||||||
|
|
||||||
#### Scenario: Панель по кромке
|
#### Scenario: Панель по кромке
|
||||||
- **WHEN** курсор касается верхней или нижней кромки экрана
|
- **WHEN** курсор касается верхней или нижней кромки экрана
|
||||||
- **THEN** соответствующая панель показывается и скрывается примерно через секунду после ухода курсора
|
- **THEN** соответствующая панель показывается, а при уходе курсора скрывается сразу
|
||||||
|
|
||||||
#### Scenario: Выход из полного экрана
|
#### Scenario: Выход из полного экрана
|
||||||
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
|
- **WHEN** пользователь повторно нажимает кнопку или клавишу `f`
|
||||||
- **THEN** полноэкранный режим закрывается
|
- **THEN** полноэкранный режим закрывается
|
||||||
|
|
||||||
|
#### Scenario: Полноэкранный режим приложения
|
||||||
|
- **WHEN** включён полноэкранный режим приложения, и пользователь нажимает `f`
|
||||||
|
- **THEN** открывается полноэкранный просмотр фото (вложенно), а полноэкранный режим приложения не выключается
|
||||||
|
|
||||||
### Requirement: Зум в просмотре
|
### Requirement: Зум в просмотре
|
||||||
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
Просмотр SHALL поддерживать зум колесом мыши без прокрутки, с привязкой к указателю и
|
||||||
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
|
верхним пределом 200% размеров фото. `Space` SHALL возвращать изображение из любого зума во
|
||||||
@@ -188,7 +192,7 @@ SHALL отображаться вне каркаса, но в общей тем
|
|||||||
|
|
||||||
#### Scenario: Стрелки соседних фото
|
#### Scenario: Стрелки соседних фото
|
||||||
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
- **WHEN** курсор находится у левой или правой кромки области просмотра
|
||||||
- **THEN** показывается соответствующая стрелка, скрываясь примерно через 300 мс после ухода; в зуме стрелки скрыты
|
- **THEN** показывается соответствующая стрелка, скрываясь сразу после ухода; в зуме стрелки скрыты
|
||||||
|
|
||||||
#### Scenario: Панорамирование
|
#### Scenario: Панорамирование
|
||||||
- **WHEN** фото увеличено
|
- **WHEN** фото увеличено
|
||||||
@@ -257,3 +261,19 @@ SHALL отображаться вне каркаса, но в общей тем
|
|||||||
#### Scenario: Нет скрытых атрибутов
|
#### Scenario: Нет скрытых атрибутов
|
||||||
- **WHEN** ни один атрибут не скрыт
|
- **WHEN** ни один атрибут не скрыт
|
||||||
- **THEN** элемент «…» не отображается
|
- **THEN** элемент «…» не отображается
|
||||||
|
|
||||||
|
### Requirement: Выбор фото в сетке
|
||||||
|
В режиме альбома одиночный выбор фото SHALL отмечать его рамкой и показывать его сведения в
|
||||||
|
контекстной панели, не открывая просмотр; двойной выбор SHALL открывать режим просмотра фото.
|
||||||
|
|
||||||
|
#### Scenario: Одиночный выбор
|
||||||
|
- **WHEN** пользователь кликает по фото в сетке
|
||||||
|
- **THEN** фото отмечено рамкой, правая панель показывает сведения об этом фото, просмотр не открывается
|
||||||
|
|
||||||
|
#### Scenario: Двойной выбор
|
||||||
|
- **WHEN** пользователь дважды кликает по фото в сетке
|
||||||
|
- **THEN** открывается режим просмотра этого фото
|
||||||
|
|
||||||
|
#### Scenario: Смена альбома
|
||||||
|
- **WHEN** пользователь переходит в другой альбом
|
||||||
|
- **THEN** отметка выбора сбрасывается
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
- [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит.
|
- [x] 8.1 Переименование фото: `PATCH /photos/:id` (права автора/owner/admin, пустое имя 400) и инлайн-редактор в правой панели; событие `photo.updated`. Проверка: переименование сохраняется, пустое имя отклоняется, чужое фото — отказ, событие приходит.
|
||||||
- [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото.
|
- [x] 8.2 Удаление фото из просмотра с модальным подтверждением; переход к соседнему и обновление ленты. Проверка: после удаления лента не содержит удалённое фото.
|
||||||
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, скрытие ~300 мс, в зуме скрыты); зум колесом к указателю без прокрутки (макс 200%), `Space` возвращает из любого зума во вписанный вид, в зуме подгружается оригинал. Проверка: поведение подтверждено в браузере (автоскрытие, зум к указателю, предел 200%, подмена превью→оригинал).
|
- [x] 8.3 Полноэкранный просмотр (кнопка и `f`) с автоскрытием верхней/нижней панелей у кромок и стрелок (1/6 края, в зуме скрыты); поведение без таймеров — панели и стрелки показываются/скрываются сразу, стрелки только в границах вьюера; зум колесом к указателю без прокрутки (макс 200%), `Space` возвращает из любого зума во вписанный вид, в зуме подгружается оригинал. Проверка: поведение подтверждено в браузере (автоскрытие, зум к указателю, предел 200%, подмена превью→оригинал).
|
||||||
- [x] 8.4 Сворачиваемые разделы правой панели с сохранением состояния и раздел «Информация» (EXIF). Проверка: состояние сохраняется между сессиями, метаданные отображаются.
|
- [x] 8.4 Сворачиваемые разделы правой панели с сохранением состояния и раздел «Информация» (EXIF). Проверка: состояние сохраняется между сессиями, метаданные отображаются.
|
||||||
- [x] 8.5 Ресайз левой и правой панелей перетаскиванием с сохранением ширины. Проверка: ширина восстанавливается после перезагрузки.
|
- [x] 8.5 Ресайз левой и правой панелей перетаскиванием с сохранением ширины. Проверка: ширина восстанавливается после перезагрузки.
|
||||||
- [x] 8.6 Лимит загрузки 100 МБ и понятные ошибки (клиентская проверка и `413 file_too_large`). Проверка: 60 МБ проходит разбор, превышение лимита даёт явную ошибку размера.
|
- [x] 8.6 Лимит загрузки 100 МБ и понятные ошибки (клиентская проверка и `413 file_too_large`). Проверка: 60 МБ проходит разбор, превышение лимита даёт явную ошибку размера.
|
||||||
@@ -67,3 +67,5 @@
|
|||||||
- [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
|
- [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
|
||||||
- [x] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
- [x] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`.
|
||||||
- [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12).
|
- [x] 9.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12).
|
||||||
|
- [x] 9.9 Выбор фото в сетке: одиночный клик выделяет фото рамкой и показывает его сведения в правой панели, двойной — открывает просмотр; стор `selection`, сброс при смене альбома. Проверка: Playwright — выделение без навигации, панель показывает инфо, двойной клик открывает просмотр (5/5).
|
||||||
|
- [x] 9.10 Вьюер и модалки: убраны таймеры автоскрытия (панели/стрелки мгновенно), стрелки появляются только в границах вьюера, `f`/кнопка переключают вложенный fullscreen вьюера (не выключают режим приложения); модалки рендерятся порталом в `document.body` (центрирование во вьюпорте). Проверка: Playwright — стрелки 4/4, fullscreen+f 6/6, центрирование 2/2.
|
||||||
|
|||||||
Reference in New Issue
Block a user