Add fixes for + button to hide
This commit is contained in:
parent
b5d96a6eff
commit
c3d05633d3
25 changed files with 251 additions and 361 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -16,3 +16,8 @@ backend/coverage/
|
|||
|
||||
# User uploaded files
|
||||
backend/uploads/
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
server.log
|
||||
backend/server.log
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
# Telegram Duplicate Prevention System
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the comprehensive duplicate prevention system implemented for Telegram message processing in the Tududi application. The system prevents duplicate inbox items from being created when the same message is processed multiple times due to network issues, processing errors, or other edge cases.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When integrating with Telegram's polling API, several scenarios can cause duplicate messages:
|
||||
- Network timeouts causing message reprocessing
|
||||
- Application restarts during message processing
|
||||
- Race conditions in polling cycles
|
||||
- Telegram API returning the same update multiple times
|
||||
- Processing failures that lead to retry attempts
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
The duplicate prevention system implements multiple layers of protection:
|
||||
|
||||
### 1. **Update ID Tracking (Application Level)**
|
||||
- **Purpose**: Prevent reprocessing of the same Telegram update
|
||||
- **Implementation**: In-memory Set tracking processed update IDs
|
||||
- **Key Format**: `${userId}-${updateId}`
|
||||
- **Memory Management**: Automatic cleanup (keeps last 1000 entries)
|
||||
|
||||
```javascript
|
||||
// Example usage
|
||||
const processedUpdates = new Set();
|
||||
const updateKey = `${user.id}-${update.update_id}`;
|
||||
|
||||
if (!processedUpdates.has(updateKey)) {
|
||||
// Process the update
|
||||
processedUpdates.add(updateKey);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Content-Based Duplicate Detection (Database Level)**
|
||||
- **Purpose**: Prevent duplicate inbox items with identical content
|
||||
- **Implementation**: Database query checking recent items (30-second window)
|
||||
- **Scope**: Per-user, per-source (telegram)
|
||||
|
||||
```javascript
|
||||
// Check for existing item within time window
|
||||
const existingItem = await InboxItem.findOne({
|
||||
where: {
|
||||
content: messageContent,
|
||||
user_id: userId,
|
||||
source: 'telegram',
|
||||
created_at: {
|
||||
[Op.gte]: new Date(Date.now() - 30000) // 30 seconds
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 3. **Telegram API Offset Management**
|
||||
- **Purpose**: Prevent re-fetching already processed updates
|
||||
- **Implementation**: Track highest processed update ID per user
|
||||
- **Persistence**: Maintained in poller state
|
||||
|
||||
## Code Structure
|
||||
|
||||
### Core Files
|
||||
|
||||
- **`services/telegramPoller.js`**: Main polling logic with duplicate prevention
|
||||
- **`services/telegramInitializer.js`**: Initialization and user management
|
||||
- **`tests/unit/services/telegramPoller.test.js`**: Unit tests for core functions
|
||||
- **`tests/integration/telegram-duplicates.test.js`**: Integration tests for database interactions
|
||||
- **`tests/integration/telegram-duplicate-scenario.test.js`**: Real-world scenario tests
|
||||
|
||||
### Key Functions
|
||||
|
||||
#### `processUpdates(user, updates)`
|
||||
- Filters out already processed updates
|
||||
- Updates user status with highest update ID
|
||||
- Processes each new update individually
|
||||
- Implements cleanup for memory management
|
||||
|
||||
#### `createInboxItem(content, userId, messageId)`
|
||||
- Checks for recent duplicates before creation
|
||||
- Creates inbox item with metadata
|
||||
- Returns existing item if duplicate found
|
||||
|
||||
#### `processMessage(user, update)`
|
||||
- Extracts message data
|
||||
- Creates inbox item (with duplicate check)
|
||||
- Sends confirmation back to Telegram
|
||||
- Handles errors gracefully
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (12 tests)
|
||||
- Update ID tracking logic
|
||||
- User list management
|
||||
- Message parameter creation
|
||||
- URL generation
|
||||
- State management
|
||||
|
||||
### Integration Tests (12 tests)
|
||||
- Database-level duplicate prevention
|
||||
- Poller state management
|
||||
- Update processing logic
|
||||
- Error handling scenarios
|
||||
|
||||
### Scenario Tests (7 tests)
|
||||
- Network issue simulations
|
||||
- Rapid consecutive messages
|
||||
- Update ID tracking in practice
|
||||
- Poller restart scenarios
|
||||
- Memory management verification
|
||||
- Edge cases and time-based logic
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all Telegram duplicate tests
|
||||
npm run test:telegram-duplicates
|
||||
|
||||
# Run specific test suites
|
||||
npm test -- --testPathPatterns="telegramPoller\.test\.js"
|
||||
npm test -- --testPathPatterns="telegram-duplicates\.test\.js"
|
||||
npm test -- --testPathPatterns="telegram-duplicate-scenario\.test\.js"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Time Windows
|
||||
- **Duplicate Detection**: 30 seconds (configurable)
|
||||
- **Memory Cleanup**: 1000 entries (configurable)
|
||||
- **Polling Interval**: 5 seconds (configurable)
|
||||
|
||||
### Memory Management
|
||||
- Automatic cleanup when processed updates exceed 1000 entries
|
||||
- Removes oldest 100 entries during cleanup
|
||||
- Prevents memory leaks in long-running processes
|
||||
|
||||
## Monitoring and Debugging
|
||||
|
||||
### Logging
|
||||
The system includes comprehensive logging for debugging:
|
||||
|
||||
```javascript
|
||||
console.log(`Processing ${updates.length} updates for user ${user.id}`);
|
||||
console.log(`Duplicate inbox item detected for user ${userId}`);
|
||||
console.log(`Successfully processed message ${messageId} for user ${user.id}`);
|
||||
```
|
||||
|
||||
### Status Monitoring
|
||||
```javascript
|
||||
const status = telegramPoller.getStatus();
|
||||
// Returns: { running, usersCount, pollInterval, userStatus }
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Network Errors
|
||||
- Graceful handling of Telegram API timeouts
|
||||
- Continuation of polling for other users if one fails
|
||||
- Detailed error logging for debugging
|
||||
|
||||
### Database Errors
|
||||
- Fallback behavior when duplicate check fails
|
||||
- Transaction safety for inbox item creation
|
||||
- Proper error responses to users
|
||||
|
||||
### Processing Errors
|
||||
- Individual update error handling
|
||||
- Continuation of processing for remaining updates
|
||||
- Error reporting via Telegram messages
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Memory Usage
|
||||
- Bounded memory growth through automatic cleanup
|
||||
- Efficient Set operations for duplicate checking
|
||||
- Minimal memory footprint per user
|
||||
|
||||
### Database Performance
|
||||
- Indexed queries for duplicate detection
|
||||
- Time-limited searches (30-second window)
|
||||
- Efficient user-scoped queries
|
||||
|
||||
### Network Efficiency
|
||||
- Optimized Telegram API calls
|
||||
- Proper offset management
|
||||
- Reasonable polling intervals
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Data Protection
|
||||
- User-scoped duplicate checking
|
||||
- Secure token handling
|
||||
- No sensitive data in logs
|
||||
|
||||
### Rate Limiting
|
||||
- Respectful Telegram API usage
|
||||
- Configurable polling intervals
|
||||
- Graceful handling of API limits
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Possible Improvements
|
||||
1. **Persistent State**: Store processed update IDs in database
|
||||
2. **Configurable Windows**: Make time windows user-configurable
|
||||
3. **Metrics Collection**: Add detailed metrics for monitoring
|
||||
4. **Retry Logic**: Implement exponential backoff for failures
|
||||
5. **Batch Processing**: Process multiple updates in batches
|
||||
|
||||
### Migration Considerations
|
||||
- Current system maintains backward compatibility
|
||||
- New features can be added incrementally
|
||||
- Existing data remains unaffected
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Duplicates Still Occurring**
|
||||
- Check if time window is appropriate for your use case
|
||||
- Verify update ID tracking is working
|
||||
- Review logs for processing errors
|
||||
|
||||
2. **Memory Usage Growing**
|
||||
- Verify cleanup logic is running
|
||||
- Check if cleanup threshold needs adjustment
|
||||
- Monitor processed updates Set size
|
||||
|
||||
3. **Performance Issues**
|
||||
- Review database query performance
|
||||
- Check polling interval settings
|
||||
- Monitor network latency to Telegram API
|
||||
|
||||
### Debug Commands
|
||||
```javascript
|
||||
// Check poller status
|
||||
const status = telegramPoller.getStatus();
|
||||
|
||||
// Manual cleanup (for testing)
|
||||
if (processedUpdates.size > 1000) {
|
||||
// Cleanup logic
|
||||
}
|
||||
|
||||
// Verify duplicate detection
|
||||
const existing = await InboxItem.findOne({ /* query */ });
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Telegram duplicate prevention system provides robust protection against message duplication through multiple complementary mechanisms. The comprehensive test suite ensures reliability, while the monitoring and debugging features facilitate maintenance and troubleshooting.
|
||||
|
||||
The system is designed to be:
|
||||
- **Reliable**: Multiple layers of protection
|
||||
- **Efficient**: Minimal performance impact
|
||||
- **Maintainable**: Well-tested and documented
|
||||
- **Scalable**: Bounded memory usage and efficient queries
|
||||
- **Debuggable**: Comprehensive logging and monitoring
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
|
||||
> backend@1.0.0 dev
|
||||
> nodemon app.js
|
||||
|
||||
[33m[nodemon] 3.1.10[39m
|
||||
[33m[nodemon] to restart at any time, enter `rs`[39m
|
||||
[33m[nodemon] watching path(s): *.*[39m
|
||||
[33m[nodemon] watching extensions: js,mjs,cjs,json[39m
|
||||
[32m[nodemon] starting `node app.js`[39m
|
||||
Loaded 20 quotes from configuration
|
||||
Server error: Error: listen EADDRINUSE: address already in use 0.0.0.0:3002
|
||||
at Server.setupListenHandle [as _listen2] (node:net:1939:16)
|
||||
at listenInCluster (node:net:1996:12)
|
||||
at node:net:2205:7
|
||||
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -48,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3002
|
||||
}
|
||||
Error getting updates for user 1: Error: Request timeout
|
||||
at ClientRequest.<anonymous> (/Users/chris/c0deLab/ProjectLand/tududi/backend/services/telegramPoller.js:93:14)
|
||||
at ClientRequest.emit (node:events:518:28)
|
||||
at TLSSocket.emitRequestTimeout (node:_http_client:863:9)
|
||||
at Object.onceWrapper (node:events:632:28)
|
||||
at TLSSocket.emit (node:events:530:35)
|
||||
at Socket._onTimeout (node:net:609:8)
|
||||
at listOnTimeout (node:internal/timers:588:17)
|
||||
at process.processTimers (node:internal/timers:523:7)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 293 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 773 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 358 KiB |
2
dist/index.html
vendored
2
dist/index.html
vendored
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<title>Tududi</title>
|
||||
<script defer src="/main.272db06f0669b56179b9.js"></script></head>
|
||||
<script defer src="/main.be344336e9db0dc716cb.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -43,6 +43,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const { t } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024);
|
||||
const [globalModalCount, setGlobalModalCount] = useState(0);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
|
|
@ -101,6 +102,26 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
setTaskModalType(type);
|
||||
};
|
||||
|
||||
// Listen for global modal events from child components
|
||||
useEffect(() => {
|
||||
const handleModalOpen = () => {
|
||||
setGlobalModalCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setGlobalModalCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
window.addEventListener('modalOpen', handleModalOpen);
|
||||
window.addEventListener('modalClose', handleModalClose);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('modalOpen', handleModalOpen);
|
||||
window.removeEventListener('modalClose', handleModalClose);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsSidebarOpen(window.innerWidth >= 1024);
|
||||
|
|
@ -449,6 +470,8 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hide floating + button when any modal is open to prevent overlap with save buttons */}
|
||||
{!isTaskModalOpen && !isProjectModalOpen && !isNoteModalOpen && !isAreaModalOpen && !isTagModalOpen && globalModalCount === 0 && (
|
||||
<button
|
||||
onClick={() => openTaskModal('simplified')}
|
||||
className="fixed bottom-6 right-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-4 shadow-lg focus:outline-none transform transition-transform duration-200 hover:scale-110 z-50"
|
||||
|
|
@ -457,6 +480,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isTaskModalOpen && (
|
||||
taskModalType === 'simplified' ? (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
|
|||
import CalendarMonthView from './Calendar/CalendarMonthView';
|
||||
import CalendarWeekView from './Calendar/CalendarWeekView';
|
||||
import CalendarDayView from './Calendar/CalendarDayView';
|
||||
import { useModalEvents } from '../hooks/useModalEvents';
|
||||
|
||||
const getLocale = (language: string) => {
|
||||
switch (language) {
|
||||
|
|
@ -58,6 +59,9 @@ const Calendar: React.FC = () => {
|
|||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isEventDetailModalOpen, setIsEventDetailModalOpen] = useState(false);
|
||||
|
||||
// Dispatch global modal events
|
||||
useModalEvents(isTaskModalOpen || isEventDetailModalOpen);
|
||||
|
||||
const locale = getLocale(i18n.language);
|
||||
|
||||
// Load Google Calendar status and tasks on component mount
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState } from 'react';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
import { TrashIcon, PencilIcon, DocumentTextIcon, FolderIcon, ClipboardDocumentListIcon } from '@heroicons/react/24/outline';
|
||||
import { TagIcon } from '@heroicons/react/24/solid';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
|
@ -118,9 +118,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const formattedDate = item.created_at
|
||||
? format(new Date(item.created_at), 'MMM dd, yyyy HH:mm')
|
||||
: '';
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowConfirmDialog(true);
|
||||
|
|
@ -139,20 +136,20 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-base font-medium text-gray-900 dark:text-gray-300 break-words">
|
||||
{item.content}
|
||||
<span className="ml-3 text-xs text-gray-500 dark:text-gray-600">
|
||||
{formattedDate}
|
||||
</span>
|
||||
<span className="ml-2 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-100 rounded p-1">
|
||||
</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<TagIcon className="h-3 w-3 mr-1 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.source}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center justify-start space-x-1 shrink-0">
|
||||
{loading && <div className="spinner" />}
|
||||
|
||||
{/* Edit Button */}
|
||||
|
|
@ -162,7 +159,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
onUpdate(item.id, item.content);
|
||||
}
|
||||
}}
|
||||
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
|
|
@ -171,7 +168,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{/* Convert to Task Button */}
|
||||
<button
|
||||
onClick={handleConvertToTask}
|
||||
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
title={t('inbox.createTask')}
|
||||
>
|
||||
<ClipboardDocumentListIcon className="h-4 w-4" />
|
||||
|
|
@ -180,7 +177,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{/* Convert to Project Button */}
|
||||
<button
|
||||
onClick={handleConvertToProject}
|
||||
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
title={t('inbox.createProject')}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
|
|
@ -189,7 +186,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{/* Convert to Note Button */}
|
||||
<button
|
||||
onClick={handleConvertToNote}
|
||||
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
title={t('inbox.createNote', 'Create Note')}
|
||||
>
|
||||
<DocumentTextIcon className="h-4 w-4" />
|
||||
|
|
@ -198,7 +195,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import NoteModal from './NoteModal';
|
|||
import MarkdownRenderer from '../Shared/MarkdownRenderer';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService';
|
||||
import { useModalEvents } from '../../hooks/useModalEvents';
|
||||
|
||||
const NoteDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
|
@ -17,6 +18,9 @@ const NoteDetails: React.FC = () => {
|
|||
const [isError, setIsError] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Dispatch global modal events
|
||||
useModalEvents(isNoteModalOpen);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNote = async () => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
updateNote,
|
||||
deleteNote as apiDeleteNote,
|
||||
} from '../utils/notesService';
|
||||
import { useModalEvents } from '../hooks/useModalEvents';
|
||||
|
||||
const Notes: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -28,6 +29,9 @@ const Notes: React.FC = () => {
|
|||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Dispatch global modal events
|
||||
useModalEvents(isNoteModalOpen);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { isAuthError } from "../../utils/authUtils";
|
|||
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { getAutoSuggestNextActionsEnabled } from "../../utils/profileService";
|
||||
import AutoSuggestNextActionBox from "./AutoSuggestNextActionBox";
|
||||
import { useModalEvents } from "../../hooks/useModalEvents";
|
||||
|
||||
type PriorityStyles = Record<PriorityType, string> & { default: string };
|
||||
|
||||
|
|
@ -49,6 +50,9 @@ const ProjectDetails: React.FC = () => {
|
|||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false);
|
||||
|
||||
// Dispatch global modal events
|
||||
useModalEvents(isModalOpen);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjectData = async () => {
|
||||
if (!id) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { fetchAreas } from "../utils/areasService";
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Project } from "../entities/Project";
|
||||
import { useModalEvents } from "../hooks/useModalEvents";
|
||||
import { PriorityType } from "../entities/Task";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import ProjectItem from "./Project/ProjectItem";
|
||||
|
|
@ -48,6 +49,9 @@ const Projects: React.FC = () => {
|
|||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeFilter = searchParams.get("active") || "all";
|
||||
|
||||
// Dispatch global modal events
|
||||
useModalEvents(isProjectModalOpen);
|
||||
const areaFilter = searchParams.get("area_id") || "";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useToast } from "../Shared/ToastContext";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { createInboxItemWithStore } from "../../utils/inboxService";
|
||||
import { isAuthError } from "../../utils/authUtils";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { useModalEvents } from "../../hooks/useModalEvents";
|
||||
// import UrlPreview from "../Shared/UrlPreview";
|
||||
// import { UrlTitleResult } from "../../utils/urlService";
|
||||
|
||||
|
|
@ -34,12 +36,29 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
|
||||
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
|
||||
|
||||
// Dispatch global modal events to hide floating + button
|
||||
useModalEvents(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
// Cleanup function to restore scroll when component unmounts
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputText(e.target.value);
|
||||
};
|
||||
|
|
@ -147,18 +166,28 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 sm:top-16 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-[45] transition-opacity duration-300 ${
|
||||
isClosing ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl md:max-w-3xl overflow-hidden transform transition-transform duration-300 ${
|
||||
className={`relative bg-white dark:bg-gray-800 border-0 sm:border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full h-full sm:h-auto sm:max-w-2xl md:max-w-3xl transform transition-transform duration-300 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
} flex flex-col`}
|
||||
>
|
||||
<div className="p-6 px-8">
|
||||
<div className="flex items-center">
|
||||
{/* Close button - only visible on mobile */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute top-4 right-4 z-10 p-2 text-gray-600 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full shadow-lg transition-colors duration-200 sm:hidden"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center sm:block sm:flex-none">
|
||||
<div className="w-full p-6 px-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center">
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
|
|
@ -179,7 +208,7 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputText.trim() || isSaving}
|
||||
className={`ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${
|
||||
className={`mt-4 sm:mt-0 sm:ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${
|
||||
inputText.trim() && !isSaving
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-blue-400 cursor-not-allowed"
|
||||
|
|
@ -196,6 +225,7 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{onToggleToday && (
|
||||
<button
|
||||
onClick={handleTodayToggle}
|
||||
className={`items-center justify-center ${task.today_move_count && task.today_move_count > 1 ? 'px-2 h-6' : 'w-6 h-6'} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
|
||||
className={`items-center justify-center ${Number(task.today_move_count) > 1 ? 'px-2 h-6' : 'w-6 h-6'} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
|
||||
task.today
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
|
||||
|
|
@ -281,9 +281,9 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
) : (
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
)}
|
||||
{task.today_move_count && task.today_move_count > 1 && (
|
||||
{Number(task.today_move_count) > 1 && (
|
||||
<span className="ml-1 text-xs font-medium">
|
||||
{task.today_move_count}
|
||||
{Number(task.today_move_count)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import TaskHeader from './TaskHeader';
|
|||
import TaskModal from './TaskModal';
|
||||
import { toggleTaskCompletion } from '../../utils/tasksService';
|
||||
import { isTaskOverdue } from '../../utils/dateUtils';
|
||||
import { useModalEvents } from '../../hooks/useModalEvents';
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
|
|
@ -26,6 +27,9 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [projectList, setProjectList] = useState<Project[]>(projects);
|
||||
|
||||
// Dispatch global modal events
|
||||
useModalEvents(isModalOpen);
|
||||
|
||||
const handleTaskClick = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
|
|
|||
42
frontend/contexts/ModalContext.tsx
Normal file
42
frontend/contexts/ModalContext.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface ModalContextType {
|
||||
isAnyModalOpen: boolean;
|
||||
openModal: () => void;
|
||||
closeModal: () => void;
|
||||
modalCount: number;
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
||||
|
||||
export const useModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useModal must be used within a ModalProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface ModalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
|
||||
const [modalCount, setModalCount] = useState(0);
|
||||
|
||||
const openModal = () => {
|
||||
setModalCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalCount(prev => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const isAnyModalOpen = modalCount > 0;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ isAnyModalOpen, openModal, closeModal, modalCount }}>
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
15
frontend/hooks/useModalEvents.ts
Normal file
15
frontend/hooks/useModalEvents.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to dispatch global modal events when local modal state changes
|
||||
* @param isOpen - Whether the modal is currently open
|
||||
*/
|
||||
export const useModalEvents = (isOpen: boolean) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
window.dispatchEvent(new CustomEvent('modalOpen'));
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('modalClose'));
|
||||
}
|
||||
}, [isOpen]);
|
||||
};
|
||||
28
frontend/hooks/useModalManager.ts
Normal file
28
frontend/hooks/useModalManager.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useModal } from '../contexts/ModalContext';
|
||||
|
||||
/**
|
||||
* Hook to automatically manage modal state with the global modal context
|
||||
* @param isOpen - Whether the modal is currently open
|
||||
* @returns Object with the modal context functions
|
||||
*/
|
||||
export const useModalManager = (isOpen: boolean) => {
|
||||
const modalContext = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
modalContext.openModal();
|
||||
} else {
|
||||
modalContext.closeModal();
|
||||
}
|
||||
|
||||
// Cleanup function to ensure we close the modal if component unmounts while open
|
||||
return () => {
|
||||
if (isOpen) {
|
||||
modalContext.closeModal();
|
||||
}
|
||||
};
|
||||
}, [isOpen, modalContext]);
|
||||
|
||||
return modalContext;
|
||||
};
|
||||
|
|
@ -27,15 +27,15 @@ echo "Setting up Docker buildx for multi-architecture builds"
|
|||
docker buildx ls | grep -q mybuilder || docker buildx create --name mybuilder --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
# Build and push multi-architecture images (AMD64, ARM64, ARMv7) with the version tag
|
||||
# Build and push multi-architecture images (AMD64, ARM64) with the version tag
|
||||
echo "Building and pushing multi-architecture docker image: chrisvel/tududi:$docker_tag"
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t chrisvel/tududi:"$docker_tag" \
|
||||
--push .
|
||||
|
||||
# Build and push multi-architecture images (AMD64, ARM64, ARMv7) with the latest tag
|
||||
# Build and push multi-architecture images (AMD64, ARM64) with the latest tag
|
||||
echo "Building and pushing multi-architecture docker image: chrisvel/tududi:latest"
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t chrisvel/tududi:latest \
|
||||
--push .
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue