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">
|
||||
import { api } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
@@ -61,9 +62,14 @@
|
||||
{#if auth.user?.id === comment.user_id}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-subtle hover:text-danger text-xs"
|
||||
onclick={() => remove(comment.id)}>удалить</button
|
||||
class="text-text-subtle hover:text-danger inline-flex shrink-0 items-center gap-0.5 text-xs"
|
||||
aria-label="Удалить комментарий"
|
||||
title="Удалить комментарий"
|
||||
onclick={() => remove(comment.id)}
|
||||
>
|
||||
<Icon name="delete" class="size-3.5" />
|
||||
удалить
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -75,9 +81,13 @@
|
||||
placeholder="Комментарий…"
|
||||
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>
|
||||
{/if}
|
||||
{#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 type { Photo } from '$lib/types';
|
||||
import Comments from './Comments.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
@@ -55,9 +56,11 @@
|
||||
class="bg-bg/95 fixed inset-0 z-50 flex flex-col"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
onkeydown={onKey}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
@@ -67,23 +70,37 @@
|
||||
{#if fresh?.download_url}
|
||||
<a
|
||||
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"
|
||||
rel="noreferrer">Скачать</a
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon name="download" class="size-4" />
|
||||
Скачать
|
||||
</a>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
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 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}
|
||||
<img
|
||||
src={fresh?.preview_url ?? fresh?.original_url ?? current.preview_url ?? current.original_url}
|
||||
@@ -93,10 +110,14 @@
|
||||
{/if}
|
||||
<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}
|
||||
disabled={index === photos.length - 1}>›</button
|
||||
disabled={index === photos.length - 1}
|
||||
>
|
||||
<Icon name="chevron_right" class="size-8" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if current}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -34,9 +35,22 @@
|
||||
</span>
|
||||
{/if}
|
||||
<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>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<script lang="ts">
|
||||
import IconButton from './IconButton.svelte';
|
||||
import type { IconName } from '$lib/icons/registry';
|
||||
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
||||
|
||||
const options: { value: Theme; label: string; icon: string }[] = [
|
||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'neutral', label: 'Neutral', icon: '🌾' },
|
||||
{ value: 'system', label: 'System', icon: '💻' }
|
||||
const options: { value: Theme; label: string; icon: IconName }[] = [
|
||||
{ value: 'light', label: 'Light', icon: 'light_mode' },
|
||||
{ value: 'dark', label: 'Dark', icon: 'dark_mode' },
|
||||
{ value: 'neutral', label: 'Neutral', icon: 'palette' },
|
||||
{ value: 'system', label: 'System', icon: 'brightness_auto' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1" role="group" aria-label="Theme">
|
||||
<div
|
||||
class="border-border bg-bg-elevated inline-flex gap-1 rounded-lg border p-1"
|
||||
role="group"
|
||||
aria-label="Theme"
|
||||
>
|
||||
{#each options as option (option.value)}
|
||||
<button
|
||||
type="button"
|
||||
title={option.label}
|
||||
aria-label={option.label}
|
||||
aria-pressed={theme.current === option.value}
|
||||
<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)}
|
||||
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}
|
||||
</button>
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -96,13 +97,15 @@
|
||||
class="hidden"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onclick={pick}
|
||||
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 ? 'Загрузка…' : 'Выбрать файлы'}
|
||||
</button>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user