feat: Lightroom-style workspace UI #2
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
export const portal: Action<HTMLElement> = (node) => {
|
||||||
|
document.body.appendChild(node);
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user