Compare commits
6 Commits
5708a8d9a0
...
d8c4cc3f9f
| Author | SHA1 | Date | |
|---|---|---|---|
| d8c4cc3f9f | |||
| dbe8418cdd | |||
| b4a66ee5c0 | |||
| db11fff026 | |||
| 2a17029751 | |||
| 434007ce68 |
@@ -1 +1,3 @@
|
|||||||
VITE_API_BASE_URL=https://redmine-reactions.marinkevich.ru
|
VITE_API_BASE_URL=https://redmine-reactions.marinkevich.ru
|
||||||
|
AMO_JWT_ISSUER=
|
||||||
|
AMO_JWT_SECRET=
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
*.swp
|
*.swp
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
redmine-reactions.pem
|
||||||
|
build
|
||||||
|
output
|
||||||
|
|||||||
+25
-6
@@ -4,16 +4,35 @@ 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 package*.json ./
|
COPY --chown=node:node package*.json ./
|
||||||
RUN echo -e 'nameserver 1.1.1.1\noptions single-request-reopen' > /etc/resolv.conf && \
|
RUN chown node:node /app -R
|
||||||
cat /etc/resolv.conf && \
|
|
||||||
npm install --verbose
|
|
||||||
|
|
||||||
# Теперь копируем все остальные исходники
|
RUN echo -e 'nameserver 1.1.1.1\noptions single-request-reopen' > /etc/resolv.conf && \
|
||||||
COPY . .
|
su - node -c "cd /app && npm install"
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
# Команда, которая будет выполняться по умолчанию для сборки проекта
|
# Команда, которая будет выполняться по умолчанию для сборки проекта
|
||||||
CMD ["npm", "run", "build"]
|
CMD ["npm", "run", "build"]
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
Build
|
||||||
|
=====
|
||||||
|
|
||||||
|
make chrome
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
make firefox
|
||||||
|
|
||||||
|
Signed
|
||||||
|
======
|
||||||
|
|
||||||
|
Needs AMO API key from Mozilla. Place it to the `.env` file.
|
||||||
|
|
||||||
|
AMO_JWT_ISSUER=$USER
|
||||||
|
AMO_JWT_SECRET=$SECRET
|
||||||
+5
-26
@@ -1,34 +1,13 @@
|
|||||||
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:
|
||||||
# Когда внутри контейнера в /app создастся папка dist, она автоматически
|
- .env
|
||||||
# появится и на нашей хост-машине.
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- ./build:/app/build
|
||||||
# Анонимный том для node_modules. Важный трюк!
|
- ./build/manifest.json:/app/manifest.json
|
||||||
# Он предотвращает перезапись папки node_modules, установленной внутри
|
- ./output:/app/output
|
||||||
# контейнера, пустой папкой с хоста.
|
|
||||||
- /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
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Redmine Reactions",
|
||||||
|
"version": "0.1.AI_BUILD",
|
||||||
|
"description": "Добавляет реакции на комментарии в локальном Redmine.",
|
||||||
|
"permissions": ["storage"],
|
||||||
|
"icons": {
|
||||||
|
"128": "public/icon.png"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://red.eltex.loc/issues/*"],
|
||||||
|
"js": ["src/content.tsx"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Redmine Reactions",
|
||||||
|
"version": "0.1.AI_BUILD",
|
||||||
|
"description": "Добавляет реакции на комментарии в локальном Redmine.",
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"https://your-domain.com/*"
|
||||||
|
],
|
||||||
|
"icons": {
|
||||||
|
"128": "public/icon.png"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://red.eltex.loc/issues/*"],
|
||||||
|
"js": ["src/content.tsx"],
|
||||||
|
"run_at": "document_start"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "reactions@your-company.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5959
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -5,9 +5,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build"
|
"build": "tsc && vite build --outDir build/dist --emptyOutDir",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"sign:firefox": "web-ext sign --source-dir build/dist --artifacts-dir build/amo --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"webextension-polyfill": "^0.10.0",
|
||||||
|
"web-ext": "^7.8.0",
|
||||||
|
"crx3": "^1.1.2",
|
||||||
"emoji-picker-react": "^4.9.2",
|
"emoji-picker-react": "^4.9.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
@@ -17,8 +22,10 @@
|
|||||||
"@types/chrome": "^0.0.254",
|
"@types/chrome": "^0.0.254",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@types/webextension-polyfill": "^0.10.7",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
+76
@@ -0,0 +1,76 @@
|
|||||||
|
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,19 +1,10 @@
|
|||||||
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';
|
||||||
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 chrome.storage.local.get('userId');
|
|
||||||
if (data.userId) return data.userId;
|
|
||||||
const newUserId = crypto.randomUUID();
|
|
||||||
await chrome.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);
|
||||||
@@ -24,16 +15,17 @@ const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const userId = await getAnonymousUserId();
|
const [userId, allReactions] = await Promise.all([
|
||||||
|
getAnonymousUserId(),
|
||||||
|
fetchReactionsForIssue(issueId)
|
||||||
|
]);
|
||||||
|
|
||||||
setMyUserId(userId);
|
setMyUserId(userId);
|
||||||
const response = await fetch(`${API_BASE_URL}/api/reactions/${issueId}`);
|
if (allReactions && allReactions[commentId]) {
|
||||||
if (!response.ok) throw new Error('Network response was not ok');
|
setReactions(allReactions[commentId]);
|
||||||
const data = await response.json();
|
|
||||||
if (data && data[commentId]) {
|
|
||||||
setReactions(data[commentId]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch reactions:", error);
|
console.error("Failed to fetch data:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,22 +43,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 currentReactions = reactions[emoji] || [];
|
const hasReacted = (reactions[emoji] || []).includes(myUserId);
|
||||||
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 newUsers = hasReacted ? (prev[emoji] || []).filter(id => id !== myUserId) : [...(prev[emoji] || []), myUserId];
|
const users = prev[emoji] || [];
|
||||||
|
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) delete newReactionsForComment[emoji];
|
if (newReactionsForComment[emoji].length === 0) {
|
||||||
|
delete newReactionsForComment[emoji];
|
||||||
|
}
|
||||||
return newReactionsForComment;
|
return newReactionsForComment;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/reactions`, {
|
const response = await toggleReactionApi(issueId, commentId, emoji, myUserId, method);
|
||||||
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);
|
||||||
@@ -79,7 +71,17 @@ const Reactions: React.FC<ReactionsProps> = ({ issueId, commentId }) => {
|
|||||||
handleReactionClick(emojiData.emoji);
|
handleReactionClick(emojiData.emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || !myUserId) return null;
|
if (isLoading) {
|
||||||
|
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}>
|
||||||
@@ -108,7 +110,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' },
|
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' },
|
||||||
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' },
|
||||||
@@ -116,6 +118,7 @@ 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;
|
||||||
|
|||||||
+53
-32
@@ -2,40 +2,61 @@ 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");
|
||||||
|
styleSheet.innerText = `
|
||||||
|
@keyframes reactions-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
// Флаг, чтобы убедиться, что стили вставляются только один раз
|
||||||
|
let stylesInjected = false;
|
||||||
|
|
||||||
// Функция для извлечения ID задачи из URL
|
function processCommentContainer(container: HTMLElement) {
|
||||||
function getIssueId(): number | null {
|
if (container.dataset.reactionsInitialized) return;
|
||||||
const match = window.location.pathname.match(/\/issues\/(\d+)/);
|
container.dataset.reactionsInitialized = 'true';
|
||||||
return match && match[1] ? parseInt(match[1], 10) : null;
|
|
||||||
|
const issueIdMatch = window.location.pathname.match(/\/issues\/(\d+)/);
|
||||||
|
if (!issueIdMatch) return;
|
||||||
|
const issueId = parseInt(issueIdMatch[1], 10);
|
||||||
|
|
||||||
|
const noteElement = container.querySelector<HTMLElement>('div[id^="note-"]');
|
||||||
|
if (!noteElement) return;
|
||||||
|
const commentId = noteElement.id;
|
||||||
|
|
||||||
|
const reactionRootEl = document.createElement('div');
|
||||||
|
reactionRootEl.style.minHeight = '36px'; // Резервируем место
|
||||||
|
container.appendChild(reactionRootEl);
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(reactionRootEl);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Reactions issueId={issueId} commentId={commentId} />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Находим все блоки с комментариями на странице
|
const observer = new MutationObserver((mutations) => {
|
||||||
const commentContainers = document.querySelectorAll('div.journal.has-notes');
|
if (!stylesInjected && document.head) {
|
||||||
|
document.head.appendChild(styleSheet);
|
||||||
|
stylesInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
const issueId = getIssueId();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (issueId) {
|
observer.observe(document, {
|
||||||
commentContainers.forEach(container => {
|
childList: true,
|
||||||
// Находим вложенный div с ID комментария
|
subtree: true,
|
||||||
const noteElement = container.querySelector<HTMLDivElement>('div[id^="note-"]');
|
});
|
||||||
if (!noteElement) return;
|
|
||||||
|
|
||||||
const commentId = noteElement.id;
|
|
||||||
|
|
||||||
// Создаем div, в который будем рендерить наш React-компонент
|
|
||||||
const reactionRootEl = document.createElement('div');
|
|
||||||
reactionRootEl.className = 'reactions-app-root';
|
|
||||||
|
|
||||||
// Вставляем наш div в конец контейнера, как вы и определили
|
|
||||||
container.appendChild(reactionRootEl);
|
|
||||||
|
|
||||||
// Создаем React-root и рендерим компонент
|
|
||||||
const root = ReactDOM.createRoot(reactionRootEl);
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<Reactions issueId={issueId} commentId={commentId} />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
"types": ["vite/client", "webextension-polyfill"],
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user