Compare commits

..

2 Commits

Author SHA1 Message Date
Сергей Маринкевич 5708a8d9a0 add readme 4 months ago
Сергей Маринкевич af7be11eb2 add firefox support 4 months ago

@ -1 +0,0 @@
BUILD=8

@ -1,3 +1 @@
VITE_API_BASE_URL=https://redmine-reactions.marinkevich.ru VITE_API_BASE_URL=https://redmine-reactions.marinkevich.ru
AMO_JWT_ISSUER=
AMO_JWT_SECRET=

2
.gitignore vendored

@ -2,5 +2,3 @@
dist dist
node_modules node_modules
redmine-reactions.pem redmine-reactions.pem
build
output

@ -4,35 +4,16 @@ FROM node:20-alpine AS builder
# Устанавливаем рабочую директорию внутри контейнера # Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app WORKDIR /app
#ENV USER=docker
#ENV GROUPNAME=$USER
#ENV UID=1000
#ENV GID=1000
#
#RUN addgroup \
# --gid "$GID" \
# "$GROUPNAME" \
#&& adduser \
# --disabled-password \
# --gecos "" \
# --home "$(pwd)" \
# --ingroup "$GROUPNAME" \
# --no-create-home \
# --uid "$UID" \
# $USER
# Копируем сначала только package.json и package-lock.json (если есть) # Копируем сначала только package.json и package-lock.json (если есть)
# Это ключевая оптимизация! Docker будет кэшировать этот слой, и npm install # Это ключевая оптимизация! Docker будет кэшировать этот слой, и npm install
# не будет запускаться каждый раз, если зависимости не менялись. # не будет запускаться каждый раз, если зависимости не менялись.
COPY --chown=node:node package*.json ./ COPY package*.json ./
RUN chown node:node /app -R
RUN echo -e 'nameserver 1.1.1.1\noptions single-request-reopen' > /etc/resolv.conf && \ RUN echo -e 'nameserver 1.1.1.1\noptions single-request-reopen' > /etc/resolv.conf && \
su - node -c "cd /app && npm install" cat /etc/resolv.conf && \
npm install --verbose
USER node
COPY --chown=node:node . . # Теперь копируем все остальные исходники
COPY . .
# Команда, которая будет выполняться по умолчанию для сборки проекта # Команда, которая будет выполняться по умолчанию для сборки проекта
CMD ["npm", "run", "build"] CMD ["npm", "run", "build"]

@ -1,56 +0,0 @@
BUILD_DIR := build
DIST_DIR := $(BUILD_DIR)/dist
OUTPUT_DIR := output
CHROME_ZIP := $(OUTPUT_DIR)/chrome/redmine-reactions.zip
FIREFOX_XPI := $(OUTPUT_DIR)/firefox/redmine-reactions.xpi
DOCKER_COMPOSE_RUN := docker-compose run --rm builder
ifneq (,$(wildcard .env))
include .env
export AMO_JWT_ISSUER AMO_JWT_SECRET
endif
include .aibuild
BUILD := $(shell echo $$(($(BUILD) + 1)))
.PHONY: all chrome firefox clean
all: chrome firefox
chrome: build output builder aibuild
@echo "==> 🏗️ Building for Chrome..."
sed -e "s/AI_BUILD/$(BUILD)/" manifest-chrome.json > ./build/manifest.json
$(DOCKER_COMPOSE_RUN) npm run build
@echo "==> 📦 Packaging Chrome extension..."
mkdir -p $(OUTPUT_DIR)/chrome
cd $(DIST_DIR) && zip -rq ../../$(CHROME_ZIP) .
@echo "==> ✅ Chrome package is ready: $(CHROME_ZIP)"
firefox: build output builder aibuild
@echo "==> 🏗️ Building for Firefox..."
sed -e "s/AI_BUILD/$(BUILD)/" manifest-firefox.json > ./build/manifest.json
$(DOCKER_COMPOSE_RUN) npm run build
@echo "==> ✍️ Signing Firefox extension (this may take a moment)..."
$(DOCKER_COMPOSE_RUN) npm run sign:firefox
@echo "==> 🚚 Moving signed XPI to output..."
mkdir -p $(OUTPUT_DIR)/firefox
find $(BUILD_DIR)/amo -name "*.xpi" -exec mv {} $(FIREFOX_XPI) \;
@echo "==> ✅ Firefox package is ready: $(FIREFOX_XPI)"
aibuild:
@echo BUILD=$(BUILD) > .aibuild
output:
mkdir output
build:
mkdir build
builder:
docker-compose build
clean:
@echo "==> 🧹 Cleaning up build artifacts..."
rm -rf $(BUILD_DIR) $(OUTPUT_DIR)

@ -1,16 +1,18 @@
Build Build
===== =====
make chrome docker-compose run --rm builder npm run build:chrome
or or
make firefox docker-compose run --rm builder npm run build:firefox
Signed Signed
====== ======
Needs AMO API key from Mozilla. Place it to the `.env` file. Needs AMO API key from Mozilla.
AMO_JWT_ISSUER=$USER docker-compose run --rm \
AMO_JWT_SECRET=$SECRET -e AMO_JWT_ISSUER="$USER" \
-e AMO_JWT_SECRET="$SECRET" \
builder npm run package:firefox-signed

@ -1,13 +1,34 @@
version: '3.8' version: '3.8'
services: services:
# Сервис для ОДНОРАЗОВОЙ СБОРКИ расширения (команда npm run build)
# Запускается командой: docker-compose run --rm builder
builder: builder:
# Собираем образ на основе нашего Dockerfile.build
build: build:
context: . context: .
dockerfile: Dockerfile.build dockerfile: Dockerfile.build
env_file: # Это магия! Мы "пробрасываем" всю текущую папку внутрь контейнера.
- .env # Когда внутри контейнера в /app создастся папка dist, она автоматически
# появится и на нашей хост-машине.
volumes: volumes:
- ./build:/app/build - .:/app
- ./build/manifest.json:/app/manifest.json # Анонимный том для node_modules. Важный трюк!
- ./output:/app/output # Он предотвращает перезапись папки node_modules, установленной внутри
# контейнера, пустой папкой с хоста.
- /app/node_modules
# Сервис для РЕЖИМА РАЗРАБОТКИ (команда npm run dev)
# Запускается командой: docker-compose up dev
dev:
build:
context: .
dockerfile: Dockerfile.build
ports:
# Пробрасываем порт Vite для hot-reload
- "5173:5173"
volumes:
- .:/app
- /app/node_modules
# Переопределяем команду по умолчанию на запуск dev-сервера
command: npm run dev

@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Redmine Reactions", "name": "Redmine Reactions",
"version": "0.1.AI_BUILD", "version": "0.1.0",
"description": "Добавляет реакции на комментарии в локальном Redmine.", "description": "Добавляет реакции на комментарии в локальном Redmine.",
"permissions": ["storage"], "permissions": ["storage"],
"icons": { "icons": {
@ -10,8 +10,7 @@
"content_scripts": [ "content_scripts": [
{ {
"matches": ["https://red.eltex.loc/issues/*"], "matches": ["https://red.eltex.loc/issues/*"],
"js": ["src/content.tsx"], "js": ["src/content.tsx"]
"run_at": "document_start"
} }
] ]
} }

@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Redmine Reactions", "name": "Redmine Reactions",
"version": "0.1.AI_BUILD", "version": "0.1.0",
"description": "Добавляет реакции на комментарии в локальном Redmine.", "description": "Добавляет реакции на комментарии в локальном Redmine.",
"permissions": [ "permissions": [
"storage", "storage",
@ -13,8 +13,7 @@
"content_scripts": [ "content_scripts": [
{ {
"matches": ["https://red.eltex.loc/issues/*"], "matches": ["https://red.eltex.loc/issues/*"],
"js": ["src/content.tsx"], "js": ["src/content.tsx"]
"run_at": "document_start"
} }
], ],
"browser_specific_settings": { "browser_specific_settings": {

@ -5,9 +5,12 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build --outDir build/dist --emptyOutDir", "build": "tsc && vite build",
"lint": "tsc --noEmit", "build:firefox": "cp manifest-firefox.json manifest.json && tsc && vite build",
"sign:firefox": "web-ext sign --source-dir build/dist --artifacts-dir build/amo --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET" "package:firefox": "npm run build:firefox && web-ext build --source-dir dist/ --overwrite-dest",
"package:firefox-signed": "echo -e 'nameserver 1.1.1.1\noptions single-request-reopen' > /etc/resolv.conf && npm run build:firefox && web-ext sign --source-dir dist/ --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET",
"build:chrome": "cp manifest-chrome.json manifest.json && npm run build",
"package:chrome": "npm run build:chrome && crx3 dist/ --key redmine-reactions.pem --output dist/redmine-reactions.crx"
}, },
"dependencies": { "dependencies": {
"webextension-polyfill": "^0.10.0", "webextension-polyfill": "^0.10.0",

@ -1,76 +0,0 @@
import browser from 'webextension-polyfill';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// Тип для ответа сервера, который мы ожидаем
type AllReactionsForIssue = {
[commentId: string]: {
[emoji: string]: string[];
};
};
// --- НАШ КЭШ ---
// Мы будем хранить Promise, а не сами данные.
let reactionsPromiseCache: Promise<AllReactionsForIssue> | null = null;
/**
* Получает все реакции для указанной задачи.
* Использует кэш на уровне модуля, чтобы избежать повторных запросов на одной странице.
*/
export async function fetchReactionsForIssue(issueId: number): Promise<AllReactionsForIssue> {
// Если Promise уже есть в кэше, просто возвращаем его.
if (reactionsPromiseCache) {
console.log('Serving reactions from cache.');
return reactionsPromiseCache;
}
console.log('Fetching reactions from network...');
// Если кэш пуст, создаем новый Promise, СОХРАНЯЕМ ЕГО В КЭШ, и возвращаем.
reactionsPromiseCache = fetch(`${API_BASE_URL}/api/reactions/${issueId}`)
.then(response => {
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return response.json();
})
.catch(error => {
// В случае ошибки, очищаем кэш, чтобы можно было попробовать снова.
reactionsPromiseCache = null;
throw error;
});
return reactionsPromiseCache;
}
/**
* Получает анонимный ID пользователя из хранилища.
* (Переносим эту логику сюда, чтобы весь код, связанный с API/Storage, был в одном месте).
*/
export async function getAnonymousUserId(): Promise<string> {
const data = await browser.storage.local.get('userId');
if (data.userId) return data.userId;
const newUserId = crypto.randomUUID();
await browser.storage.local.set({ userId: newUserId });
return newUserId;
}
/**
* Отправляет запрос на добавление/удаление реакции.
*/
export async function toggleReactionApi(
issueId: number,
commentId: string,
emoji: string,
userId: string,
method: 'POST' | 'DELETE'
) {
return fetch(`${API_BASE_URL}/api/reactions`, {
method,
headers: {
'Content-Type': 'application/json',
'X-User-ID': userId,
},
body: JSON.stringify({ issueId, commentId, emoji }),
});
}

@ -1,10 +1,20 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'; import EmojiPicker, { EmojiClickData } from 'emoji-picker-react';
import { fetchReactionsForIssue, getAnonymousUserId, toggleReactionApi } from '../api'; import browser from 'webextension-polyfill';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
type CommentReactions = { [emoji: string]: string[] }; type CommentReactions = { [emoji: string]: string[] };
interface ReactionsProps { issueId: number; commentId: string; } interface ReactionsProps { issueId: number; commentId: string; }
async function getAnonymousUserId(): Promise<string> {
const data = await browser.storage.local.get('userId');
if (data.userId) return data.userId;
const newUserId = crypto.randomUUID();
await browser.storage.local.set({ userId: newUserId });
return newUserId;
}
const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => { const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
const [reactions, setReactions] = useState<CommentReactions>({}); const [reactions, setReactions] = useState<CommentReactions>({});
const [myUserId, setMyUserId] = useState<string | null>(null); const [myUserId, setMyUserId] = useState<string | null>(null);
@ -15,17 +25,16 @@ const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [userId, allReactions] = await Promise.all([ const userId = await getAnonymousUserId();
getAnonymousUserId(),
fetchReactionsForIssue(issueId)
]);
setMyUserId(userId); setMyUserId(userId);
if (allReactions && allReactions[commentId]) { const response = await fetch(`${API_BASE_URL}/api/reactions/${issueId}`);
setReactions(allReactions[commentId]); if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
if (data && data[commentId]) {
setReactions(data[commentId]);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch data:", error); console.error("Failed to fetch reactions:", error);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -43,22 +52,22 @@ const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
const handleReactionClick = async (emoji: string) => { const handleReactionClick = async (emoji: string) => {
if (!myUserId) return; if (!myUserId) return;
const hasReacted = (reactions[emoji] || []).includes(myUserId); const currentReactions = reactions[emoji] || [];
const hasReacted = currentReactions.includes(myUserId);
const method = hasReacted ? 'DELETE' : 'POST'; const method = hasReacted ? 'DELETE' : 'POST';
const previousReactions = { ...reactions }; const previousReactions = { ...reactions };
setReactions(prev => { setReactions(prev => {
const users = prev[emoji] || []; const newUsers = hasReacted ? (prev[emoji] || []).filter(id => id !== myUserId) : [...(prev[emoji] || []), myUserId];
const newUsers = hasReacted ? users.filter(id => id !== myUserId) : [...users, myUserId];
const newReactionsForComment = { ...prev, [emoji]: newUsers }; const newReactionsForComment = { ...prev, [emoji]: newUsers };
if (newReactionsForComment[emoji].length === 0) { if (newReactionsForComment[emoji].length === 0) delete newReactionsForComment[emoji];
delete newReactionsForComment[emoji];
}
return newReactionsForComment; return newReactionsForComment;
}); });
try { try {
const response = await toggleReactionApi(issueId, commentId, emoji, myUserId, method); const response = await fetch(`${API_BASE_URL}/api/reactions`, {
method,
headers: { 'Content-Type': 'application/json', 'X-User-ID': myUserId },
body: JSON.stringify({ issueId, commentId, emoji }),
});
if (!response.ok) throw new Error('Server returned an error'); if (!response.ok) throw new Error('Server returned an error');
} catch (error) { } catch (error) {
console.error("Failed to update reaction:", error); console.error("Failed to update reaction:", error);
@ -71,17 +80,7 @@ const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
handleReactionClick(emojiData.emoji); handleReactionClick(emojiData.emoji);
}; };
if (isLoading) { if (isLoading || !myUserId) return null;
return (
<div style={styles.container}>
<div style={{ ...styles.skeleton, width: '50px' }}></div>
<div style={{ ...styles.skeleton, width: '45px' }}></div>
<div style={styles.addButtonSkeleton}></div>
</div>
);
}
if (!myUserId) return null;
return ( return (
<div style={styles.container}> <div style={styles.container}>
@ -110,7 +109,7 @@ const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
}; };
const styles: { [key: string]: React.CSSProperties } = { const styles: { [key: string]: React.CSSProperties } = {
container: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '6px', marginTop: '8px', marginBottom: '8px', paddingLeft: '10px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', fontSize: '13px', minHeight: '36px' }, container: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '6px', marginTop: '8px', marginBottom: '8px', paddingLeft: '10px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', fontSize: '13px' },
badge: { display: 'flex', alignItems: 'center', padding: '2px 8px', border: '1px solid #dcdcdc', borderRadius: '12px', cursor: 'pointer', transition: 'background-color 0.2s', backgroundColor: '#f7f7f7' }, badge: { display: 'flex', alignItems: 'center', padding: '2px 8px', border: '1px solid #dcdcdc', borderRadius: '12px', cursor: 'pointer', transition: 'background-color 0.2s', backgroundColor: '#f7f7f7' },
badgeSelected: { backgroundColor: '#e6f2ff', borderColor: '#99ccff' }, badgeSelected: { backgroundColor: '#e6f2ff', borderColor: '#99ccff' },
emoji: { marginRight: '4px', fontSize: '15px', lineHeight: '1' }, emoji: { marginRight: '4px', fontSize: '15px', lineHeight: '1' },
@ -118,7 +117,6 @@ const styles: { [key: string]: React.CSSProperties } = {
countSelected: { color: '#005cc5' }, countSelected: { color: '#005cc5' },
addButton: { display: 'flex', alignItems: 'center', padding: '2px 8px', border: '1px dashed #ccc', borderRadius: '12px', backgroundColor: 'transparent', cursor: 'pointer', color: '#555', fontFamily: 'inherit', fontSize: '13px' }, addButton: { display: 'flex', alignItems: 'center', padding: '2px 8px', border: '1px dashed #ccc', borderRadius: '12px', backgroundColor: 'transparent', cursor: 'pointer', color: '#555', fontFamily: 'inherit', fontSize: '13px' },
pickerWrapper: { position: 'absolute', bottom: '100%', left: 0, marginBottom: '10px', zIndex: 1000 }, pickerWrapper: { position: 'absolute', bottom: '100%', left: 0, marginBottom: '10px', zIndex: 1000 },
skeleton: { height: '26px', backgroundColor: '#eef0f2', borderRadius: '12px', animation: 'reactions-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite' },
addButtonSkeleton: { height: '26px', width: '42px', backgroundColor: 'transparent', border: '1px dashed #e0e0e0', borderRadius: '12px' },
}; };
export default Reactions; export default Reactions;

@ -2,61 +2,40 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import Reactions from './components/Reactions'; import Reactions from './components/Reactions';
// --- Подготовка --- console.log('Redmine Reactions Extension Loaded!');
// Создаем элемент <style>, но пока не вставляем его
const styleSheet = document.createElement("style"); // Функция для извлечения ID задачи из URL
styleSheet.innerText = ` function getIssueId(): number | null {
@keyframes reactions-pulse { const match = window.location.pathname.match(/\/issues\/(\d+)/);
0%, 100% { opacity: 1; } return match && match[1] ? parseInt(match[1], 10) : null;
50% { opacity: 0.5; } }
}
`; // Находим все блоки с комментариями на странице
// Флаг, чтобы убедиться, что стили вставляются только один раз const commentContainers = document.querySelectorAll('div.journal.has-notes');
let stylesInjected = false;
const issueId = getIssueId();
function processCommentContainer(container: HTMLElement) {
if (container.dataset.reactionsInitialized) return; if (issueId) {
container.dataset.reactionsInitialized = 'true'; commentContainers.forEach(container => {
// Находим вложенный div с ID комментария
const issueIdMatch = window.location.pathname.match(/\/issues\/(\d+)/); const noteElement = container.querySelector<HTMLDivElement>('div[id^="note-"]');
if (!issueIdMatch) return;
const issueId = parseInt(issueIdMatch[1], 10);
const noteElement = container.querySelector<HTMLElement>('div[id^="note-"]');
if (!noteElement) return; if (!noteElement) return;
const commentId = noteElement.id; const commentId = noteElement.id;
// Создаем div, в который будем рендерить наш React-компонент
const reactionRootEl = document.createElement('div'); const reactionRootEl = document.createElement('div');
reactionRootEl.style.minHeight = '36px'; // Резервируем место reactionRootEl.className = 'reactions-app-root';
// Вставляем наш div в конец контейнера, как вы и определили
container.appendChild(reactionRootEl); container.appendChild(reactionRootEl);
// Создаем React-root и рендерим компонент
const root = ReactDOM.createRoot(reactionRootEl); const root = ReactDOM.createRoot(reactionRootEl);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Reactions issueId={issueId} commentId={commentId} /> <Reactions issueId={issueId} commentId={commentId} />
</React.StrictMode> </React.StrictMode>
); );
});
} }
const observer = new MutationObserver((mutations) => {
if (!stylesInjected && document.head) {
document.head.appendChild(styleSheet);
stylesInjected = true;
}
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof HTMLElement) {
if (node.matches('div.journal.has-notes')) {
processCommentContainer(node);
}
node.querySelectorAll<HTMLElement>('div.journal.has-notes').forEach(processCommentContainer);
}
}
}
});
observer.observe(document, {
childList: true,
subtree: true,
});

Loading…
Cancel
Save