diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e6952dd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,72 @@
+# Izipus — менеджер шаблонов ответов
+
+Расширение для браузеров на базе Chromium, которое собирает текстовые шаблоны в одном месте и позволяет копировать их в буфер обмена одним кликом.
+
+## Возможности
+
+- **Категории шаблонов** — шаблоны сгруппированы по сценариям (ответы клиентам, запросы партнёрам, коды ошибок и т.д.). Состояние разворота категорий сохраняется
+- **Быстрое копирование** — один клик копирует текст в буфер. `Alt+1...0` для первых десяти шаблонов, `Alt+Shift+1...0` для английской версии
+- **Поиск** — быстрый поиск по названиям и содержимому шаблонов
+- **Пользовательские шаблоны** — создание, редактирование и удаление своих шаблонов. Сохраняются в `chrome.storage.sync`
+- **Экспорт/импорт** — экспорт всех шаблонов в JSON, импорт через drag&drop, выбор файла или вставку текста
+- **Темы** — тёмная, светлая и системная тема с переключением в один клик
+- **Проверка обновлений** — кнопка в шапке запрашивает версию из репозитория (Gitea) и при наличии новой версии открывает страницу репозитория для ручной загрузки
+
+## Обновление расширения (вручную)
+
+Браузер **не разрешает** расширению самостоятельно скачивать и устанавливать себе новую версию с произвольного URL (Gitea, GitHub и т.п.) — это ограничение безопасности. Поэтому возможен только такой сценарий:
+
+1. В расширении нажмите кнопку **«Проверить обновления»** (иконка обновления в шапке).
+2. При первом нажатии браузер запросит доступ к репозиторию — разрешите.
+3. Если доступна новая версия, откроется вкладка с репозиторием. Скачайте архив (например, **Clone** → **Download ZIP** или страница релизов).
+4. Распакуйте архив поверх папки, в которой установлено расширение (замените файлы).
+5. Откройте `chrome://extensions` и нажмите **Обновить** (или перезагрузите расширение).
+
+**Если расширение установлено из клона репозитория (git clone):** можно обновляться скриптом в корне проекта:
+- **Windows:** двойной клик по `update.bat`.
+- **Linux / macOS:** в терминале выполните `chmod +x update.sh` (один раз), затем `./update.sh`.
+
+После обновления перезагрузите расширение в `chrome://extensions`.
+
+Версия для сравнения берётся из `manifest.json` в ветке `main` (или `master`) репозитория. Убедитесь, что в репозитории в этой ветке поле `version` в `manifest.json` обновлено.
+
+## Установка
+
+1. Скачайте или клонируйте репозиторий
+2. Откройте `chrome://extensions` в браузере
+3. Включите "Режим разработчика"
+4. Нажмите "Загрузить распакованное" и укажите папку проекта
+5. Иконка расширения появится на панели инструментов
+
+## Структура проекта
+
+```
+src/
+├── popup.html # HTML разметка
+├── popup.js # Главный файл инициализации
+├── popup.css # Стили
+├── templates.json # Предустановленные шаблоны
+└── js/ # Модули приложения
+ ├── constants.js # Константы
+ ├── storage.js # Работа с хранилищем
+ ├── clipboard.js # Буфер обмена
+ ├── templates.js # Валидация и работа с шаблонами
+ ├── ui.js # Модальные окна и категории
+ ├── hotkeys.js # Горячие клавиши
+ ├── search.js # Поиск
+ ├── theme.js # Управление темой
+ └── update-check.js # Проверка версии в репозитории (Gitea)
+```
+
+## Разработка
+
+- **Редактирование шаблонов** — тексты в `src/templates.json`, после изменений перезагрузите расширение
+- **Добавление шаблонов** — создайте элемент в `popup.html` с нужным `id` и добавьте текст в `templates.json`
+- **Стили** — все стили в `src/popup.css`, используется Bootstrap для базовых компонентов
+
+## Ограничения
+
+- Лимит `chrome.storage.sync` — 100 КБ на расширение. При превышении экспортируйте данные
+- Предустановленные шаблоны редактируются только через JSON/HTML
+- Только ручная установка, публикация в магазинах не планируется
+- Обновление расширения только вручную: расширение не может само скачать и установить новую версию; кнопка «Проверить обновления» лишь показывает, есть ли новая версия в репозитории, и открывает страницу для загрузки
diff --git a/manifest.json b/manifest.json
index 6c993f4..91251d3 100644
--- a/manifest.json
+++ b/manifest.json
@@ -21,9 +21,12 @@
"storage",
"cookies"
],
+ "optional_host_permissions": [
+ "https://git.gorshenin.info/*"
+ ],
"browser_specific_settings": {
"gecko": {
"id": "dmitriy.gorshenin1@gmail.com"
}
}
-}
\ No newline at end of file
+}
diff --git a/src/js/clipboard.js b/src/js/clipboard.js
new file mode 100644
index 0000000..1701ef1
--- /dev/null
+++ b/src/js/clipboard.js
@@ -0,0 +1,86 @@
+export const loadClipboardTexts = async () => {
+ try {
+ const resourceUrl = typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.getURL
+ ? chrome.runtime.getURL('src/templates.json')
+ : 'src/templates.json';
+ const response = await fetch(resourceUrl);
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ const data = await response.json();
+ return data.clipboardTexts || data;
+ } catch (error) {
+ console.error('Не удалось загрузить предустановленные шаблоны:', error);
+ return {};
+ }
+};
+
+export const copyToClipboard = async (text) => {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (error) {
+ console.error('Не удалось скопировать текст:', error);
+ return false;
+ }
+};
+
+const normalizePredefinedId = (id) => {
+ if (!id) return id;
+ return id.replace(/Ru$|En$|Eng$/, '');
+};
+
+export const initializeClipboardButtons = (clipboardTexts, favorites = [], onToggleFavorite = null) => {
+ const processedTemplates = new Set();
+
+ Object.keys(clipboardTexts).forEach(buttonId => {
+ const button = document.getElementById(buttonId);
+ if (!button) {
+ return;
+ }
+ const templateEl = button.closest('.template');
+ if (!templateEl) {
+ return;
+ }
+
+ const normalizedId = normalizePredefinedId(buttonId);
+ const isFirstForTemplate = !processedTemplates.has(normalizedId);
+ if (isFirstForTemplate) {
+ processedTemplates.add(normalizedId);
+ }
+
+ const clipboardValue = clipboardTexts[buttonId];
+ if (clipboardValue && templateEl) {
+ const existingSearch = templateEl.dataset.searchText || templateEl.textContent.toLowerCase();
+ templateEl.dataset.searchText = `${existingSearch} ${clipboardValue.toLowerCase()}`.trim();
+ const buttonLabel = button.textContent.trim().toLowerCase();
+ if (buttonLabel === 'ru') {
+ templateEl.dataset.templateRu = clipboardValue;
+ } else if (buttonLabel === 'eng' || buttonLabel === 'en') {
+ templateEl.dataset.templateEn = clipboardValue;
+ }
+ }
+ button.addEventListener('click', () => {
+ if (clipboardTexts[buttonId]) {
+ copyToClipboard(clipboardTexts[buttonId]);
+ }
+ });
+
+ if (isFirstForTemplate && onToggleFavorite && !templateEl.querySelector('.template-favorite')) {
+ const isFavorite = favorites.includes(normalizedId);
+ const favoriteBtn = document.createElement('button');
+ favoriteBtn.className = 'template-favorite btn';
+ favoriteBtn.type = 'button';
+ favoriteBtn.title = isFavorite ? 'Удалить из избранного' : 'Добавить в избранное';
+ favoriteBtn.innerHTML = ``;
+ favoriteBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const currentState = templateEl.dataset.favorite === 'true';
+ await onToggleFavorite(normalizedId, !currentState);
+ });
+ templateEl.insertBefore(favoriteBtn, templateEl.firstChild);
+ templateEl.dataset.templateId = normalizedId;
+ templateEl.dataset.favorite = isFavorite ? 'true' : 'false';
+ }
+ });
+};
diff --git a/src/js/constants.js b/src/js/constants.js
new file mode 100644
index 0000000..d11bd11
--- /dev/null
+++ b/src/js/constants.js
@@ -0,0 +1,12 @@
+export const MIN_TITLE_LENGTH = 3;
+export const MIN_TEXT_LENGTH = 5;
+export const HOTKEY_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
+
+/**
+ * Базовый URL репозитория для проверки обновлений и страницы «Скачать».
+ * Поддерживаются Gitea и GitLab — по хосту выбирается формат raw-URL для manifest.json.
+ * Примеры:
+ * Gitea: https://git.gorshenin.info/Dgors03/Answer_Templates
+ * GitLab: https://gitlab.com/username/izipus
+ */
+export const REPO_BASE = 'https://git.gorshenin.info/Dgors03/Answer_Templates';
diff --git a/src/js/hotkeys.js b/src/js/hotkeys.js
new file mode 100644
index 0000000..d0a9840
--- /dev/null
+++ b/src/js/hotkeys.js
@@ -0,0 +1,69 @@
+import { HOTKEY_KEYS } from './constants.js';
+import { copyToClipboard } from './clipboard.js';
+
+export class HotkeyManager {
+ constructor() {
+ this.assignedHotkeys = new Map();
+ this.setupGlobalHandler();
+ }
+
+ updateAssignments() {
+ this.assignedHotkeys.clear();
+ document.querySelectorAll('.hotkey-badge').forEach(badge => badge.remove());
+
+ const visibleTemplates = Array.from(document.querySelectorAll('.template')).filter(template => {
+ return !template.classList.contains('filtered-out') && template.offsetParent !== null;
+ });
+
+ visibleTemplates.forEach((template, index) => {
+ if (index >= HOTKEY_KEYS.length) {
+ delete template.dataset.hotkey;
+ return;
+ }
+ const key = HOTKEY_KEYS[index];
+ this.assignedHotkeys.set(key, template);
+ template.dataset.hotkey = key;
+
+ const titleNode = template.querySelector('.template-title');
+ if (titleNode) {
+ const badge = document.createElement('span');
+ badge.className = 'hotkey-badge';
+ badge.textContent = `Alt+${key}`;
+ titleNode.prepend(badge);
+ }
+ });
+ }
+
+ setupGlobalHandler() {
+ document.addEventListener('keydown', (event) => {
+ if (!event.altKey || event.ctrlKey || event.metaKey) {
+ return;
+ }
+ const key = event.key.toLowerCase();
+ if (!HOTKEY_KEYS.includes(key)) {
+ return;
+ }
+ const template = this.assignedHotkeys.get(key);
+ if (!template) {
+ return;
+ }
+ event.preventDefault();
+
+ const preferEnglish = event.shiftKey;
+ const ruText = template.dataset.templateRu || '';
+ const enText = template.dataset.templateEn || '';
+ const textToCopy = preferEnglish && enText ? enText : (ruText || enText);
+
+ if (!textToCopy) {
+ console.warn('Нет текста для копирования по горячей клавише.');
+ return;
+ }
+
+ copyToClipboard(textToCopy)
+ .then(() => {
+ console.log(`Текст скопирован по Alt+${key}${preferEnglish ? ' (ENG)' : ' (RU)'}`);
+ })
+ .catch(err => console.error('Не удалось скопировать текст по горячей клавише:', err));
+ });
+ }
+}
diff --git a/src/js/search.js b/src/js/search.js
new file mode 100644
index 0000000..58ac698
--- /dev/null
+++ b/src/js/search.js
@@ -0,0 +1,54 @@
+export class SearchManager {
+ constructor(categoryManager, hotkeyManager) {
+ this.categoryManager = categoryManager;
+ this.hotkeyManager = hotkeyManager;
+ this.searchInput = document.querySelector('.template-search');
+ this.init();
+ }
+
+ init() {
+ if (this.searchInput) {
+ this.searchInput.addEventListener('input', (event) => {
+ this.filter(event.target.value);
+ });
+ }
+ }
+
+ filter(query) {
+ const normalized = query.trim().toLowerCase();
+ const controllers = this.categoryManager.getControllers();
+
+ if (!normalized) {
+ controllers.forEach(controller => {
+ controller.category.classList.remove('filtered-out');
+ controller.content.querySelectorAll('.template').forEach(template => {
+ template.classList.remove('filtered-out');
+ });
+ const storedState = localStorage.getItem(controller.categoryId);
+ const shouldOpen = storedState === null
+ ? (controller.category.dataset.category === 'custom' || controller.category.dataset.category === 'favorites')
+ : storedState === 'true';
+ controller.setOpenState(shouldOpen);
+ });
+ this.hotkeyManager.updateAssignments();
+ return;
+ }
+
+ controllers.forEach(controller => {
+ let hasMatches = false;
+ controller.content.querySelectorAll('.template').forEach(template => {
+ const searchSource = (template.dataset.searchText || template.textContent || '').toLowerCase();
+ const matches = searchSource.includes(normalized);
+ template.classList.toggle('filtered-out', !matches);
+ if (matches) {
+ hasMatches = true;
+ }
+ });
+ controller.category.classList.toggle('filtered-out', !hasMatches);
+ if (hasMatches) {
+ controller.setOpenState(true);
+ }
+ });
+ this.hotkeyManager.updateAssignments();
+ }
+}
diff --git a/src/js/storage.js b/src/js/storage.js
new file mode 100644
index 0000000..b1c52ba
--- /dev/null
+++ b/src/js/storage.js
@@ -0,0 +1,142 @@
+export const STORAGE_KEY = 'templates';
+export const FAVORITES_KEY = 'favorites';
+
+export const createStorageProvider = () => {
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
+ return {
+ async get() {
+ return new Promise((resolve, reject) => {
+ chrome.storage.sync.get([STORAGE_KEY], (result) => {
+ if (chrome.runtime && chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError);
+ return;
+ }
+ resolve(result[STORAGE_KEY] || []);
+ });
+ });
+ },
+ async set(value) {
+ return new Promise((resolve, reject) => {
+ chrome.storage.sync.set({ [STORAGE_KEY]: value }, () => {
+ if (chrome.runtime && chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError);
+ return;
+ }
+ resolve();
+ });
+ });
+ }
+ };
+ }
+
+ return {
+ async get() {
+ return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
+ },
+ async set(value) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
+ }
+ };
+};
+
+export class TemplateStorage {
+ constructor() {
+ this.provider = createStorageProvider();
+ this.templates = [];
+ }
+
+ async load() {
+ try {
+ this.templates = await this.provider.get();
+ } catch (error) {
+ console.error('Не удалось загрузить пользовательские шаблоны:', error);
+ this.templates = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
+ }
+ return this.templates;
+ }
+
+ async save() {
+ try {
+ await this.provider.set(this.templates);
+ } catch (error) {
+ console.error('Не удалось сохранить пользовательские шаблоны:', error);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.templates));
+ }
+ }
+
+ add(template) {
+ this.templates.push(template);
+ return this.save();
+ }
+
+ update(id, key, value) {
+ const templateIndex = this.templates.findIndex(t => t.id === id);
+ if (templateIndex !== -1) {
+ this.templates[templateIndex][key] = value;
+ return this.save();
+ }
+ }
+
+ remove(id) {
+ this.templates = this.templates.filter(t => t.id !== id);
+ return this.save();
+ }
+
+ getAll() {
+ return [...this.templates];
+ }
+
+ setTemplates(templates) {
+ this.templates = templates;
+ return this.save();
+ }
+
+ async getFavorites() {
+ try {
+ const result = await this.provider.get();
+ if (typeof result === 'object' && !Array.isArray(result) && result.favorites) {
+ return result.favorites;
+ }
+ const favoritesData = typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync
+ ? await new Promise((resolve) => {
+ chrome.storage.sync.get([FAVORITES_KEY], (result) => {
+ resolve(result[FAVORITES_KEY] || []);
+ });
+ })
+ : JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
+ return favoritesData;
+ } catch (error) {
+ return JSON.parse(localStorage.getItem(FAVORITES_KEY)) || [];
+ }
+ }
+
+ async saveFavorites(favorites) {
+ try {
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
+ await new Promise((resolve) => {
+ chrome.storage.sync.set({ [FAVORITES_KEY]: favorites }, () => resolve());
+ });
+ } else {
+ localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
+ }
+ } catch (error) {
+ localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites));
+ }
+ }
+
+ async toggleFavorite(templateId, isFavorite) {
+ const favorites = await this.getFavorites();
+ if (isFavorite) {
+ if (!favorites.includes(templateId)) {
+ favorites.push(templateId);
+ }
+ } else {
+ const index = favorites.indexOf(templateId);
+ if (index > -1) {
+ favorites.splice(index, 1);
+ }
+ }
+ await this.saveFavorites(favorites);
+ return favorites;
+ }
+}
diff --git a/src/js/templates.js b/src/js/templates.js
new file mode 100644
index 0000000..1b2c685
--- /dev/null
+++ b/src/js/templates.js
@@ -0,0 +1,324 @@
+import { MIN_TITLE_LENGTH, MIN_TEXT_LENGTH } from './constants.js';
+import { copyToClipboard } from './clipboard.js';
+
+export const generateTemplateId = () => {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return crypto.randomUUID();
+ }
+ return `tpl-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+};
+
+export const isTitleUnique = (title, templates, ignoreId = null) => {
+ const normalized = title.toLowerCase();
+ return !templates.some(template =>
+ template.title.toLowerCase() === normalized && template.id !== ignoreId
+ );
+};
+
+export const validateTitle = (rawTitle, templates, ignoreId = null) => {
+ const title = (rawTitle || '').trim();
+ if (title.length < MIN_TITLE_LENGTH) {
+ alert(`Название должно содержать не менее ${MIN_TITLE_LENGTH} символов.`);
+ return null;
+ }
+ if (!isTitleUnique(title, templates, ignoreId)) {
+ alert('Шаблон с таким названием уже существует. Пожалуйста, выберите другое название.');
+ return null;
+ }
+ return title;
+};
+
+export const validateText = (rawText, isOptional = false) => {
+ const text = (rawText || '').trim();
+ if (!text.length && isOptional) {
+ return '';
+ }
+ if (text.length < MIN_TEXT_LENGTH) {
+ alert(`Текст должен содержать не менее ${MIN_TEXT_LENGTH} символов.`);
+ return null;
+ }
+ return text;
+};
+
+export const ensureUniqueTitle = (title, usedTitles) => {
+ let candidate = title;
+ let suffix = 1;
+ while (usedTitles.has(candidate.toLowerCase())) {
+ candidate = `${title} (${suffix})`;
+ suffix += 1;
+ }
+ usedTitles.add(candidate.toLowerCase());
+ return candidate;
+};
+
+export const sanitizeImportedTemplate = (rawTemplate, usedIds, usedTitles) => {
+ if (!rawTemplate || typeof rawTemplate !== 'object') {
+ throw new Error('Неверная структура шаблона.');
+ }
+
+ const rawTitle = typeof rawTemplate.title === 'string' ? rawTemplate.title.trim() : '';
+ if (rawTitle.length < MIN_TITLE_LENGTH) {
+ throw new Error(`Название "${rawTitle}" слишком короткое.`);
+ }
+ const title = ensureUniqueTitle(rawTitle, usedTitles);
+
+ const ruTextRaw = typeof rawTemplate.RUText === 'string' ? rawTemplate.RUText.trim() : '';
+ if (ruTextRaw.length < MIN_TEXT_LENGTH) {
+ throw new Error(`Текст RU для "${title}" короче ${MIN_TEXT_LENGTH} символов.`);
+ }
+
+ const enTextRaw = typeof rawTemplate.ENText === 'string' ? rawTemplate.ENText.trim() : '';
+ if (enTextRaw && enTextRaw.length < MIN_TEXT_LENGTH) {
+ throw new Error(`Текст EN для "${title}" короче ${MIN_TEXT_LENGTH} символов.`);
+ }
+
+ let id = typeof rawTemplate.id === 'string' && rawTemplate.id.trim()
+ ? rawTemplate.id
+ : generateTemplateId();
+ while (usedIds.has(id)) {
+ id = generateTemplateId();
+ }
+ usedIds.add(id);
+
+ return {
+ id,
+ title,
+ RUText: ruTextRaw,
+ ENText: enTextRaw
+ };
+};
+
+export const createTemplateElement = ({ id, title, RUText, ENText, favorite = false }, callbacks) => {
+ const { onUpdate, onDelete, onHotkeyUpdate, onToggleFavorite } = callbacks;
+
+ const updateSearchDataset = (templateEl, titleEl, textRUEl, textENEl) => {
+ const ru = textRUEl.dataset.ruText || '';
+ const en = textENEl.dataset.enText || '';
+ const combined = `${titleEl.textContent} ${ru} ${en}`.toLowerCase();
+ templateEl.dataset.searchText = combined;
+ templateEl.dataset.templateRu = ru;
+ if (en) {
+ templateEl.dataset.templateEn = en;
+ } else {
+ delete templateEl.dataset.templateEn;
+ }
+ };
+
+ const templateEl = document.createElement('span');
+ templateEl.dataset.templateId = id;
+ templateEl.classList.add('template');
+ templateEl.setAttribute('tabindex', '0');
+
+ templateEl.innerHTML = `
+
+
+
+ `;
+
+ const titleEl = templateEl.querySelector('.template-title');
+ const favoriteBtn = templateEl.querySelector('.template-favorite');
+ const favoriteIcon = favoriteBtn?.querySelector('i');
+ const textRUEl = templateEl.querySelector('.template-ru-text');
+ const textENEl = templateEl.querySelector('.template-en-text');
+ const titleInputEl = templateEl.querySelector('.template-title-input');
+ const textRUInputEl = templateEl.querySelector('.template-textarea-ru');
+ const textENInputEl = templateEl.querySelector('.template-textarea-en');
+ const editBtn = templateEl.querySelector('.template-edit');
+ const deleteBtn = templateEl.querySelector('.template-delete');
+
+ const updateFavoriteIcon = (isFavorite) => {
+ if (favoriteIcon) {
+ favoriteIcon.className = `fa-${isFavorite ? 'solid' : 'regular'} fa-star`;
+ favoriteBtn.title = isFavorite ? 'Удалить из избранного' : 'Добавить в избранное';
+ }
+ templateEl.dataset.favorite = isFavorite ? 'true' : 'false';
+ };
+
+ const setInitialValues = () => {
+ titleEl.textContent = title;
+ textRUEl.dataset.ruText = RUText;
+ textRUEl.textContent = 'RU';
+ textENEl.dataset.enText = ENText;
+ textENEl.textContent = 'ENG';
+ titleInputEl.value = title;
+ textRUInputEl.value = RUText;
+ textENInputEl.value = ENText;
+ updateFavoriteIcon(favorite);
+ updateSearchDataset(templateEl, titleEl, textRUEl, textENEl);
+ };
+
+ setInitialValues();
+
+ if (favoriteBtn && onToggleFavorite) {
+ favoriteBtn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ const currentState = templateEl.dataset.favorite === 'true';
+ const newFavoriteState = !currentState;
+ await onToggleFavorite(id, newFavoriteState);
+ updateFavoriteIcon(newFavoriteState);
+ });
+ }
+
+ const toggleVisibility = (...elements) => {
+ elements.forEach(el => el.classList.toggle('hidden'));
+ };
+
+ editBtn.addEventListener('click', () => {
+ toggleVisibility(titleEl, textRUEl, textENEl, titleInputEl, textRUInputEl, textENInputEl);
+ if (!titleInputEl.classList.contains('hidden')) {
+ titleInputEl.focus();
+ }
+ });
+
+ deleteBtn.addEventListener('click', async () => {
+ await onDelete(id);
+ templateEl.remove();
+ if (onHotkeyUpdate) {
+ onHotkeyUpdate();
+ }
+ });
+
+ const handleTitleChange = async (event) => {
+ const templates = callbacks.getTemplates();
+ const newValue = validateTitle(event.target.value, templates, id);
+ if (!newValue) {
+ event.target.value = titleEl.textContent;
+ return;
+ }
+ titleEl.textContent = newValue;
+ event.target.value = newValue;
+ await onUpdate(id, 'title', newValue);
+ updateSearchDataset(templateEl, titleEl, textRUEl, textENEl);
+ if (onHotkeyUpdate) {
+ onHotkeyUpdate();
+ }
+ };
+
+ const handleTextChange = async (event, buttonEl, datasetKey, storageKey, isOptional = false) => {
+ const newValue = validateText(event.target.value, isOptional);
+ if (newValue === null) {
+ event.target.value = buttonEl.dataset[datasetKey] || '';
+ return;
+ }
+ buttonEl.dataset[datasetKey] = newValue;
+ await onUpdate(id, storageKey, newValue);
+ updateSearchDataset(templateEl, titleEl, textRUEl, textENEl);
+ };
+
+ titleInputEl.addEventListener('change', async (event) => {
+ await handleTitleChange(event);
+ });
+
+ textRUInputEl.addEventListener('change', async (e) => {
+ await handleTextChange(e, textRUEl, 'ruText', 'RUText');
+ });
+
+ textENInputEl.addEventListener('change', async (e) => {
+ await handleTextChange(e, textENEl, 'enText', 'ENText', true);
+ });
+
+ const handleCopyText = async (event, key) => {
+ const text = event.target.dataset[key];
+ if (!text) {
+ console.warn('Нет текста для копирования.');
+ return;
+ }
+ await copyToClipboard(text);
+ };
+
+ textRUEl.addEventListener('click', (e) => handleCopyText(e, 'ruText'));
+ textENEl.addEventListener('click', (e) => handleCopyText(e, 'enText'));
+
+ return templateEl;
+};
+
+export const downloadJsonFile = async (data, filename) => {
+ const jsonString = JSON.stringify(data, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json' });
+ const objectUrl = URL.createObjectURL(blob);
+
+ const revokeLater = () => setTimeout(() => URL.revokeObjectURL(objectUrl), 5000);
+
+ if (typeof chrome !== 'undefined' && chrome.downloads && chrome.downloads.download) {
+ try {
+ await new Promise((resolve, reject) => {
+ chrome.downloads.download({
+ url: objectUrl,
+ filename,
+ saveAs: false
+ }, (downloadId) => {
+ if (chrome.runtime && chrome.runtime.lastError) {
+ reject(chrome.runtime.lastError);
+ return;
+ }
+ if (!downloadId) {
+ reject(new Error('Download id is empty'));
+ return;
+ }
+ resolve(downloadId);
+ });
+ });
+ } finally {
+ revokeLater();
+ }
+ return;
+ }
+
+ try {
+ const downloadLink = document.createElement('a');
+ downloadLink.href = objectUrl;
+ downloadLink.download = filename;
+ downloadLink.rel = 'noopener';
+ downloadLink.style.display = 'none';
+
+ document.body.appendChild(downloadLink);
+ downloadLink.click();
+ document.body.removeChild(downloadLink);
+ } finally {
+ revokeLater();
+ }
+};
+
+export const importTemplatesFromText = async (jsonText, templateStorage) => {
+ let parsed;
+ try {
+ parsed = JSON.parse(jsonText);
+ } catch (error) {
+ throw new Error('Неверный формат JSON.');
+ }
+
+ const templates = Array.isArray(parsed) ? parsed : (parsed.templates || []);
+ if (!Array.isArray(templates)) {
+ throw new Error('Ожидается массив шаблонов или объект с полем "templates".');
+ }
+
+ const usedIds = new Set(templateStorage.getAll().map(t => t.id));
+ const usedTitles = new Set(templateStorage.getAll().map(t => t.title.toLowerCase()));
+
+ const sanitized = templates.map(rawTemplate => {
+ try {
+ return sanitizeImportedTemplate(rawTemplate, usedIds, usedTitles);
+ } catch (error) {
+ throw new Error(`Ошибка при обработке шаблона: ${error.message}`);
+ }
+ });
+
+ const existingTemplates = templateStorage.getAll();
+ templateStorage.setTemplates([...existingTemplates, ...sanitized]);
+
+ return sanitized;
+};
diff --git a/src/js/theme.js b/src/js/theme.js
new file mode 100644
index 0000000..dbc5a8f
--- /dev/null
+++ b/src/js/theme.js
@@ -0,0 +1,90 @@
+export class ThemeManager {
+ constructor() {
+ this.themeToggle = document.getElementById('theme-toggle');
+ this.themeIcon = document.getElementById('theme-icon');
+ this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+ this.currentThemeSetting = localStorage.getItem('theme') || 'system';
+
+ this.themeLabels = {
+ dark: 'тёмная',
+ light: 'светлая',
+ system: 'системная'
+ };
+
+ this.themeIcons = {
+ dark: 'fa-sun',
+ light: 'fa-moon',
+ system: 'fa-circle-half-stroke'
+ };
+
+ this.init();
+ }
+
+ init() {
+ if (this.themeToggle) {
+ this.themeToggle.addEventListener('click', () => {
+ const nextTheme = this.getNextTheme(this.currentThemeSetting);
+ this.applyTheme(nextTheme);
+ });
+ }
+
+ const handleSystemThemeChange = () => {
+ if (this.currentThemeSetting === 'system') {
+ this.applyTheme('system', { persist: false });
+ }
+ };
+
+ if (typeof this.mediaQuery.addEventListener === 'function') {
+ this.mediaQuery.addEventListener('change', handleSystemThemeChange);
+ } else if (typeof this.mediaQuery.addListener === 'function') {
+ this.mediaQuery.addListener(handleSystemThemeChange);
+ }
+
+ this.applyTheme(this.currentThemeSetting, { persist: false });
+ }
+
+ getNextTheme(theme) {
+ if (theme === 'dark') return 'light';
+ if (theme === 'light') return 'system';
+ return 'dark';
+ }
+
+ resolveEffectiveTheme(theme) {
+ if (theme === 'system') {
+ return this.mediaQuery.matches ? 'dark' : 'light';
+ }
+ return theme;
+ }
+
+ updateToggleMeta(theme) {
+ if (!this.themeToggle) {
+ return;
+ }
+ const applied = this.resolveEffectiveTheme(theme);
+ const currentLabel = theme === 'system'
+ ? `${this.themeLabels[theme]} · сейчас ${this.themeLabels[applied]}`
+ : this.themeLabels[theme];
+ const nextTheme = this.getNextTheme(theme);
+ const nextLabel = this.themeLabels[nextTheme];
+ const description = `Тема: ${currentLabel}. Клик — ${nextLabel}.`;
+ this.themeToggle.setAttribute('title', description);
+ this.themeToggle.setAttribute('aria-label', description);
+ }
+
+ applyTheme(theme, { persist = true } = {}) {
+ this.currentThemeSetting = theme;
+ const effective = this.resolveEffectiveTheme(theme);
+ document.body.classList.toggle('dark-theme', effective === 'dark');
+
+ if (this.themeIcon) {
+ const iconClass = this.themeIcons[theme === 'system' ? 'system' : effective];
+ this.themeIcon.className = `fa-solid ${iconClass}`;
+ }
+
+ this.updateToggleMeta(theme);
+
+ if (persist) {
+ localStorage.setItem('theme', theme);
+ }
+ }
+}
diff --git a/src/js/ui.js b/src/js/ui.js
new file mode 100644
index 0000000..6f35cef
--- /dev/null
+++ b/src/js/ui.js
@@ -0,0 +1,390 @@
+class Modal {
+ constructor(modalElement) {
+ this.modal = modalElement;
+ this.isOpen = false;
+ }
+
+ open() {
+ if (!this.modal) {
+ return Promise.reject(new Error('Modal element not found'));
+ }
+ this.modal.classList.remove('hidden');
+ document.body.classList.add('modal-open');
+ this.isOpen = true;
+ }
+
+ close() {
+ if (!this.modal) {
+ return;
+ }
+ this.modal.classList.add('hidden');
+ document.body.classList.remove('modal-open');
+ this.isOpen = false;
+ }
+
+ isModalOpen() {
+ return this.isOpen;
+ }
+}
+
+export class TemplateModal extends Modal {
+ constructor(modalElement, { validateTitle, validateText, getTemplates }) {
+ super(modalElement);
+ this.form = modalElement?.querySelector('.modal-form');
+ this.titleInput = modalElement?.querySelector('#modal-template-title');
+ this.ruInput = modalElement?.querySelector('#modal-template-ru');
+ this.enInput = modalElement?.querySelector('#modal-template-en');
+ this.cancelButton = modalElement?.querySelector('#modal-template-cancel');
+ this.validateTitle = validateTitle;
+ this.validateText = validateText;
+ this.getTemplates = getTemplates;
+ }
+
+ open() {
+ return new Promise((resolve, reject) => {
+ if (!this.modal || !this.form || !this.titleInput || !this.ruInput || !this.enInput) {
+ reject(new Error('Template modal is not available'));
+ return;
+ }
+
+ const cleanup = () => {
+ this.close();
+ this.form.removeEventListener('submit', handleSubmit);
+ if (this.cancelButton) {
+ this.cancelButton.removeEventListener('click', handleCancel);
+ }
+ this.modal.removeEventListener('click', handleBackdropClick);
+ document.removeEventListener('keydown', handleKeydown, true);
+ };
+
+ const handleCancel = () => {
+ cleanup();
+ reject(new Error('cancelled'));
+ };
+
+ const handleSubmit = (event) => {
+ event.preventDefault();
+
+ const templates = this.getTemplates();
+ const title = this.validateTitle(this.titleInput.value, templates);
+ if (!title) {
+ this.titleInput.focus();
+ return;
+ }
+
+ const ruText = this.validateText(this.ruInput.value);
+ if (ruText === null) {
+ this.ruInput.focus();
+ return;
+ }
+
+ const enText = this.validateText(this.enInput.value, true);
+ if (enText === null) {
+ this.enInput.focus();
+ return;
+ }
+
+ cleanup();
+ resolve({
+ title,
+ RUText: ruText,
+ ENText: enText
+ });
+ };
+
+ const handleBackdropClick = (event) => {
+ if (event.target === this.modal) {
+ handleCancel();
+ }
+ };
+
+ const handleKeydown = (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ handleCancel();
+ }
+ };
+
+ this.form.reset();
+ this.titleInput.value = '';
+ this.ruInput.value = '';
+ this.enInput.value = '';
+
+ super.open();
+
+ this.form.addEventListener('submit', handleSubmit);
+ if (this.cancelButton) {
+ this.cancelButton.addEventListener('click', handleCancel);
+ }
+ this.modal.addEventListener('click', handleBackdropClick);
+ document.addEventListener('keydown', handleKeydown, true);
+
+ requestAnimationFrame(() => {
+ this.titleInput.focus({ preventScroll: true });
+ });
+ });
+ }
+}
+
+export class ImportModal extends Modal {
+ constructor(modalElement) {
+ super(modalElement);
+ this.form = modalElement?.querySelector('#import-form');
+ this.jsonTextarea = modalElement?.querySelector('#import-json-input');
+ this.cancelButton = modalElement?.querySelector('#import-cancel');
+ this.applyButton = modalElement?.querySelector('#import-apply');
+ this.dropzone = modalElement?.querySelector('#import-dropzone');
+ this.fileTrigger = modalElement?.querySelector('#import-file-trigger');
+ this.fileInput = document.querySelector('.template-import-input');
+ this.filenameLabel = modalElement?.querySelector('#import-selected-filename');
+ this.fileCallback = null;
+ }
+
+ open() {
+ return new Promise((resolve, reject) => {
+ if (!this.modal || !this.form || !this.jsonTextarea) {
+ reject(new Error('Import modal unavailable'));
+ return;
+ }
+
+ let resolved = false;
+
+ const cleanup = () => {
+ this.close();
+ document.removeEventListener('keydown', handleKeydown, true);
+ this.modal.removeEventListener('click', handleBackdropClick);
+ this.form.removeEventListener('submit', handleSubmit);
+ if (this.cancelButton) {
+ this.cancelButton.removeEventListener('click', handleCancel);
+ }
+ if (this.dropzone) {
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
+ this.dropzone.removeEventListener(evt, handleDropzoneEvents);
+ });
+ this.dropzone.removeEventListener('keydown', handleDropzoneKeydown);
+ this.dropzone.classList.remove('drag-over');
+ }
+ this.fileCallback = null;
+ this.reset();
+ };
+
+ const finish = (value) => {
+ if (resolved) {
+ return;
+ }
+ resolved = true;
+ cleanup();
+ if (value === null) {
+ reject(new Error('cancelled'));
+ } else {
+ resolve(value);
+ }
+ };
+
+ const handleCancel = () => finish(null);
+
+ const handleBackdropClick = (event) => {
+ if (event.target === this.modal) {
+ finish(null);
+ }
+ };
+
+ const handleKeydown = (event) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ finish(null);
+ }
+ };
+
+ const handleSubmit = (event) => {
+ event.preventDefault();
+ const value = this.jsonTextarea.value.trim();
+ if (!value) {
+ alert('Вставьте JSON перед импортом или выберите файл.');
+ this.jsonTextarea.focus();
+ return;
+ }
+ finish(value);
+ };
+
+ const loadFileIntoTextarea = async (file) => {
+ if (!file) {
+ return;
+ }
+ try {
+ const text = await file.text();
+ this.jsonTextarea.value = text;
+ if (this.filenameLabel) {
+ this.filenameLabel.textContent = `Выбран файл: ${file.name}`;
+ }
+ } catch (error) {
+ console.error('Не удалось прочитать файл для импорта:', error);
+ alert('Не получилось прочитать файл. Попробуйте другой файл или вставьте содержимое вручную.');
+ }
+ };
+
+ const handleDropzoneEvents = async (event) => {
+ event.preventDefault();
+ if (!this.dropzone) {
+ return;
+ }
+ if (event.type === 'dragenter' || event.type === 'dragover') {
+ this.dropzone.classList.add('drag-over');
+ return;
+ }
+ if (event.type === 'dragleave') {
+ this.dropzone.classList.remove('drag-over');
+ return;
+ }
+ if (event.type === 'drop') {
+ this.dropzone.classList.remove('drag-over');
+ const file = event.dataTransfer?.files?.[0] || null;
+ if (file) {
+ await loadFileIntoTextarea(file);
+ }
+ }
+ };
+
+ const handleDropzoneKeydown = async (event) => {
+ if (!this.dropzone) {
+ return;
+ }
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ if (this.fileTrigger) {
+ this.fileTrigger.click();
+ }
+ }
+ };
+
+ this.fileCallback = async (file) => {
+ await loadFileIntoTextarea(file);
+ };
+
+ if (this.fileTrigger && this.fileInput) {
+ this.fileTrigger.addEventListener('click', () => {
+ this.fileInput.value = '';
+ this.fileInput.click();
+ });
+ }
+
+ if (this.fileInput) {
+ this.fileInput.addEventListener('change', async () => {
+ if (this.fileCallback) {
+ const file = this.fileInput.files?.[0] || null;
+ await this.fileCallback(file);
+ }
+ });
+ }
+
+ if (this.dropzone) {
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
+ this.dropzone.addEventListener(evt, handleDropzoneEvents);
+ });
+ this.dropzone.addEventListener('keydown', handleDropzoneKeydown);
+ }
+
+ super.open();
+ document.addEventListener('keydown', handleKeydown, true);
+ this.modal.addEventListener('click', handleBackdropClick);
+ this.form.addEventListener('submit', handleSubmit);
+ if (this.cancelButton) {
+ this.cancelButton.addEventListener('click', handleCancel);
+ }
+
+ requestAnimationFrame(() => {
+ this.jsonTextarea.focus({ preventScroll: true });
+ });
+ });
+ }
+
+ reset() {
+ if (this.jsonTextarea) {
+ this.jsonTextarea.value = '';
+ }
+ if (this.filenameLabel) {
+ this.filenameLabel.textContent = '';
+ }
+ if (this.fileInput) {
+ this.fileInput.value = '';
+ }
+ }
+}
+
+export class CategoryManager {
+ constructor() {
+ this.controllers = [];
+ this.init();
+ }
+
+ init() {
+ document.querySelectorAll('.category').forEach(category => {
+ const header = category.querySelector('h3');
+ const content = category.querySelector('.category-content');
+ const icon = header?.querySelector('.toggle-icon');
+ if (!header || !content) {
+ return;
+ }
+
+ header.setAttribute('tabindex', '0');
+ header.setAttribute('role', 'button');
+
+ const categoryId = header.textContent.trim();
+
+ const setOpenState = (isOpen, persist = false) => {
+ if (isOpen) {
+ content.classList.add('show');
+ if (icon) {
+ icon.classList.add('fa-chevron-up');
+ icon.classList.remove('fa-chevron-down');
+ }
+ } else {
+ content.classList.remove('show');
+ if (icon) {
+ icon.classList.add('fa-chevron-down');
+ icon.classList.remove('fa-chevron-up');
+ }
+ }
+ if (persist) {
+ localStorage.setItem(categoryId, isOpen ? 'true' : 'false');
+ }
+ header.setAttribute('aria-expanded', String(isOpen));
+ };
+
+ const storedState = localStorage.getItem(categoryId);
+ const defaultOpen = category.dataset.category === 'custom' || category.dataset.category === 'favorites';
+ const isCategoryOpen = storedState === null ? defaultOpen : storedState === 'true';
+ setOpenState(isCategoryOpen);
+
+ const toggleCategory = (persist = true) => {
+ const nextState = !content.classList.contains('show');
+ setOpenState(nextState, persist);
+ };
+
+ header.addEventListener('click', () => {
+ toggleCategory(true);
+ });
+
+ header.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ toggleCategory(true);
+ }
+ });
+
+ this.controllers.push({
+ category,
+ content,
+ icon,
+ categoryId,
+ setOpenState: (isOpen, persist = false) => {
+ setOpenState(isOpen, persist);
+ }
+ });
+ });
+ }
+
+ getControllers() {
+ return this.controllers;
+ }
+}
diff --git a/src/js/update-check.js b/src/js/update-check.js
new file mode 100644
index 0000000..5555c6c
--- /dev/null
+++ b/src/js/update-check.js
@@ -0,0 +1,77 @@
+import { REPO_BASE } from './constants.js';
+
+/**
+ * Возвращает URL сырого manifest.json в репозитории.
+ * Gitea: .../raw/branch/{branch}/manifest.json
+ * GitLab: .../-/raw/{branch}/manifest.json
+ */
+function getManifestUrl(repoBase, branch = 'main') {
+ const base = repoBase.replace(/\/$/, '');
+ try {
+ const host = new URL(base).host.toLowerCase();
+ if (host.includes('gitlab')) {
+ return `${base}/-/raw/${branch}/manifest.json`;
+ }
+ return `${base}/raw/branch/${branch}/manifest.json`;
+ } catch {
+ return `${base}/raw/branch/${branch}/manifest.json`;
+ }
+}
+
+/**
+ * Сравнивает две версии в формате "X.Y.Z" (семантическое сравнение).
+ * @returns {number} -1 если current < latest, 0 если равны, 1 если current > latest
+ */
+function compareVersions(current, latest) {
+ if (!current || !latest) return 0;
+ const parse = (v) => v.replace(/^v/, '').split('.').map(Number);
+ const a = parse(current);
+ const b = parse(latest);
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
+ const x = a[i] ?? 0;
+ const y = b[i] ?? 0;
+ if (x < y) return -1;
+ if (x > y) return 1;
+ }
+ return 0;
+}
+
+/**
+ * Проверяет наличие новой версии расширения в репозитории.
+ * Требует разрешения на доступ к REPO_BASE (optional_host_permissions).
+ *
+ * @returns {Promise<{ hasUpdate: boolean, current: string, latest: string | null, downloadUrl: string, error?: string }>}
+ */
+export async function checkForUpdate() {
+ const current = typeof chrome !== 'undefined' && chrome.runtime?.getManifest
+ ? chrome.runtime.getManifest().version
+ : '0.0.0';
+ const downloadUrl = REPO_BASE;
+
+ try {
+ let url = getManifestUrl(REPO_BASE, 'main');
+ let res = await fetch(url, { cache: 'no-store' });
+ if (res.status === 404) {
+ url = getManifestUrl(REPO_BASE, 'master');
+ res = await fetch(url, { cache: 'no-store' });
+ }
+ if (!res.ok) {
+ return { hasUpdate: false, current, latest: null, downloadUrl, error: `HTTP ${res.status}` };
+ }
+ const data = await res.json();
+ const latest = data?.version ? String(data.version).trim() : null;
+ if (!latest) {
+ return { hasUpdate: false, current, latest: null, downloadUrl, error: 'В репозитории не найден version в manifest' };
+ }
+ const hasUpdate = compareVersions(current, latest) < 0;
+ return { hasUpdate, current, latest, downloadUrl };
+ } catch (err) {
+ return {
+ hasUpdate: false,
+ current,
+ latest: null,
+ downloadUrl,
+ error: err?.message || 'Не удалось проверить обновления'
+ };
+ }
+}
diff --git a/src/popup.css b/src/popup.css
index 2cdb32b..2d2f50b 100644
--- a/src/popup.css
+++ b/src/popup.css
@@ -18,14 +18,71 @@ div.container {
div.header {
display: flex;
flex-direction: row;
- justify-content: center;
+ justify-content: space-between;
+ align-items: center;
padding: 6px;
+ gap: 12px;
+ flex-wrap: wrap;
}
h1 {
padding: 6px;
}
+.action-buttons {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.update-check-status {
+ padding: 6px 8px;
+ margin: 0 6px 6px;
+ font-size: 13px;
+ border-radius: 6px;
+ background: #e8f4e8;
+ color: #1a5c1a;
+}
+.update-check-status.update-check-error {
+ background: #fce8e8;
+ color: #8b2020;
+}
+.dark-theme .update-check-status {
+ background: rgba(40, 80, 40, 0.4);
+ color: #a8e0a8;
+}
+.dark-theme .update-check-status.update-check-error {
+ background: rgba(80, 40, 40, 0.4);
+ color: #e0a0a0;
+}
+
+.search-row {
+ padding: 0 6px 10px 6px;
+}
+
+.template-search {
+ width: 100%;
+ background-color: #f5f8ff;
+ transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+}
+
+.template-search:focus-visible {
+ outline: none;
+ border-color: #7aa6e6;
+ box-shadow: 0 0 0 2px rgba(122, 166, 230, 0.2);
+}
+
+.dark-theme .template-search {
+ background-color: #2f2f2f;
+ color: #f5f5f5;
+ border-color: #555;
+}
+
+.dark-theme .template-search::placeholder {
+ color: rgba(245, 245, 245, 0.6);
+}
+
h3 {
padding: 6px;
font-family: Helvetica, Arial, sans-serif; /* Более минималистичный шрифт для категорий */
@@ -81,6 +138,9 @@ span.template {
display: flex;
flex-direction: row;
justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 12px;
height: fit-content;
padding: 8px 7px;
border: 1px solid #8888c6; /* Цвет рамки совпадает с рамкой категории */
@@ -102,8 +162,62 @@ span.template:hover {
color: #fff; /* Белый цвет текста в тёмной теме */
}
+.template-title {
+ flex: 1 1 auto;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ word-break: break-word;
+}
+
+.template-favorite {
+ margin-right: 4px;
+ padding: 4px 8px;
+ background-color: transparent;
+ border: none;
+ color: #ffc107;
+ cursor: pointer;
+ font-size: 16px;
+ transition: transform 0.2s ease, color 0.2s ease;
+}
+
+.template-favorite:hover {
+ transform: scale(1.1);
+ color: #ff9800;
+}
+
+.template-favorite:focus-visible {
+ outline: 2px solid #ffc107;
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.dark-theme .template-favorite {
+ color: #ffc107;
+}
+
+.dark-theme .template-favorite:hover {
+ color: #ffb74d;
+}
+
+.bigFormochka {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: flex-start;
+ justify-content: flex-end;
+}
+
.formochka {
- margin-left: 10px;
+ flex: 1 1 220px;
+ min-width: 200px;
+ max-width: 320px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-left: 0;
}
#template-title-input {
@@ -113,11 +227,38 @@ span.template:hover {
span.buttons {
display: flex;
align-items: center;
+ gap: 6px;
+}
+
+span.template > span.buttons {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+span.template > span.buttons:last-child {
+ justify-content: flex-end;
}
span.buttonsLong {
display: flex;
- justify-content: center;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 6px;
+}
+
+.buttonsShort {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ justify-content: flex-start;
+}
+
+.buttonsShort .btn {
+ flex: 0 0 auto;
}
span.template:hover {
@@ -128,6 +269,178 @@ span.template:hover {
display: none;
}
+.filtered-out {
+ display: none !important;
+}
+
+.hotkey-badge {
+ display: inline-block;
+ margin-right: 6px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background-color: #8888c6;
+ color: #fff;
+ font-size: 0.6rem;
+ letter-spacing: 0.02em;
+}
+
+.dark-theme .hotkey-badge {
+ background-color: #555;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ z-index: 1500;
+}
+
+.modal-backdrop.hidden {
+ display: none;
+}
+
+.modal-card {
+ background: #ffffff;
+ color: #1c1c1c;
+ width: min(480px, 100%);
+ max-height: 95vh;
+ border-radius: 12px;
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
+ padding: 20px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+}
+
+.dark-theme .modal-card {
+ background: #2d2d2d;
+ color: #f1f1f1;
+}
+
+.modal-title {
+ margin: 0;
+ font-size: 1.3rem;
+ font-weight: 600;
+}
+
+.modal-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.modal-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.modal-info {
+ font-size: 0.9rem;
+ color: #6c757d;
+}
+
+.modal-field span {
+ font-weight: 500;
+}
+
+.modal-field textarea,
+.modal-field input {
+ width: 100%;
+ min-width: 0;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 8px;
+}
+
+.modal-backdrop .btn {
+ min-width: 92px;
+}
+
+.text-danger {
+ color: #c0392b;
+}
+
+.dark-theme .text-danger {
+ color: #e57373;
+}
+
+.import-dropzone {
+ border: 2px dashed #8aa1ff;
+ border-radius: 10px;
+ padding: 16px;
+ text-align: center;
+ background: rgba(138, 161, 255, 0.05);
+ transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
+ cursor: pointer;
+}
+
+.import-dropzone:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(138, 161, 255, 0.35);
+}
+
+.import-dropzone.drag-over {
+ border-color: #4c6fff;
+ background: rgba(138, 161, 255, 0.18);
+}
+
+.import-dropzone-title {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.import-dropzone-subtitle {
+ margin: 6px 0 12px 0;
+ font-size: 0.85rem;
+ color: #6c757d;
+}
+
+.dark-theme .import-dropzone {
+ border-color: #6579d6;
+ background: rgba(101, 121, 214, 0.05);
+}
+
+.dark-theme .import-dropzone.drag-over {
+ border-color: #8aa1ff;
+ background: rgba(138, 161, 255, 0.25);
+}
+
+.import-dropzone-filename {
+ display: block;
+ margin-top: 10px;
+ font-size: 0.85rem;
+ color: #495057;
+}
+
+.dark-theme .import-dropzone-filename {
+ color: #d7dbdf;
+}
+
+.modal-field textarea#import-json-input {
+ font-family: 'Fira Code', 'JetBrains Mono', Consolas, 'Liberation Mono', monospace;
+ min-height: 180px;
+ resize: vertical;
+}
+
+.text-muted {
+ color: #6c757d;
+}
+
+.dark-theme .text-muted {
+ color: #9ca4ab;
+}
+
.category {
margin-bottom: 10px;
border: solid #8888c6 1px; /* Тонкая рамка для категории */
@@ -144,6 +457,12 @@ span.template:hover {
cursor: pointer; /* Курсор при наведении на весь блок */
width: 100%; /* Заголовок занимает всю ширину */
box-sizing: border-box; /* Чтобы паддинги не влияли на размер */
+ outline: none;
+}
+
+.category h3:focus-visible {
+ box-shadow: 0 0 0 2px rgba(122, 166, 230, 0.35);
+ border-radius: 6px;
}
.toggle-icon {
@@ -186,22 +505,37 @@ body.dark-theme {
}
.theme-btn {
- position: absolute;
- top: 10px;
- left: 10px;
- background: transparent;
- border: none;
- cursor: pointer;
- font-size: 1.5rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ border: 1px solid #c5d9f7;
+ background: #fff;
color: #333;
- z-index: 1000;
- transition: color 0.3s ease;
+ transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+}
+
+.theme-btn:hover,
+.theme-btn:focus-visible {
+ background: #e7f0fe;
+ border-color: #88aee5;
+ outline: none;
}
.dark-theme .theme-btn {
+ background: #2f2f2f;
+ border-color: #555;
color: #fff;
}
+.dark-theme .theme-btn:hover,
+.dark-theme .theme-btn:focus-visible {
+ background: #3e3e3e;
+ border-color: #777;
+}
+
/* Кнопка добавления шаблона */
.add-template-btn {
position: absolute;
@@ -254,14 +588,23 @@ body.dark-theme {
/* Стили для input и textarea */
input, textarea {
width: 100%;
- height: 40px; /* Сделаем высоту фиксированной */
+ max-width: 100%;
+ min-width: 0;
padding: 8px;
box-sizing: border-box;
margin-top: 4px;
}
-/* Убираем возможность изменения размеров textarea */
textarea {
- resize: none;
+ resize: vertical;
+ min-height: 64px;
+}
+
+body.modal-open {
+ overflow: hidden;
+}
+
+.dark-theme body.modal-open {
+ overflow: hidden;
}
diff --git a/src/popup.html b/src/popup.html
index 2f91d6b..848239a 100644
--- a/src/popup.html
+++ b/src/popup.html
@@ -11,16 +11,42 @@
-
+
+
+
+
+
+
+ Мои шаблоны
+
+
+
+
+
Промежуточные ответы
@@ -460,9 +486,54 @@
+
+
+
+
+
Импорт шаблонов
+
+
+
- 1--div>
-
+