feat(frontend): replace emoji with inline Material Symbols icon system
Add a self-contained icon system: vendored Material Symbols registry, an <Icon> component that references a single <symbol> sprite via <use>, plus IconButton/IconSprite. Replace every emoji/Unicode glyph (logo, theme switcher, cover placeholder, like/comment counts, lightbox controls, back links) and add leading icons to key controls. Decorative icons are aria-hidden; icon-only buttons get labels; theme toggles expose aria-pressed; counters get accessible names. Recorded in openspec change material-icons-ui (all tasks complete).
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import type { Comment } from '$lib/types';
|
import type { Comment } from '$lib/types';
|
||||||
|
|
||||||
@@ -61,9 +62,14 @@
|
|||||||
{#if auth.user?.id === comment.user_id}
|
{#if auth.user?.id === comment.user_id}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-text-subtle hover:text-danger text-xs"
|
class="text-text-subtle hover:text-danger inline-flex shrink-0 items-center gap-0.5 text-xs"
|
||||||
onclick={() => remove(comment.id)}>удалить</button
|
aria-label="Удалить комментарий"
|
||||||
|
title="Удалить комментарий"
|
||||||
|
onclick={() => remove(comment.id)}
|
||||||
>
|
>
|
||||||
|
<Icon name="delete" class="size-3.5" />
|
||||||
|
удалить
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -75,9 +81,13 @@
|
|||||||
placeholder="Комментарий…"
|
placeholder="Комментарий…"
|
||||||
class="border-border bg-bg text-text flex-1 rounded-lg border px-3 py-1.5 text-sm"
|
class="border-border bg-bg text-text flex-1 rounded-lg border px-3 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
<button class="bg-primary text-primary-fg rounded-lg px-3 py-1.5 text-sm" type="submit"
|
<button
|
||||||
>Отправить</button
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
|
<Icon name="send" class="size-4" />
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { icons, type IconName } from '$lib/icons/registry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: IconName;
|
||||||
|
label?: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, label, class: className = 'size-5' }: Props = $props();
|
||||||
|
|
||||||
|
const known = $derived(name in icons);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if known}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class={className}
|
||||||
|
role={label ? 'img' : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-hidden={label ? undefined : 'true'}
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<use href="#i-{name}" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
import type { IconName } from '$lib/icons/registry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: IconName;
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
pressed?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
onclick?: (event: MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
active = false,
|
||||||
|
pressed,
|
||||||
|
disabled = false,
|
||||||
|
class: className = '',
|
||||||
|
onclick
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const state = $derived(
|
||||||
|
active ? 'bg-primary text-primary-fg' : 'text-text-muted hover:bg-bg-muted hover:text-text'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{disabled}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
aria-pressed={pressed}
|
||||||
|
{onclick}
|
||||||
|
class="inline-flex items-center justify-center rounded-md transition-colors disabled:opacity-50 {state} {className}"
|
||||||
|
>
|
||||||
|
<Icon {name} />
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { icons } from '$lib/icons/registry';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Единственный источник path-данных. Не использовать display:none — некоторые браузеры
|
||||||
|
тогда не рисуют <use>; прячем через нулевой размер и overflow.
|
||||||
|
-->
|
||||||
|
<svg width="0" height="0" class="absolute h-0 w-0 overflow-hidden" aria-hidden="true" focusable="false">
|
||||||
|
{#each Object.entries(icons) as [key, def] (key)}
|
||||||
|
<symbol id="i-{key}" viewBox="0 0 24 24">{@html def.path}</symbol>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
import Comments from './Comments.svelte';
|
import Comments from './Comments.svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
@@ -55,9 +56,11 @@
|
|||||||
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
|
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
|
onkeydown={onKey}
|
||||||
ontouchstart={onTouchStart}
|
ontouchstart={onTouchStart}
|
||||||
ontouchend={onTouchEnd}
|
ontouchend={onTouchEnd}
|
||||||
>
|
>
|
||||||
@@ -67,23 +70,37 @@
|
|||||||
{#if fresh?.download_url}
|
{#if fresh?.download_url}
|
||||||
<a
|
<a
|
||||||
href={fresh.download_url}
|
href={fresh.download_url}
|
||||||
class="border-border text-text rounded-lg border px-3 py-1.5 text-sm"
|
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer">Скачать</a
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
|
<Icon name="download" class="size-4" />
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
class="border-border text-text rounded-lg border px-3 py-1.5 text-sm">✕</button
|
aria-label="Закрыть"
|
||||||
|
title="Закрыть"
|
||||||
|
class="border-border text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
|
||||||
>
|
>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 items-center justify-center overflow-hidden px-2 pb-2">
|
<div class="flex flex-1 items-center justify-center overflow-hidden px-2 pb-2">
|
||||||
<button type="button" class="text-text-muted px-2 text-3xl" onclick={prev} disabled={index === 0}
|
<button
|
||||||
>‹</button
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center px-2"
|
||||||
|
aria-label="Предыдущее фото"
|
||||||
|
title="Предыдущее фото"
|
||||||
|
onclick={prev}
|
||||||
|
disabled={index === 0}
|
||||||
>
|
>
|
||||||
|
<Icon name="chevron_left" class="size-8" />
|
||||||
|
</button>
|
||||||
{#if current}
|
{#if current}
|
||||||
<img
|
<img
|
||||||
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
|
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
|
||||||
@@ -93,10 +110,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-text-muted px-2 text-3xl"
|
class="text-text-muted hover:text-text inline-flex items-center px-2"
|
||||||
|
aria-label="Следующее фото"
|
||||||
|
title="Следующее фото"
|
||||||
onclick={next}
|
onclick={next}
|
||||||
disabled={index === photos.length - 1}>›</button
|
disabled={index === photos.length - 1}
|
||||||
>
|
>
|
||||||
|
<Icon name="chevron_right" class="size-8" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if current}
|
{#if current}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -34,9 +35,22 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="bg-bg/70 text-text absolute right-1 bottom-1 rounded px-1.5 py-0.5 text-xs"
|
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
|
||||||
>
|
>
|
||||||
♥ {photo.likes_count} · 💬 {photo.comments_count}
|
<span class="inline-flex items-center gap-0.5" aria-label="{photo.likes_count} лайков">
|
||||||
|
<Icon
|
||||||
|
name={photo.liked_by_me ? 'favorite' : 'favorite_outline'}
|
||||||
|
class="size-3.5"
|
||||||
|
/>
|
||||||
|
{photo.likes_count}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-0.5"
|
||||||
|
aria-label="{photo.comments_count} комментариев"
|
||||||
|
>
|
||||||
|
<Icon name="mode_comment" class="size-3.5" />
|
||||||
|
{photo.comments_count}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import IconButton from './IconButton.svelte';
|
||||||
|
import type { IconName } from '$lib/icons/registry';
|
||||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||||
|
|
||||||
const options: { value: Theme; label: string; icon: string }[] = [
|
const options: { value: Theme; label: string; icon: IconName }[] = [
|
||||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
{ value: 'light', label: 'Light', icon: 'light_mode' },
|
||||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
{ value: 'dark', label: 'Dark', icon: 'dark_mode' },
|
||||||
{ value: 'neutral', label: 'Neutral', icon: '🌾' },
|
{ value: 'neutral', label: 'Neutral', icon: 'palette' },
|
||||||
{ value: 'system', label: 'System', icon: '💻' }
|
{ value: 'system', label: 'System', icon: 'brightness_auto' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1" role="group" aria-label="Theme">
|
<div
|
||||||
{#each options as option (option.value)}
|
class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1"
|
||||||
<button
|
role="group"
|
||||||
type="button"
|
aria-label="Theme"
|
||||||
title={option.label}
|
|
||||||
aria-label={option.label}
|
|
||||||
aria-pressed={theme.current === option.value}
|
|
||||||
onclick={() => theme.set(option.value)}
|
|
||||||
class="rounded-md px-2 py-1 text-sm transition-colors {theme.current === option.value
|
|
||||||
? 'bg-primary text-primary-fg'
|
|
||||||
: 'text-text-muted hover:bg-bg-muted'}"
|
|
||||||
>
|
>
|
||||||
{option.icon}
|
{#each options as option (option.value)}
|
||||||
</button>
|
<IconButton
|
||||||
|
name={option.icon}
|
||||||
|
label={option.label}
|
||||||
|
active={theme.current === option.value}
|
||||||
|
pressed={theme.current === option.value}
|
||||||
|
class="px-2 py-1 text-sm"
|
||||||
|
onclick={() => theme.set(option.value)}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
import type { Photo } from '$lib/types';
|
import type { Photo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -96,13 +97,15 @@
|
|||||||
class="hidden"
|
class="hidden"
|
||||||
onchange={onChange}
|
onchange={onChange}
|
||||||
/>
|
/>
|
||||||
|
<Icon name="cloud_upload" class="text-text-subtle mx-auto mb-3 size-10" />
|
||||||
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
|
<p class="text-text mb-3 text-sm">Перетащите фото сюда или</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={pick}
|
onclick={pick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
|
<Icon name="add_photo_alternate" class="size-4" />
|
||||||
{busy ? 'Загрузка…' : 'Выбрать файлы'}
|
{busy ? 'Загрузка…' : 'Выбрать файлы'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Вендоренные иконки Google Material Symbols (стиль filled), источник — Iconify
|
||||||
|
// (prefix: material-symbols). Лицензия Apache-2.0. Не редактировать вручную без
|
||||||
|
// необходимости; при добавлении иконки добавьте запись сюда и используйте <Icon name>.
|
||||||
|
//
|
||||||
|
// Generated file: см. openspec/changes/material-icons-ui (design.md).
|
||||||
|
|
||||||
|
export interface IconDef {
|
||||||
|
/** Внутренняя разметка <symbol> (обычно один <path>). */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const icons = {
|
||||||
|
photo_camera: { path: '<path fill="currentColor" d="M12 17.5q1.875 0 3.188-1.312T16.5 13t-1.312-3.187T12 8.5T8.813 9.813T7.5 13t1.313 3.188T12 17.5m0-2q-1.05 0-1.775-.725T9.5 13t.725-1.775T12 10.5t1.775.725T14.5 13t-.725 1.775T12 15.5M4 21q-.825 0-1.412-.587T2 19V7q0-.825.588-1.412T4 5h3.15L9 3h6l1.85 2H20q.825 0 1.413.588T22 7v12q0 .825-.587 1.413T20 21z"/>' },
|
||||||
|
login: { path: '<path fill="currentColor" d="M12 21v-2h7V5h-7V3h7q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm-2-4l-1.375-1.45l2.55-2.55H3v-2h8.175l-2.55-2.55L10 7l5 5z"/>' },
|
||||||
|
logout: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z"/>' },
|
||||||
|
light_mode: { path: '<path fill="currentColor" d="M8.463 15.538Q7 14.075 7 12t1.463-3.537T12 7t3.538 1.463T17 12t-1.463 3.538T12 17t-3.537-1.463M5 13H1v-2h4zm18 0h-4v-2h4zM11 5V1h2v4zm0 18v-4h2v4zM6.4 7.75L3.875 5.325L5.3 3.85l2.4 2.5zm12.3 12.4l-2.425-2.525L17.6 16.25l2.525 2.425zM16.25 6.4l2.425-2.525L20.15 5.3l-2.5 2.4zM3.85 18.7l2.525-2.425L7.75 17.6l-2.425 2.525z"/>' },
|
||||||
|
dark_mode: { path: '<path fill="currentColor" d="M12 21q-3.75 0-6.375-2.625T3 12t2.625-6.375T12 3q.35 0 .688.025t.662.075q-1.025.725-1.638 1.888T11.1 7.5q0 2.25 1.575 3.825T16.5 12.9q1.375 0 2.525-.613T20.9 10.65q.05.325.075.662T21 12q0 3.75-2.625 6.375T12 21"/>' },
|
||||||
|
palette: { path: '<path fill="currentColor" d="M12 22q-2.05 0-3.875-.788t-3.187-2.15t-2.15-3.187T2 12q0-2.075.813-3.9t2.2-3.175T8.25 2.788T12.2 2q2 0 3.775.688t3.113 1.9t2.125 2.875T22 11.05q0 2.875-1.75 4.413T16 17h-1.85q-.225 0-.312.125t-.088.275q0 .3.375.863t.375 1.287q0 1.25-.687 1.85T12 22m-4.425-9.425Q8 12.15 8 11.5t-.425-1.075T6.5 10t-1.075.425T5 11.5t.425 1.075T6.5 13t1.075-.425m3-4Q11 8.15 11 7.5t-.425-1.075T9.5 6t-1.075.425T8 7.5t.425 1.075T9.5 9t1.075-.425m5 0Q16 8.15 16 7.5t-.425-1.075T14.5 6t-1.075.425T13 7.5t.425 1.075T14.5 9t1.075-.425m3 4Q19 12.15 19 11.5t-.425-1.075T17.5 10t-1.075.425T16 11.5t.425 1.075T17.5 13t1.075-.425"/>' },
|
||||||
|
brightness_auto: { path: '<path fill="currentColor" d="M7.8 16h1.6l.8-2.3h3.65l.8 2.3h1.55l-3.4-9h-1.6zm2.85-3.6l1.3-3.75h.1l1.3 3.75zM12 23.3L8.65 20H4v-4.65L.7 12L4 8.65V4h4.65L12 .7L15.35 4H20v4.65L23.3 12L20 15.35V20h-4.65z"/>' },
|
||||||
|
image: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13z"/>' },
|
||||||
|
add: { path: '<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z"/>' },
|
||||||
|
photo_library: { path: '<path fill="currentColor" d="M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>' },
|
||||||
|
group: { path: '<path fill="currentColor" d="M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825"/>' },
|
||||||
|
settings: { path: '<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>' },
|
||||||
|
cloud_upload: { path: '<path fill="currentColor" d="M11 20H6.5q-2.275 0-3.887-1.575T1 14.575q0-1.95 1.175-3.475T5.25 9.15q.625-2.3 2.5-3.725T12 4q2.925 0 4.963 2.038T19 11q1.725.2 2.863 1.488T23 15.5q0 1.875-1.312 3.188T18.5 20H13v-7.15l1.6 1.55L16 13l-4-4l-4 4l1.4 1.4l1.6-1.55z"/>' },
|
||||||
|
add_photo_alternate: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h9q-.5.65-.75 1.425T13 6q0 2.075 1.463 3.538T18 11q.8 0 1.575-.25T21 10v9q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13zm11-8V7h-2V5h2V3h2v2h2v2h-2v2z"/>' },
|
||||||
|
expand_more: { path: '<path fill="currentColor" d="m12 15.4l-6-6L7.4 8l4.6 4.6L16.6 8L18 9.4z"/>' },
|
||||||
|
favorite: { path: '<path fill="currentColor" d="m12 21l-1.45-1.3q-2.525-2.275-4.175-3.925T3.75 12.812T2.388 10.4T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.3 0 2.475.55T12 4.75q.85-1 2.025-1.55t2.475-.55q2.35 0 3.925 1.575T22 8.15q0 1.15-.387 2.25t-1.363 2.412t-2.625 2.963T13.45 19.7z"/>' },
|
||||||
|
favorite_outline: { path: '<path fill="currentColor" d="m12 21l-1.45-1.3q-2.525-2.275-4.175-3.925T3.75 12.812T2.388 10.4T2 8.15Q2 5.8 3.575 4.225T7.5 2.65q1.3 0 2.475.55T12 4.75q.85-1 2.025-1.55t2.475-.55q2.35 0 3.925 1.575T22 8.15q0 1.15-.387 2.25t-1.363 2.412t-2.625 2.963T13.45 19.7zm0-2.7q2.4-2.15 3.95-3.687t2.45-2.675t1.25-2.026T20 8.15q0-1.5-1-2.5t-2.5-1q-1.175 0-2.175.662T12.95 7h-1.9q-.375-1.025-1.375-1.687T7.5 4.65q-1.5 0-2.5 1t-1 2.5q0 .875.35 1.763t1.25 2.025t2.45 2.675T12 18.3m0-6.825"/>' },
|
||||||
|
mode_comment: { path: '<path fill="currentColor" d="M4 18q-.825 0-1.412-.587T2 16V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v18l-4-4z"/>' },
|
||||||
|
send: { path: '<path fill="currentColor" d="M3 20v-6l8-2l-8-2V4l19 8z"/>' },
|
||||||
|
delete: { path: '<path fill="currentColor" d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z"/>' },
|
||||||
|
close: { path: '<path fill="currentColor" d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z"/>' },
|
||||||
|
chevron_left: { path: '<path fill="currentColor" d="m14 18l-6-6l6-6l1.4 1.4l-4.6 4.6l4.6 4.6z"/>' },
|
||||||
|
chevron_right: { path: '<path fill="currentColor" d="M12.6 12L8 7.4L9.4 6l6 6l-6 6L8 16.6z"/>' },
|
||||||
|
download: { path: '<path fill="currentColor" d="m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"/>' },
|
||||||
|
arrow_back: { path: '<path fill="currentColor" d="m7.825 13l5.6 5.6L12 20l-8-8l8-8l1.425 1.4l-5.6 5.6H20v2z"/>' },
|
||||||
|
save: { path: '<path fill="currentColor" d="M21 7v12q0 .825-.587 1.413T19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h12zm-6.875 10.125Q15 16.25 15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18t2.125-.875M6 10h9V6H6z"/>' },
|
||||||
|
add_link: { path: '<path fill="currentColor" d="M17 20v-3h-3v-2h3v-3h2v3h3v2h-3v3zm-6-3H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm14-1h-2q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12"/>' },
|
||||||
|
link_off: { path: '<path fill="currentColor" d="m19.25 16.45l-1.5-1.55q1-.275 1.625-1.063T20 12q0-1.25-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12q0 1.425-.737 2.625T19.25 16.45M15.85 13l-2-2H16v2zm3.95 9.6L1.4 4.2l1.4-1.4l18.4 18.4zM11 17H7q-2.075 0-3.537-1.463T2 12q0-1.725 1.05-3.075t2.7-1.775L7.6 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h1.625l1.975 2z"/>' },
|
||||||
|
delete_forever: { path: '<path fill="currentColor" d="m9.4 16.5l2.6-2.6l2.6 2.6l1.4-1.4l-2.6-2.6L16 9.9l-1.4-1.4l-2.6 2.6l-2.6-2.6L8 9.9l2.6 2.6L8 15.1zM7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21z"/>' },
|
||||||
|
content_copy: { path: '<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>' },
|
||||||
|
check: { path: '<path fill="currentColor" d="m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>' }
|
||||||
|
} satisfies Record<string, IconDef>;
|
||||||
|
|
||||||
|
export type IconName = keyof typeof icons;
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import IconSprite from '$lib/components/IconSprite.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { theme } from '$lib/stores/theme.svelte';
|
import { theme } from '$lib/stores/theme.svelte';
|
||||||
import { authApi } from '$lib/api';
|
import { authApi } from '$lib/api';
|
||||||
@@ -28,20 +30,33 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<IconSprite />
|
||||||
<div class="bg-bg text-text min-h-screen">
|
<div class="bg-bg text-text min-h-screen">
|
||||||
<header class="border-border bg-bg-elevated border-b">
|
<header class="border-border bg-bg-elevated border-b">
|
||||||
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3">
|
||||||
<a href="/" class="text-lg font-semibold">📷 Galo</a>
|
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<Icon name="photo_camera" />
|
||||||
|
Galo
|
||||||
|
</a>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if auth.isAuthenticated}
|
{#if auth.isAuthenticated}
|
||||||
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
|
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-text-muted hover:text-text text-sm"
|
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
||||||
onclick={logout}>Выйти</button
|
onclick={logout}
|
||||||
>
|
>
|
||||||
|
<Icon name="logout" class="size-4" />
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<a href="/login" class="text-text-muted hover:text-text text-sm">Войти</a>
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-4" />
|
||||||
|
Войти
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import type { Album } from '$lib/types';
|
import type { Album } from '$lib/types';
|
||||||
|
|
||||||
@@ -47,7 +48,13 @@
|
|||||||
<div class="py-16 text-center">
|
<div class="py-16 text-center">
|
||||||
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
|
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
|
||||||
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
|
<p class="text-text-muted mb-6">Войдите, чтобы создавать альбомы и делиться фотографиями.</p>
|
||||||
<a href="/login" class="bg-primary text-primary-fg rounded-lg px-5 py-2.5 font-medium">Войти</a>
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-5" />
|
||||||
|
Войти
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mb-6 flex items-center justify-between gap-4">
|
<div class="mb-6 flex items-center justify-between gap-4">
|
||||||
@@ -58,9 +65,13 @@
|
|||||||
placeholder="Название альбома"
|
placeholder="Название альбома"
|
||||||
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
|
class="border-border bg-bg-elevated text-text rounded-lg border px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
|
<button
|
||||||
>Создать</button
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
|
<Icon name="add" class="size-4" />
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,7 +80,10 @@
|
|||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="text-text-muted">Загрузка…</p>
|
<p class="text-text-muted">Загрузка…</p>
|
||||||
{:else if albums.length === 0}
|
{:else if albums.length === 0}
|
||||||
<p class="text-text-muted">Альбомов пока нет. Создайте первый.</p>
|
<div class="text-text-muted flex flex-col items-center gap-3 py-12">
|
||||||
|
<Icon name="photo_library" class="size-16 text-text-subtle" />
|
||||||
|
<p>Альбомов пока нет. Создайте первый.</p>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each albums as album (album.id)}
|
{#each albums as album (album.id)}
|
||||||
@@ -78,7 +92,9 @@
|
|||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
|
class="border-border bg-bg-elevated hover:border-primary block overflow-hidden rounded-xl border transition-colors"
|
||||||
>
|
>
|
||||||
<div class="bg-bg-muted flex aspect-video items-center justify-center text-4xl">🖼️</div>
|
<div class="bg-bg-muted flex aspect-video items-center justify-center">
|
||||||
|
<Icon name="image" class="size-16 text-text-subtle" />
|
||||||
|
</div>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h2 class="text-text font-semibold">{album.title}</h2>
|
<h2 class="text-text font-semibold">{album.title}</h2>
|
||||||
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
|
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
import Lightbox from '$lib/components/Lightbox.svelte';
|
import Lightbox from '$lib/components/Lightbox.svelte';
|
||||||
import Uploader from '$lib/components/Uploader.svelte';
|
import Uploader from '$lib/components/Uploader.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
const albumId = $derived($page.params.id);
|
const albumId = $derived($page.params.id ?? '');
|
||||||
|
|
||||||
let album = $state<Album | null>(null);
|
let album = $state<Album | null>(null);
|
||||||
let role = $state('');
|
let role = $state('');
|
||||||
@@ -129,13 +130,19 @@
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a
|
<a
|
||||||
href="/albums/{albumId}/members"
|
href="/albums/{albumId}/members"
|
||||||
class="border-border text-text rounded-lg border px-3 py-2 text-sm">Участники</a
|
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
|
<Icon name="group" class="size-4" />
|
||||||
|
Участники
|
||||||
|
</a>
|
||||||
{#if role === 'owner' || role === 'admin'}
|
{#if role === 'owner' || role === 'admin'}
|
||||||
<a
|
<a
|
||||||
href="/albums/{albumId}/settings"
|
href="/albums/{albumId}/settings"
|
||||||
class="border-border text-text rounded-lg border px-3 py-2 text-sm">Настройки</a
|
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
|
<Icon name="settings" class="size-4" />
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,9 +166,12 @@
|
|||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
|
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
||||||
onclick={loadMore}>Показать ещё</button
|
onclick={loadMore}
|
||||||
>
|
>
|
||||||
|
<Icon name="expand_more" class="size-4" />
|
||||||
|
Показать ещё
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
import type { Album, AlbumMember } from '$lib/types';
|
import type { Album, AlbumMember } from '$lib/types';
|
||||||
|
|
||||||
@@ -74,11 +75,22 @@
|
|||||||
error = (e as Error).message;
|
error = (e as Error).message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copy(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
/* clipboard недоступен */
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Участники</h1>
|
<h1 class="text-2xl font-bold">Участники</h1>
|
||||||
<a href="/albums/{albumId}" class="text-primary text-sm">← К альбому</a>
|
<a href="/albums/{albumId}" class="text-primary inline-flex items-center gap-1 text-sm">
|
||||||
|
<Icon name="arrow_back" class="size-4" />
|
||||||
|
К альбому
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
||||||
@@ -105,9 +117,12 @@
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-danger text-sm"
|
class="text-danger inline-flex items-center gap-1 text-sm"
|
||||||
onclick={() => removeMember(member.user_id)}>Удалить</button
|
onclick={() => removeMember(member.user_id)}
|
||||||
>
|
>
|
||||||
|
<Icon name="delete" class="size-4" />
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-text-muted text-sm">{member.role}</span>
|
<span class="text-text-muted text-sm">{member.role}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -148,11 +163,26 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
|
<button
|
||||||
>Создать ссылку</button
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
|
<Icon name="add_link" class="size-4" />
|
||||||
|
Создать ссылку
|
||||||
|
</button>
|
||||||
{#if inviteUrl}
|
{#if inviteUrl}
|
||||||
<p class="text-text-muted break-all text-sm">Ссылка: <a class="text-primary" href={inviteUrl}>{inviteUrl}</a></p>
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-text-muted break-all">Ссылка: <a class="text-primary" href={inviteUrl}>{inviteUrl}</a></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-muted hover:text-text inline-flex shrink-0 items-center gap-1"
|
||||||
|
aria-label="Скопировать ссылку"
|
||||||
|
title="Скопировать ссылку"
|
||||||
|
onclick={() => copy(inviteUrl)}
|
||||||
|
>
|
||||||
|
<Icon name="content_copy" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import type { Album, ShareLink } from '$lib/types';
|
import type { Album, ShareLink } from '$lib/types';
|
||||||
|
|
||||||
const albumId = $derived($page.params.id);
|
const albumId = $derived($page.params.id);
|
||||||
@@ -81,7 +82,10 @@
|
|||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold">Настройки</h1>
|
<h1 class="text-2xl font-bold">Настройки</h1>
|
||||||
<a href="/albums/{albumId}" class="text-primary text-sm">← К альбому</a>
|
<a href="/albums/{albumId}" class="text-primary inline-flex items-center gap-1 text-sm">
|
||||||
|
<Icon name="arrow_back" class="size-4" />
|
||||||
|
К альбому
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
|
||||||
@@ -103,7 +107,13 @@
|
|||||||
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2"
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit">Сохранить</button>
|
<button
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<Icon name="save" class="size-4" />
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
|
{#if saved}<span class="text-success ml-3 text-sm">Сохранено</span>{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -117,9 +127,13 @@
|
|||||||
class="border-border bg-bg text-text w-32 rounded-lg border px-2 py-1.5"
|
class="border-border bg-bg text-text w-32 rounded-lg border px-2 py-1.5"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm" type="submit"
|
<button
|
||||||
>Создать ссылку</button
|
class="bg-primary text-primary-fg inline-flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm"
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
|
<Icon name="add_link" class="size-4" />
|
||||||
|
Создать ссылку
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<ul class="divide-border divide-y">
|
<ul class="divide-border divide-y">
|
||||||
{#each shareLinks as link (link.id)}
|
{#each shareLinks as link (link.id)}
|
||||||
@@ -129,7 +143,14 @@
|
|||||||
· <a class="text-primary" href="/share/{link.token}">/share/{link.token}</a>
|
· <a class="text-primary" href="/share/{link.token}">/share/{link.token}</a>
|
||||||
</span>
|
</span>
|
||||||
{#if !link.revoked_at}
|
{#if !link.revoked_at}
|
||||||
<button type="button" class="text-danger" onclick={() => revoke(link.id)}>Отозвать</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-danger inline-flex shrink-0 items-center gap-1"
|
||||||
|
onclick={() => revoke(link.id)}
|
||||||
|
>
|
||||||
|
<Icon name="link_off" class="size-4" />
|
||||||
|
Отозвать
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -138,6 +159,10 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-danger text-danger rounded-lg border px-4 py-2 text-sm"
|
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
||||||
onclick={removeAlbum}>Удалить альбом</button
|
onclick={removeAlbum}
|
||||||
>
|
>
|
||||||
|
<Icon name="delete_forever" class="size-4" />
|
||||||
|
Удалить альбом
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import { auth } from '$lib/stores/auth.svelte';
|
import { auth } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
const token = $derived($page.params.token);
|
const token = $derived($page.params.token);
|
||||||
@@ -43,13 +44,20 @@
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={accept}
|
onclick={accept}
|
||||||
class="bg-primary text-primary-fg rounded-lg px-5 py-2.5 font-medium"
|
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
||||||
>
|
>
|
||||||
|
<Icon name="check" class="size-5" />
|
||||||
{busy ? 'Принятие…' : 'Принять приглашение'}
|
{busy ? 'Принятие…' : 'Принять приглашение'}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="mb-4 text-sm">Войдите или зарегистрируйтесь, чтобы принять приглашение.</p>
|
<p class="mb-4 text-sm">Войдите или зарегистрируйтесь, чтобы принять приглашение.</p>
|
||||||
<a href="/login" class="bg-primary text-primary-fg rounded-lg px-5 py-2.5 font-medium">Войти</a>
|
<a
|
||||||
|
href="/login"
|
||||||
|
class="bg-primary text-primary-fg inline-flex items-center gap-2 rounded-lg px-5 py-2.5 font-medium"
|
||||||
|
>
|
||||||
|
<Icon name="login" class="size-5" />
|
||||||
|
Войти
|
||||||
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-text-muted">Загрузка…</p>
|
<p class="text-text-muted">Загрузка…</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||||
import type { Album, Photo } from '$lib/types';
|
import type { Album, Photo } from '$lib/types';
|
||||||
|
|
||||||
@@ -54,9 +55,12 @@
|
|||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
|
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
|
||||||
onclick={loadMore}>Показать ещё</button
|
onclick={loadMore}
|
||||||
>
|
>
|
||||||
|
<Icon name="expand_more" class="size-4" />
|
||||||
|
Показать ещё
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -64,6 +68,10 @@
|
|||||||
<div
|
<div
|
||||||
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center"
|
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') active = -1;
|
||||||
|
}}
|
||||||
onclick={() => (active = -1)}
|
onclick={() => (active = -1)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -71,13 +79,27 @@
|
|||||||
alt={photos[active].original_filename}
|
alt={photos[active].original_filename}
|
||||||
class="max-h-[90vh] max-w-full object-contain"
|
class="max-h-[90vh] max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
|
<div class="absolute top-4 right-4 flex gap-2">
|
||||||
<a
|
<a
|
||||||
href={photos[active].download_url}
|
href={photos[active].download_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="border-border bg-bg-elevated text-text absolute top-4 right-4 rounded-lg border px-3 py-1.5 text-sm"
|
class="border-border bg-bg-elevated text-text inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm"
|
||||||
onclick={(e) => e.stopPropagation()}>Скачать</a
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<Icon name="download" class="size-4" />
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
title="Закрыть"
|
||||||
|
class="border-border bg-bg-elevated text-text inline-flex items-center justify-center rounded-lg border px-2 py-1.5"
|
||||||
|
onclick={() => (active = -1)}
|
||||||
|
>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-10
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Фронтенд — SvelteKit 2 + Svelte 5, `adapter-static` в режиме SPA (`ssr=false`),
|
||||||
|
собирается в статику и вшивается в Go-бинарник через `go:embed`. Стили — Tailwind v4,
|
||||||
|
семантические токены через CSS-переменные и `data-theme` на `<html>` (темы light/dark/neutral
|
||||||
|
+ system). Внешние CDN-запросы нежелательны: приложение self-hosted и должно работать
|
||||||
|
полностью автономно. Сейчас все «иконки» — эмодзи и юникод-глифы, рендерящиеся системным
|
||||||
|
шрифтом (мотивация — в proposal.md).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Единый набор векторных иконок, самодостаточный (без внешних запросов и без иконочных шрифтов).
|
||||||
|
- Иконки наследуют цвет текущей темы и не влияют на размеры/раскладку при переключении тем.
|
||||||
|
- Переиспользуемый компонентный API, спрайт для дедупликации повторяющихся иконок.
|
||||||
|
- Доступность: корректное поведение для декоративных и смысловых иконок и для тумблеров.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Не менять доменную логику, API, БД и backend.
|
||||||
|
- Не вводить анимации/переходы иконок.
|
||||||
|
- Не добавлять внешние зависимости (иконочные пакеты, Vite-плагины).
|
||||||
|
- Не делать полный редизайн макета — только замена иконок и ведущие иконки контролов.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Inline SVG через спрайт `<symbol>` + `<use>`
|
||||||
|
В `+layout.svelte` монтируется один скрытый `<svg>` со всеми `<symbol id="i-...">`;
|
||||||
|
места использования рисуют `<svg><use href="#i-..."/></svg>`.
|
||||||
|
- **Почему:** нет внешних запросов, работает внутри статичного бандла, `currentColor`
|
||||||
|
наследуется через `<use>`, а данные пути хранятся один раз в DOM (важно для сеток, где
|
||||||
|
`favorite`/`mode_comment` повторяются на каждой плитке).
|
||||||
|
- **Альтернативы (отклонены):** иконочный шрифт через CDN — внешний запрос, FOUT, ломает
|
||||||
|
автономность; внешний файл-спрайт `<use href="/icons.svg#id">` — межфайловые `<use>` имеют
|
||||||
|
ограничения в ряде браузеров; «иконка = компонент с полным inline-путём» — дублирование
|
||||||
|
path-данных в DOM при массовом повторении.
|
||||||
|
|
||||||
|
### 2. Вендорим данные иконок в `registry.ts`, без зависимостей
|
||||||
|
Реестр — объект `имя -> { path }`, где `path` — сырая inner-разметка (`<path d="..." .../>`,
|
||||||
|
иногда с `fill-rule`/`clip-rule`). Источник — Google Material Symbols (через Iconify,
|
||||||
|
prefix `material-symbols`); базовые имена дают стиль **filled**, поэтому набор вендорится в
|
||||||
|
filled-стиле, а для состояний-пар берутся отдельные ключи (`favorite` = filled, лайкнут;
|
||||||
|
`favorite-outline` = контурный, не лайкнут). Примечание: контурного варианта для всех
|
||||||
|
иконок в источнике нет, поэтому единый filled-набор согласованнее, чем смесь стилей.
|
||||||
|
- **Почему:** ноль зависимостей и supply-chain, малый бандл, полный контроль, согласуется с
|
||||||
|
философией «один статичный бинарник».
|
||||||
|
- **Альтернативы (отклонены):** `@mdi/js` — готовые path-строки, но новая зависимость и другой
|
||||||
|
визуальный набор; Iconify + `unplugin-icons` — мощный DX, но тяжёлый тулчейн ради ~25 иконок.
|
||||||
|
- **Задел:** при росте набора переход на `@mdi/js` не меняет компонентный API — только источник
|
||||||
|
данных реестра.
|
||||||
|
|
||||||
|
### 3. Компонентный API
|
||||||
|
- `Icon.svelte`: пропсы `name`, необязательный `label`; размер и цвет задаются классами
|
||||||
|
(`size-4/5/6/16`, `text-*`). По умолчанию `aria-hidden="true" focusable="false"`; при
|
||||||
|
заданном `label` — `role="img"` + `aria-label` и `aria-hidden` снимается.
|
||||||
|
- `IconSprite.svelte`: рендерит все `<symbol>` из реестра один раз, монтируется в `+layout.svelte`.
|
||||||
|
- `IconButton.svelte`: иконочная кнопка с обязательным `label` (попадает в `aria-label`).
|
||||||
|
- **Неизвестное имя:** в dev — `console.warn` и пустой бокс (не падать); в prod — пусто.
|
||||||
|
|
||||||
|
### 4. Стиль и метрики
|
||||||
|
- `fill="currentColor"`, шкала размеров: `size-4` (в тексте/счётчиках), `size-5`
|
||||||
|
(кнопки/навигация), `size-6` (панель лайтбокса), `size-16` (плейсхолдеры).
|
||||||
|
- Пара состояний лайка: `favorite-outline` (не лайкнут) / `favorite` (залитая, лайкнут).
|
||||||
|
|
||||||
|
### 5. Маппинг иконок тем
|
||||||
|
`light_mode`, `dark_mode`, `palette` (нейтральная), `brightness_auto` (системная).
|
||||||
|
|
||||||
|
### 6. Доступность
|
||||||
|
- Декоративные иконки рядом с текстом/внутри кнопки с текстом — `aria-hidden`.
|
||||||
|
- Одиночные иконочные кнопки (тумблеры тем) — `aria-label` на `<button>` + `aria-pressed`.
|
||||||
|
- Счётчики лайков/комментариев: иконка декоративная, число несёт доступное имя
|
||||||
|
(`aria-label="12 лайков"`).
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Спрайт должен оставаться в DOM]** → `<use>` ссылается на `#id` в том же документе;
|
||||||
|
`IconSprite` живёт в `+layout.svelte` и не размонтируется.
|
||||||
|
- **[`{@html}` в `<symbol>`]** → данные статичны и вендорятся на этапе сборки, не приходят от
|
||||||
|
пользователя; риск XSS отсутствует.
|
||||||
|
- **[Рост DOM при больших сетках]** → митигируется спрайтом (одна копия пути).
|
||||||
|
- **[Визуальная регрессия/раскладка]** → размеры задаём классами, иконки в квадратном
|
||||||
|
`viewBox 0 0 24 24`; проверяем на трёх темах.
|
||||||
|
- **[Лицензия/атрибуция]** → Material Symbols под Apache 2.0; добавляем уведомление об
|
||||||
|
источнике рядом с реестром.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Сейчас все «иконки» в интерфейсе — это эмодзи и юникод-глифы, которые рисует системный
|
||||||
|
шрифт. Они по-разному выглядят в разных ОС и браузерах (проект self-hosted — парк
|
||||||
|
устройств непредсказуем), плохо наследуют цвет темы и семантически неточны: 🌾 для
|
||||||
|
«нейтральной темы», 💻 для «системной», ♥/💬 для метрик. Нужен единый, самодостаточный
|
||||||
|
набор векторных иконок без внешних запросов, согласованный с тремя темами и доступностью.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Ввести inline-SVG иконочную систему: спрайт `<symbol>` + `<use>`, реестр иконок
|
||||||
|
(`registry.ts`), компоненты `Icon`, `IconSprite`, `IconButton`.
|
||||||
|
- Заменить все эмодзи/юникод-глифы на SVG-иконки: логотип (`📷`), переключатель тем
|
||||||
|
(`☀️ 🌙 🌾 💻`), плейсхолдер обложки (`🖼️`), лайк/комментарий (`♥ 💬`), закрытие
|
||||||
|
и стрелки лайтбокса (`✕ ‹ ›`), «назад» (`←`).
|
||||||
|
- Добавить ведущие иконки ключевым контролам: создать, загрузить, скачать, сохранить,
|
||||||
|
отправить, удалить, выйти, участники, настройки, share-ссылки, принять инвайт.
|
||||||
|
- Источник данных — Google Material Symbols (стиль outlined) плюс `*_fill`-варианты для
|
||||||
|
состояний (например, лайк нажат/не нажат). Данные иконок вендорятся в репозиторий,
|
||||||
|
новые зависимости не добавляются.
|
||||||
|
- Проход по доступности: декоративные иконки скрыты от скринридеров (`aria-hidden`),
|
||||||
|
смысловые/одиночные получают `aria-label`/`role="img"`, тумблеры тем — `aria-pressed`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `ui-iconography`: единая inline-SVG система иконок фронтенда, её доступность и
|
||||||
|
наследование цветовых токенов тем.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
_Нет — существующие спеки описывают доменную логику (albums, auth, photos, …); иконочная
|
||||||
|
система их требований не меняет._
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Код:** `frontend/src/lib/icons/*`, `frontend/src/lib/components/Icon.svelte`,
|
||||||
|
`IconSprite.svelte`, `IconButton.svelte`, правки в `+layout.svelte` и во всех
|
||||||
|
экранах/компонентах (`ThemeSwitcher`, `PhotoGrid`, `Lightbox`, `Uploader`, `Comments`,
|
||||||
|
список альбомов, страница альбома, участники, настройки, инвайт, публичный share).
|
||||||
|
- **Зависимости:** без новых — данные иконок вендорятся.
|
||||||
|
- **Совместимость:** backend, API и БД не затрагиваются; меняется только фронтенд-разметка
|
||||||
|
и доступность.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Единая система иконок веб-интерфейса: самодостаточные векторные иконки, которые наследуют
|
||||||
|
цвет темы и корректно ведут себя для вспомогательных технологий.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Самодостаточные векторные иконки
|
||||||
|
Интерфейс SHALL отображать иконки как векторную графику, встроенную в документ, без запросов
|
||||||
|
к внешним источникам за иконками.
|
||||||
|
|
||||||
|
#### Scenario: Отображение иконки
|
||||||
|
- **WHEN** интерфейс показывает иконку (например, лайк, тему, закрытие лайтбокса)
|
||||||
|
- **THEN** она отрисована встроенной векторной графикой, а не эмодзи или символом системного шрифта
|
||||||
|
|
||||||
|
#### Scenario: Автономная загрузка
|
||||||
|
- **WHEN** страница загружается без доступа к внешней сети, кроме собственного сервера
|
||||||
|
- **THEN** все иконки отображаются, так как не требуют внешних ресурсов
|
||||||
|
|
||||||
|
### Requirement: Наследование цвета темы
|
||||||
|
Иконка SHALL наследовать цвет текущей темы и изменять его при переключении темы.
|
||||||
|
|
||||||
|
#### Scenario: Переключение темы
|
||||||
|
- **WHEN** пользователь переключает тему (light/dark/neutral)
|
||||||
|
- **THEN** цвет иконок меняется вместе с темой без дополнительных действий
|
||||||
|
|
||||||
|
### Requirement: Доступность иконок
|
||||||
|
Система SHALL скрывать декоративные иконки от вспомогательных технологий и давать доступное
|
||||||
|
имя смысловым иконочным элементам управления.
|
||||||
|
|
||||||
|
#### Scenario: Декоративная иконка
|
||||||
|
- **WHEN** иконка сопровождает видимый текст или находится внутри кнопки с текстом
|
||||||
|
- **THEN** она скрыта от скринридеров
|
||||||
|
|
||||||
|
#### Scenario: Иконочная кнопка без текста
|
||||||
|
- **WHEN** кнопка содержит только иконку (например, выбор темы)
|
||||||
|
- **THEN** кнопка имеет доступное имя
|
||||||
|
|
||||||
|
#### Scenario: Тумблер темы
|
||||||
|
- **WHEN** кнопка выбора темы активна или неактивна
|
||||||
|
- **THEN** её состояние передаётся вспомогательным технологиям как «нажата/не нажата»
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Ядро иконочной системы
|
||||||
|
|
||||||
|
- [x] 1.1 Создать `frontend/src/lib/icons/registry.ts`: записи `имя -> { path, filled?, label? }` с вендоренной inner-разметкой Material Symbols; проверить, что реестр импортируется без ошибок и содержит все иконки из инвентаря
|
||||||
|
- [x] 1.2 Реализовать `Icon.svelte` (пропсы `name`, `label`; `aria-hidden`/`focusable`, `role=img` при `label`, классы размера, `fill=currentColor`); проверить рендер известного и неизвестного имени (dev-warn)
|
||||||
|
- [x] 1.3 Реализовать `IconSprite.svelte` (один скрытый `<svg>` со всеми `<symbol id="i-...">` из реестра) и смонтировать его в `+layout.svelte`; проверить, что `<use>` в дочерних компонентах разрешается
|
||||||
|
- [x] 1.4 Реализовать `IconButton.svelte` с обязательным `label` (-> `aria-label` на `<button>`); проверить доступное имя в дереве доступности
|
||||||
|
|
||||||
|
## 2. Замена эмодзи и юникод-глифов
|
||||||
|
|
||||||
|
- [x] 2.1 `ThemeSwitcher`: заменить `☀️ 🌙 🌾 💻` на `light_mode`/`dark_mode`/`palette`/`brightness_auto`; проверить переключение тем и `aria-pressed`
|
||||||
|
- [x] 2.2 Логотип в `+layout.svelte`: заменить `📷` на `photo_camera` рядом с текстом «Galo»; проверить отображение в трёх темах
|
||||||
|
- [x] 2.3 Список альбомов: заменить `🖼️` на `image` (крупный плейсхолдер); проверить вёрстку карточки
|
||||||
|
- [x] 2.4 `PhotoGrid`: заменить `♥`/`💬` на `favorite`/`favorite_fill` и `mode_comment`; проверить счётчики и состояние «лайкнут»
|
||||||
|
- [x] 2.5 `Lightbox`: заменить `✕ ‹ ›` на `close`/`chevron_left`/`chevron_right`; проверить навигацию и закрытие
|
||||||
|
- [x] 2.6 Ссылки «назад к альбому» (участники, настройки): заменить `←` на `arrow_back`; проверить навигацию
|
||||||
|
|
||||||
|
## 3. Ведущие иконки контролов
|
||||||
|
|
||||||
|
- [x] 3.1 Список альбомов и страница альбома: добавить `add`/`photo_library` (создать, пустой список); проверить создание альбома
|
||||||
|
- [x] 3.2 `Uploader`: `cloud_upload`/`add_photo_alternate`; проверить загрузку фото
|
||||||
|
- [x] 3.3 `Lightbox`/share: `download`; проверить скачивание
|
||||||
|
- [x] 3.4 Участники/настройки: `group`, `settings`, `add_link`, `link_off`, `save`, `delete_forever`; проверить роли, share-ссылки, сохранение и удаление
|
||||||
|
- [x] 3.5 Навигация и вход: `login`, `logout`, `check`, `send`, `delete`, `content_copy`, `expand_more`; проверить login/logout, инвайт и комментарии
|
||||||
|
|
||||||
|
## 4. Доступность
|
||||||
|
|
||||||
|
- [x] 4.1 Проставить `aria-hidden="true" focusable="false"` декоративным иконкам и `aria-label` иконочным кнопкам; проверить через дерево доступности браузера
|
||||||
|
- [x] 4.2 Дать счётчикам лайков/комментариев доступные имена (`aria-label="N лайков"`); проверить, что число читается скринридером
|
||||||
|
|
||||||
|
## 5. Проверка
|
||||||
|
|
||||||
|
- [x] 5.1 Собрать фронтенд (`make frontend`) и прогнать `svelte-check`; проверить отсутствие ошибок типов и сборки
|
||||||
|
- [x] 5.2 Playwright-проверка: иконки отрисованы, эмодзи/юникод-глифы в разметке отсутствуют; проверить на трёх темах
|
||||||
Reference in New Issue
Block a user