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
+1
View File
@@ -115,6 +115,7 @@ func (s *Server) Router() *gin.Engine {
authed.Use(s.auth.Middleware()) authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me) authed.GET("/auth/me", authHandler.Me)
authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
authed.GET("/sse/albums/:id", s.albumSSE) authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE) authed.GET("/sse/me", s.meSSE)
+78 -1
View File
@@ -1,6 +1,9 @@
package auth package auth
import ( import (
"encoding/json"
"fmt"
"io"
"net/http" "net/http"
"github.com/gin-gonic/gin" "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")) httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
return 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})
} }
+22 -3
View File
@@ -2,6 +2,7 @@ package auth
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"github.com/jackc/pgx/v5" "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) { func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx, 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)) FROM users WHERE lower(email) = lower($1)`, email))
} }
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx, 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)) FROM users WHERE id = $1`, id))
} }
func (s *Store) scanUser(row pgx.Row) (*models.User, error) { func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
var u models.User 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
if prefs != "" && prefs != "null" {
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
}
return &u, nil 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 { func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
+7 -6
View File
@@ -3,12 +3,13 @@ package models
import "time" import "time"
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"` AvatarURL string `json:"avatar_url,omitempty"`
CreatedAt time.Time `json:"created_at"` Preferences map[string]any `json:"preferences,omitempty"`
CreatedAt time.Time `json:"created_at"`
} }
type PublicUser struct { type PublicUser struct {
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS preferences;
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb;
+10
View File
@@ -0,0 +1,10 @@
import type { Action } from 'svelte/action';
export const portal: Action<HTMLElement> = (node) => {
document.body.appendChild(node);
return {
destroy() {
node.remove();
}
};
};
+7 -1
View File
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { auth, type Tokens, type User } from './stores/auth.svelte'; import { auth, type Tokens, type User } from './stores/auth.svelte';
import type { Preferences } from './types';
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@@ -97,5 +98,10 @@ export const authApi = {
}), }),
logout: (refresh_token: string) => logout: (refresh_token: string) =>
api('/auth/logout', { method: 'POST', body: { refresh_token } }), 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
})
}; };
+77 -198
View File
@@ -5,9 +5,11 @@
import CoverPicker from './CoverPicker.svelte'; import CoverPicker from './CoverPicker.svelte';
import Comments from './Comments.svelte'; import Comments from './Comments.svelte';
import PanelSection from './PanelSection.svelte'; import PanelSection from './PanelSection.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 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 albumId = $derived($page.params.id ?? '');
const photoId = $derived($page.params.photoId ?? ''); const photoId = $derived($page.params.photoId ?? '');
@@ -28,203 +30,41 @@
const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>); const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>);
const EXIF_LABELS: Record<string, string> = { const prefs = $derived(auth.preferences);
Make: 'Производитель', const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
Model: 'Модель', const visiblePrefs = $derived(prefs.photo_info?.visible);
LensMake: 'Производитель объектива', const effectiveVisible = $derived(
LensModel: 'Объектив', new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
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 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(() => { $effect(() => {
const id = albumId; const id = albumId;
@@ -247,6 +87,7 @@
const id = photoId; const id = photoId;
editingName = false; editingName = false;
nameError = ''; nameError = '';
showAll = false;
if (!id) { if (!id) {
photo = null; photo = null;
return; return;
@@ -362,6 +203,20 @@
</div> </div>
<PanelSection id="photo-info" title="Информация"> <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"> <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">{photo.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(photo.size)}</span></div>
@@ -370,12 +225,23 @@
<span>Разрешение</span> <span>Разрешение</span>
<span class="text-text">{photo.width}×{photo.height}</span> <span class="text-text">{photo.width}×{photo.height}</span>
</div> </div>
{#each exifRows as row (row.key)} {#each showAll ? allExifRows : visibleExifRows as row (row.key)}
<div class={metaRow}> <div class={metaRow}>
<span>{row.label}</span> <span>{row.label}</span>
<span class="text-text">{row.value}</span> <span class="text-text">{row.value}</span>
</div> </div>
{/each} {/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} {#if photo.taken_at}
<div class={metaRow}> <div class={metaRow}>
<span>Снято</span> <span>Снято</span>
@@ -399,6 +265,19 @@
<Comments photoId={photo.id} /> <Comments photoId={photo.id} />
</PanelSection> </PanelSection>
</div> </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} {/if}
{:else if album} {:else if album}
<div> <div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
interface Props { interface Props {
title: string; title: string;
@@ -27,6 +28,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<div <div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api'; import { api } from '$lib/api';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte'; import { albums } from '$lib/stores/albums.svelte';
import type { Album, Photo } from '$lib/types'; import type { Album, Photo } from '$lib/types';
@@ -50,6 +51,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<div <div
use:portal
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import { albums } from '$lib/stores/albums.svelte'; import { albums } from '$lib/stores/albums.svelte';
import type { Album } from '$lib/types'; import type { Album } from '$lib/types';
@@ -42,6 +43,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<div <div
use:portal
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
+19 -13
View File
@@ -7,10 +7,11 @@
id: string; id: string;
title: string; title: string;
open?: boolean; open?: boolean;
actions?: Snippet;
children: Snippet; children: Snippet;
} }
let { id, title, open = true, children }: Props = $props(); let { id, title, open = true, actions, children }: Props = $props();
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let isOpen = $state(open); let isOpen = $state(open);
@@ -28,18 +29,23 @@
</script> </script>
<section class="border-border border-b"> <section class="border-border border-b">
<button <div class="flex items-center">
type="button" <button
class="text-text-muted hover:text-text flex w-full items-center justify-between gap-2 px-4 py-2.5 text-left" type="button"
aria-expanded={isOpen} class="text-text-muted hover:text-text flex flex-1 items-center justify-between gap-2 px-4 py-2.5 text-left"
onclick={toggle} aria-expanded={isOpen}
> onclick={toggle}
<span class="text-xs font-semibold tracking-wide uppercase">{title}</span> >
<Icon <span class="text-xs font-semibold tracking-wide uppercase">{title}</span>
name="expand_more" <Icon
class="size-4 shrink-0 transition-transform {isOpen ? 'rotate-180' : ''}" name="expand_more"
/> class="size-4 shrink-0 transition-transform {isOpen ? 'rotate-180' : ''}"
</button> />
</button>
{#if actions}
<div class="flex items-center pr-3">{@render actions()}</div>
{/if}
</div>
{#if isOpen} {#if isOpen}
<div class="px-4 pb-4">{@render children()}</div> <div class="px-4 pb-4">{@render children()}</div>
{/if} {/if}
@@ -0,0 +1,179 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { portal } from '$lib/actions/portal';
import type { Preferences, PhotoInfoPreferences } from '$lib/types';
interface Props {
attributes: string[];
defaults: Record<string, string>;
visible: string[];
labels: Record<string, string>;
busy?: boolean;
onSave: (prefs: Preferences) => void;
onReset: () => void;
onClose: () => void;
}
let {
attributes,
defaults,
visible,
labels,
busy = false,
onSave,
onReset,
onClose
}: Props = $props();
// svelte-ignore state_referenced_locally
let checked = $state<Set<string>>(new Set(visible));
// svelte-ignore state_referenced_locally
let labelDraft = $state<Record<string, string>>({ ...labels });
function isChecked(key: string): boolean {
return checked.has(key);
}
function setChecked(key: string, value: boolean) {
const next = new Set(checked);
if (value) next.add(key);
else next.delete(key);
checked = next;
}
function selectAll() {
checked = new Set(attributes);
}
function deselectAll() {
checked = new Set();
}
function save() {
const cleanLabels: Record<string, string> = {};
for (const [key, value] of Object.entries(labelDraft)) {
const trimmed = value.trim();
if (trimmed) cleanLabels[key] = trimmed;
}
const photo_info: PhotoInfoPreferences = {
visible: attributes.filter((key) => checked.has(key))
};
if (Object.keys(cleanLabels).length) photo_info.labels = cleanLabels;
onSave({ photo_info });
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape' && !busy) onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<div
use:portal
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="Настройки атрибутов"
tabindex="-1"
onclick={(e) => {
if (e.target === e.currentTarget && !busy) onClose();
}}
onkeydown={onKey}
>
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-lg flex-col rounded-xl border">
<div class="border-border flex items-center justify-between border-b px-5 py-3">
<h2 class="text-text text-base font-semibold">Настройки атрибутов</h2>
<div class="flex items-center gap-1">
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
aria-label="Отметить все"
title="Отметить все"
onclick={selectAll}
>
<Icon name="check_all" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
aria-label="Снять все"
title="Снять все"
onclick={deselectAll}
>
<Icon name="uncheck_all" class="size-4" />
</button>
<button
type="button"
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded transition-colors"
aria-label="Закрыть"
onclick={onClose}
>
<Icon name="close" class="size-4" />
</button>
</div>
</div>
<p class="text-text-muted px-5 pt-3 text-xs">
Отметьте, какие атрибуты показывать, и задайте свои подписи. Скрытые атрибуты доступны по
кнопке «…» в разделе «Информация».
</p>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-3">
{#if attributes.length === 0}
<p class="text-text-subtle py-4 text-sm">Нет доступных атрибутов.</p>
{:else}
<ul class="space-y-1.5">
{#each attributes as key (key)}
<li class="flex items-center gap-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="accent-primary size-4"
checked={isChecked(key)}
onchange={(e) => setChecked(key, e.currentTarget.checked)}
/>
<span class="text-text w-44 shrink-0 truncate text-sm">{defaults[key] ?? key}</span>
</label>
<input
type="text"
class="border-border bg-bg text-text min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
placeholder={defaults[key] ?? key}
maxlength="128"
value={labelDraft[key] ?? ''}
oninput={(e) => (labelDraft[key] = e.currentTarget.value)}
/>
</li>
{/each}
</ul>
{/if}
</div>
<div class="border-border flex items-center justify-between gap-2 border-t px-5 py-3">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-3 py-1.5 text-xs"
onclick={onReset}
>
Сбросить
</button>
<div class="flex gap-2">
<button
type="button"
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
onclick={onClose}
>
Отмена
</button>
<button
type="button"
disabled={busy}
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
onclick={save}
>
{busy ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</div>
</div>
</div>
+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);
+3 -1
View File
@@ -46,7 +46,9 @@ export const icons = {
wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' }, wallpaper: { path: '<path fill="currentColor" d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm6 9l-4 5h12l-3-4l-2.03 2.71zm7-4.5c0-.83-.67-1.5-1.5-1.5S14 7.67 14 8.5s.67 1.5 1.5 1.5S17 9.33 17 8.5M20 2h-7v2h7v7h2V4c0-1.1-.9-2-2-2m0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4z"/>' },
fullscreen: { path: '<path fill="currentColor" d="M7 14H5v5h5v-2H7zm-2-4h2V7h3V5H5zm12 7h-3v2h5v-5h-2zM14 5v2h3v3h2V5z"/>' }, fullscreen: { path: '<path fill="currentColor" d="M7 14H5v5h5v-2H7zm-2-4h2V7h3V5H5zm12 7h-3v2h5v-5h-2zM14 5v2h3v3h2V5z"/>' },
fullscreen_exit: { path: '<path fill="currentColor" d="M5 16h3v3h2v-5H5zm3-8H5v2h5V5H8zm6 11h2v-3h3v-2h-5zm2-11v3h-2v2h5V5z"/>' }, fullscreen_exit: { path: '<path fill="currentColor" d="M5 16h3v3h2v-5H5zm3-8H5v2h5V5H8zm6 11h2v-3h3v-2h-5zm2-11v3h-2v2h5V5z"/>' },
contrast: { path: '<path fill="currentColor" fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4a8 8 0 0 1 0 16z"/>' } contrast: { path: '<path fill="currentColor" fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 4a8 8 0 0 1 0 16z"/>' },
check_all: { path: '<path fill="currentColor" d="M18 7l-1.41-1.41l-6.34 6.34l1.41 1.41zm4.24-1.41L11.66 16.17L7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41L6 19l1.41-1.41L1.83 12z"/>' },
uncheck_all: { path: '<path fill="currentColor" d="M1.79 12l5.58 5.59L5.96 19L.37 13.41zm.45-7.78L12.9 14.89l-1.28 1.28L7.44 12l-1.41 1.41L11.62 19l2.69-2.69l4.89 4.89l1.41-1.41L3.65 2.81zm14.9 9.27L23.62 7L22.2 5.59l-6.48 6.48zm-1.18-6.49l-1.41-1.41l-3.65 3.66l1.41 1.41z"/>' }
} satisfies Record<string, IconDef>; } satisfies Record<string, IconDef>;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;
+15
View File
@@ -1,8 +1,11 @@
import type { Preferences } from '$lib/types';
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
name: string; name: string;
avatar_url?: string; avatar_url?: string;
preferences?: Preferences;
} }
export interface Tokens { 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() { clear() {
this.access = null; this.access = null;
this.refresh = null; this.refresh = null;
+10
View File
@@ -1,8 +1,18 @@
export interface PhotoInfoPreferences {
visible?: string[];
labels?: Record<string, string>;
}
export interface Preferences {
photo_info?: PhotoInfoPreferences;
}
export interface User { export interface User {
id: string; id: string;
email?: string; email?: string;
name: string; name: string;
avatar_url?: string; avatar_url?: string;
preferences?: Preferences;
} }
export interface Album { export interface Album {
+1 -1
View File
@@ -15,7 +15,7 @@
if (auth.isAuthenticated && !auth.user) { if (auth.isAuthenticated && !auth.user) {
authApi authApi
.me() .me()
.then((data) => auth.setUser(data.user)) .then((data) => auth.setUser({ ...data.user, preferences: data.preferences ?? {} }))
.catch(() => {}); .catch(() => {});
} }
}); });
@@ -132,20 +132,23 @@ filmstrip, панель метаданных/комментариев, тулб
панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома панель обходилась одним запросом. Переименование публикует `photo.updated`, страница альбома
обновляет запись в списке. обновляет запись в списке.
### 17. Настройки информации: оверрайды в профиле пользователя ### 17. Настройки информации: явный список видимых в профиле пользователя
Настройки раздела «Информация» хранятся в профиле как оверрайды, а не как полный список: Настройки раздела «Информация» хранятся в профиле как явный список видимых атрибутов:
`users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида `users.preferences` (jsonb, дефолт `{}`) с секцией `photo_info` вида
`{ "hidden": ["Contrast", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие ключа `{ "visible": ["Make", "Model", ...], "labels": { "Make": "Камера", ... } }`. Отсутствие
означает, что атрибут видим и подпись дефолтная, поэтому новые и незнакомые теги EXIF не секции `photo_info` означает базовый набор по умолчанию (камера, объектив, выдержка,
«теряются» и не требуют миграций данных. `GET /auth/me` отдаёт `preferences` вместе с диафрагма, ISO, фокусное расстояние) — не «все атрибуты». Список `visible` хранится явно,
пользователем; сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной поэтому различимо состояние «ничего не выбрано»; новые и незнакомые теги не «теряются» (их
валидацией: строки и массивы строк, ограничение длины подписи/ключа и числа скрытых ключей). можно включить в диалоге). `GET /auth/me` отдаёт `preferences` вместе с пользователем;
Настройки не входят в `PublicUser` (приватны). UI: `PanelSection` получает слот `actions` под сохранение — `PATCH /auth/me/preferences` (полная замена объекта с серверной валидацией:
шестерёнку; модалка `PhotoInfoSettingsDialog` строится из объединения известного каталога строки и массивы строк, ограничение длины подписи/ключа и числа записей). Пустой объект
подписей и ключей текущего фото; список в панели фильтруется по оверрайдам, а скрытые (`{}`) удаляет настройки, возвращая базовый набор. Настройки не входят в `PublicUser`
атрибуты сворачиваются под кнопку «…» (раскрытие — временное, для текущего фото). (приватны). UI: `PanelSection` получает слот `actions` под шестерёнку; модалка
Альтернативы: полный упорядоченный список в профиле (хрупок к новым тегам, нужны миграции) и `PhotoInfoSettingsDialog` строится из объединения известного каталога подписей и ключей
хранение на клиенте (не переносится между устройствами) — отклонены. текущего фото и даёт действия «отметить все»/«снять все» и «Сбросить»; список в панели
фильтруется по сохранённым видимым атрибутам, а остальные сворачиваются под кнопку «…»
(раскрытие — временное, для текущего фото). Альтернатива с оверрайдами `hidden` (пусто = все
видимы) отклонена: не даёт базовый набор по умолчанию.
### 18. Метаданные фото: устойчивый парсер и хранение в jsonb ### 18. Метаданные фото: устойчивый парсер и хранение в jsonb
EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`) EXIF извлекается при загрузке и хранится в `photos.exif` (jsonb, миграция `000003_photo_exif`)
@@ -42,9 +42,11 @@
- **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с - **Разделы и панели.** Правая панель — сворачиваемые разделы (состояние сохраняется), с
разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием. разделом «Информация» (EXIF/метаданные); левая и правая панели ресайзятся перетаскиванием.
- **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую - **Настройки информации.** Раздел «Информация» получает кнопку-шестерёнку, открывающую
модалку со списком EXIF-атрибутов: переключатели видимости и пользовательские подписи. модалку со списком EXIF-атрибутов: галочки видимости (с действиями «отметить все»/«снять
Скрытые атрибуты сворачиваются под элементом «…» (клик раскрывает все). Настройки хранятся все»), пользовательские подписи и сброс. Скрытые атрибуты сворачиваются под элементом «…»
в профиле пользователя (server-side) и применяются ко всем фото. (клик раскрывает все). Настройки хранятся в профиле пользователя (server-side), применяются
ко всем фото, а при отсутствии настроек показывается базовый набор; «Сбросить» удаляет
настройки из профиля.
- **Счётчики.** В ответе альбома — число фото и участников. - **Счётчики.** В ответе альбома — число фото и участников.
- **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт, - **Темы и хром.** Существующие 4 темы сохраняются; `dark` — графит Lightroom и дефолт,
`neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая → `neutral` — тёплый графит; переключатель упорядочен тёмная → нейтральная → светлая →
@@ -219,21 +219,27 @@ SHALL отображаться вне каркаса, но в общей тем
### Requirement: Настройки списка атрибутов информации ### Requirement: Настройки списка атрибутов информации
Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим Раздел «Информация» SHALL предоставлять доступ к настройкам (иконка-шестерёнка), открывающим
модальное окно, в котором пользователь SHALL выбирать отображаемые EXIF-атрибуты и задавать модальное окно со списком EXIF-атрибутов, где пользователь SHALL отмечать отображаемые
их подписи. Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле атрибуты и задавать их подписи. В окне SHALL быть доступны действия «отметить все» и
пользователя. При отсутствии настроек SHALL отображаться все доступные атрибуты. «снять все». Настройки SHALL применяться ко всем фото и SHALL сохраняться в профиле
пользователя. При отсутствии сохранённых настроек SHALL отображаться базовый набор атрибутов
(камера, объектив, выдержка, диафрагма, ISO, фокусное расстояние).
#### Scenario: Открытие настроек #### Scenario: Открытие настроек
- **WHEN** пользователь нажимает иконку настроек в разделе «Информация» - **WHEN** пользователь нажимает иконку настроек в разделе «Информация»
- **THEN** открывается модальное окно со списком EXIF-атрибутов: переключатели видимости и поля подписей - **THEN** открывается модальное окно со списком EXIF-атрибутов: галочки видимости и поля подписей, а также действия «отметить все»/«снять все»
#### Scenario: Значения по умолчанию
- **WHEN** у пользователя нет сохранённых настроек
- **THEN** в разделе «Информация» показан базовый набор атрибутов, а остальные доступны по элементу «…»
#### Scenario: Сохранение настроек #### Scenario: Сохранение настроек
- **WHEN** пользователь скрывает атрибут, задаёт подпись и сохраняет - **WHEN** пользователь отмечает атрибуты, задаёт подпись и сохраняет
- **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле - **THEN** раздел «Информация» учитывает изменения, а настройки сохраняются в профиле
#### Scenario: Сброс настроек #### Scenario: Сброс настроек
- **WHEN** пользователь сбрасывает настройки к значениям по умолчанию - **WHEN** пользователь сбрасывает настройки
- **THEN** показываются все атрибуты с исходными подписями - **THEN** сохранённые настройки удаляются из профиля, и отображается базовый набор с исходными подписями
### Requirement: Свёртка скрытых атрибутов ### Requirement: Свёртка скрытых атрибутов
Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент Если настройками часть атрибутов скрыта, в конце списка атрибутов SHALL отображаться элемент
@@ -59,10 +59,11 @@
## 9. Настройки информации пользователя ## 9. Настройки информации пользователя
- [ ] 9.1 Миграция `000004_user_preferences` (up/down): `ADD COLUMN preferences jsonb NOT NULL DEFAULT '{}'`. Проверка: `make migrate` применяется, `\d users` показывает колонку, down её снимает. - [x] 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, валидный сохраняется. - [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, валидный сохраняется.
- [ ] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы. - [x] 9.3 Маршрут `PATCH /auth/me/preferences` в `app.go` под middleware. Проверка: без токена 401, с токеном сохранение и чтение согласованы.
- [ ] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`. - [x] 9.4 Frontend: `types.ts` (+`preferences`), auth-стор (+`preferences`, сохранение через API). Проверка: `cd frontend && npm run check`.
- [ ] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле. - [x] 9.5 `PanelSection` — слот `actions` для шестерёнки; новый `PhotoInfoSettingsDialog` (список атрибутов, видимость, подписи, сброс к дефолту). Проверка: изменения применяются к разделу и сохраняются в профиле.
- [ ] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы. - [x] 9.6 `AlbumPanel`: фильтрация атрибутов по оверрайдам, элемент «…» для показа скрытых (при отсутствии скрытых — не показывается). Проверка: скрытые сворачиваются, «…» раскрывает все, дефолт — все видимы.
- [ ] 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).