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:
@@ -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 { auth, type Tokens, type User } from './stores/auth.svelte';
|
||||
import type { Preferences } from './types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -97,5 +98,10 @@ export const authApi = {
|
||||
}),
|
||||
logout: (refresh_token: string) =>
|
||||
api('/auth/logout', { method: 'POST', body: { refresh_token } }),
|
||||
me: () => api<{ user: User }>('/auth/me')
|
||||
me: () => api<{ user: User; preferences?: Preferences }>('/auth/me'),
|
||||
savePreferences: (preferences: Preferences) =>
|
||||
api<{ preferences: Preferences }>('/auth/me/preferences', {
|
||||
method: 'PATCH',
|
||||
body: preferences
|
||||
})
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import CoverPicker from './CoverPicker.svelte';
|
||||
import Comments from './Comments.svelte';
|
||||
import PanelSection from './PanelSection.svelte';
|
||||
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
|
||||
import type { Album, Photo, Preferences } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
const photoId = $derived($page.params.photoId ?? '');
|
||||
@@ -28,203 +30,41 @@
|
||||
|
||||
const exif = $derived((photo?.exif ?? {}) as Record<string, unknown>);
|
||||
|
||||
const EXIF_LABELS: Record<string, string> = {
|
||||
Make: 'Производитель',
|
||||
Model: 'Модель',
|
||||
LensMake: 'Производитель объектива',
|
||||
LensModel: 'Объектив',
|
||||
ExposureTime: 'Выдержка',
|
||||
ShutterSpeedValue: 'Выдержка (APEX)',
|
||||
FNumber: 'Диафрагма',
|
||||
ApertureValue: 'Диафрагма (APEX)',
|
||||
MaxApertureValue: 'Макс. диафрагма',
|
||||
FocalLength: 'Фокусное расстояние',
|
||||
FocalLengthIn35mmFilm: 'Фокусное (35 мм)',
|
||||
ISOSpeedRatings: 'ISO',
|
||||
PhotographicSensitivity: 'ISO',
|
||||
ExposureProgram: 'Программа съёмки',
|
||||
ExposureMode: 'Режим экспозиции',
|
||||
ExposureBiasValue: 'Экспокоррекция',
|
||||
BrightnessValue: 'Яркость (APEX)',
|
||||
MeteringMode: 'Режим замера',
|
||||
LightSource: 'Источник света',
|
||||
Flash: 'Вспышка',
|
||||
WhiteBalance: 'Баланс белого',
|
||||
Orientation: 'Ориентация',
|
||||
Software: 'ПО',
|
||||
ColorSpace: 'Цветовое пространство',
|
||||
SceneCaptureType: 'Тип сцены',
|
||||
Contrast: 'Контраст',
|
||||
Saturation: 'Насыщенность',
|
||||
Sharpness: 'Резкость',
|
||||
CustomRendered: 'Обработка',
|
||||
GainControl: 'Усиление',
|
||||
SensingMethod: 'Метод съёмки',
|
||||
SubjectDistanceRange: 'Дистанция',
|
||||
DigitalZoomRatio: 'Цифровой зум',
|
||||
CompressedBitsPerPixel: 'Сжатие (бит/пиксель)',
|
||||
ImageDescription: 'Описание'
|
||||
};
|
||||
const EXIF_SKIP = new Set([
|
||||
'GPSLatitude',
|
||||
'GPSLongitude',
|
||||
'GPSLatitudeRef',
|
||||
'GPSLongitudeRef',
|
||||
'GPSAltitude',
|
||||
'GPSAltitudeRef',
|
||||
'GPSVersionID',
|
||||
'GPSDateStamp',
|
||||
'GPSTimeStamp',
|
||||
'GPSProcessingMethod',
|
||||
'GPSAreaInformation',
|
||||
'GPSImgDirection',
|
||||
'GPSImgDirectionRef',
|
||||
'GPSDestBearing',
|
||||
'GPSDestBearingRef',
|
||||
'GPSSpeed',
|
||||
'GPSSpeedRef',
|
||||
'GPSTrack',
|
||||
'GPSTrackRef',
|
||||
'GPSMapDatum',
|
||||
'GPSDifferential',
|
||||
'DateTime',
|
||||
'DateTimeDigitized',
|
||||
'DateTimeOriginal',
|
||||
'SubSecTime',
|
||||
'SubSecTimeDigitized',
|
||||
'SubSecTimeOriginal',
|
||||
'ExifVersion',
|
||||
'FlashpixVersion',
|
||||
'InteroperabilityVersion',
|
||||
'InteroperabilityIndex',
|
||||
'ExifTag',
|
||||
'GPSTag',
|
||||
'InteroperabilityTag',
|
||||
'ExifIFDPointer',
|
||||
'GPSInfoIFDPointer',
|
||||
'ComponentsConfiguration',
|
||||
'MakerNote',
|
||||
'UserComment',
|
||||
'FileSource',
|
||||
'SceneType',
|
||||
'PhotometricInterpretation',
|
||||
'YCbCrPositioning',
|
||||
'ResolutionUnit',
|
||||
'BitsPerSample',
|
||||
'XResolution',
|
||||
'YResolution',
|
||||
'ImageWidth',
|
||||
'ImageLength',
|
||||
'PixelXDimension',
|
||||
'PixelYDimension'
|
||||
]);
|
||||
|
||||
const EXIF_ENUMS: Record<string, Record<number, string>> = {
|
||||
Orientation: {
|
||||
1: 'Горизонтальная',
|
||||
2: 'Зеркальная (гориз.)',
|
||||
3: 'Повёрнута на 180°',
|
||||
4: 'Зеркальная (верт.)',
|
||||
5: 'Зеркальная + 90° CW',
|
||||
6: 'Повёрнута на 90° CW',
|
||||
7: 'Зеркальная + 90° CCW',
|
||||
8: 'Повёрнута на 90° CCW'
|
||||
},
|
||||
ExposureProgram: {
|
||||
0: 'Не задано',
|
||||
1: 'Ручная',
|
||||
2: 'Программная',
|
||||
3: 'Приоритет диафрагмы',
|
||||
4: 'Приоритет выдержки',
|
||||
5: 'Творческая',
|
||||
6: 'Спортивная',
|
||||
7: 'Портрет',
|
||||
8: 'Пейзаж'
|
||||
},
|
||||
ExposureMode: { 0: 'Авто', 1: 'Ручной', 2: 'Авто-брекетинг' },
|
||||
MeteringMode: {
|
||||
0: 'Неизвестно',
|
||||
1: 'Средневзвешенный',
|
||||
2: 'Центрально-взвешенный',
|
||||
3: 'Точечный',
|
||||
4: 'Многозонный',
|
||||
5: 'Матричный',
|
||||
6: 'Частичный',
|
||||
255: 'Другое'
|
||||
},
|
||||
LightSource: {
|
||||
0: 'Неизвестно',
|
||||
1: 'Дневной свет',
|
||||
2: 'Люминесцентный',
|
||||
3: 'Лампа накаливания',
|
||||
4: 'Вспышка',
|
||||
9: 'Хорошая погода',
|
||||
10: 'Облачно',
|
||||
11: 'Тень'
|
||||
},
|
||||
Flash: {
|
||||
0: 'Не сработала',
|
||||
1: 'Сработала',
|
||||
9: 'Сработала (принуд.)',
|
||||
16: 'Отключена',
|
||||
24: 'Не сработала (авто)',
|
||||
25: 'Сработала (авто)'
|
||||
},
|
||||
WhiteBalance: { 0: 'Авто', 1: 'Ручной' },
|
||||
SceneCaptureType: { 0: 'Стандартный', 1: 'Пейзаж', 2: 'Портрет', 3: 'Ночной' },
|
||||
Contrast: { 0: 'Обычный', 1: 'Мягкий', 2: 'Жёсткий' },
|
||||
Saturation: { 0: 'Обычная', 1: 'Низкая', 2: 'Высокая' },
|
||||
Sharpness: { 0: 'Обычная', 1: 'Мягкая', 2: 'Жёсткая' },
|
||||
CustomRendered: { 0: 'Обычная', 1: 'Особая' },
|
||||
GainControl: {
|
||||
0: 'Нет',
|
||||
1: 'Малое усиление',
|
||||
2: 'Большое усиление',
|
||||
3: 'Низкое усиление',
|
||||
4: 'Высокое усиление'
|
||||
},
|
||||
SensingMethod: {
|
||||
1: 'Не задано',
|
||||
2: 'Однокристальный',
|
||||
3: 'Двухчиповый',
|
||||
4: 'Трёхчиповый',
|
||||
5: 'Цветной последовательный',
|
||||
7: 'Трёхлинейный',
|
||||
8: 'Цветной последовательный'
|
||||
},
|
||||
SubjectDistanceRange: { 0: 'Неизвестно', 1: 'Макро', 2: 'Близко', 3: 'Далеко' },
|
||||
ColorSpace: { 1: 'sRGB', 65535: 'Некалиброванное' }
|
||||
};
|
||||
|
||||
function fmtExif(key: string, value: unknown): string {
|
||||
if (value == null) return '';
|
||||
const n = Number(value);
|
||||
if (key === 'ExposureTime' && !Number.isNaN(n) && n > 0) {
|
||||
return n >= 1 ? `${n} с` : `1/${Math.round(1 / n)}`;
|
||||
}
|
||||
if (
|
||||
(key === 'FNumber' || key === 'ApertureValue' || key === 'MaxApertureValue') &&
|
||||
!Number.isNaN(n)
|
||||
) {
|
||||
return `f/${n}`;
|
||||
}
|
||||
if ((key === 'FocalLength' || key === 'FocalLengthIn35mmFilm') && !Number.isNaN(n)) {
|
||||
return `${n} мм`;
|
||||
}
|
||||
if (key === 'ExposureBiasValue' && !Number.isNaN(n)) {
|
||||
return `${n > 0 ? '+' : ''}${n} EV`;
|
||||
}
|
||||
const enums = EXIF_ENUMS[key];
|
||||
if (enums && !Number.isNaN(n) && enums[n] != null) return enums[n];
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const exifRows = $derived(
|
||||
Object.entries(exif)
|
||||
.filter(([k, v]) => !EXIF_SKIP.has(k) && v != null && String(v) !== '')
|
||||
.map(([k, v]) => ({ key: k, label: EXIF_LABELS[k] ?? k, value: fmtExif(k, v) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
|
||||
const prefs = $derived(auth.preferences);
|
||||
const labelOverrides = $derived(prefs.photo_info?.labels ?? {});
|
||||
const visiblePrefs = $derived(prefs.photo_info?.visible);
|
||||
const effectiveVisible = $derived(
|
||||
new Set(Array.isArray(visiblePrefs) ? visiblePrefs : EXIF_BASE)
|
||||
);
|
||||
const allExifRows = $derived(buildExifRows(exif, labelOverrides));
|
||||
const visibleExifRows = $derived(allExifRows.filter((r) => effectiveVisible.has(r.key)));
|
||||
const hiddenCount = $derived(allExifRows.length - visibleExifRows.length);
|
||||
const attributeKeys = $derived(
|
||||
[...new Set([...EXIF_CATALOG, ...Object.keys(exif)])].sort((a, b) =>
|
||||
(EXIF_LABELS[a] ?? a).localeCompare(EXIF_LABELS[b] ?? b, 'ru')
|
||||
)
|
||||
);
|
||||
|
||||
let settingsOpen = $state(false);
|
||||
let showAll = $state(false);
|
||||
let savingPrefs = $state(false);
|
||||
|
||||
async function savePreferences(next: Preferences) {
|
||||
savingPrefs = true;
|
||||
try {
|
||||
const res = await api<{ preferences: Preferences }>('/auth/me/preferences', {
|
||||
method: 'PATCH',
|
||||
body: next
|
||||
});
|
||||
auth.setPreferences(res.preferences ?? {});
|
||||
showAll = false;
|
||||
settingsOpen = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
savingPrefs = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
@@ -247,6 +87,7 @@
|
||||
const id = photoId;
|
||||
editingName = false;
|
||||
nameError = '';
|
||||
showAll = false;
|
||||
if (!id) {
|
||||
photo = null;
|
||||
return;
|
||||
@@ -362,6 +203,20 @@
|
||||
</div>
|
||||
|
||||
<PanelSection id="photo-info" title="Информация">
|
||||
{#snippet actions()}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Настройки атрибутов"
|
||||
title="Настройки атрибутов"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
settingsOpen = true;
|
||||
}}
|
||||
>
|
||||
<Icon name="settings" class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
<dl class="space-y-1">
|
||||
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
|
||||
<div class={metaRow}><span>Файл</span><span class="text-text">{formatSize(photo.size)}</span></div>
|
||||
@@ -370,12 +225,23 @@
|
||||
<span>Разрешение</span>
|
||||
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||
</div>
|
||||
{#each exifRows as row (row.key)}
|
||||
{#each showAll ? allExifRows : visibleExifRows as row (row.key)}
|
||||
<div class={metaRow}>
|
||||
<span>{row.label}</span>
|
||||
<span class="text-text">{row.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if hiddenCount > 0 && !showAll}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text w-full py-0.5 text-left text-xs transition-colors"
|
||||
aria-label="Показать все атрибуты"
|
||||
title="Показать все атрибуты"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
…
|
||||
</button>
|
||||
{/if}
|
||||
{#if photo.taken_at}
|
||||
<div class={metaRow}>
|
||||
<span>Снято</span>
|
||||
@@ -399,6 +265,19 @@
|
||||
<Comments photoId={photo.id} />
|
||||
</PanelSection>
|
||||
</div>
|
||||
|
||||
{#if settingsOpen}
|
||||
<PhotoInfoSettingsDialog
|
||||
attributes={attributeKeys}
|
||||
defaults={EXIF_LABELS}
|
||||
visible={[...effectiveVisible]}
|
||||
labels={prefs.photo_info?.labels ?? {}}
|
||||
busy={savingPrefs}
|
||||
onSave={savePreferences}
|
||||
onReset={() => savePreferences({})}
|
||||
onClose={() => (settingsOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if album}
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -27,6 +28,7 @@
|
||||
<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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
@@ -50,6 +51,7 @@
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div
|
||||
use:portal
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import type { Album } from '$lib/types';
|
||||
|
||||
@@ -42,6 +43,7 @@
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div
|
||||
use:portal
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
id: string;
|
||||
title: string;
|
||||
open?: boolean;
|
||||
actions?: 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
|
||||
let isOpen = $state(open);
|
||||
@@ -28,18 +29,23 @@
|
||||
</script>
|
||||
|
||||
<section class="border-border border-b">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text flex w-full items-center justify-between gap-2 px-4 py-2.5 text-left"
|
||||
aria-expanded={isOpen}
|
||||
onclick={toggle}
|
||||
>
|
||||
<span class="text-xs font-semibold tracking-wide uppercase">{title}</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 shrink-0 transition-transform {isOpen ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text flex flex-1 items-center justify-between gap-2 px-4 py-2.5 text-left"
|
||||
aria-expanded={isOpen}
|
||||
onclick={toggle}
|
||||
>
|
||||
<span class="text-xs font-semibold tracking-wide uppercase">{title}</span>
|
||||
<Icon
|
||||
name="expand_more"
|
||||
class="size-4 shrink-0 transition-transform {isOpen ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{#if actions}
|
||||
<div class="flex items-center pr-3">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOpen}
|
||||
<div class="px-4 pb-4">{@render children()}</div>
|
||||
{/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"/>' },
|
||||
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"/>' },
|
||||
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>;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Preferences } from '$lib/types';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
preferences?: Preferences;
|
||||
}
|
||||
|
||||
export interface Tokens {
|
||||
@@ -56,6 +59,18 @@ class AuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
get preferences(): Preferences {
|
||||
return this.user?.preferences ?? {};
|
||||
}
|
||||
|
||||
setPreferences(preferences: Preferences) {
|
||||
if (!this.user) return;
|
||||
this.user = { ...this.user, preferences };
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.access = null;
|
||||
this.refresh = null;
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
export interface PhotoInfoPreferences {
|
||||
visible?: string[];
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
photo_info?: PhotoInfoPreferences;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email?: string;
|
||||
name: string;
|
||||
avatar_url?: string;
|
||||
preferences?: Preferences;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
|
||||
Reference in New Issue
Block a user