mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add i18n - dict, en\ru, state
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
import type { CategoryId } from '../categories';
|
||||||
|
|
||||||
|
export const LOCALES = ['ru', 'en'] as const;
|
||||||
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
|
export const BASE_LOCALE: Locale = 'ru';
|
||||||
|
|
||||||
|
export const LOCALE_TAGS: Record<Locale, string> = {
|
||||||
|
ru: 'ru-RU',
|
||||||
|
en: 'en-US'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isLocale(value: unknown): value is Locale {
|
||||||
|
return typeof value === 'string' && (LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToolStrings = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Подписи параметров по их id. */
|
||||||
|
params?: Record<string, string>;
|
||||||
|
/** Подписи опций select: paramId -> value -> label. */
|
||||||
|
options?: Record<string, Record<string, string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeaderStrings = {
|
||||||
|
workspace: string;
|
||||||
|
catalog: string;
|
||||||
|
sectionsAria: string;
|
||||||
|
footerNote: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Dict = {
|
||||||
|
header: HeaderStrings;
|
||||||
|
categories: Record<CategoryId, string>;
|
||||||
|
home: Record<string, string>;
|
||||||
|
catalog: Record<string, string>;
|
||||||
|
toolPage: Record<string, string>;
|
||||||
|
chain: Record<string, string>;
|
||||||
|
sourceCard: Record<string, string>;
|
||||||
|
resultCard: Record<string, string>;
|
||||||
|
paramsCard: Record<string, string>;
|
||||||
|
textInput: Record<string, string>;
|
||||||
|
textResult: Record<string, string>;
|
||||||
|
download: Record<string, string>;
|
||||||
|
infoPanel: Record<string, string>;
|
||||||
|
dropZone: Record<string, string>;
|
||||||
|
search: Record<string, string>;
|
||||||
|
ui: Record<string, string>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
tools: Record<string, ToolStrings>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
|
export const en: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: 'Workspace',
|
||||||
|
catalog: 'Catalog',
|
||||||
|
sectionsAria: 'Sections',
|
||||||
|
footerNote:
|
||||||
|
'All operations run locally in your browser — your files are never uploaded anywhere.'
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
convert: 'Convert',
|
||||||
|
alpha: 'Transparency',
|
||||||
|
color: 'Color',
|
||||||
|
geometry: 'Geometry',
|
||||||
|
filters: 'Filters',
|
||||||
|
analyze: 'Analyze',
|
||||||
|
generate: 'Generate'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
defaultTitle: 'easy-png-tools — PNG utilities right in your browser',
|
||||||
|
heroTitle: 'What do you want to do with the image?',
|
||||||
|
heroLead: 'Find a tool — everything runs locally in your browser.',
|
||||||
|
restoreLast: '↩ Restore last: {title}',
|
||||||
|
changeTool: '← Change tool'
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
pageTitle: 'All tools — easy-png-tools',
|
||||||
|
metaDescription:
|
||||||
|
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
|
||||||
|
heading: 'Tool catalog',
|
||||||
|
lead: '{count} utilities for working with PNG. Everything runs locally in your browser.'
|
||||||
|
},
|
||||||
|
toolPage: {
|
||||||
|
fallbackTitle: 'Tool',
|
||||||
|
legendSource: 'Source',
|
||||||
|
legendSummary: 'Summary',
|
||||||
|
legendResult: 'Result',
|
||||||
|
legendParams: 'Parameters',
|
||||||
|
stepHeading: 'Step {n}',
|
||||||
|
removeStepAria: 'Remove step',
|
||||||
|
stepError: 'Step {n} ({title}): {msg}'
|
||||||
|
},
|
||||||
|
chain: {
|
||||||
|
stepLabel: 'Step {n}: {title}',
|
||||||
|
inputLegend: 'Input',
|
||||||
|
resultLegend: 'Result',
|
||||||
|
paramsLegend: 'Parameters',
|
||||||
|
busyTitle: 'Processing…',
|
||||||
|
busyHint: 'Running chain step',
|
||||||
|
removeStepAria: 'Remove step'
|
||||||
|
},
|
||||||
|
sourceCard: {
|
||||||
|
replaceImage: 'Replace image'
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
emptyTitle: 'The result will appear here',
|
||||||
|
emptyHint: 'First upload a source image on the left',
|
||||||
|
processingTitle: 'Processing…',
|
||||||
|
processingHint: 'The image is being processed, this will take a moment',
|
||||||
|
recalc: 'Recalculating…',
|
||||||
|
nextTool: '⛓ Next tool',
|
||||||
|
breakChain: '✂ Break the chain'
|
||||||
|
},
|
||||||
|
paramsCard: {
|
||||||
|
noParams: 'This tool has no parameters — the result is ready as is.'
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
heading: 'Text',
|
||||||
|
placeholder: 'Paste data here',
|
||||||
|
aria: 'Text data',
|
||||||
|
decode: 'Decode'
|
||||||
|
},
|
||||||
|
textResult: {
|
||||||
|
outputAria: 'Text result',
|
||||||
|
copied: 'Copied',
|
||||||
|
copy: 'Copy',
|
||||||
|
downloadTxt: 'Download .txt'
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
busy: 'Preparing file…',
|
||||||
|
file: 'Download .{ext}'
|
||||||
|
},
|
||||||
|
infoPanel: {
|
||||||
|
dimensions: 'Dimensions',
|
||||||
|
alpha: 'Alpha channel',
|
||||||
|
alphaYes: 'yes — there are semi-transparent pixels',
|
||||||
|
alphaNo: 'no',
|
||||||
|
colorCount: 'Unique colors (RGBA)'
|
||||||
|
},
|
||||||
|
dropZone: {
|
||||||
|
pickDefault: 'Drop an image here or click to choose a file',
|
||||||
|
overlayDefault: 'Release the file to replace the image'
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Find a tool…',
|
||||||
|
aria: 'Search tools',
|
||||||
|
nothingFound: 'Nothing found — try another word.'
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
showMask: 'Show mask',
|
||||||
|
decrease: 'Decrease',
|
||||||
|
increase: 'Increase',
|
||||||
|
reset: 'Reset',
|
||||||
|
pipette: 'Eyedropper'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noImageRun: 'This tool does not process images',
|
||||||
|
workerFailed: 'Worker execution failed',
|
||||||
|
workerUnavailable: 'Worker is unavailable',
|
||||||
|
notFound: 'Tool not found'
|
||||||
|
},
|
||||||
|
tools: {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getLocale, initLocale, setLocale } from './locale.svelte';
|
||||||
|
import { interpolate, t } from './t';
|
||||||
|
import { ru } from './ru';
|
||||||
|
|
||||||
|
type Store = Record<string, string>;
|
||||||
|
|
||||||
|
function stubStorage(): { store: Store } {
|
||||||
|
const store: Store = {};
|
||||||
|
vi.stubGlobal('localStorage', {
|
||||||
|
getItem: (k: string) => (k in store ? store[k] : null),
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
store[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vi.stubGlobal('window', {});
|
||||||
|
return { store };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setLocale('ru');
|
||||||
|
delete (ru.home as Record<string, string>).onlyRuKey;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('t', () => {
|
||||||
|
it('возвращает строку по точечному пути активной локали', () => {
|
||||||
|
expect(t('header.workspace')).toBe('Рабочая область');
|
||||||
|
setLocale('en');
|
||||||
|
expect(t('header.catalog')).toBe('Catalog');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
|
||||||
|
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
|
||||||
|
setLocale('en');
|
||||||
|
expect(t('home.onlyRuKey')).toBe('Только русская строка');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестный путь возвращает сам путь', () => {
|
||||||
|
expect(t('no.such.key')).toBe('no.such.key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('interpolate', () => {
|
||||||
|
it('подставляет переменные в шаблон', () => {
|
||||||
|
expect(interpolate('Шаг {n} из {total}', { n: 2, total: 5 })).toBe('Шаг 2 из 5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('оставляет плейсхолдер без переменной как есть', () => {
|
||||||
|
expect(interpolate('Привет, {name}!', {})).toBe('Привет, {name}!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('без переменных возвращает строку без изменений', () => {
|
||||||
|
expect(interpolate('Просто текст')).toBe('Просто текст');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('персист локали', () => {
|
||||||
|
it('initLocale читает сохранённый выбор', () => {
|
||||||
|
const { store } = stubStorage();
|
||||||
|
store['locale'] = 'en';
|
||||||
|
initLocale();
|
||||||
|
expect(getLocale()).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initLocale игнорирует мусор в хранилище', () => {
|
||||||
|
const { store } = stubStorage();
|
||||||
|
store['locale'] = 'fr';
|
||||||
|
initLocale();
|
||||||
|
expect(getLocale()).toBe('ru');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLocale сохраняет выбор в localStorage', () => {
|
||||||
|
const { store } = stubStorage();
|
||||||
|
initLocale();
|
||||||
|
setLocale('en');
|
||||||
|
expect(store['locale']).toBe('en');
|
||||||
|
expect(getLocale()).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { BASE_LOCALE, isLocale, type Dict, type Locale } from './dict';
|
||||||
|
import { ru } from './ru';
|
||||||
|
import { en } from './en';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'locale';
|
||||||
|
|
||||||
|
const DICTS: Record<Locale, Dict> = { ru, en };
|
||||||
|
|
||||||
|
let locale = $state<Locale>(BASE_LOCALE);
|
||||||
|
|
||||||
|
function storage(): Storage | null {
|
||||||
|
return typeof localStorage === 'undefined' ? null : localStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocale(): Locale {
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDict(): Dict {
|
||||||
|
return DICTS[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
|
||||||
|
export function getMergedDict(): Dict {
|
||||||
|
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
|
||||||
|
const active = DICTS[locale];
|
||||||
|
const base = DICTS[BASE_LOCALE];
|
||||||
|
return new Proxy(base, {
|
||||||
|
get(_target, section: string) {
|
||||||
|
const a = (active as unknown as Record<string, unknown>)[section];
|
||||||
|
const b = (base as unknown as Record<string, unknown>)[section];
|
||||||
|
if (
|
||||||
|
a && b && typeof a === 'object' && typeof b === 'object'
|
||||||
|
) {
|
||||||
|
return { ...(b as object), ...(a as object) };
|
||||||
|
}
|
||||||
|
return a ?? b;
|
||||||
|
}
|
||||||
|
}) as Dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocale(next: Locale): void {
|
||||||
|
locale = next;
|
||||||
|
storage()?.setItem(STORAGE_KEY, next);
|
||||||
|
syncLangAttr();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
|
||||||
|
export function initLocale(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const saved = storage()?.getItem(STORAGE_KEY);
|
||||||
|
if (isLocale(saved)) locale = saved;
|
||||||
|
syncLangAttr();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLangAttr(): void {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Dict } from './dict';
|
||||||
|
|
||||||
|
export const ru: Dict = {
|
||||||
|
header: {
|
||||||
|
workspace: 'Рабочая область',
|
||||||
|
catalog: 'Каталог',
|
||||||
|
sectionsAria: 'Разделы',
|
||||||
|
footerNote:
|
||||||
|
'Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.'
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
convert: 'Конвертация',
|
||||||
|
alpha: 'Прозрачность',
|
||||||
|
color: 'Цвет',
|
||||||
|
geometry: 'Геометрия',
|
||||||
|
filters: 'Фильтры',
|
||||||
|
analyze: 'Анализ',
|
||||||
|
generate: 'Генерация'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
defaultTitle: 'easy-png-tools — PNG-утилиты прямо в браузере',
|
||||||
|
heroTitle: 'Что делаем с изображением?',
|
||||||
|
heroLead: 'Найдите инструмент — все операции выполняются локально в браузере.',
|
||||||
|
restoreLast: '↩ Вернуть последний: {title}',
|
||||||
|
changeTool: '← Сменить инструмент'
|
||||||
|
},
|
||||||
|
catalog: {
|
||||||
|
pageTitle: 'Все инструменты — easy-png-tools',
|
||||||
|
metaDescription:
|
||||||
|
'Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений.',
|
||||||
|
heading: 'Каталог инструментов',
|
||||||
|
lead: '{count} утилит для работы с PNG. Все операции выполняются локально в браузере.'
|
||||||
|
},
|
||||||
|
toolPage: {
|
||||||
|
fallbackTitle: 'Инструмент',
|
||||||
|
legendSource: 'Исходник',
|
||||||
|
legendSummary: 'Сводка',
|
||||||
|
legendResult: 'Результат',
|
||||||
|
legendParams: 'Параметры',
|
||||||
|
stepHeading: 'Шаг {n}',
|
||||||
|
removeStepAria: 'Убрать шаг',
|
||||||
|
stepError: 'Шаг {n} ({title}): {msg}'
|
||||||
|
},
|
||||||
|
chain: {
|
||||||
|
stepLabel: 'Шаг {n}: {title}',
|
||||||
|
inputLegend: 'Вход',
|
||||||
|
resultLegend: 'Результат',
|
||||||
|
paramsLegend: 'Параметры',
|
||||||
|
busyTitle: 'Обработка…',
|
||||||
|
busyHint: 'Выполняется шаг цепочки',
|
||||||
|
removeStepAria: 'Убрать шаг'
|
||||||
|
},
|
||||||
|
sourceCard: {
|
||||||
|
replaceImage: 'Заменить изображение'
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
emptyTitle: 'Результат появится здесь',
|
||||||
|
emptyHint: 'Сначала загрузите исходное изображение слева',
|
||||||
|
processingTitle: 'Обработка…',
|
||||||
|
processingHint: 'Изображение обрабатывается, это займёт немного времени',
|
||||||
|
recalc: 'Пересчёт…',
|
||||||
|
nextTool: '⛓ Следующий инструмент',
|
||||||
|
breakChain: '✂ Оборвать цепочку'
|
||||||
|
},
|
||||||
|
paramsCard: {
|
||||||
|
noParams: 'У этого инструмента нет параметров — результат уже готов.'
|
||||||
|
},
|
||||||
|
textInput: {
|
||||||
|
heading: 'Текст',
|
||||||
|
placeholder: 'Вставьте данные сюда',
|
||||||
|
aria: 'Текстовые данные',
|
||||||
|
decode: 'Декодировать'
|
||||||
|
},
|
||||||
|
textResult: {
|
||||||
|
outputAria: 'Текстовый результат',
|
||||||
|
copied: 'Скопировано',
|
||||||
|
copy: 'Копировать',
|
||||||
|
downloadTxt: 'Скачать .txt'
|
||||||
|
},
|
||||||
|
download: {
|
||||||
|
busy: 'Готовим файл…',
|
||||||
|
file: 'Скачать .{ext}'
|
||||||
|
},
|
||||||
|
infoPanel: {
|
||||||
|
dimensions: 'Размеры',
|
||||||
|
alpha: 'Альфа-канал',
|
||||||
|
alphaYes: 'есть — есть полупрозрачные пиксели',
|
||||||
|
alphaNo: 'нет',
|
||||||
|
colorCount: 'Уникальных цветов (RGBA)'
|
||||||
|
},
|
||||||
|
dropZone: {
|
||||||
|
pickDefault: 'Перетащите изображение сюда или нажмите, чтобы выбрать файл',
|
||||||
|
overlayDefault: 'Отпустите файл, чтобы заменить изображение'
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
placeholder: 'Найдите инструмент…',
|
||||||
|
aria: 'Поиск инструмента',
|
||||||
|
nothingFound: 'Ничего не найдено — попробуйте другое слово.'
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
showMask: 'Показать маску',
|
||||||
|
decrease: 'Уменьшить',
|
||||||
|
increase: 'Увеличить',
|
||||||
|
reset: 'Сбросить',
|
||||||
|
pipette: 'Пипетка'
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
noImageRun: 'Этот инструмент не обрабатывает изображения',
|
||||||
|
workerFailed: 'Ошибка исполнения в воркере',
|
||||||
|
workerUnavailable: 'Воркер недоступен',
|
||||||
|
notFound: 'Инструмент не найден'
|
||||||
|
},
|
||||||
|
tools: {}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getMergedDict } from './locale.svelte';
|
||||||
|
|
||||||
|
export function interpolate(template: string, vars?: Record<string, string | number>): string {
|
||||||
|
if (!vars) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||||
|
name in vars ? String(vars[name]) : match
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
|
||||||
|
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
|
||||||
|
*/
|
||||||
|
export function t(path: string, vars?: Record<string, string | number>): string {
|
||||||
|
let node: unknown = getMergedDict();
|
||||||
|
for (const part of path.split('.')) {
|
||||||
|
if (node && typeof node === 'object' && part in (node as Record<string, unknown>)) {
|
||||||
|
node = (node as Record<string, unknown>)[part];
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof node === 'string' ? interpolate(node, vars) : path;
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
|
||||||
|
import { t } from '$lib/i18n/t';
|
||||||
|
import { LOCALES, type Locale } from '$lib/i18n/dict';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
onMount(() => initLocale());
|
||||||
|
|
||||||
|
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -12,9 +20,22 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">easy-png-tools</a>
|
<a href="/" class="brand">easy-png-tools</a>
|
||||||
<nav aria-label="Разделы">
|
<nav aria-label={t('header.sectionsAria')}>
|
||||||
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
<a class="nav-link workspace-link" href="/">{t('header.workspace')}</a>
|
||||||
<a class="nav-link" href="/list-tools">Каталог</a>
|
<a class="nav-link" href="/list-tools">{t('header.catalog')}</a>
|
||||||
|
<div class="lang-switch" role="group" aria-label="Language / Язык">
|
||||||
|
{#each LOCALES as l (l)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="lang-btn"
|
||||||
|
class:active={getLocale() === l}
|
||||||
|
aria-pressed={getLocale() === l}
|
||||||
|
onclick={() => setLocale(l)}
|
||||||
|
>
|
||||||
|
{LANG_LABELS[l]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -23,9 +44,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p class="text-caption text-muted">
|
<p class="text-caption text-muted">{t('header.footerNote')}</p>
|
||||||
Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.
|
|
||||||
</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,6 +96,36 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lang-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: 2px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-s);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: calc(var(--radius-s) - 1px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-link {
|
.workspace-link {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user