feat(preferences): configurable photo info attributes stored in user profile
- backend: users.preferences jsonb (migration 000004), Me returns preferences, PATCH /auth/me/preferences with validation (visible list + labels) - frontend: gear in info section opens PhotoInfoSettingsDialog; base attribute set shown by default, remaining under ellipsis; per-attribute visibility and custom labels persisted to the profile; reset clears settings from the profile - frontend: dialog has select-all/deselect-all icon buttons (no 'show all'); EXIF constants extracted to lib/exif.ts - fix: render modal overlays via portal to document.body so they center in the viewport despite transformed panel/rail ancestors - openspec: sync lightroom-workspace-ui artifacts (spec/design/proposal/tasks 9.8)
This commit is contained in:
@@ -5,9 +5,11 @@
|
||||
import CoverPicker from './CoverPicker.svelte';
|
||||
import Comments from './Comments.svelte';
|
||||
import PanelSection from './PanelSection.svelte';
|
||||
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
|
||||
import type { Album, Photo, Preferences } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
const photoId = $derived($page.params.photoId ?? '');
|
||||
@@ -28,203 +30,41 @@
|
||||
|
||||
const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>);
|
||||
|
||||
const EXIF_LABELS: Record<string, string> = {
|
||||
Make: 'Производитель',
|
||||
Model: 'Модель',
|
||||
LensMake: 'Производитель объектива',
|
||||
LensModel: 'Объектив',
|
||||
ExposureTime: 'Выдержка',
|
||||
ShutterSpeedValue: 'Выдержка (APEX)',
|
||||
FNumber: 'Диафрагма',
|
||||
ApertureValue: 'Диафрагма (APEX)',
|
||||
MaxApertureValue: 'Макс. диафрагма',
|
||||
FocalLength: 'Фокусное расстояние',
|
||||
FocalLengthIn35mmFilm: 'Фокусное (35 мм)',
|
||||
ISOSpeedRatings: 'ISO',
|
||||
PhotographicSensitivity: 'ISO',
|
||||
ExposureProgram: 'Программа съёмки',
|
||||
ExposureMode: 'Режим экспозиции',
|
||||
ExposureBiasValue: 'Экспокоррекция',
|
||||
BrightnessValue: 'Яркость (APEX)',
|
||||
MeteringMode: 'Режим замера',
|
||||
LightSource: 'Источник света',
|
||||
Flash: 'Вспышка',
|
||||
WhiteBalance: 'Баланс белого',
|
||||
Orientation: 'Ориентация',
|
||||
Software: 'ПО',
|
||||
ColorSpace: 'Цветовое пространство',
|
||||
SceneCaptureType: 'Тип сцены',
|
||||
Contrast: 'Контраст',
|
||||
Saturation: 'Насыщенность',
|
||||
Sharpness: 'Резкость',
|
||||
CustomRendered: 'Обработка',
|
||||
GainControl: 'Усиление',
|
||||
SensingMethod: 'Метод съёмки',
|
||||
SubjectDistanceRange: 'Дистанция',
|
||||
DigitalZoomRatio: 'Цифровой зум',
|
||||
CompressedBitsPerPixel: 'Сжатие (бит/пиксель)',
|
||||
ImageDescription: 'Описание'
|
||||
};
|
||||
const EXIF_SKIP = new Set([
|
||||
'GPSLatitude',
|
||||
'GPSLongitude',
|
||||
'GPSLatitudeRef',
|
||||
'GPSLongitudeRef',
|
||||
'GPSAltitude',
|
||||
'GPSAltitudeRef',
|
||||
'GPSVersionID',
|
||||
'GPSDateStamp',
|
||||
'GPSTimeStamp',
|
||||
'GPSProcessingMethod',
|
||||
'GPSAreaInformation',
|
||||
'GPSImgDirection',
|
||||
'GPSImgDirectionRef',
|
||||
'GPSDestBearing',
|
||||
'GPSDestBearingRef',
|
||||
'GPSSpeed',
|
||||
'GPSSpeedRef',
|
||||
'GPSTrack',
|
||||
'GPSTrackRef',
|
||||
'GPSMapDatum',
|
||||
'GPSDifferential',
|
||||
'DateTime',
|
||||
'DateTimeDigitized',
|
||||
'DateTimeOriginal',
|
||||
'SubSecTime',
|
||||
'SubSecTimeDigitized',
|
||||
'SubSecTimeOriginal',
|
||||
'ExifVersion',
|
||||
'FlashpixVersion',
|
||||
'InteroperabilityVersion',
|
||||
'InteroperabilityIndex',
|
||||
'ExifTag',
|
||||
'GPSTag',
|
||||
'InteroperabilityTag',
|
||||
'ExifIFDPointer',
|
||||
'GPSInfoIFDPointer',
|
||||
'ComponentsConfiguration',
|
||||
'MakerNote',
|
||||
'UserComment',
|
||||
'FileSource',
|
||||
'SceneType',
|
||||
'PhotometricInterpretation',
|
||||
'YCbCrPositioning',
|
||||
'ResolutionUnit',
|
||||
'BitsPerSample',
|
||||
'XResolution',
|
||||
'YResolution',
|
||||
'ImageWidth',
|
||||
'ImageLength',
|
||||
'PixelXDimension',
|
||||
'PixelYDimension'
|
||||
]);
|
||||
|
||||
const EXIF_ENUMS: Record<string, Record<number, string>> = {
|
||||
Orientation: {
|
||||
1: 'Горизонтальная',
|
||||
2: 'Зеркальная (гориз.)',
|
||||
3: 'Повёрнута на 180°',
|
||||
4: 'Зеркальная (верт.)',
|
||||
5: 'Зеркальная + 90° CW',
|
||||
6: 'Повёрнута на 90° CW',
|
||||
7: 'Зеркальная + 90° CCW',
|
||||
8: 'Повёрнута на 90° CCW'
|
||||
},
|
||||
ExposureProgram: {
|
||||
0: 'Не задано',
|
||||
1: 'Ручная',
|
||||
2: 'Программная',
|
||||
3: 'Приоритет диафрагмы',
|
||||
4: 'Приоритет выдержки',
|
||||
5: 'Творческая',
|
||||
6: 'Спортивная',
|
||||
7: 'Портрет',
|
||||
8: 'Пейзаж'
|
||||
},
|
||||
ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' },
|
||||
MeteringMode: {
|
||||
0: 'Неизвестно',
|
||||
1: 'Средневзвешенный',
|
||||
2: 'Центрально-взвешенный',
|
||||
3: 'Точечный',
|
||||
4: 'Многозонный',
|
||||
5: 'Матричный',
|
||||
6: 'Частичный',
|
||||
255: 'Другое'
|
||||
},
|
||||
LightSource: {
|
||||
0: 'Неизвестно',
|
||||
1: 'Дневной свет',
|
||||
2: 'Люминесцентный',
|
||||
3: 'Лампа накаливания',
|
||||
4: 'Вспышка',
|
||||
9: 'Хорошая погода',
|
||||
10: 'Облачно',
|
||||
11: 'Тень'
|
||||
},
|
||||
Flash: {
|
||||
0: 'Не сработала',
|
||||
1: 'Сработала',
|
||||
9: 'Сработала (принуд.)',
|
||||
16: 'Отключена',
|
||||
24: 'Не сработала (авто)',
|
||||
25: 'Сработала (авто)'
|
||||
},
|
||||
WhiteBalance: { 0: 'Авто', 1: 'Ручной' },
|
||||
SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' },
|
||||
Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' },
|
||||
Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' },
|
||||
Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' },
|
||||
CustomRendered: { 0: 'Обычная', 1: 'Особая' },
|
||||
GainControl: {
|
||||
0: 'Нет',
|
||||
1: 'Малое усиление',
|
||||
2: 'Большое усиление',
|
||||
3: 'Низкое усиление',
|
||||
4: 'Высокое усиление'
|
||||
},
|
||||
SensingMethod: {
|
||||
1: 'Не задано',
|
||||
2: 'Однокристальный',
|
||||
3: 'Двухчиповый',
|
||||
4: 'Трёхчиповый',
|
||||
5: 'Цветной последовательный',
|
||||
7: 'Трёхлинейный',
|
||||
8: 'Цветной последовательный'
|
||||
},
|
||||
SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' },
|
||||
ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' }
|
||||
};
|
||||
|
||||
function fmtExif(key: string, value: unknown): string {
|
||||
if (value == null) return '';
|
||||
const n = Number(value);
|
||||
if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) {
|
||||
return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`;
|
||||
}
|
||||
if (
|
||||
(key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') &&
|
||||
!Number.isNaN(n)
|
||||
) {
|
||||
return `f/${n}`;
|
||||
}
|
||||
if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) {
|
||||
return `${n} мм`;
|
||||
}
|
||||
if (key === 'ExposureBiasValue' && !Number.isNaN(n)) {
|
||||
return `${n > 0 ? '+' : ''}${n} EV`;
|
||||
}
|
||||
const enums = EXIF_ENUMS[key];
|
||||
if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n];
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const exifRows = $derived(
|
||||
Object.entries(exif)
|
||||
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
|
||||
.map(([k, v]) => ({ key: k, label: EXIF_LABELS[k] ?? k, value: fmtExif(k, v) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||||
const prefs = $derived(auth.preferences);
|
||||
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
|
||||
const visiblePrefs = $derived(prefs.photo_info?.visible);
|
||||
const effectiveVisible = $derived(
|
||||
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
|
||||
);
|
||||
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
|
||||
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
|
||||
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
|
||||
const attributeKeys = $derived(
|
||||
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
|
||||
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
|
||||
)
|
||||
);
|
||||
|
||||
let settingsOpen = $state(false);
|
||||
let showAll = $state(false);
|
||||
let savingPrefs = $state(false);
|
||||
|
||||
async function savePreferences(next: Preferences) {
|
||||
savingPrefs = true;
|
||||
try {
|
||||
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
|
||||
method: 'PATCH',
|
||||
body: next
|
||||
});
|
||||
auth.setPreferences(res.preferences ?? {});
|
||||
showAll = false;
|
||||
settingsOpen = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
savingPrefs = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
@@ -247,6 +87,7 @@
|
||||
const id = photoId;
|
||||
editingName = false;
|
||||
nameError = '';
|
||||
showAll = false;
|
||||
if (!id) {
|
||||
photo = null;
|
||||
return;
|
||||
@@ -362,6 +203,20 @@
|
||||
</div>
|
||||
|
||||
<PanelSection id="photo-info" title="Информация">
|
||||
{#snippet actions()}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Настройки атрибутов"
|
||||
title="Настройки атрибутов"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
settingsOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
<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">{formatSize(photo.size)}</span></div>
|
||||
@@ -370,12 +225,23 @@
|
||||
<span>Разрешение</span>
|
||||
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||
</div>
|
||||
{#each exifRows as row (row.key)}
|
||||
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
||||
<div class={metaRow}>
|
||||
<span>{row.label}</span>
|
||||
<span class="text-text">{row.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if hiddenCount > 0 && !showAll}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
|
||||
aria-label="Показать все атрибуты"
|
||||
title="Показать все атрибуты"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
{/if}
|
||||
{#if photo.taken_at}
|
||||
<div class={metaRow}>
|
||||
<span>Снято</span>
|
||||
@@ -399,6 +265,19 @@
|
||||
<Comments photoId={photo.id} />
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
{#if settingsOpen}
|
||||
<PhotoInfoSettingsDialog
|
||||
attributes={attributeKeys}
|
||||
defaults={EXIF_LABELS}
|
||||
visible={[...effectiveVisible]}
|
||||
labels={prefs.photo_info?.labels ?? {}}
|
||||
busy={savingPrefs}
|
||||
onSave={savePreferences}
|
||||
onReset={() => savePreferences({})}
|
||||
onClose={() => (settingsOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if album}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user