From 6fef043a106fc455f1e207239232a005cf48e54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B5=D1=80=D0=B3=D0=B5=D0=B9=20=D0=9C=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 22 Aug 2025 14:47:30 +0700 Subject: [PATCH] init --- .env | 1 + .gitignore | 3 ++ Dockerfile.build | 19 +++++++ docker-compose.yml | 34 ++++++++++++ manifest.json | 16 ++++++ package.json | 24 +++++++++ public/icon.png | Bin 0 -> 23838 bytes src/components/Reactions.tsx | 121 +++++++++++++++++++++++++++++++++++++++++++ src/content.tsx | 41 +++++++++++++++ src/vite-env.d.ts | 10 ++++ tsconfig.json | 25 +++++++++ tsconfig.node.json | 11 ++++ vite.config.ts | 11 ++++ 13 files changed, 316 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 Dockerfile.build create mode 100644 docker-compose.yml create mode 100644 manifest.json create mode 100644 package.json create mode 100644 public/icon.png create mode 100644 src/components/Reactions.tsx create mode 100644 src/content.tsx create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts 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 0000000000000000000000000000000000000000..0af03f1dc5ad0c7265083e60dab3b6270538b832 GIT binary patch literal 23838 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 literal 0 HcmV?d00001 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 }), + ], +})