commit 6fef043a106fc455f1e207239232a005cf48e54e Author: Сергей Маринкевич Date: Fri Aug 22 14:47:30 2025 +0700 init diff --git a/.env b/.env new file mode 100644 index 0000000..beb44b4 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://redmine-reactions.marinkevich.ru diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfb07cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.swp +dist +node_modules diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..696c925 --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,19 @@ +# Используем легковесный официальный образ Node.js. Alpine - это минималистичный дистрибутив Linux. +FROM node:20-alpine AS builder + +# Устанавливаем рабочую директорию внутри контейнера +WORKDIR /app + +# Копируем сначала только package.json и package-lock.json (если есть) +# Это ключевая оптимизация! Docker будет кэшировать этот слой, и npm install +# не будет запускаться каждый раз, если зависимости не менялись. +COPY package*.json ./ +RUN echo -e 'nameserver 1.1.1.1\noptions single-request-reopen' > /etc/resolv.conf && \ + cat /etc/resolv.conf && \ + npm install --verbose + +# Теперь копируем все остальные исходники +COPY . . + +# Команда, которая будет выполняться по умолчанию для сборки проекта +CMD ["npm", "run", "build"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..74b0b19 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + # Сервис для ОДНОРАЗОВОЙ СБОРКИ расширения (команда npm run build) + # Запускается командой: docker-compose run --rm builder + builder: + # Собираем образ на основе нашего Dockerfile.build + build: + context: . + dockerfile: Dockerfile.build + # Это магия! Мы "пробрасываем" всю текущую папку внутрь контейнера. + # Когда внутри контейнера в /app создастся папка dist, она автоматически + # появится и на нашей хост-машине. + volumes: + - .:/app + # Анонимный том для node_modules. Важный трюк! + # Он предотвращает перезапись папки 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 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d72c291 --- /dev/null +++ b/manifest.json @@ -0,0 +1,16 @@ +{ + "manifest_version": 3, + "name": "Redmine Reactions", + "version": "0.1.0", + "description": "Добавляет реакции на комментарии в локальном Redmine.", + "permissions": ["storage"], + "icons": { + "128": "public/icon.png" + }, + "content_scripts": [ + { + "matches": ["https://red.eltex.loc/issues/*"], + "js": ["src/content.tsx"] + } + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e40d12 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "redmine-reactions-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build" + }, + "dependencies": { + "emoji-picker-react": "^4.9.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.21", + "@types/chrome": "^0.0.254", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..0af03f1 Binary files /dev/null and b/public/icon.png differ diff --git a/src/components/Reactions.tsx b/src/components/Reactions.tsx new file mode 100644 index 0000000..48a2d73 --- /dev/null +++ b/src/components/Reactions.tsx @@ -0,0 +1,121 @@ +import React, { useState, useEffect, useRef } from 'react'; +import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +type CommentReactions = { [emoji: string]: string[] }; +interface ReactionsProps { issueId: number; commentId: string; } + +async function getAnonymousUserId(): Promise { + 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 = ({ issueId, commentId }) => { + const [reactions, setReactions] = useState({}); + const [myUserId, setMyUserId] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [showPicker, setShowPicker] = useState(false); + const pickerContainerRef = useRef(null); + + useEffect(() => { + const fetchData = async () => { + try { + const userId = await getAnonymousUserId(); + setMyUserId(userId); + const response = await fetch(`${API_BASE_URL}/api/reactions/${issueId}`); + 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) { + console.error("Failed to fetch reactions:", error); + } finally { + setIsLoading(false); + } + }; + fetchData(); + + const handleClickOutside = (event: MouseEvent) => { + if (pickerContainerRef.current && !pickerContainerRef.current.contains(event.target as Node)) { + setShowPicker(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [issueId, commentId]); + + const handleReactionClick = async (emoji: string) => { + if (!myUserId) return; + const currentReactions = reactions[emoji] || []; + const hasReacted = currentReactions.includes(myUserId); + const method = hasReacted ? 'DELETE' : 'POST'; + const previousReactions = { ...reactions }; + setReactions(prev => { + const newUsers = hasReacted ? (prev[emoji] || []).filter(id => id !== myUserId) : [...(prev[emoji] || []), myUserId]; + const newReactionsForComment = { ...prev, [emoji]: newUsers }; + if (newReactionsForComment[emoji].length === 0) delete newReactionsForComment[emoji]; + return newReactionsForComment; + }); + try { + 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'); + } catch (error) { + console.error("Failed to update reaction:", error); + setReactions(previousReactions); + } + }; + + const onEmojiClick = (emojiData: EmojiClickData) => { + setShowPicker(false); + handleReactionClick(emojiData.emoji); + }; + + if (isLoading || !myUserId) return null; + + return ( +
+ {Object.entries(reactions).map(([emoji, users]) => { + if (users.length === 0) return null; + const isSelected = users.includes(myUserId); + return ( +
handleReactionClick(emoji)}> + {emoji} + {users.length} +
+ ); + })} +
+ + {showPicker && ( +
+ +
+ )} +
+
+ ); +}; + +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' }, + 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' }, + emoji: { marginRight: '4px', fontSize: '15px', lineHeight: '1' }, + count: { fontWeight: 600, color: '#333' }, + 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' }, + pickerWrapper: { position: 'absolute', bottom: '100%', left: 0, marginBottom: '10px', zIndex: 1000 }, +}; + +export default Reactions; diff --git a/src/content.tsx b/src/content.tsx new file mode 100644 index 0000000..487dbb3 --- /dev/null +++ b/src/content.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import Reactions from './components/Reactions'; + +console.log('Redmine Reactions Extension Loaded!'); + +// Функция для извлечения ID задачи из URL +function getIssueId(): number | null { + const match = window.location.pathname.match(/\/issues\/(\d+)/); + return match && match[1] ? parseInt(match[1], 10) : null; +} + +// Находим все блоки с комментариями на странице +const commentContainers = document.querySelectorAll('div.journal.has-notes'); + +const issueId = getIssueId(); + +if (issueId) { + commentContainers.forEach(container => { + // Находим вложенный div с ID комментария + const noteElement = container.querySelector('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( + + + + ); + }); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..3cad6d3 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string; + // можно добавлять и другие переменные +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..bf80ba4 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { crx } from '@crxjs/vite-plugin' +import manifest from './manifest.json' + +export default defineConfig({ + plugins: [ + react(), + crx({ manifest }), + ], +})