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:
iu
2026-10-10 13:19:29 +03:00
parent 84ce4d1930
commit 997ec5e6c1
21 changed files with 582 additions and 69 deletions
+19 -4
View File
@@ -3,6 +3,8 @@
import { goto } from '$app/navigation';
import '../app.css';
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 { theme } from '$lib/stores/theme.svelte';
import { authApi } from '$lib/api';
@@ -28,20 +30,33 @@
}
</script>
<IconSprite />
<div class="bg-bg text-text min-h-screen">
<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">
<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">
{#if auth.isAuthenticated}
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
<button
type="button"
class="text-text-muted hover:text-text text-sm"
onclick={logout}>Выйти</button
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
onclick={logout}
>
<Icon name="logout" class="size-4" />
Выйти
</button>
{: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}
<ThemeSwitcher />
</div>
+21 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album } from '$lib/types';
@@ -47,7 +48,13 @@
<div class="py-16 text-center">
<h1 class="mb-2 text-2xl font-bold">Общие фотоальбомы</h1>
<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>
{:else}
<div class="mb-6 flex items-center justify-between gap-4">
@@ -58,9 +65,13 @@
placeholder="Название альбома"
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>
</div>
@@ -69,7 +80,10 @@
{#if loading}
<p class="text-text-muted">Загрузка…</p>
{: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}
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each albums as album (album.id)}
@@ -78,7 +92,9 @@
href="/albums/{album.id}"
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">
<h2 class="text-text font-semibold">{album.title}</h2>
<p class="text-text-muted line-clamp-2 text-sm">{album.description}</p>
+15 -5
View File
@@ -4,11 +4,12 @@
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import Lightbox from '$lib/components/Lightbox.svelte';
import Uploader from '$lib/components/Uploader.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
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 role = $state('');
@@ -129,13 +130,19 @@
<div class="flex gap-2">
<a
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'}
<a
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}
</div>
</div>
@@ -159,9 +166,12 @@
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}>Показать ещё</button
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Album, AlbumMember } from '$lib/types';
@@ -74,11 +75,22 @@
error = (e as Error).message;
}
}
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
} catch {
/* clipboard недоступен */
}
}
</script>
<div class="mb-6 flex items-center justify-between">
<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>
{#if error}<p class="text-danger mb-4 text-sm">{error}</p>{/if}
@@ -105,9 +117,12 @@
</select>
<button
type="button"
class="text-danger text-sm"
onclick={() => removeMember(member.user_id)}>Удалить</button
class="text-danger inline-flex items-center gap-1 text-sm"
onclick={() => removeMember(member.user_id)}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
{:else}
<span class="text-text-muted text-sm">{member.role}</span>
{/if}
@@ -148,11 +163,26 @@
/>
</label>
</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}
<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}
</form>
{/if}
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import type { Album, ShareLink } from '$lib/types';
const albumId = $derived($page.params.id);
@@ -81,7 +82,10 @@
<div class="mb-6 flex items-center justify-between">
<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>
{#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"
></textarea>
</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}
</form>
@@ -117,9 +127,13 @@
class="border-border bg-bg text-text w-32 rounded-lg border px-2 py-1.5"
/>
</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>
<ul class="divide-border divide-y">
{#each shareLinks as link (link.id)}
@@ -129,7 +143,14 @@
· <a class="text-primary" href="/share/{link.token}">/share/{link.token}</a>
</span>
{#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}
</li>
{/each}
@@ -138,6 +159,10 @@
<button
type="button"
class="border-danger text-danger rounded-lg border px-4 py-2 text-sm"
onclick={removeAlbum}>Удалить альбом</button
class="border-danger text-danger inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={removeAlbum}
>
<Icon name="delete_forever" class="size-4" />
Удалить альбом
</button>
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
const token = $derived($page.params.token);
@@ -43,13 +44,20 @@
type="button"
disabled={busy}
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 ? 'Принятие…' : 'Принять приглашение'}
</button>
{:else}
<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}
{:else}
<p class="text-text-muted">Загрузка…</p>
+31 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import Icon from '$lib/components/Icon.svelte';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import type { Album, Photo } from '$lib/types';
@@ -54,9 +55,12 @@
<div class="mt-6 text-center">
<button
type="button"
class="border-border text-text rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}>Показать ещё</button
class="border-border text-text inline-flex items-center gap-1.5 rounded-lg border px-4 py-2 text-sm"
onclick={loadMore}
>
<Icon name="expand_more" class="size-4" />
Показать ещё
</button>
</div>
{/if}
@@ -64,6 +68,10 @@
<div
class="bg-bg/95 fixed inset-0 z-50 flex items-center justify-center"
role="dialog"
tabindex="-1"
onkeydown={(e) => {
if (e.key === 'Escape') active = -1;
}}
onclick={() => (active = -1)}
>
<img
@@ -71,13 +79,27 @@
alt={photos[active].original_filename}
class="max-h-[90vh] max-w-full object-contain"
/>
<a
href={photos[active].download_url}
target="_blank"
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"
onclick={(e) => e.stopPropagation()}>Скачать</a
>
<div class="absolute top-4 right-4 flex gap-2">
<a
href={photos[active].download_url}
target="_blank"
rel="noreferrer"
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()}
>
<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>
{/if}
{:else}