init
commit
6fef043a10
@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=https://redmine-reactions.marinkevich.ru
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
*.swp
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
@ -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"]
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@ -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<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, setReactions] = useState<CommentReactions>({});
|
||||||
|
const [myUserId, setMyUserId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const pickerContainerRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{Object.entries(reactions).map(([emoji, users]) => {
|
||||||
|
if (users.length === 0) return null;
|
||||||
|
const isSelected = users.includes(myUserId);
|
||||||
|
return (
|
||||||
|
<div key={emoji} style={{ ...styles.badge, ...(isSelected ? styles.badgeSelected : {}) }} title={users.join(', ')} onClick={() => handleReactionClick(emoji)}>
|
||||||
|
<span style={styles.emoji}>{emoji}</span>
|
||||||
|
<span style={{ ...styles.count, ...(isSelected ? styles.countSelected : {}) }}>{users.length}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ position: 'relative' }} ref={pickerContainerRef}>
|
||||||
|
<button style={styles.addButton} title="Добавить реакцию" onClick={() => setShowPicker(prev => !prev)}>
|
||||||
|
<span>+😊</span>
|
||||||
|
</button>
|
||||||
|
{showPicker && (
|
||||||
|
<div style={styles.pickerWrapper}>
|
||||||
|
<EmojiPicker onEmojiClick={onEmojiClick} height={350} width={300} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
// можно добавлять и другие переменные
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@ -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" }]
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["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 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue