diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0107107..5bfc986 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine { authed.Use(s.auth.Middleware()) authed.GET("/auth/me", authHandler.Me) + authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences) authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/me", s.meSSE) diff --git a/backend/internal/auth/handlers.go b/backend/internal/auth/handlers.go index 3b0700e..352ec41 100644 --- a/backend/internal/auth/handlers.go +++ b/backend/internal/auth/handlers.go @@ -1,6 +1,9 @@ package auth import ( + "encoding/json" + "fmt" + "io" "net/http" "github.com/gin-gonic/gin" @@ -89,5 +92,79 @@ func (h *Handler) Me(c *gin.Context) { httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found")) return } - c.JSON(http.StatusOK, gin.H{"user": u.Public()}) + prefs := u.Preferences + if prefs == nil { + prefs = map[string]any{} + } + c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs}) +} + +const ( + maxVisibleAttrs = 500 + maxLabelEntries = 500 + maxPreferenceKeyLen = 128 + maxPreferenceValueLen = 128 +) + +type photoInfoPreferences struct { + Visible []string `json:"visible"` + Labels map[string]string `json:"labels,omitempty"` +} + +type preferencesPayload struct { + PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"` +} + +func (p preferencesPayload) validate() error { + pi := p.PhotoInfo + if pi == nil { + return nil + } + if len(pi.Visible) > maxVisibleAttrs { + return fmt.Errorf("too many visible attributes") + } + for _, k := range pi.Visible { + if k == "" || len(k) > maxPreferenceKeyLen { + return fmt.Errorf("invalid visible attribute key") + } + } + if len(pi.Labels) > maxLabelEntries { + return fmt.Errorf("too many label overrides") + } + for k, v := range pi.Labels { + if k == "" || len(k) > maxPreferenceKeyLen { + return fmt.Errorf("invalid label key") + } + if len(v) > maxPreferenceValueLen { + return fmt.Errorf("label value too long") + } + } + return nil +} + +func (h *Handler) UpdatePreferences(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not read request body")) + return + } + var payload preferencesPayload + if err := json.Unmarshal(body, &payload); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object")) + return + } + if err := payload.validate(); err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error())) + return + } + raw, err := json.Marshal(payload) + if err != nil { + httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences")) + return + } + if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil { + httpx.RespondError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"preferences": payload}) } diff --git a/backend/internal/auth/store.go b/backend/internal/auth/store.go index 5b3173d..054375c 100644 --- a/backend/internal/auth/store.go +++ b/backend/internal/auth/store.go @@ -2,6 +2,7 @@ package auth import ( "context" + "encoding/json" "errors" "github.com/jackc/pgx/v5" @@ -29,27 +30,45 @@ func (s *Store) CreateUser(ctx context.Context, u *models.User) error { func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { return s.scanUser(s.pool.QueryRow(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at + `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), + COALESCE(preferences, '{}'::jsonb)::text, created_at FROM users WHERE lower(email) = lower($1)`, email)) } func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { return s.scanUser(s.pool.QueryRow(ctx, - `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at + `SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), + COALESCE(preferences, '{}'::jsonb)::text, created_at FROM users WHERE id = $1`, id)) } func (s *Store) scanUser(row pgx.Row) (*models.User, error) { var u models.User - if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil { + var prefs string + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } + if prefs != "" && prefs != "null" { + _ = json.Unmarshal([]byte(prefs), &u.Preferences) + } return &u, nil } +func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error { + tag, err := s.pool.Exec(ctx, + `UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw)) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error { _, err := s.pool.Exec(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index b236c6c..0ff50b2 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -3,12 +3,13 @@ package models import "time" type User struct { - ID string `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"-"` - Name string `json:"name"` - AvatarURL string `json:"avatar_url,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url,omitempty"` + Preferences map[string]any `json:"preferences,omitempty"` + CreatedAt time.Time `json:"created_at"` } type PublicUser struct { diff --git a/backend/migrations/000004_user_preferences.down.sql b/backend/migrations/000004_user_preferences.down.sql new file mode 100644 index 0000000..109d772 --- /dev/null +++ b/backend/migrations/000004_user_preferences.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS preferences; diff --git a/backend/migrations/000004_user_preferences.up.sql b/backend/migrations/000004_user_preferences.up.sql new file mode 100644 index 0000000..d62d6ab --- /dev/null +++ b/backend/migrations/000004_user_preferences.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb; diff --git a/frontend/src/lib/actions/portal.ts b/frontend/src/lib/actions/portal.ts new file mode 100644 index 0000000..45755bd --- /dev/null +++ b/frontend/src/lib/actions/portal.ts @@ -0,0 +1,10 @@ +import type { Action } from 'svelte/action'; + +export const portal: Action = (node) => { + document.body.appendChild(node); + return { + destroy() { + node.remove(); + } + }; +}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b0cc522..7d45bc8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,5 +1,6 @@ import { goto } from '$app/navigation'; import { auth, type Tokens, type User } from './stores/auth.svelte'; +import type { Preferences } from './types'; export class ApiError extends Error { status: number; @@ -97,5 +98,10 @@ export const authApi = { }), logout: (refresh_token: string) => api('/auth/logout', { method: 'POST', body: { refresh_token } }), - me: () => api<{ user: User }>('/auth/me') + me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'), + savePreferences: (preferences: Preferences) => + api<{ preferences: Preferences }>('/auth/me/preferences', { + method: 'PATCH', + body: preferences + }) }; diff --git a/frontend/src/lib/components/AlbumPanel.svelte b/frontend/src/lib/components/AlbumPanel.svelte index b9d59e1..37ee6a9 100644 --- a/frontend/src/lib/components/AlbumPanel.svelte +++ b/frontend/src/lib/components/AlbumPanel.svelte @@ -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); - const EXIF_LABELS: Record = { - 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> = { - 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 @@ + {#snippet actions()} + + {/snippet}
Автор{photo.uploader.name}
Файл{formatSize(photo.size)}
@@ -370,12 +225,23 @@ Разрешение {photo.width}×{photo.height} - {#each exifRows as row (row.key)} + {#each showAll ? allExifRows : visibleExifRows as row (row.key)}
{row.label} {row.value}
{/each} + {#if hiddenCount > 0 && !showAll} + + {/if} {#if photo.taken_at}
Снято @@ -399,6 +265,19 @@
+ + {#if settingsOpen} + savePreferences({})} + onClose={() => (settingsOpen = false)} + /> + {/if} {/if} {:else if album}
diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte index 862640f..5903918 100644 --- a/frontend/src/lib/components/ConfirmDialog.svelte +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -1,5 +1,6 @@
- +
+ + {#if actions} +
{@render actions()}
+ {/if} +
{#if isOpen}
{@render children()}
{/if} diff --git a/frontend/src/lib/components/PhotoInfoSettingsDialog.svelte b/frontend/src/lib/components/PhotoInfoSettingsDialog.svelte new file mode 100644 index 0000000..6d921bc --- /dev/null +++ b/frontend/src/lib/components/PhotoInfoSettingsDialog.svelte @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/lib/exif.ts b/frontend/src/lib/exif.ts new file mode 100644 index 0000000..27c66db --- /dev/null +++ b/frontend/src/lib/exif.ts @@ -0,0 +1,224 @@ +export const EXIF_LABELS: Record = { + 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> = { + 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, + labels: Record = {} +): 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); diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 609a58d..2319b24 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -46,7 +46,9 @@ export const icons = { wallpaper: { path: '' }, fullscreen: { path: '' }, fullscreen_exit: { path: '' }, - contrast: { path: '' } + contrast: { path: '' }, + check_all: { path: '' }, + uncheck_all: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index 4e9cce8..8c7c26c 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -1,8 +1,11 @@ +import type { Preferences } from '$lib/types'; + export interface User { id: string; email: string; name: string; avatar_url?: string; + preferences?: Preferences; } export interface Tokens { @@ -56,6 +59,18 @@ class AuthStore { } } + get preferences(): Preferences { + return this.user?.preferences ?? {}; + } + + setPreferences(preferences: Preferences) { + if (!this.user) return; + this.user = { ...this.user, preferences }; + if (typeof localStorage !== 'undefined') { + localStorage.setItem(USER_KEY, JSON.stringify(this.user)); + } + } + clear() { this.access = null; this.refresh = null; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3482dc4..7b1b5ab 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,8 +1,18 @@ +export interface PhotoInfoPreferences { + visible?: string[]; + labels?: Record; +} + +export interface Preferences { + photo_info?: PhotoInfoPreferences; +} + export interface User { id: string; email?: string; name: string; avatar_url?: string; + preferences?: Preferences; } export interface Album { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1deca08..1cef464 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -15,7 +15,7 @@ if (auth.isAuthenticated && !auth.user) { authApi .me() - .then((data) => auth.setUser(data.user)) + .then((data) => auth.setUser({ ...data.user, preferences: data.preferences ?? {} })) .catch(() => {}); } }); diff --git a/openspec/changes/lightroom-workspace-ui/design.md b/openspec/changes/lightroom-workspace-ui/design.md index cbb931e..1a05bab 100644 --- a/openspec/changes/lightroom-workspace-ui/design.md +++ b/openspec/changes/lightroom-workspace-ui/design.md @@ -132,20 +132,23 @@ filmstrip, панель метаданных/комментариев, тулб панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома обновляет запись в списке. -### 17. Настройки информации: оверрайды в профиле пользователя -Настройки раздела «Информация» хранятся в профиле как оверрайды, а не как полный список: +### 17. Настройки информации: явный список видимых в профиле пользователя +Настройки раздела «Информация» хранятся в профиле как явный список видимых атрибутов: `users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида -`{ "hidden": ["Contrast", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие ключа -означает, что атрибут видим и подпись дефолтная, поэтому новые и незнакомые теги EXIF не -«теряются» и не требуют миграций данных. `GET /auth/me` отдаёт `preferences` вместе с -пользователем; сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной -валидацией: строки и массивы строк, ограничение длины подписи/ключа и числа скрытых ключей). -Настройки не входят в `PublicUser` (приватны). UI: `PanelSection` получает слот `actions` под -шестерёнку; модалка `PhotoInfoSettingsDialog` строится из объединения известного каталога -подписей и ключей текущего фото; список в панели фильтруется по оверрайдам, а скрытые -атрибуты сворачиваются под кнопку «…» (раскрытие — временное, для текущего фото). -Альтернативы: полный упорядоченный список в профиле (хрупок к новым тегам, нужны миграции) и -хранение на клиенте (не переносится между устройствами) — отклонены. +`{ "visible": ["Make", "Model", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие +секции `photo_info` означает базовый набор по умолчанию (камера, объектив, выдержка, +диафрагма, ISO, фокусное расстояние) — не «все атрибуты». Список `visible` хранится явно, +поэтому различимо состояние «ничего не выбрано»; новые и незнакомые теги не «теряются» (их +можно включить в диалоге). `GET /auth/me` отдаёт `preferences` вместе с пользователем; +сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной валидацией: +строки и массивы строк, ограничение длины подписи/ключа и числа записей). Пустой объект +(`{}`) удаляет настройки, возвращая базовый набор. Настройки не входят в `PublicUser` +(приватны). UI: `PanelSection` получает слот `actions` под шестерёнку; модалка +`PhotoInfoSettingsDialog` строится из объединения известного каталога подписей и ключей +текущего фото и даёт действия «отметить все»/«снять все» и «Сбросить»; список в панели +фильтруется по сохранённым видимым атрибутам, а остальные сворачиваются под кнопку «…» +(раскрытие — временное, для текущего фото). Альтернатива с оверрайдами `hidden` (пусто = все +видимы) отклонена: не даёт базовый набор по умолчанию. ### 18. Метаданные фото: устойчивый парсер и хранение в jsonb EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`) diff --git a/openspec/changes/lightroom-workspace-ui/proposal.md b/openspec/changes/lightroom-workspace-ui/proposal.md index 6244448..2d84718 100644 --- a/openspec/changes/lightroom-workspace-ui/proposal.md +++ b/openspec/changes/lightroom-workspace-ui/proposal.md @@ -42,9 +42,11 @@ - **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием. - **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую - модалку со списком EXIF-атрибутов: переключатели видимости и пользовательские подписи. - Скрытые атрибуты сворачиваются под элементом «…» (клик раскрывает все). Настройки хранятся - в профиле пользователя (server-side) и применяются ко всем фото. + модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять + все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…» + (клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются + ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет + настройки из профиля. - **Счётчики.** В ответе альбома — число фото и участников. - **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт, `neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая → diff --git a/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md index 2b5841f..97f9c06 100644 --- a/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md +++ b/openspec/changes/lightroom-workspace-ui/specs/ui-workspace/spec.md @@ -219,21 +219,27 @@ SHALL отображаться вне каркаса, но в общей тем ### Requirement: Настройки списка атрибутов информации Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим -модальное окно, в котором пользователь SHALL выбирать отображаемые EXIF-атрибуты и задавать -их подписи. Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле -пользователя. При отсутствии настроек SHALL отображаться все доступные атрибуты. +модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые +атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и +«снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле +пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов +(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние). #### Scenario: Открытие настроек - **WHEN** пользователь нажимает иконку настроек в разделе «Информация» -- **THEN** открывается модальное окно со списком EXIF-атрибутов: переключатели видимости и поля подписей +- **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все» + +#### Scenario: Значения по умолчанию +- **WHEN** у пользователя нет сохранённых настроек +- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…» #### Scenario: Сохранение настроек -- **WHEN** пользователь скрывает атрибут, задаёт подпись и сохраняет +- **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет - **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле #### Scenario: Сброс настроек -- **WHEN** пользователь сбрасывает настройки к значениям по умолчанию -- **THEN** показываются все атрибуты с исходными подписями +- **WHEN** пользователь сбрасывает настройки +- **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями ### Requirement: Свёртка скрытых атрибутов Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент diff --git a/openspec/changes/lightroom-workspace-ui/tasks.md b/openspec/changes/lightroom-workspace-ui/tasks.md index 21da5ff..21a4e87 100644 --- a/openspec/changes/lightroom-workspace-ui/tasks.md +++ b/openspec/changes/lightroom-workspace-ui/tasks.md @@ -59,10 +59,11 @@ ## 9. Настройки информации пользователя -- [ ] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает. -- [ ] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется. -- [ ] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы. -- [ ] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`. -- [ ] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле. -- [ ] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы. -- [ ] 9.7 Проверка: `cd frontend && npm run check`, `cd backend && go test ./...`, `make build`, `openspec validate "lightroom-workspace-ui"`. +- [x] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает. +- [x] 9.2 `models.User` (+`Preferences`), `auth/store.go` (выборка `preferences` в `GetUserByID`, метод `UpdatePreferences`), `auth/handlers.go`/`service.go` (`Me` возвращает `preferences`; `PATCH /auth/me/preferences` с валидацией структуры, длины подписей/ключей и числа скрытых). Проверка: невалидный payload → 400, валидный сохраняется. +- [x] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы. +- [x] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`. +- [x] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле. +- [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.8 Базовый набор атрибутов по умолчанию при пустых настройках (модель `visible` вместо `hidden`); в диалоге — иконки «отметить все»/«снять все» вместо «Показать всё»; «Сбросить» удаляет настройки из профиля (`PATCH {}`). Проверка: Playwright — дефолт даёт базовый набор и «…», снятие/отметка всех, сброс очищает профиль (12/12).