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:
iu
2026-10-10 21:04:08 +03:00
parent e1592549b2
commit 1b4906648d
23 changed files with 703 additions and 254 deletions
+224
View File
@@ -0,0 +1,224 @@
export 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: 'Описание'
};
export 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'
]);
export 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: 'Некалиброванное' }
};
export function formatExifValue(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);
}
export interface ExifRow {
key: string;
label: string;
value: string;
}
export const EXIF_BASE: string[] = [
'Make',
'Model',
'LensModel',
'ExposureTime',
'FNumber',
'ISOSpeedRatings',
'FocalLength',
'FocalLengthIn35mmFilm'
];
export function buildExifRows(
exif: Record<string, unknown>,
labels: Record<string, string> = {}
): ExifRow[] {
return Object.entries(exif)
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
.map(([k, v]) => ({
key: k,
label: labels[k] ?? EXIF_LABELS[k] ?? k,
value: formatExifValue(k, v)
}))
.sort((a, b) => a.label.localeCompare(b.label, 'ru'));
}
export const EXIF_CATALOG: string[] = Object.keys(EXIF_LABELS);