I18n (#67)
This commit is contained in:
parent
912cfacb70
commit
5c427ef314
101 changed files with 9413 additions and 702 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,3 +9,4 @@ node_modules
|
|||
.env
|
||||
|
||||
public/js/bundle.js
|
||||
.aider*
|
||||
|
|
|
|||
10
Gemfile
10
Gemfile
|
|
@ -1,8 +1,9 @@
|
|||
source 'https://rubygems.org'
|
||||
|
||||
gem 'puma'
|
||||
gem 'rake'
|
||||
gem 'sinatra'
|
||||
gem 'rack-protection', '~> 3.1.0'
|
||||
gem 'rake', '~> 13.0'
|
||||
gem 'rufus-scheduler', '~> 3.8.2'
|
||||
|
||||
# DB
|
||||
gem 'sinatra-activerecord'
|
||||
|
|
@ -11,10 +12,11 @@ gem 'sinatra-namespace'
|
|||
gem 'sqlite3'
|
||||
|
||||
# Authentication
|
||||
gem 'bcrypt'
|
||||
gem 'bcrypt', '~> 3.1'
|
||||
|
||||
# Other
|
||||
gem 'byebug'
|
||||
gem 'byebug', '~> 11.1'
|
||||
gem 'nokogiri', '~> 1.15'
|
||||
gem 'rerun'
|
||||
|
||||
# Development
|
||||
|
|
|
|||
25
Gemfile.lock
25
Gemfile.lock
|
|
@ -26,9 +26,14 @@ GEM
|
|||
connection_pool (2.4.1)
|
||||
drb (2.2.0)
|
||||
ruby2_keywords
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
faker (3.2.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.16.3)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.6.3)
|
||||
|
|
@ -42,12 +47,17 @@ GEM
|
|||
ruby2_keywords (~> 0.0.1)
|
||||
mutex_m (0.2.0)
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.4)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
puma (6.4.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8)
|
||||
rack-protection (3.1.0)
|
||||
|
|
@ -55,7 +65,7 @@ GEM
|
|||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rainbow (3.1.1)
|
||||
rake (13.1.0)
|
||||
rake (13.2.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
|
|
@ -78,6 +88,8 @@ GEM
|
|||
parser (>= 3.2.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rufus-scheduler (3.8.2)
|
||||
fugit (~> 1.1, >= 1.1.6)
|
||||
sinatra (3.1.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
|
|
@ -105,19 +117,22 @@ GEM
|
|||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
arm64-darwin-24
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt
|
||||
byebug
|
||||
bcrypt (~> 3.1)
|
||||
byebug (~> 11.1)
|
||||
faker
|
||||
minitest
|
||||
nokogiri (~> 1.15)
|
||||
puma
|
||||
rack-protection (~> 3.1.0)
|
||||
rack-test
|
||||
rake
|
||||
rake (~> 13.0)
|
||||
rerun
|
||||
rubocop
|
||||
sinatra
|
||||
rufus-scheduler (~> 3.8.2)
|
||||
sinatra-activerecord
|
||||
sinatra-cross_origin
|
||||
sinatra-namespace
|
||||
|
|
|
|||
75
README.md
75
README.md
|
|
@ -23,6 +23,11 @@ This app allows users to manage their tasks, projects, areas, notes, and tags in
|
|||
- **Area Categorization**: Group projects into areas for better organization and focus.
|
||||
- **Due Date Tracking**: Set due dates for tasks and view them based on due date categories.
|
||||
- **Responsive Design**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones.
|
||||
- **Multi-Language Support**: Available in multiple languages including English, German (de), Greek (el), Spanish (es), Japanese (jp), and Ukrainian (ua) among others.
|
||||
- **Telegram Integration**:
|
||||
- Create tasks directly through Telegram messages
|
||||
- Receive daily digests of your tasks
|
||||
- Quick capture of ideas and todos on the go
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
|
|
@ -40,31 +45,57 @@ First pull the latest image:
|
|||
docker pull chrisvel/tududi:latest
|
||||
```
|
||||
|
||||
Then set up the necessary environment variables:
|
||||
### ⚙️ Environment Variables
|
||||
|
||||
- `TUDUDI_USER_EMAIL`
|
||||
- `TUDUDI_USER_PASSWORD`
|
||||
- `TUDUDI_SESSION_SECRET`
|
||||
- `TUDUDI_INTERNAL_SSL_ENABLED`
|
||||
The following environment variables are used to configure tududi:
|
||||
|
||||
1. (Optional) Create a random session secret:
|
||||
```bash
|
||||
openssl rand -hex 64
|
||||
```
|
||||
#### Required Variables:
|
||||
- `TUDUDI_USER_EMAIL` - Initial admin user's email address (e.g., `admin@example.com`)
|
||||
- `TUDUDI_USER_PASSWORD` - Initial admin user's password (use a strong password!)
|
||||
- `TUDUDI_SESSION_SECRET` - Session encryption key (generate with `openssl rand -hex 64`)
|
||||
|
||||
2. Run the Docker container:
|
||||
```bash
|
||||
docker run \
|
||||
-e TUDUDI_USER_EMAIL=myemail@example.com \
|
||||
-e TUDUDI_USER_PASSWORD=mysecurepassword \
|
||||
-e TUDUDI_SESSION_SECRET=your_generated_hash_here \
|
||||
-e TUDUDI_INTERNAL_SSL_ENABLED=false \
|
||||
-v ~/tududi_db:/usr/src/app/tududi_db \
|
||||
-p 9292:9292 \
|
||||
-d chrisvel/tududi:latest
|
||||
```
|
||||
#### Optional Variables:
|
||||
- `TUDUDI_INTERNAL_SSL_ENABLED` - Set to 'true' if using HTTPS internally (default: false)
|
||||
- `TUDUDI_ALLOWED_ORIGINS` - Controls CORS access for different deployment scenarios:
|
||||
- Not set: Only allows localhost origins
|
||||
- Specific domains: `https://tududi.com,http://localhost:9292`
|
||||
- Allow all (development only): Set to empty string `""`
|
||||
|
||||
3. Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials.
|
||||
#### Common Configuration Examples:
|
||||
|
||||
##### Local Development
|
||||
```bash
|
||||
export TUDUDI_USER_EMAIL=dev@local.test
|
||||
export TUDUDI_USER_PASSWORD=devpassword123
|
||||
export TUDUDI_SESSION_SECRET=$(openssl rand -hex 64)
|
||||
export TUDUDI_INTERNAL_SSL_ENABLED=false
|
||||
# TUDUDI_ALLOWED_ORIGINS not set - defaults to localhost only
|
||||
```
|
||||
|
||||
##### Production with Reverse Proxy
|
||||
```bash
|
||||
export TUDUDI_USER_EMAIL=admin@yourdomain.com
|
||||
export TUDUDI_USER_PASSWORD=your-secure-password-here
|
||||
export TUDUDI_SESSION_SECRET=$(openssl rand -hex 64)
|
||||
export TUDUDI_INTERNAL_SSL_ENABLED=true
|
||||
export TUDUDI_ALLOWED_ORIGINS=https://tududi.yourdomain.com
|
||||
```
|
||||
|
||||
### 🚀 Running with Docker
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e TUDUDI_USER_EMAIL=myemail@example.com \
|
||||
-e TUDUDI_USER_PASSWORD=mysecurepassword \
|
||||
-e TUDUDI_SESSION_SECRET=$(openssl rand -hex 64) \
|
||||
-e TUDUDI_INTERNAL_SSL_ENABLED=false \
|
||||
-e TUDUDI_ALLOWED_ORIGINS=https://tududi,http://tududi:9292 \
|
||||
-v ~/tududi_db:/usr/src/app/tududi_db \
|
||||
-p 9292:9292 \
|
||||
-d chrisvel/tududi:latest
|
||||
```
|
||||
|
||||
Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials.
|
||||
|
||||
## 🚧 Development
|
||||
|
||||
|
|
@ -150,7 +181,7 @@ Contributions to `tududi` are welcome. To contribute:
|
|||
2. Create a new branch (\`git checkout -b feature/AmazingFeature\`).
|
||||
3. Make your changes.
|
||||
4. Commit your changes (\`git commit -m 'Add some AmazingFeature'\`).
|
||||
5. Push to the branch (\`git push origin feature/AmazingFeature\`).
|
||||
5. Push to the branch (\`git push origin fexature/AmazingFeature\`).
|
||||
6. Open a pull request.
|
||||
|
||||
## 📜 License
|
||||
|
|
|
|||
37
app.rb
37
app.rb
|
|
@ -3,15 +3,23 @@ require 'sinatra/activerecord'
|
|||
require 'securerandom'
|
||||
require 'byebug'
|
||||
|
||||
# Models
|
||||
require './app/models/user'
|
||||
require './app/models/area'
|
||||
require './app/models/project'
|
||||
require './app/models/task'
|
||||
require './app/models/tag'
|
||||
require './app/models/note'
|
||||
require './app/models/inbox_item'
|
||||
|
||||
# Services
|
||||
require './app/services/task_summary_service'
|
||||
require './app/services/url_title_extractor_service'
|
||||
require './config/initializers/scheduler'
|
||||
require './config/initializers/telegram_initializer'
|
||||
|
||||
# Helpers
|
||||
require './app/helpers/authentication_helper'
|
||||
|
||||
require './app/routes/authentication_routes'
|
||||
require './app/routes/tasks_routes'
|
||||
require './app/routes/projects_routes'
|
||||
|
|
@ -19,6 +27,10 @@ require './app/routes/areas_routes'
|
|||
require './app/routes/notes_routes'
|
||||
require './app/routes/tags_routes'
|
||||
require './app/routes/users_routes'
|
||||
require './app/routes/inbox_routes'
|
||||
require './app/routes/telegram_poller'
|
||||
require './app/routes/telegram_routes'
|
||||
require './app/routes/url_routes'
|
||||
|
||||
require 'sinatra/cross_origin'
|
||||
|
||||
|
|
@ -39,6 +51,9 @@ configure do
|
|||
same_site: secure_flag ? :none : :lax
|
||||
set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) }
|
||||
|
||||
# Ensure ActiveRecord connection is established
|
||||
ActiveRecord::Base.establish_connection
|
||||
|
||||
# Auto-create user if not exists
|
||||
if ENV['TUDUDI_USER_EMAIL'] && ENV['TUDUDI_USER_PASSWORD'] && ActiveRecord::Base.connection.table_exists?('users')
|
||||
user = User.find_or_initialize_by(email: ENV['TUDUDI_USER_EMAIL'])
|
||||
|
|
@ -47,9 +62,12 @@ configure do
|
|||
user.save
|
||||
end
|
||||
end
|
||||
|
||||
# Initialize the Telegram polling after database is ready
|
||||
initialize_telegram_polling
|
||||
end
|
||||
|
||||
use Rack::Protection
|
||||
use Rack::Protection, except: [:http_origin]
|
||||
|
||||
before do
|
||||
require_login
|
||||
|
|
@ -60,8 +78,21 @@ configure do
|
|||
end
|
||||
|
||||
before do
|
||||
response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8080'
|
||||
allowed_origins = ['http://localhost:8080', 'http://localhost:9292']
|
||||
|
||||
if ENV['TUDUDI_ALLOWED_ORIGINS']
|
||||
if ENV['TUDUDI_ALLOWED_ORIGINS'].strip.empty?
|
||||
response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
|
||||
else
|
||||
allowed_origins.concat(ENV['TUDUDI_ALLOWED_ORIGINS'].split(',').map(&:strip))
|
||||
if request.env['HTTP_ORIGIN'] && allowed_origins.include?(request.env['HTTP_ORIGIN'])
|
||||
response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept, X-Requested-With'
|
||||
end
|
||||
|
||||
options '*' do
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# config/database.yml
|
||||
default: &default
|
||||
adapter: sqlite3
|
||||
pool: 5
|
||||
pool: 15
|
||||
timeout: 5000
|
||||
|
||||
development:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, Suspense, lazy } from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
|
|
@ -6,8 +6,8 @@ import {
|
|||
Navigate,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Login from "./components/Login";
|
||||
import Tasks from "./components/Tasks";
|
||||
import NotFound from "./components/Shared/NotFound";
|
||||
import ProjectDetails from "./components/Project/ProjectDetails";
|
||||
import Projects from "./components/Projects";
|
||||
|
|
@ -21,14 +21,50 @@ import ProfileSettings from "./components/Profile/ProfileSettings";
|
|||
import Layout from "./Layout";
|
||||
import { User } from "./entities/User";
|
||||
import TasksToday from "./components/Task/TasksToday";
|
||||
import LoadingScreen from "./components/Shared/LoadingScreen";
|
||||
import InboxItems from "./components/Inbox/InboxItems";
|
||||
// Lazy load Tasks component to prevent issues with tags loading
|
||||
const Tasks = lazy(() => import("./components/Tasks"));
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("App component - i18n initialized:", i18n.isInitialized);
|
||||
console.log("App component - Current language:", i18n.language);
|
||||
console.log("App component - Has translation loaded:", i18n.hasResourceBundle(i18n.language, 'translation'));
|
||||
|
||||
// Force reload translations for the current language
|
||||
if (i18n.isInitialized) {
|
||||
// Create a direct fetch to verify the translation file is accessible
|
||||
fetch(`/locales/${i18n.language}/translation.json`)
|
||||
.then(response => {
|
||||
console.log(`Translation file fetch response: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch translation file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log("Translation file content retrieved manually:", Object.keys(data));
|
||||
// Force add the resource bundle
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
console.log("Resource bundle manually added for:", i18n.language);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error manually fetching translation file:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/current_user", {
|
||||
|
|
@ -40,6 +76,19 @@ const App: React.FC = () => {
|
|||
const data = await response.json();
|
||||
if (data.user) {
|
||||
setCurrentUser(data.user);
|
||||
|
||||
// Set the language based on user's profile if available
|
||||
if (data.user.language) {
|
||||
console.log("Setting language from user profile:", data.user.language);
|
||||
i18n.changeLanguage(data.user.language)
|
||||
.then(() => {
|
||||
console.log("Language changed to:", i18n.language);
|
||||
// After changing language, verify resource bundle
|
||||
console.log("Has resource bundle after change:",
|
||||
i18n.hasResourceBundle(i18n.language, 'translation'));
|
||||
})
|
||||
.catch(err => console.error("Error changing language:", err));
|
||||
}
|
||||
} else {
|
||||
navigate("/login");
|
||||
}
|
||||
|
|
@ -89,29 +138,36 @@ const App: React.FC = () => {
|
|||
}
|
||||
}, [currentUser, location.pathname, navigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading...
|
||||
</div>
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
{i18n.t('common.loading', 'Loading application... Please wait.')}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{currentUser ? (
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
setCurrentUser={setCurrentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
>
|
||||
<Routes>
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
{currentUser ? (
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
setCurrentUser={setCurrentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route path="/tasks" element={<Tasks />} />
|
||||
<Route path="/tasks" element={
|
||||
<Suspense fallback={<div className="p-4">{i18n.t('common.loading', 'Loading...')}</div>}>
|
||||
<Tasks />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/inbox" element={<InboxItems />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/areas" element={<Areas />} />
|
||||
|
|
@ -127,10 +183,10 @@ const App: React.FC = () => {
|
|||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import "./styles/tailwind.css";
|
||||
|
|
@ -6,8 +7,8 @@ import ProjectModal from "./components/Project/ProjectModal";
|
|||
import NoteModal from "./components/Note/NoteModal";
|
||||
import AreaModal from "./components/Area/AreaModal";
|
||||
import TagModal from "./components/Tag/TagModal";
|
||||
import SimplifiedTaskModal from "./components/Task/SimplifiedTaskModal";
|
||||
import TaskModal from "./components/Task/TaskModal";
|
||||
|
||||
import { Note } from "./entities/Note";
|
||||
import { Area } from "./entities/Area";
|
||||
import { Tag } from "./entities/Tag";
|
||||
|
|
@ -36,11 +37,14 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
toggleDarkMode,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
|
||||
const [isTagModalOpen, setIsTagModalOpen] = useState(false);
|
||||
const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>('simplified');
|
||||
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||
|
|
@ -90,9 +94,10 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
},
|
||||
} = useStore();
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(
|
||||
window.innerWidth >= 1024
|
||||
);
|
||||
const openTaskModal = (type: 'simplified' | 'full' = 'simplified') => {
|
||||
setIsTaskModalOpen(true);
|
||||
setTaskModalType(type);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
|
|
@ -143,10 +148,6 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
setSelectedNote(null);
|
||||
};
|
||||
|
||||
const openTaskModal = () => {
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const closeTaskModal = () => {
|
||||
setIsTaskModalOpen(false);
|
||||
setNewTask(null);
|
||||
|
|
@ -311,7 +312,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
className={`flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
||||
>
|
||||
<div className="text-xl text-gray-700 dark:text-gray-200">
|
||||
Loading...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -347,7 +348,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
||||
>
|
||||
<div className="text-xl text-red-500">Error fetching data.</div>
|
||||
<div className="text-xl text-red-500">{t('errors.somethingWentWrong')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -389,14 +390,13 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<button
|
||||
onClick={openTaskModal}
|
||||
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"
|
||||
aria-label="Open Task Modal"
|
||||
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"
|
||||
aria-label="Quick Capture"
|
||||
title={t('inbox.captureThought')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -411,25 +411,27 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Modals */}
|
||||
{isTaskModalOpen && (
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={closeTaskModal}
|
||||
task={
|
||||
newTask || {
|
||||
id: undefined,
|
||||
taskModalType === 'simplified' ? (
|
||||
<SimplifiedTaskModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={closeTaskModal}
|
||||
onSave={handleSaveTask}
|
||||
/>
|
||||
) : (
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={closeTaskModal}
|
||||
task={{
|
||||
name: "",
|
||||
status: "not_started",
|
||||
project_id: undefined,
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={() => {}}
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
}}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={() => {}}
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{isProjectModalOpen && (
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import React, { useEffect, useState } from 'react';
|
|||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AreaDetails: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { areas } = useStore((state) => state.areasStore);
|
||||
const [area, setArea] = useState<Area | null>(null);
|
||||
|
|
@ -24,7 +26,7 @@ const AreaDetails: React.FC = () => {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading area details...
|
||||
{t('areas.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -34,7 +36,7 @@ const AreaDetails: React.FC = () => {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">
|
||||
{isError ? 'Error loading area details.' : 'Area not found.'}
|
||||
{isError ? t('areas.error') : t('areas.notFound')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -44,14 +46,14 @@ const AreaDetails: React.FC = () => {
|
|||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-5xl mx-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Area: {area?.name}
|
||||
{t('areas.details')}: {area?.name}
|
||||
</h2>
|
||||
<p className="text-md text-gray-700 dark:text-gray-300">{area?.description}</p>
|
||||
<Link
|
||||
to={`/projects?area_id=${area?.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"
|
||||
>
|
||||
View Projects in {area?.name}
|
||||
{t('areas.viewProjects', { name: area?.name })}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AreaModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -10,6 +11,7 @@ interface AreaModalProps {
|
|||
}
|
||||
|
||||
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<Area>({
|
||||
id: area?.id || 0,
|
||||
name: area?.name || '',
|
||||
|
|
@ -79,7 +81,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
setError('Area name is required.');
|
||||
setError(t('errors.areaNameRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -88,11 +90,11 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
|
||||
try {
|
||||
await onSave(formData);
|
||||
showSuccessToast(`Area ${formData.id ? 'updated' : 'created'} successfully!`);
|
||||
showSuccessToast(formData.id ? t('success.areaUpdated') : t('success.areaCreated'));
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
showErrorToast('Failed to save area.');
|
||||
showErrorToast(t('errors.failedToSaveArea'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -132,14 +134,14 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter area name"
|
||||
placeholder={t('forms.areaNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Area Description */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
{t('forms.areaDescription')}
|
||||
</label>
|
||||
<textarea
|
||||
id="areaDescription"
|
||||
|
|
@ -148,7 +150,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter area description"
|
||||
placeholder={t('forms.areaDescriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,7 +165,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
onClick={handleClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -172,10 +174,10 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
|
|||
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out ${isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
? t('modals.submitting')
|
||||
: formData.id && formData.id !== 0
|
||||
? 'Update Area'
|
||||
: 'Create Area'}
|
||||
? t('modals.updateArea')
|
||||
: t('modals.createArea')}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
|
|
@ -12,6 +13,7 @@ import { fetchAreas, createArea, updateArea, deleteArea } from '../utils/areasSe
|
|||
import { Area } from '../entities/Area';
|
||||
|
||||
const Areas: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { areas, setAreas, setLoading, setError } = useStore((state) => state.areasStore);
|
||||
|
||||
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
|
||||
|
|
@ -105,20 +107,20 @@ const Areas: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<Squares2X2Icon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
|
||||
Areas
|
||||
{t('areas.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateArea}
|
||||
className="bg-blue-500 text-white rounded-md px-4 py-2 hover:bg-blue-600"
|
||||
>
|
||||
Add Area
|
||||
{t('areas.addArea')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Areas List */}
|
||||
{areas.length === 0 ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">No areas found.</p>
|
||||
<p className="text-gray-700 dark:text-gray-300">{t('areas.noAreasFound')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{areas.map((area) => (
|
||||
|
|
@ -146,16 +148,16 @@ const Areas: React.FC = () => {
|
|||
<button
|
||||
onClick={() => handleEditArea(area)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${area.name}`}
|
||||
title={`Edit ${area.name}`}
|
||||
aria-label={t('areas.editAreaAriaLabel', { name: area.name })}
|
||||
title={t('areas.editAreaTitle', { name: area.name })}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfirmDialog(area)}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${area.name}`}
|
||||
title={`Delete ${area.name}`}
|
||||
aria-label={t('areas.deleteAreaAriaLabel', { name: area.name })}
|
||||
title={t('areas.deleteAreaTitle', { name: area.name })}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -178,8 +180,8 @@ const Areas: React.FC = () => {
|
|||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && areaToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Area"
|
||||
message={`Are you sure you want to delete the area "${areaToDelete.name}"?`}
|
||||
title={t('modals.deleteArea.title')}
|
||||
message={t('modals.deleteArea.message', { name: areaToDelete.name })}
|
||||
onConfirm={handleDeleteArea}
|
||||
onCancel={closeConfirmDialog}
|
||||
/>
|
||||
|
|
|
|||
256
app/frontend/components/Inbox/InboxItemDetail.tsx
Normal file
256
app/frontend/components/Inbox/InboxItemDetail.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
import { TrashIcon, PencilIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
|
||||
interface InboxItemDetailProps {
|
||||
item: InboxItem;
|
||||
onProcess: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onUpdate?: (id: number, content: string) => Promise<void>;
|
||||
openTaskModal: (task: Task, inboxItemId?: number) => void;
|
||||
openProjectModal: (project: Project | null, inboxItemId?: number) => void;
|
||||
openNoteModal: (note: Note | null, inboxItemId?: number) => void;
|
||||
}
|
||||
|
||||
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
||||
item,
|
||||
onProcess,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
openTaskModal,
|
||||
openProjectModal,
|
||||
openNoteModal
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Handle click outside of dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const handleConvertToTask = () => {
|
||||
const newTask: Task = {
|
||||
name: item.content,
|
||||
status: 'not_started',
|
||||
priority: 'medium'
|
||||
};
|
||||
|
||||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use requestAnimationFrame for better timing than setTimeout
|
||||
// This ensures the DOM has updated before we trigger the modal open
|
||||
requestAnimationFrame(() => {
|
||||
// To better prevent flicker, wait one extra frame
|
||||
requestAnimationFrame(() => {
|
||||
if (item.id !== undefined) {
|
||||
openTaskModal(newTask, item.id);
|
||||
} else {
|
||||
openTaskModal(newTask);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleConvertToProject = () => {
|
||||
const newProject: Project = {
|
||||
name: item.content,
|
||||
description: '',
|
||||
active: true
|
||||
};
|
||||
|
||||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use requestAnimationFrame for better timing than setTimeout
|
||||
// This ensures the DOM has updated before we trigger the modal open
|
||||
requestAnimationFrame(() => {
|
||||
// To better prevent flicker, wait one extra frame
|
||||
requestAnimationFrame(() => {
|
||||
if (item.id !== undefined) {
|
||||
openProjectModal(newProject, item.id);
|
||||
} else {
|
||||
openProjectModal(newProject);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleConvertToNote = async () => {
|
||||
let title = item.content.split('\n')[0] || item.content.substring(0, 50);
|
||||
let content = item.content;
|
||||
let isBookmark = false;
|
||||
|
||||
try {
|
||||
const { isUrl, extractUrlTitle } = await import("../../utils/urlService");
|
||||
|
||||
if (isUrl(item.content.trim())) {
|
||||
setLoading(true);
|
||||
const result = await extractUrlTitle(item.content.trim());
|
||||
setLoading(false);
|
||||
|
||||
if (result && result.title) {
|
||||
title = result.title;
|
||||
content = item.content;
|
||||
isBookmark = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking URL or extracting title:", error);
|
||||
}
|
||||
|
||||
// Simple array of tag objects for the note
|
||||
const tagObjects = isBookmark ? [{ name: "bookmark" }] : [];
|
||||
|
||||
console.log("Creating note with bookmark tag:", isBookmark);
|
||||
|
||||
const newNote: Note = {
|
||||
title: title,
|
||||
content: content,
|
||||
tags: tagObjects
|
||||
};
|
||||
|
||||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use requestAnimationFrame for better timing than setTimeout
|
||||
// This ensures the DOM has updated before we trigger the modal open
|
||||
requestAnimationFrame(() => {
|
||||
// To better prevent flicker, wait one extra frame
|
||||
requestAnimationFrame(() => {
|
||||
if (item.id !== undefined) {
|
||||
openNoteModal(newNote, item.id);
|
||||
} else {
|
||||
openNoteModal(newNote);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const formattedDate = item.created_at
|
||||
? format(new Date(item.created_at), 'MMM dd, yyyy HH:mm')
|
||||
: '';
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (item.id !== undefined) {
|
||||
onDelete(item.id);
|
||||
}
|
||||
setShowConfirmDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex-1 mr-4">
|
||||
<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">
|
||||
{item.source}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-0">
|
||||
{loading && <div className="spinner" />}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onUpdate && item.id !== undefined) {
|
||||
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"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full"
|
||||
title={t('inbox.convertTo', 'Convert to')}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-700 shadow-md rounded-md z-10">
|
||||
<ul className="py-1" role="menu" aria-orientation="vertical">
|
||||
<li
|
||||
className="px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer"
|
||||
onClick={handleConvertToTask}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('inbox.createTask')}
|
||||
</li>
|
||||
<li
|
||||
className="px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer"
|
||||
onClick={handleConvertToProject}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('inbox.createProject')}
|
||||
</li>
|
||||
<li
|
||||
className="px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer"
|
||||
onClick={handleConvertToNote}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('inbox.createNote', 'Create Note')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={t('inbox.deleteConfirmTitle', 'Delete Item')}
|
||||
message={t('inbox.deleteConfirmMessage', 'Are you sure you want to delete this inbox item? This action cannot be undone.')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxItemDetail;
|
||||
405
app/frontend/components/Inbox/InboxItems.tsx
Normal file
405
app/frontend/components/Inbox/InboxItems.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
import {
|
||||
loadInboxItemsToStore,
|
||||
processInboxItemWithStore,
|
||||
deleteInboxItemWithStore,
|
||||
updateInboxItemWithStore
|
||||
} from '../../utils/inboxService';
|
||||
import InboxItemDetail from './InboxItemDetail';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InboxIcon } from '@heroicons/react/24/outline';
|
||||
import LoadingScreen from '../Shared/LoadingScreen';
|
||||
import TaskModal from '../Task/TaskModal';
|
||||
import ProjectModal from '../Project/ProjectModal';
|
||||
import NoteModal from '../Note/NoteModal';
|
||||
import SimplifiedTaskModal from '../Task/SimplifiedTaskModal';
|
||||
import { fetchProjects } from '../../utils/projectsService';
|
||||
import { createTask } from '../../utils/tasksService';
|
||||
import { createProject } from '../../utils/projectsService';
|
||||
import { createNote } from '../../utils/notesService';
|
||||
import { isUrl } from '../../utils/urlService';
|
||||
import { useStore } from '../../store/useStore';
|
||||
|
||||
const InboxItems: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
// Access store data
|
||||
const { inboxItems, isLoading, isError } = useStore(state => state.inboxStore);
|
||||
|
||||
// Modal states
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
// Data for modals
|
||||
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
|
||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
|
||||
// Track the current inbox item ID being converted (for task/project/note conversion)
|
||||
const [currentConversionItemId, setCurrentConversionItemId] = useState<number | null>(null);
|
||||
|
||||
// Track the current inbox item being edited
|
||||
const [itemToEdit, setItemToEdit] = useState<number | null>(null);
|
||||
|
||||
// Fetch projects for modals
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [areas, setAreas] = useState<any[]>([]);
|
||||
|
||||
// Wrapped in useCallback to prevent dependency issues in useEffect
|
||||
const refreshInboxItems = useCallback(() => {
|
||||
loadInboxItemsToStore();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial data loading
|
||||
refreshInboxItems();
|
||||
loadProjects();
|
||||
|
||||
// Set up an event listener for force reload
|
||||
const handleForceReload = () => {
|
||||
// Wait a short time to ensure the backend has processed the new item
|
||||
setTimeout(() => {
|
||||
refreshInboxItems();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Handler for the inboxItemsUpdated custom event
|
||||
const handleInboxItemsUpdated = (event: CustomEvent<{count: number, firstItemContent: string}>) => {
|
||||
console.log(`Received inboxItemsUpdated event: ${event.detail.count} new items`);
|
||||
|
||||
// Show toast notifications for new items
|
||||
if (event.detail.count > 0) {
|
||||
// Show notification for the first new item
|
||||
showSuccessToast(t('inbox.newTelegramItem', 'New item from Telegram: {{content}}', {
|
||||
content: event.detail.firstItemContent
|
||||
}));
|
||||
|
||||
// If multiple new items, show a summary notification as well
|
||||
if (event.detail.count > 1) {
|
||||
showSuccessToast(t('inbox.multipleNewItems', '{{count}} more new items added', {
|
||||
count: event.detail.count - 1
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up polling for new inbox items (especially from Telegram)
|
||||
// This ensures real-time updates when items are added externally
|
||||
const pollInterval = setInterval(() => {
|
||||
refreshInboxItems();
|
||||
}, 5000); // Check for new items every 5 seconds
|
||||
|
||||
// Add event listeners
|
||||
window.addEventListener('forceInboxReload', handleForceReload);
|
||||
window.addEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
window.removeEventListener('forceInboxReload', handleForceReload);
|
||||
window.removeEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
|
||||
};
|
||||
}, [refreshInboxItems, showSuccessToast, t]);
|
||||
|
||||
// Load projects for the modals
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const projectData = await fetchProjects();
|
||||
setProjects(projectData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load projects:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessItem = async (id: number) => {
|
||||
try {
|
||||
await processInboxItemWithStore(id);
|
||||
showSuccessToast(t('inbox.itemProcessed'));
|
||||
} catch (error) {
|
||||
console.error('Failed to process inbox item:', error);
|
||||
showErrorToast(t('inbox.processError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateItem = async (id: number, content: string): Promise<void> => {
|
||||
// When edit button is clicked, we open the SimplifiedTaskModal instead of doing inline editing
|
||||
setItemToEdit(id);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveEditedItem = async (text: string) => {
|
||||
try {
|
||||
if (itemToEdit !== null) {
|
||||
await updateInboxItemWithStore(itemToEdit, text);
|
||||
showSuccessToast(t('inbox.itemUpdated'));
|
||||
}
|
||||
setIsEditModalOpen(false);
|
||||
setItemToEdit(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update inbox item:', error);
|
||||
showErrorToast(t('inbox.updateError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (id: number) => {
|
||||
try {
|
||||
await deleteInboxItemWithStore(id);
|
||||
showSuccessToast(t('inbox.itemDeleted'));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete inbox item:', error);
|
||||
showErrorToast(t('inbox.deleteError'));
|
||||
}
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const handleOpenTaskModal = (task: Task, inboxItemId?: number) => {
|
||||
setTaskToEdit(task);
|
||||
|
||||
if (inboxItemId) {
|
||||
setCurrentConversionItemId(inboxItemId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setIsTaskModalOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenProjectModal = (project: Project | null, inboxItemId?: number) => {
|
||||
setProjectToEdit(project);
|
||||
|
||||
if (inboxItemId) {
|
||||
setCurrentConversionItemId(inboxItemId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setIsProjectModalOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenNoteModal = (note: Note | null, inboxItemId?: number) => {
|
||||
// If note has content that's a URL, ensure it has a bookmark tag
|
||||
if (note && note.content && isUrl(note.content.trim())) {
|
||||
if (!note.tags) {
|
||||
note.tags = [{ name: 'bookmark' }];
|
||||
} else if (!note.tags.some(tag => tag.name === 'bookmark')) {
|
||||
note.tags.push({ name: 'bookmark' });
|
||||
}
|
||||
console.log("Opening NoteModal with URL content and tags:", note.tags);
|
||||
}
|
||||
|
||||
setNoteToEdit(note);
|
||||
|
||||
if (inboxItemId) {
|
||||
setCurrentConversionItemId(inboxItemId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setIsNoteModalOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveTask = async (task: Task) => {
|
||||
try {
|
||||
await createTask(task);
|
||||
showSuccessToast(t('task.createSuccess'));
|
||||
|
||||
// Process the inbox item after successful task creation
|
||||
if (currentConversionItemId !== null) {
|
||||
await handleProcessItem(currentConversionItemId);
|
||||
setCurrentConversionItemId(null);
|
||||
}
|
||||
|
||||
setIsTaskModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
showErrorToast(t('task.createError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProject = async (project: Project) => {
|
||||
try {
|
||||
await createProject(project);
|
||||
showSuccessToast(t('project.createSuccess'));
|
||||
|
||||
// Process the inbox item after successful project creation
|
||||
if (currentConversionItemId !== null) {
|
||||
await handleProcessItem(currentConversionItemId);
|
||||
setCurrentConversionItemId(null);
|
||||
}
|
||||
|
||||
setIsProjectModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
showErrorToast(t('project.createError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNote = async (note: Note) => {
|
||||
try {
|
||||
// Check if the content appears to be a URL and add the bookmark tag
|
||||
const noteContent = note.content || '';
|
||||
const isBookmarkContent = isUrl(noteContent.trim());
|
||||
|
||||
// Ensure tags property exists
|
||||
if (!note.tags) {
|
||||
note.tags = [];
|
||||
}
|
||||
|
||||
// Add a bookmark tag if content is a URL and doesn't already have the tag
|
||||
if (isBookmarkContent && !note.tags.some(tag => tag.name === 'bookmark')) {
|
||||
// Use spread operator to create a new array with the bookmark tag added
|
||||
note.tags = [...note.tags, { name: 'bookmark' }];
|
||||
}
|
||||
|
||||
console.log('Creating note with tags:', JSON.stringify(note.tags));
|
||||
|
||||
// Create the note with proper tags
|
||||
await createNote(note);
|
||||
showSuccessToast(t('note.createSuccess', 'Note created successfully'));
|
||||
|
||||
// Process the inbox item after successful note creation
|
||||
if (currentConversionItemId !== null) {
|
||||
await handleProcessItem(currentConversionItemId);
|
||||
setCurrentConversionItemId(null);
|
||||
}
|
||||
|
||||
setIsNoteModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create note:', error);
|
||||
showErrorToast(t('note.createError', 'Failed to create note'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const project = await createProject({ name, active: true });
|
||||
showSuccessToast(t('project.createSuccess'));
|
||||
return project;
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
showErrorToast(t('project.createError'));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (inboxItems.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 space-y-4 text-center text-gray-600 dark:text-gray-300">
|
||||
<InboxIcon className="h-16 w-16" />
|
||||
<h3 className="text-xl font-semibold">{t('inbox.empty')}</h3>
|
||||
<p>{t('inbox.emptyDescription')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex items-center mb-8">
|
||||
<InboxIcon className="h-6 w-6 mr-2" />
|
||||
<h1 className="text-2xl font-light">{t('inbox.title')}</h1>
|
||||
</div>
|
||||
|
||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('taskViews.inbox', 'Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don\'t have a due date will appear here. This is your \'brain dump\' area where you can quickly note down tasks and organize them later.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{inboxItems.map((item) => (
|
||||
<InboxItemDetail
|
||||
key={item.id}
|
||||
item={item}
|
||||
onProcess={handleProcessItem}
|
||||
onDelete={handleDeleteItem}
|
||||
onUpdate={handleUpdateItem}
|
||||
openTaskModal={handleOpenTaskModal}
|
||||
openProjectModal={handleOpenProjectModal}
|
||||
openNoteModal={handleOpenNoteModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task Modal - Always render it but control visibility with isOpen */}
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen && taskToEdit !== null}
|
||||
onClose={() => {
|
||||
// First set the modal to not open, then clear the task
|
||||
setIsTaskModalOpen(false);
|
||||
// Clear task data after modal is closed
|
||||
setTimeout(() => {
|
||||
if (!isTaskModalOpen) {
|
||||
setTaskToEdit(null);
|
||||
}
|
||||
}, 300); // Match the animation duration in TaskModal
|
||||
}}
|
||||
task={taskToEdit || { name: '', status: 'not_started', priority: 'medium' }}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={() => {}} // No need to delete since it's a new task
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
|
||||
{/* Project Modal - Always render it but control visibility with isOpen */}
|
||||
<ProjectModal
|
||||
isOpen={isProjectModalOpen && projectToEdit !== null}
|
||||
onClose={() => {
|
||||
// First set the modal to not open, then clear the project
|
||||
setIsProjectModalOpen(false);
|
||||
// Clear project data after modal is closed
|
||||
setTimeout(() => {
|
||||
if (!isProjectModalOpen) {
|
||||
setProjectToEdit(null);
|
||||
}
|
||||
}, 300); // Match the animation duration
|
||||
}}
|
||||
onSave={handleSaveProject}
|
||||
project={projectToEdit || undefined}
|
||||
areas={areas}
|
||||
/>
|
||||
|
||||
{/* Note Modal - Always render it but control visibility with isOpen */}
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen && noteToEdit !== null}
|
||||
onClose={() => {
|
||||
// First set the modal to not open, then clear the note
|
||||
setIsNoteModalOpen(false);
|
||||
// Clear note data after modal is closed
|
||||
setTimeout(() => {
|
||||
if (!isNoteModalOpen) {
|
||||
setNoteToEdit(null);
|
||||
}
|
||||
}, 300); // Match the animation duration
|
||||
}}
|
||||
onSave={handleSaveNote}
|
||||
note={noteToEdit || { title: '', content: '' }}
|
||||
/>
|
||||
|
||||
{/* Edit Inbox Item Modal */}
|
||||
{isEditModalOpen && itemToEdit !== null && (
|
||||
<SimplifiedTaskModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setItemToEdit(null);
|
||||
}}
|
||||
onSave={() => {}} // Not used in edit mode
|
||||
initialText={inboxItems.find(item => item.id === itemToEdit)?.content || ""}
|
||||
editMode={true}
|
||||
onEdit={handleSaveEditedItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxItems;
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import i18n from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -24,6 +27,13 @@ const Login: React.FC = () => {
|
|||
|
||||
if (response.ok) {
|
||||
console.log('Login successful:', data);
|
||||
|
||||
if (data.user && data.user.language) {
|
||||
console.log('Setting language from login response:', data.user.language);
|
||||
await i18n.changeLanguage(data.user.language);
|
||||
console.log('Language changed to:', i18n.language);
|
||||
}
|
||||
|
||||
navigate('/today');
|
||||
} else {
|
||||
setError(data.errors[0] || 'Login failed. Please try again.');
|
||||
|
|
@ -36,7 +46,6 @@ const Login: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="bg-gray-100 flex flex-col items-center justify-center min-h-screen px-4">
|
||||
{/* Logo with engraved effect */}
|
||||
<h1 className="text-5xl font-bold text-gray-300 mb-6">
|
||||
tududi
|
||||
</h1>
|
||||
|
|
@ -52,7 +61,7 @@ const Login: React.FC = () => {
|
|||
htmlFor="email"
|
||||
className="block text-gray-600 mb-1"
|
||||
>
|
||||
Email
|
||||
{t('auth.email', 'Email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
|
|
@ -69,7 +78,7 @@ const Login: React.FC = () => {
|
|||
htmlFor="password"
|
||||
className="block text-gray-600 mb-1"
|
||||
>
|
||||
Password
|
||||
{t('auth.password', 'Password')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
|
|
@ -85,7 +94,7 @@ const Login: React.FC = () => {
|
|||
type="submit"
|
||||
className="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Login
|
||||
{t('auth.login', 'Login')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { UserIcon, Bars3Icon } from "@heroicons/react/24/solid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface NavbarProps {
|
||||
isDarkMode: boolean;
|
||||
|
|
@ -22,6 +23,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -108,13 +110,13 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
to="/profile"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Profile
|
||||
{t('navigation.profile')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Logout
|
||||
{t('navigation.logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useToast } from '../Shared/ToastContext';
|
|||
import TagInput from '../Tag/TagInput';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
interface NoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -13,6 +13,7 @@ interface NoteModalProps {
|
|||
}
|
||||
|
||||
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formData, setFormData] = useState<Note>({
|
||||
id: note?.id || 0,
|
||||
title: note?.title || '',
|
||||
|
|
@ -35,7 +36,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
setAvailableTags(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags', error);
|
||||
showErrorToast('Failed to load available tags.');
|
||||
showErrorToast(t('errors.failedToLoadTags'));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -46,13 +47,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Extract tag names for display
|
||||
const tagNames = note?.tags?.map((tag) => tag.name) || [];
|
||||
console.log("NoteModal received note with tags:", note?.tags);
|
||||
console.log("Converted tag names:", tagNames);
|
||||
|
||||
setFormData({
|
||||
id: note?.id || 0,
|
||||
title: note?.title || '',
|
||||
content: note?.content || '',
|
||||
tags: note?.tags || [],
|
||||
});
|
||||
setTags(note?.tags?.map((tag) => tag.name) || []);
|
||||
setTags(tagNames);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, note]);
|
||||
|
|
@ -101,6 +107,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
};
|
||||
|
||||
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||
console.log("NoteModal tags changed to:", newTags);
|
||||
setTags(newTags);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -110,7 +117,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
setError('Note title is required.');
|
||||
setError(t('errors.noteTitleRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -118,13 +125,22 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// Convert string tags to tag objects
|
||||
const noteTags: Tag[] = tags.map(tagName => ({ name: tagName }));
|
||||
await onSave({ ...formData, tags: noteTags });
|
||||
showSuccessToast(formData.id && formData.id !== 0 ? 'Note updated successfully!' : 'Note created successfully!');
|
||||
|
||||
console.log("Submitting note with tags array:", tags);
|
||||
console.log("Converting to note tags:", noteTags);
|
||||
|
||||
// Create final form data with the tags
|
||||
const finalFormData = { ...formData, tags: noteTags };
|
||||
console.log("Final note data being saved:", finalFormData);
|
||||
|
||||
await onSave(finalFormData);
|
||||
showSuccessToast(formData.id && formData.id !== 0 ? t('success.noteUpdated') : t('success.noteCreated'));
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
showErrorToast('Failed to save note.');
|
||||
showErrorToast(t('errors.failedToSaveNote'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -167,13 +183,13 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter note title"
|
||||
placeholder={t('forms.noteTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
{t('forms.tags')} {tags.length > 0 ? `(${tags.join(', ')})` : ''}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
|
|
@ -186,7 +202,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
|
||||
<div className="pb-3 flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Content
|
||||
{t('forms.noteContent')}
|
||||
</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
|
|
@ -195,7 +211,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
onChange={handleChange}
|
||||
rows={20}
|
||||
className="block w-full h-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter note content"
|
||||
placeholder={t('forms.noteContentPlaceholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
|
|
@ -208,7 +224,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
onClick={handleClose}
|
||||
className="px-4 py-2 text-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -219,10 +235,10 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
}`}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Submitting...'
|
||||
? t('modals.submitting')
|
||||
: formData.id && formData.id !== 0
|
||||
? 'Update Note'
|
||||
: 'Create Note'}
|
||||
? t('modals.updateNote')
|
||||
: t('modals.createNote')}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookOpenIcon,
|
||||
PencilSquareIcon,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
} from '../utils/notesService';
|
||||
|
||||
const Notes: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
|
|
@ -90,7 +92,7 @@ const Notes: React.FC = () => {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading notes...
|
||||
{t('notes.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -99,7 +101,7 @@ const Notes: React.FC = () => {
|
|||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">Error loading notes.</div>
|
||||
<div className="text-red-500 text-lg">{t('notes.error')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -112,7 +114,7 @@ const Notes: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
|
||||
Notes
|
||||
{t('notes.title')}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -123,7 +125,7 @@ const Notes: React.FC = () => {
|
|||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
placeholder={t('notes.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
|
||||
|
|
@ -133,13 +135,13 @@ const Notes: React.FC = () => {
|
|||
|
||||
{/* Notes List */}
|
||||
{filteredNotes.length === 0 ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">No notes found.</p>
|
||||
<p className="text-gray-700 dark:text-gray-300">{t('notes.noNotesFound')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-1">
|
||||
{filteredNotes.map((note) => (
|
||||
<li
|
||||
key={note.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-2 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<Link
|
||||
|
|
@ -148,16 +150,13 @@ const Notes: React.FC = () => {
|
|||
>
|
||||
{note.title}
|
||||
</Link>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
|
||||
{note.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditNote(note)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${note.title}`}
|
||||
title={`Edit ${note.title}`}
|
||||
aria-label={t('notes.editNoteAriaLabel', { noteTitle: note.title })}
|
||||
title={t('notes.editNoteTitle', { noteTitle: note.title })}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -166,9 +165,8 @@ const Notes: React.FC = () => {
|
|||
setNoteToDelete(note);
|
||||
setIsConfirmDialogOpen(true);
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${note.title}`}
|
||||
title={`Delete ${note.title}`}
|
||||
aria-label={t('notes.deleteNoteAriaLabel', { noteTitle: note.title })}
|
||||
title={t('notes.deleteNoteTitle', { noteTitle: note.title })}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -191,8 +189,8 @@ const Notes: React.FC = () => {
|
|||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && noteToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Note"
|
||||
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
|
||||
title={t('modals.deleteNote.title')}
|
||||
message={t('modals.deleteNote.message', { noteTitle: noteToDelete.title })}
|
||||
onConfirm={handleDeleteNote}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
import React, { useState, useEffect, ChangeEvent, FormEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
currentUser: { id: number; email: string };
|
||||
|
|
@ -11,51 +15,304 @@ interface Profile {
|
|||
language: string;
|
||||
timezone: string;
|
||||
avatar_image: string | null;
|
||||
telegram_bot_token: string | null;
|
||||
telegram_chat_id: string | null;
|
||||
task_summary_enabled: boolean;
|
||||
task_summary_frequency: string;
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
success: boolean;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
}
|
||||
|
||||
interface TelegramBotInfo {
|
||||
username: string;
|
||||
polling_status: any;
|
||||
chat_url: string;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const capitalize = (str: string): string => {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
// Format frequency for display
|
||||
const formatFrequency = (frequency: string): string => {
|
||||
if (frequency.endsWith('h')) {
|
||||
const value = frequency.replace('h', '');
|
||||
return `${value} ${parseInt(value) === 1 ? 'hour' : 'hours'}`;
|
||||
} else if (frequency === 'daily') {
|
||||
return '1 day';
|
||||
} else if (frequency === 'weekly') {
|
||||
return '1 week';
|
||||
} else if (frequency === 'weekdays') {
|
||||
return 'Weekdays';
|
||||
}
|
||||
return frequency;
|
||||
};
|
||||
|
||||
/**
|
||||
* ProfileSettings Component
|
||||
* Displays and manages user profile settings including appearance, language,
|
||||
* timezone, telegram integration, and task summary settings.
|
||||
*/
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
// State variables
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
const [formData, setFormData] = useState<Partial<Profile>>({
|
||||
appearance: 'light',
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
avatar_image: '',
|
||||
telegram_bot_token: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [updateKey, setUpdateKey] = useState(0);
|
||||
const [isChangingLanguage, setIsChangingLanguage] = useState(false);
|
||||
const [telegramBotToken, setTelegramBotToken] = useState('');
|
||||
const [telegramChatId, setTelegramChatId] = useState('');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isSendingSummary, setIsSendingSummary] = useState(false);
|
||||
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [telegramError, setTelegramError] = useState<string | null>(null);
|
||||
const [telegramBotInfo, setTelegramBotInfo] = useState<TelegramBotInfo | null>(null);
|
||||
|
||||
// Force update function for language changes
|
||||
const forceUpdate = useCallback(() => {
|
||||
setUpdateKey(prevKey => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch scheduler status data
|
||||
const fetchSchedulerStatus = async () => {
|
||||
try {
|
||||
setLoadingStatus(true);
|
||||
const response = await fetch('/api/profile/task-summary/status');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('profile.statusFetchError', 'Failed to fetch scheduler status.'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSchedulerStatus(data);
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Send task summary now
|
||||
const handleSendTaskSummaryNow = async () => {
|
||||
try {
|
||||
setIsSendingSummary(true);
|
||||
const response = await fetch('/api/profile/task-summary/send-now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.sendSummaryFailed', 'Failed to send summary.'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showSuccessToast(data.message);
|
||||
|
||||
// Fetch the updated scheduler status if enabled
|
||||
if (data.enabled) {
|
||||
fetchSchedulerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
} finally {
|
||||
setIsSendingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form field changes
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Handle language change immediately
|
||||
if (name === 'language' && value !== i18n.language) {
|
||||
handleLanguageChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
try {
|
||||
setIsChangingLanguage(true);
|
||||
console.log(`Changing language to: ${value}`);
|
||||
|
||||
// Change the i18n language
|
||||
await i18n.changeLanguage(value);
|
||||
|
||||
// Explicitly force the document's lang attribute to match
|
||||
document.documentElement.lang = value;
|
||||
|
||||
// Verify translations are loaded
|
||||
const resources = i18n.getResourceBundle(value, 'translation');
|
||||
console.log('Resources loaded for language:', value, resources ? 'Yes' : 'No');
|
||||
|
||||
if (!resources || Object.keys(resources).length === 0) {
|
||||
console.warn('Translations might not be fully loaded for:', value);
|
||||
|
||||
|
||||
// Try to load translations manually if needed
|
||||
const loadPath = `/locales/${value}/translation.json`;
|
||||
try {
|
||||
const response = await fetch(loadPath);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
i18n.addResourceBundle(value, 'translation', data, true, true);
|
||||
console.log('Manually loaded translations for:', value);
|
||||
|
||||
// Force app to recognize new translations
|
||||
if (window.forceLanguageReload) {
|
||||
window.forceLanguageReload(value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to manually load translations:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Force another update to ensure UI reflects new language
|
||||
setTimeout(() => {
|
||||
forceUpdate();
|
||||
|
||||
// Try to load translations again if they still aren't available
|
||||
const checkAndLoadResources = i18n.getResourceBundle(value, 'translation');
|
||||
if (!checkAndLoadResources || Object.keys(checkAndLoadResources).length === 0) {
|
||||
console.warn('Still no translations after initial load, forcing reload');
|
||||
if (window.forceLanguageReload) {
|
||||
window.forceLanguageReload(value);
|
||||
}
|
||||
}
|
||||
|
||||
// If change event wasn't fired, mark as complete after a delay
|
||||
setTimeout(() => {
|
||||
if (isChangingLanguage) {
|
||||
setIsChangingLanguage(false);
|
||||
}
|
||||
}, 800); // Longer timeout to ensure translations load
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error('Error changing language:', error);
|
||||
setIsChangingLanguage(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch profile data when component mounts
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/profile');
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to fetch profile.');
|
||||
throw new Error(t('profile.fetchError', 'Failed to fetch profile data.'));
|
||||
}
|
||||
const data: Profile = await response.json();
|
||||
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
setFormData({
|
||||
appearance: data.appearance,
|
||||
language: data.language,
|
||||
timezone: data.timezone,
|
||||
appearance: data.appearance || 'light',
|
||||
language: data.language || 'en',
|
||||
timezone: data.timezone || 'UTC',
|
||||
avatar_image: data.avatar_image || '',
|
||||
telegram_bot_token: data.telegram_bot_token || '',
|
||||
});
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setTelegramBotToken(data.telegram_bot_token || '');
|
||||
setTelegramChatId(data.telegram_chat_id || '');
|
||||
|
||||
// Fetch scheduler status if task summaries are enabled
|
||||
if (data.task_summary_enabled) {
|
||||
fetchSchedulerStatus();
|
||||
}
|
||||
|
||||
// If user has a token, check polling status
|
||||
if (data.telegram_bot_token) {
|
||||
fetchPollingStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPollingStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/telegram/polling-status');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('profile.pollingStatusError', 'Failed to fetch polling status.'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setIsPolling(data.running);
|
||||
|
||||
// If bot token exists but polling is not active, start polling automatically
|
||||
if (data.token_exists && !data.running) {
|
||||
console.log('Telegram bot token exists but polling not active. Starting polling automatically...');
|
||||
handleStartPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching polling status:', error);
|
||||
}
|
||||
};
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
// Add an effect to monitor language changes
|
||||
useEffect(() => {
|
||||
console.log(`Component refreshed with key: ${updateKey}, language: ${i18n.language}`);
|
||||
}, [updateKey, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
console.log(`Language changed to ${lng}`);
|
||||
// Force component to re-render when language changes
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
// Handler for the custom app-language-changed event
|
||||
const handleAppLanguageChanged = (event: CustomEvent<{ language: string }>) => {
|
||||
console.log('Custom language change event received:', event.detail.language);
|
||||
// Force an update to re-render with new translations
|
||||
forceUpdate();
|
||||
// Mark language change as complete after a short delay
|
||||
// This ensures the UI has time to update with new translations
|
||||
setTimeout(() => {
|
||||
setIsChangingLanguage(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Add language change listeners
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
window.addEventListener('app-language-changed', handleAppLanguageChanged as EventListener);
|
||||
|
||||
// Clean up listeners on unmount
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
window.removeEventListener('app-language-changed', handleAppLanguageChanged as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
|
|
@ -67,6 +324,125 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSetupTelegram = async () => {
|
||||
setTelegramSetupStatus('loading');
|
||||
setTelegramError(null);
|
||||
setTelegramBotInfo(null);
|
||||
|
||||
try {
|
||||
// Validate the token format
|
||||
if (!formData.telegram_bot_token || !formData.telegram_bot_token.includes(':')) {
|
||||
throw new Error(t('profile.invalidTelegramToken'));
|
||||
}
|
||||
|
||||
// Send setup request to the server
|
||||
const response = await fetch('/api/telegram/setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: formData.telegram_bot_token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.telegramSetupFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTelegramSetupStatus('success');
|
||||
setSuccess(t('profile.telegramSetupSuccess'));
|
||||
|
||||
// Save bot info for display
|
||||
if (data.bot) {
|
||||
setTelegramBotInfo(data.bot);
|
||||
setIsPolling(true);
|
||||
|
||||
// Explicitly verify polling is started
|
||||
if (!data.bot.polling_status?.running) {
|
||||
console.log('Polling not started automatically during setup. Starting manually...');
|
||||
// Small delay to ensure the server has registered the token
|
||||
setTimeout(() => {
|
||||
handleStartPolling();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Format the URL to start the bot chat
|
||||
const botUsername = data.bot?.username || formData.telegram_bot_token.split(':')[0];
|
||||
|
||||
// Open the Telegram bot chat in a new window
|
||||
window.open(`https://t.me/${botUsername}`, '_blank');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Telegram setup error:', error);
|
||||
setTelegramSetupStatus('error');
|
||||
setTelegramError((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPolling = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/telegram/start-polling', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.startPollingFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setIsPolling(true);
|
||||
showSuccessToast(t('profile.pollingStarted'));
|
||||
|
||||
// Update bot info if available
|
||||
if (telegramBotInfo) {
|
||||
setTelegramBotInfo({
|
||||
...telegramBotInfo,
|
||||
polling_status: data.status
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Start polling error:', error);
|
||||
showErrorToast(t('profile.pollingError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopPolling = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/telegram/stop-polling', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.stopPollingFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setIsPolling(false);
|
||||
showSuccessToast(t('profile.pollingStopped', 'Polling stopped successfully.'));
|
||||
|
||||
// Update bot info if available
|
||||
if (telegramBotInfo) {
|
||||
setTelegramBotInfo({
|
||||
...telegramBotInfo,
|
||||
polling_status: data.status
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stop polling error:', error);
|
||||
showErrorToast(t('profile.pollingError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
|
@ -90,7 +466,14 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
|
||||
const updatedProfile: Profile = await response.json();
|
||||
setProfile(updatedProfile);
|
||||
setSuccess('Profile updated successfully.');
|
||||
|
||||
// Make sure to update language if it was changed
|
||||
if (updatedProfile.language !== i18n.language) {
|
||||
console.log('Updating language after form submission:', updatedProfile.language);
|
||||
await i18n.changeLanguage(updatedProfile.language);
|
||||
}
|
||||
|
||||
setSuccess(t('profile.successMessage'));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
|
|
@ -100,7 +483,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading profile settings...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -115,19 +498,18 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
<div className="max-w-5xl mx-auto p-6" key={`profile-settings-${updateKey}`}>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Profile Settings
|
||||
{t('profile.title')}
|
||||
</h2>
|
||||
|
||||
{success && <div className="mb-4 text-green-500">{success}</div>}
|
||||
{error && <div className="mb-4 text-red-500">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Appearance Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Appearance
|
||||
{t('profile.appearance')}
|
||||
</label>
|
||||
<select
|
||||
name="appearance"
|
||||
|
|
@ -135,15 +517,14 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">{t('profile.lightMode', 'Light')}</option>
|
||||
<option value="dark">{t('profile.darkMode', 'Dark')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Language Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Language
|
||||
{t('profile.language')}
|
||||
</label>
|
||||
<select
|
||||
name="language"
|
||||
|
|
@ -151,16 +532,26 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
{/* Add more languages if necessary */}
|
||||
<option value="en">{t('profile.english')}</option>
|
||||
<option value="es">{t('profile.spanish')}</option>
|
||||
<option value="el">{t('profile.greek')}</option>
|
||||
<option value="jp">{t('profile.japanese')}</option>
|
||||
<option value="ua">{t('profile.ukrainian')}</option>
|
||||
<option value="de">{t('profile.deutsch')}</option>
|
||||
</select>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.languageChangedNote', 'Language changes are applied immediately')}
|
||||
</p>
|
||||
{isChangingLanguage && (
|
||||
<div className="mt-2 text-sm text-blue-500 animate-pulse">
|
||||
{t('profile.languageChanging', 'Changing language...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Timezone
|
||||
{t('profile.timezone')}
|
||||
</label>
|
||||
<select
|
||||
name="timezone"
|
||||
|
|
@ -175,33 +566,281 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Avatar Image Upload */}
|
||||
{/* <div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Avatar Image
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="mt-1 block w-full text-sm text-gray-500 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-200 dark:hover:file:bg-gray-600"
|
||||
/>
|
||||
{formData.avatar_image && (
|
||||
<img
|
||||
src={formData.avatar_image}
|
||||
alt="Avatar Preview"
|
||||
className="mt-2 h-24 w-24 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div> */}
|
||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
|
||||
{t('profile.telegramIntegration', 'Telegram Integration')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t('profile.telegramDescription', 'Connect your Tududi account to a Telegram bot to add items to your inbox via Telegram messages.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.telegramBotToken', 'Telegram Bot Token')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="telegram_bot_token"
|
||||
value={formData.telegram_bot_token || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ"
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.telegramTokenDescription', 'Create a bot with @BotFather on Telegram and paste the token here.')}
|
||||
</p>
|
||||
|
||||
{profile?.telegram_chat_id && (
|
||||
<div className="mb-4 p-2 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-800 rounded text-green-800 dark:text-green-200">
|
||||
<p className="text-sm">
|
||||
{t('profile.telegramConnected', 'Your Telegram account is connected! Send messages to your bot to add items to your Tududi inbox.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telegramError && (
|
||||
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
|
||||
<p className="text-sm">{telegramError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telegramBotInfo && (
|
||||
<div className="mb-4 p-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-2">
|
||||
{t('profile.botConfigured', 'Bot configured successfully!')}
|
||||
</p>
|
||||
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="font-semibold">{t('profile.botUsername', 'Bot Username:')} </span>
|
||||
@{telegramBotInfo.username}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">{t('profile.pollingStatus', 'Polling Status:')} </p>
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<div className={`w-3 h-3 rounded-full mr-2 ${isPolling ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span>{isPolling ? t('profile.pollingActive') : t('profile.pollingInactive')}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mb-2">
|
||||
{t('profile.pollingNote', 'Polling periodically checks for new messages from Telegram and adds them to your inbox.')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center mt-2">
|
||||
{isPolling ? (
|
||||
<button
|
||||
onClick={handleStopPolling}
|
||||
className="px-3 py-1 bg-red-600 text-white dark:bg-red-700 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 text-center mb-2 sm:mb-0 sm:mr-3"
|
||||
>
|
||||
{t('profile.stopPolling', 'Stop Polling')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartPolling}
|
||||
className="px-3 py-1 bg-blue-600 text-white dark:bg-blue-700 rounded text-sm hover:bg-blue-700 dark:hover:bg-blue-800 text-center mb-2 sm:mb-0 sm:mr-3"
|
||||
>
|
||||
{t('profile.startPolling', 'Start Polling')}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={telegramBotInfo.chat_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 bg-green-600 text-white dark:bg-green-700 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800 text-center mb-2 sm:mb-0 sm:mr-3"
|
||||
>
|
||||
{t('profile.openTelegram', 'Open in Telegram')}
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const testMessage = prompt('Enter a test message:');
|
||||
if (testMessage) {
|
||||
const response = await fetch(`/api/telegram/test/${profile?.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testMessage })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showSuccessToast(t('profile.testMessageSent', 'Test message sent successfully!'));
|
||||
} else {
|
||||
showErrorToast(t('profile.testMessageFailed', 'Failed to send test message.'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test message error:', error);
|
||||
showErrorToast(t('profile.testMessageError', 'Error sending test message.'));
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-purple-600 text-white dark:bg-purple-700 rounded text-sm hover:bg-purple-700 dark:hover:bg-purple-800 text-center"
|
||||
>
|
||||
{t('profile.testTelegramMessage', 'Test Telegram')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetupTelegram}
|
||||
disabled={!formData.telegram_bot_token || telegramSetupStatus === 'loading'}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
!formData.telegram_bot_token || telegramSetupStatus === 'loading'
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{telegramSetupStatus === 'loading'
|
||||
? t('profile.settingUp', 'Setting up...')
|
||||
: t('profile.setupTelegram', 'Setup Telegram')}
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
|
||||
{t('profile.taskSummaryNotifications', 'Task Summary Notifications')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t('profile.taskSummaryDescription', 'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.enableTaskSummary', 'Enable Task Summaries')}
|
||||
</label>
|
||||
<div
|
||||
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
||||
profile?.task_summary_enabled ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile/task-summary/toggle', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.toggleFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfile(prev => prev ? ({...prev, task_summary_enabled: data.enabled}) : null);
|
||||
showSuccessToast(data.message);
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
||||
profile?.task_summary_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.summaryFrequency', 'Summary Frequency')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['1h', '2h', '4h', '8h', '12h', 'daily', 'weekly'].map((frequency) => (
|
||||
<button
|
||||
key={frequency}
|
||||
type="button"
|
||||
className={`px-3 py-1.5 text-sm rounded-full ${
|
||||
profile?.task_summary_frequency === frequency
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile/task-summary/frequency', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ frequency })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.frequencyUpdateFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Update the profile with the new frequency
|
||||
setProfile(prev => prev ? ({...prev, task_summary_frequency: frequency}) : null);
|
||||
showSuccessToast(data.message);
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(`profile.frequency.${frequency}`, formatFrequency(frequency))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.frequencyHelp', 'Choose how often you want to receive task summaries.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!profile?.telegram_bot_token || !profile?.telegram_chat_id}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
!profile?.telegram_bot_token || !profile?.telegram_chat_id
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile/task-summary/send-now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.sendSummaryFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showSuccessToast(data.message);
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('profile.sendTestSummary', 'Send Test Summary')}
|
||||
</button>
|
||||
{(!profile?.telegram_bot_token || !profile?.telegram_chat_id) && (
|
||||
<p className="mt-2 text-xs text-red-500">
|
||||
{t('profile.telegramRequiredForSummaries', 'Telegram integration must be set up to use task summaries.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
Save Changes
|
||||
{t('profile.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
1255
app/frontend/components/Profile/ProfileSettings.tsx.bak
Normal file
1255
app/frontend/components/Profile/ProfileSettings.tsx.bak
Normal file
File diff suppressed because it is too large
Load diff
687
app/frontend/components/Profile/ProfileSettings.tsx.clean
Normal file
687
app/frontend/components/Profile/ProfileSettings.tsx.clean
Normal file
|
|
@ -0,0 +1,687 @@
|
|||
import React, { useState, useEffect, ChangeEvent, FormEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
interface ProfileSettingsProps {
|
||||
currentUser: { id: number; email: string };
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: number;
|
||||
email: string;
|
||||
appearance: 'light' | 'dark';
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar_image: string | null;
|
||||
telegram_bot_token: string | null;
|
||||
telegram_chat_id: string | null;
|
||||
}
|
||||
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
// Add this to check the initial language
|
||||
console.log('Current language on component mount:', i18n.language);
|
||||
console.log('Available languages:', i18n.languages);
|
||||
console.log('Available namespaces:', i18n.options.ns);
|
||||
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
// Use React's forceUpdate pattern with a function to guarantee a fresh render
|
||||
const [updateKey, setUpdateKey] = useState(0);
|
||||
const forceUpdate = useCallback(() => {
|
||||
setUpdateKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Add a state for tracking if language is actively changing
|
||||
const [isChangingLanguage, setIsChangingLanguage] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
appearance: 'light',
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
avatar_image: '',
|
||||
telegram_bot_token: '',
|
||||
});
|
||||
|
||||
const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [telegramError, setTelegramError] = useState<string | null>(null);
|
||||
const [telegramBotInfo, setTelegramBotInfo] = useState<{
|
||||
username: string;
|
||||
polling_status: any;
|
||||
chat_url: string;
|
||||
} | null>(null);
|
||||
const [isPolling, setIsPolling] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile', {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to fetch profile.');
|
||||
}
|
||||
const data: Profile = await response.json();
|
||||
setProfile(data);
|
||||
setFormData({
|
||||
appearance: data.appearance,
|
||||
language: data.language,
|
||||
timezone: data.timezone,
|
||||
avatar_image: data.avatar_image || '',
|
||||
telegram_bot_token: data.telegram_bot_token || '',
|
||||
});
|
||||
|
||||
// If user has a token, check polling status and start if not running
|
||||
if (data.telegram_bot_token) {
|
||||
console.log('User has Telegram token, checking polling status...');
|
||||
fetchPollingStatus();
|
||||
|
||||
// Also set an interval to check polling status every 30 seconds
|
||||
// This ensures polling is restarted if it stops unexpectedly
|
||||
const checkInterval = setInterval(() => {
|
||||
if (data.telegram_bot_token) {
|
||||
fetchPollingStatus();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Clean up interval on component unmount
|
||||
return () => clearInterval(checkInterval);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProfile();
|
||||
}, []);
|
||||
|
||||
// Fetch polling status
|
||||
const fetchPollingStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/telegram/polling-status');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setIsPolling(data.is_polling);
|
||||
|
||||
// If the user has a token and we've previously set up the bot
|
||||
if (profile?.telegram_bot_token && telegramBotInfo?.username) {
|
||||
// Update polling status in the bot info
|
||||
setTelegramBotInfo({
|
||||
...telegramBotInfo,
|
||||
polling_status: data.status
|
||||
});
|
||||
}
|
||||
|
||||
// If user has token but polling isn't active, start it automatically
|
||||
if (profile?.telegram_bot_token && !data.is_polling) {
|
||||
console.log('Telegram bot token exists but polling not active. Starting polling automatically...');
|
||||
handleStartPolling();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching polling status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add an effect to monitor language changes
|
||||
// Add effect with the updateKey dependency to refresh component on language change
|
||||
useEffect(() => {
|
||||
console.log(`Component refreshed with key: ${updateKey}, language: ${i18n.language}`);
|
||||
}, [updateKey, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
console.log(`Language changed to ${lng}`);
|
||||
// Force component to re-render when language changes
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
// Handler for the custom app-language-changed event
|
||||
const handleAppLanguageChanged = (event: CustomEvent<{ language: string }>) => {
|
||||
console.log('Custom language change event received:', event.detail.language);
|
||||
// Force an update to re-render with new translations
|
||||
forceUpdate();
|
||||
// Mark language change as complete after a short delay
|
||||
// This ensures the UI has time to update with new translations
|
||||
setTimeout(() => {
|
||||
setIsChangingLanguage(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Add language change listeners
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
window.addEventListener('app-language-changed', handleAppLanguageChanged as EventListener);
|
||||
|
||||
// Clean up listeners on unmount
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
window.removeEventListener('app-language-changed', handleAppLanguageChanged as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleChange = async (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
// Change language immediately when selected
|
||||
if (name === 'language' && value !== i18n.language) {
|
||||
console.log('Changing language to:', value);
|
||||
// Set flag to indicate language is changing
|
||||
setIsChangingLanguage(true);
|
||||
|
||||
try {
|
||||
// Save language preference to localStorage for persistence
|
||||
localStorage.setItem('i18nextLng', value);
|
||||
|
||||
// First, force a re-render to indicate language is changing
|
||||
forceUpdate();
|
||||
|
||||
// Trigger language change in i18next with a more robust approach
|
||||
await i18n.changeLanguage(value);
|
||||
console.log('Language changed successfully to:', i18n.language);
|
||||
|
||||
// Explicitly force the document's lang attribute to match
|
||||
document.documentElement.lang = value;
|
||||
|
||||
// Verify translations are loaded
|
||||
const resources = i18n.getResourceBundle(value, 'translation');
|
||||
console.log('Resources loaded for language:', value, resources ? 'Yes' : 'No');
|
||||
|
||||
if (!resources || Object.keys(resources).length === 0) {
|
||||
console.warn('Translations might not be fully loaded for:', value);
|
||||
|
||||
// Try to load translations manually if needed
|
||||
const loadPath = `/locales/${value}/translation.json`;
|
||||
try {
|
||||
const response = await fetch(loadPath);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
i18n.addResourceBundle(value, 'translation', data, true, true);
|
||||
console.log('Manually loaded translations for:', value);
|
||||
|
||||
// Force app to recognize new translations
|
||||
if (window.forceLanguageReload) {
|
||||
window.forceLanguageReload(value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to manually load translations:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Force another update to ensure UI reflects new language
|
||||
setTimeout(() => {
|
||||
forceUpdate();
|
||||
|
||||
// Try to load translations again if they still aren't available
|
||||
const checkAndLoadResources = i18n.getResourceBundle(value, 'translation');
|
||||
if (!checkAndLoadResources || Object.keys(checkAndLoadResources).length === 0) {
|
||||
console.warn('Still no translations after initial load, forcing reload');
|
||||
if (window.forceLanguageReload) {
|
||||
window.forceLanguageReload(value);
|
||||
}
|
||||
}
|
||||
|
||||
// If change event wasn't fired, mark as complete after a delay
|
||||
setTimeout(() => {
|
||||
if (isChangingLanguage) {
|
||||
setIsChangingLanguage(false);
|
||||
}
|
||||
}, 800); // Longer timeout to ensure translations load
|
||||
}, 200);
|
||||
} catch (error) {
|
||||
console.error('Error changing language:', error);
|
||||
setIsChangingLanguage(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormData((prev) => ({ ...prev, avatar_image: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupTelegram = async () => {
|
||||
setTelegramSetupStatus('loading');
|
||||
setTelegramError(null);
|
||||
setTelegramBotInfo(null);
|
||||
|
||||
try {
|
||||
// Validate the token format
|
||||
if (!formData.telegram_bot_token || !formData.telegram_bot_token.includes(':')) {
|
||||
throw new Error(t('profile.invalidTelegramToken'));
|
||||
}
|
||||
|
||||
// Send setup request to the server
|
||||
const response = await fetch('/api/telegram/setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: formData.telegram_bot_token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.telegramSetupFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTelegramSetupStatus('success');
|
||||
setSuccess(t('profile.telegramSetupSuccess'));
|
||||
|
||||
// Save bot info for display
|
||||
if (data.bot) {
|
||||
setTelegramBotInfo(data.bot);
|
||||
setIsPolling(true);
|
||||
|
||||
// Explicitly verify polling is started
|
||||
if (!data.bot.polling_status?.running) {
|
||||
console.log('Polling not started automatically during setup. Starting manually...');
|
||||
// Small delay to ensure the server has registered the token
|
||||
setTimeout(() => {
|
||||
handleStartPolling();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Format the URL to start the bot chat
|
||||
const botUsername = data.bot?.username || formData.telegram_bot_token.split(':')[0];
|
||||
|
||||
// Open the Telegram bot chat in a new window
|
||||
window.open(`https://t.me/${botUsername}`, '_blank');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Telegram setup error:', error);
|
||||
setTelegramSetupStatus('error');
|
||||
setTelegramError((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartPolling = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/telegram/start-polling', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.startPollingFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setIsPolling(true);
|
||||
showSuccessToast(t('profile.pollingStarted'));
|
||||
|
||||
// Update bot info if available
|
||||
if (telegramBotInfo) {
|
||||
setTelegramBotInfo({
|
||||
...telegramBotInfo,
|
||||
polling_status: data.status
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Start polling error:', error);
|
||||
showErrorToast(t('profile.pollingError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopPolling = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/telegram/stop-polling', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.stopPollingFailed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setIsPolling(false);
|
||||
showSuccessToast(t('profile.pollingStopped'));
|
||||
|
||||
// Update bot info if available
|
||||
if (telegramBotInfo) {
|
||||
setTelegramBotInfo({
|
||||
...telegramBotInfo,
|
||||
polling_status: data.status
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Stop polling error:', error);
|
||||
showErrorToast(t('profile.pollingError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to update profile.');
|
||||
}
|
||||
|
||||
const updatedProfile: Profile = await response.json();
|
||||
setProfile(updatedProfile);
|
||||
|
||||
// Make sure to update language if it was changed
|
||||
if (updatedProfile.language !== i18n.language) {
|
||||
console.log('Updating language after form submission:', updatedProfile.language);
|
||||
await i18n.changeLanguage(updatedProfile.language);
|
||||
}
|
||||
|
||||
setSuccess(t('profile.successMessage'));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6" key={`profile-settings-${updateKey}`}>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{t('profile.title')}
|
||||
</h2>
|
||||
|
||||
{/* Debug information */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="mb-4 p-2 bg-gray-100 dark:bg-gray-800 text-xs font-mono">
|
||||
<p>Current language: {i18n.language}</p>
|
||||
<p>Initialized: {i18n.isInitialized ? 'Yes' : 'No'}</p>
|
||||
<p>Available languages: {i18n.languages?.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && <div className="mb-4 text-green-500">{success}</div>}
|
||||
{error && <div className="mb-4 text-red-500">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Appearance Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.appearance')}
|
||||
</label>
|
||||
<select
|
||||
name="appearance"
|
||||
value={formData.appearance}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="light">{t('profile.lightMode', 'Light')}</option>
|
||||
<option value="dark">{t('profile.darkMode', 'Dark')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Language Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.language')}
|
||||
</label>
|
||||
<select
|
||||
name="language"
|
||||
value={formData.language}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="en">{t('profile.english')}</option>
|
||||
<option value="es">{t('profile.spanish')}</option>
|
||||
<option value="el">{t('profile.greek')}</option>
|
||||
<option value="jp">{t('profile.japanese')}</option>
|
||||
<option value="ua">{t('profile.ukrainian')}</option>
|
||||
<option value="de">{t('profile.deutsch')}</option>
|
||||
{/* Add more languages if necessary */}
|
||||
</select>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.languageChangedNote', 'Language changes are applied immediately')}
|
||||
</p>
|
||||
{isChangingLanguage && (
|
||||
<div className="mt-2 text-sm text-blue-500 animate-pulse">
|
||||
{t('profile.languageChanging', 'Changing language...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timezone Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.timezone')}
|
||||
</label>
|
||||
<select
|
||||
name="timezone"
|
||||
value={formData.timezone}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Telegram Integration */}
|
||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
|
||||
{t('profile.telegramIntegration', 'Telegram Integration')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t('profile.telegramDescription', 'Connect your Tududi account to a Telegram bot to add items to your inbox via Telegram messages.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.telegramBotToken', 'Telegram Bot Token')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="telegram_bot_token"
|
||||
value={formData.telegram_bot_token}
|
||||
onChange={handleChange}
|
||||
placeholder="123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ"
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.telegramTokenDescription', 'Create a bot with @BotFather on Telegram and paste the token here.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{profile?.telegram_chat_id && (
|
||||
<div className="mb-4 p-2 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-800 rounded text-green-800 dark:text-green-200">
|
||||
<p className="text-sm">
|
||||
{t('profile.telegramConnected', 'Your Telegram account is connected! Send messages to your bot to add items to your Tududi inbox.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telegramError && (
|
||||
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
|
||||
<p className="text-sm">{telegramError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telegramBotInfo && (
|
||||
<div className="mb-4 p-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-2">
|
||||
{t('profile.botConfigured', 'Bot configured successfully!')}
|
||||
</p>
|
||||
|
||||
<div className="text-sm space-y-1">
|
||||
<p>
|
||||
<span className="font-semibold">{t('profile.botUsername', 'Bot Username:')} </span>
|
||||
@{telegramBotInfo.username}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold mb-1">{t('profile.pollingStatus', 'Polling Status:')} </p>
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<div className={`w-3 h-3 rounded-full mr-2 ${isPolling ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span>{isPolling ? t('profile.pollingActive') : t('profile.pollingInactive')}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs mb-2">
|
||||
{t('profile.pollingNote', 'Polling periodically checks for new messages from Telegram and adds them to your inbox.')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center mt-2">
|
||||
{isPolling ? (
|
||||
<button
|
||||
onClick={handleStopPolling}
|
||||
className="px-3 py-1 bg-red-600 text-white dark:bg-red-700 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800 text-center mb-2 sm:mb-0 sm:mr-3"
|
||||
>
|
||||
{t('profile.stopPolling', 'Stop Polling')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStartPolling}
|
||||
className="px-3 py-1 bg-blue-600 text-white dark:bg-blue-700 rounded text-sm hover:bg-blue-700 dark:hover:bg-blue-800 text-center mb-2 sm:mb-0 sm:mr-3"
|
||||
>
|
||||
{t('profile.startPolling', 'Start Polling')}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={telegramBotInfo.chat_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 bg-green-600 text-white dark:bg-green-700 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800 text-center mb-2 sm:mb-0 sm:mr-3"
|
||||
>
|
||||
{t('profile.openTelegram', 'Open in Telegram')}
|
||||
</a>
|
||||
|
||||
{/* Test button for development */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const testMessage = prompt('Enter a test message:');
|
||||
if (testMessage) {
|
||||
const response = await fetch(`/api/telegram/test/${profile?.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: testMessage })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
showSuccessToast(t('profile.testMessageSent', 'Test message sent successfully!'));
|
||||
} else {
|
||||
showErrorToast(t('profile.testMessageFailed', 'Failed to send test message.'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Test message error:', error);
|
||||
showErrorToast(t('profile.testMessageError', 'Error sending test message.'));
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-purple-600 text-white dark:bg-purple-700 rounded text-sm hover:bg-purple-700 dark:hover:bg-purple-800 text-center"
|
||||
>
|
||||
{t('profile.testTelegramMessage', 'Test Telegram')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetupTelegram}
|
||||
disabled={!formData.telegram_bot_token || telegramSetupStatus === 'loading'}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
!formData.telegram_bot_token || telegramSetupStatus === 'loading'
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{telegramSetupStatus === 'loading'
|
||||
? t('profile.settingUp', 'Setting up...')
|
||||
: t('profile.setupTelegram', 'Setup Telegram')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Avatar Image Upload */}
|
||||
{/* <div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Avatar Image
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="mt-1 block w-full text-sm text-gray-500 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-200 dark:hover:file:bg-gray-600"
|
||||
/>
|
||||
{formData.avatar_image && (
|
||||
<img
|
||||
src={formData.avatar_image}
|
||||
alt="Avatar Preview"
|
||||
className="mt-2 h-24 w-24 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div> */}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{t('profile.saveChanges')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettings;
|
||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { Link } from "react-router-dom";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ProjectItemProps {
|
||||
project: Project;
|
||||
|
|
@ -37,6 +38,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
setProjectToDelete,
|
||||
setIsConfirmDialogOpen,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
|
|
@ -54,7 +56,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg"
|
||||
style={{ height: "140px" }}
|
||||
>
|
||||
<span className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20">
|
||||
<span
|
||||
className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20"
|
||||
aria-label={t("projectItem.projectInitials")}
|
||||
>
|
||||
{getProjectInitials(project.name)}
|
||||
</span>
|
||||
<div
|
||||
|
|
@ -86,6 +91,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
activeDropdown === project.id ? null : project.id ?? null
|
||||
)
|
||||
}
|
||||
aria-label={t("projectItem.toggleDropdownMenu")}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -96,7 +102,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
onClick={() => handleEditProject(project)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Edit
|
||||
{t("projectItem.edit")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -106,7 +112,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
}}
|
||||
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Delete
|
||||
{t("projectItem.delete")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -125,7 +131,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getCompletionPercentage(project?.id)}%
|
||||
{t("projectItem.completionPercentage", { percentage: getCompletionPercentage(project?.id) })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { PriorityType } from "../../entities/Task";
|
|||
import Switch from "../Shared/Switch";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { fetchTags } from "../../utils/tagsService";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -51,6 +52,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
|
|
@ -212,13 +214,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter project name"
|
||||
placeholder={t('project.name', 'Enter project name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
{t('forms.description', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
id="projectDescription"
|
||||
|
|
@ -227,13 +229,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
value={formData.description || ""}
|
||||
onChange={handleChange}
|
||||
className="block w-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Enter project description (optional)"
|
||||
placeholder={t('forms.areaDescriptionPlaceholder', 'Enter project description (optional)')}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Due Date
|
||||
{t('forms.dueDate', 'Due Date')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
|
|
@ -246,7 +248,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Priority
|
||||
{t('forms.priority', 'Priority')}
|
||||
</label>
|
||||
<PriorityDropdown
|
||||
value={formData.priority || "medium"}
|
||||
|
|
@ -258,7 +260,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
{t('forms.tags', 'Tags')}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
|
|
@ -271,7 +273,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Area (optional)
|
||||
{t('common.area', 'Area')} ({t('forms.optional', 'optional')})
|
||||
</label>
|
||||
<select
|
||||
id="projectArea"
|
||||
|
|
@ -280,7 +282,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onChange={handleChange}
|
||||
className="block w-full rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
>
|
||||
<option value="">No Area</option>
|
||||
<option value="">{t('common.none', 'No Area')}</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
|
|
@ -298,7 +300,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
htmlFor="active"
|
||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Active
|
||||
{t('projects.active', 'Active')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -310,7 +312,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onClick={handleDeleteClick}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Delete
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -318,14 +320,14 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onClick={handleClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
{project ? "Update Project" : "Create Project"}
|
||||
{project ? t('modals.updateProject', 'Update Project') : t('modals.createProject', 'Create Project')}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import ConfirmDialog from "./Shared/ConfirmDialog";
|
|||
import ProjectModal from "./Project/ProjectModal";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { fetchProjects, createProject, updateProject, deleteProject } from "../utils/projectsService";
|
||||
import { fetchAreas } from "../utils/areasService";
|
||||
import { fetchAreas } from "../utils/areasService";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Project } from "../entities/Project";
|
||||
import { PriorityType, StatusType } from "../entities/Task";
|
||||
|
|
@ -32,6 +33,7 @@ const getPriorityStyles = (priority: PriorityType) => {
|
|||
};
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore);
|
||||
const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore);
|
||||
const { isLoading, isError } = useStore((state) => state.projectsStore);
|
||||
|
|
@ -179,7 +181,7 @@ useEffect(() => {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading projects...
|
||||
{t('projects.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -188,7 +190,7 @@ useEffect(() => {
|
|||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">Error loading projects.</div>
|
||||
<div className="text-red-500 text-lg">{t('projects.error')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -199,7 +201,7 @@ useEffect(() => {
|
|||
<div className="flex items-center mb-8">
|
||||
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
|
||||
Projects
|
||||
{t('projects.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
|
@ -213,7 +215,7 @@ useEffect(() => {
|
|||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
aria-label="Card View"
|
||||
aria-label={t("projects.cardViewAriaLabel")}
|
||||
>
|
||||
<Squares2X2Icon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -225,7 +227,7 @@ useEffect(() => {
|
|||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
aria-label="List View"
|
||||
aria-label={t("projects.listViewAriaLabel")}
|
||||
>
|
||||
<Bars3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -237,7 +239,7 @@ useEffect(() => {
|
|||
htmlFor="activeFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Status
|
||||
{t('common.status')}
|
||||
</label>
|
||||
<select
|
||||
id="activeFilter"
|
||||
|
|
@ -245,9 +247,9 @@ useEffect(() => {
|
|||
onChange={handleActiveFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
<option value="all">All</option>
|
||||
<option value="true">{t('projects.filters.active')}</option>
|
||||
<option value="false">{t('projects.filters.inactive')}</option>
|
||||
<option value="all">{t('projects.filters.all')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -256,7 +258,7 @@ useEffect(() => {
|
|||
htmlFor="areaFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Area
|
||||
{t('common.area')}
|
||||
</label>
|
||||
<select
|
||||
id="areaFilter"
|
||||
|
|
@ -264,7 +266,7 @@ useEffect(() => {
|
|||
onChange={handleAreaFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
<option value="">{t('projects.filters.allAreas')}</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id?.toString()}>
|
||||
{area.name}
|
||||
|
|
@ -281,7 +283,7 @@ useEffect(() => {
|
|||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
placeholder={t('projects.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
|
||||
|
|
@ -299,7 +301,7 @@ useEffect(() => {
|
|||
>
|
||||
{Object.keys(groupedProjects).length === 0 ? (
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
No projects found.
|
||||
{t('projects.noProjectsFound')}
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(groupedProjects).map((areaName) => (
|
||||
|
|
@ -347,8 +349,8 @@ useEffect(() => {
|
|||
|
||||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
message={`Are you sure you want to delete the project "${projectToDelete?.name}"?`}
|
||||
title={t('modals.deleteProject.title')}
|
||||
message={t('modals.deleteProject.message', { projectName: projectToDelete?.name })}
|
||||
onConfirm={handleDeleteProject}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
|
|
@ -8,23 +9,25 @@ interface ConfirmDialogProps {
|
|||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded shadow-lg">
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{title}</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6">{message}</p>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-8">{message}</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 focus:outline-none"
|
||||
>
|
||||
Delete
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
10
app/frontend/components/Shared/LoadingScreen.tsx
Normal file
10
app/frontend/components/Shared/LoadingScreen.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
|
||||
const LoadingScreen: React.FC = () => (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div className="text-lg">Loading application... Please wait.</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingScreen;
|
||||
|
||||
|
|
@ -1,19 +1,21 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline';
|
||||
import { PriorityType } from '../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PriorityDropdownProps {
|
||||
value: PriorityType;
|
||||
onChange: (value: PriorityType) => void;
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Low', icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'medium', label: 'Medium', icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'high', label: 'High', icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
|
||||
];
|
||||
|
||||
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: t('priority.low', 'Low'), icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'medium', label: t('priority.medium', 'Medium'), icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'high', label: t('priority.high', 'High'), icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
|
||||
];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -55,7 +57,7 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
|
|||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{selectedPriority ? selectedPriority.icon : ''}
|
||||
<span>{selectedPriority ? selectedPriority.label : 'Select Priority'}</span>
|
||||
<span>{selectedPriority ? selectedPriority.label : t('forms.priority', 'Select Priority')}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import { StatusType } from '../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface StatusDropdownProps {
|
||||
value: StatusType;
|
||||
onChange: (value: StatusType) => void;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: 'not_started', label: 'Not Started', icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'in_progress', label: 'In Progress', icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'done', label: 'Done', icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'archived', label: 'Archived', icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
];
|
||||
|
||||
const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const statuses = [
|
||||
{ value: 'not_started', label: t('status.notStarted', 'Not Started'), icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'in_progress', label: t('status.inProgress', 'In Progress'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'done', label: t('status.done', 'Done'), icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'archived', label: t('status.archived', 'Archived'), icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface SidebarProps {
|
|||
currentUser: { email: string };
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
openTaskModal: () => void;
|
||||
openTaskModal: (type?: 'simplified' | 'full') => void;
|
||||
openProjectModal: () => void;
|
||||
openNoteModal: (note: Note | null) => void;
|
||||
openAreaModal: (area: Area | null) => void;
|
||||
|
|
@ -71,7 +71,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
<div className="px-3 pb-3 pt-8">
|
||||
{/* Sidebar Contents */}
|
||||
<CreateNewDropdownButton
|
||||
openTaskModal={openTaskModal}
|
||||
openTaskModal={(type) => openTaskModal(type || 'full')}
|
||||
openProjectModal={openProjectModal}
|
||||
openNoteModal={openNoteModal}
|
||||
openAreaModal={openAreaModal}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import {
|
|||
BookOpenIcon,
|
||||
Squares2X2Icon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { Area } from '../../entities/Area';
|
||||
|
||||
interface CreateNewDropdownButtonProps {
|
||||
openTaskModal: () => void;
|
||||
openTaskModal: (type?: 'simplified' | 'full') => void;
|
||||
openProjectModal: () => void;
|
||||
openNoteModal: (note: Note | null) => void;
|
||||
openAreaModal: (area: Area | null) => void;
|
||||
|
|
@ -23,6 +24,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
openNoteModal,
|
||||
openAreaModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
|
|
@ -32,7 +34,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
const handleDropdownSelect = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Task':
|
||||
openTaskModal();
|
||||
openTaskModal('full');
|
||||
break;
|
||||
case 'Project':
|
||||
openProjectModal();
|
||||
|
|
@ -50,10 +52,10 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
};
|
||||
|
||||
const dropdownItems = [
|
||||
{ label: 'Task', icon: <ClipboardIcon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Project', icon: <FolderIcon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Note', icon: <BookOpenIcon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Area', icon: <Squares2X2Icon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Task', translationKey: 'dropdown.task', icon: <ClipboardIcon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Project', translationKey: 'dropdown.project', icon: <FolderIcon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Note', translationKey: 'dropdown.note', icon: <BookOpenIcon className="h-5 w-5 mr-2" /> },
|
||||
{ label: 'Area', translationKey: 'dropdown.area', icon: <Squares2X2Icon className="h-5 w-5 mr-2" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -69,7 +71,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
className="w-5 h-5 mr-2 text-gray-500 dark:text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Create New
|
||||
{t('dropdown.createNew')}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className="w-5 h-5 text-gray-500 dark:text-gray-400"
|
||||
|
|
@ -86,7 +88,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{dropdownItems.map(({ label, icon }) => (
|
||||
{dropdownItems.map(({ label, translationKey, icon }) => (
|
||||
<li
|
||||
key={label}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer flex items-center"
|
||||
|
|
@ -94,7 +96,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
role="menuitem"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{t(translationKey)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
import { Squares2X2Icon, PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Location } from "react-router-dom";
|
||||
import { Area } from "../../entities/Area";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SidebarAreasProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -16,6 +17,7 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
|
|||
location,
|
||||
openAreaModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isActiveArea = (path: string) => {
|
||||
return location.pathname === path
|
||||
? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
|
|
@ -40,7 +42,7 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
|
|||
>
|
||||
<span className="flex items-center">
|
||||
<Squares2X2Icon className="h-5 w-5 mr-2" />
|
||||
AREAS
|
||||
{t('sidebar.areas')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -48,8 +50,8 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
|
|||
openAreaModal(null);
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
aria-label="Add Area"
|
||||
title="Add Area"
|
||||
aria-label={t('sidebar.addAreaAriaLabel')}
|
||||
title={t('sidebar.addAreaTitle')}
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import React from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CalendarIcon,
|
||||
ArrowRightCircleIcon,
|
||||
InboxIcon,
|
||||
ClockIcon,
|
||||
PauseCircleIcon,
|
||||
CheckCircleIcon,
|
||||
ListBulletIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
|
@ -17,19 +16,30 @@ interface SidebarNavProps {
|
|||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/tasks?type=upcoming', title: 'Upcoming', icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks?type=next', title: 'Next Actions', icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
|
||||
{ path: '/tasks?type=inbox', title: 'Inbox', icon: <InboxIcon className="h-5 w-5" />, query: 'type=inbox' },
|
||||
// { path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
|
||||
// { path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
|
||||
{ path: '/tasks?status=done', title: 'Completed', icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
|
||||
{ path: '/tasks', title: 'All Tasks', icon: <ListBulletIcon className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/inbox', title: t('sidebar.inbox', 'Inbox'), icon: <InboxIcon className="h-5 w-5" /> },
|
||||
{ path: '/today', title: t('sidebar.today', 'Today'), icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks?type=next', title: t('sidebar.nextActions', 'Next Actions'), icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
|
||||
// { path: '/tasks?type=someday', title: t('sidebar.someday', 'Someday'), icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
|
||||
// { path: '/tasks?type=waiting', title: t('sidebar.waitingFor', 'Waiting for'), icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
|
||||
{ path: '/tasks?status=done', title: t('sidebar.completed', 'Completed'), icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
|
||||
{ path: '/tasks', title: t('sidebar.allTasks', 'All Tasks'), icon: <ListBulletIcon className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
const isActive = (path: string, query?: string) => {
|
||||
// Handle special case for paths without query parameters
|
||||
if (path === '/inbox' || path === '/today') {
|
||||
const isPathMatch = location.pathname === path;
|
||||
return isPathMatch
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
|
||||
// Regular case for /tasks with query params
|
||||
const isPathMatch = location.pathname === '/tasks';
|
||||
const isQueryMatch = query ? location.search.includes(query) : location.search === '';
|
||||
return isPathMatch && isQueryMatch
|
||||
|
|
@ -40,18 +50,23 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
|
|||
return (
|
||||
<ul className="flex flex-col space-y-1">
|
||||
{navLinks.map((link) => (
|
||||
<li key={link.path}>
|
||||
<button
|
||||
onClick={() => handleNavClick(link.path, link.title, link.icon)}
|
||||
className={`w-full text-left px-4 py-1 flex items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
|
||||
link.path,
|
||||
link.query
|
||||
)}`}
|
||||
>
|
||||
{link.icon}
|
||||
<span className="ml-2">{link.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
<React.Fragment key={link.path}>
|
||||
<li>
|
||||
<button
|
||||
onClick={() => handleNavClick(link.path, link.title, link.icon)}
|
||||
className={`w-full text-left px-4 py-1 flex items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
|
||||
link.path,
|
||||
link.query
|
||||
)}`}
|
||||
>
|
||||
{link.icon}
|
||||
<span className="ml-2">{link.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
{link.path === '/inbox' && (
|
||||
<li className="py-1" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { Location } from 'react-router-dom';
|
||||
import { BookOpenIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SidebarNotesProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -16,6 +17,7 @@ const SidebarNotes: React.FC<SidebarNotesProps> = ({
|
|||
location,
|
||||
openNoteModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isActiveNote = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
|
|
@ -33,7 +35,7 @@ const SidebarNotes: React.FC<SidebarNotesProps> = ({
|
|||
>
|
||||
<span className="flex items-center">
|
||||
<BookOpenIcon className="h-5 w-5 mr-2" />
|
||||
NOTES
|
||||
{t('sidebar.notes')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { FolderIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SidebarProjectsProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -14,6 +15,7 @@ const SidebarProjects: React.FC<SidebarProjectsProps> = ({
|
|||
location,
|
||||
openProjectModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isActiveProject = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
|
|
@ -31,7 +33,7 @@ const SidebarProjects: React.FC<SidebarProjectsProps> = ({
|
|||
>
|
||||
<span className="flex items-center">
|
||||
<FolderIcon className="h-5 w-5 mr-2" />
|
||||
PROJECTS
|
||||
{t('sidebar.projects')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import { Location } from 'react-router-dom';
|
||||
import { TagIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SidebarTagsProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -16,6 +17,8 @@ const SidebarTags: React.FC<SidebarTagsProps> = ({
|
|||
location,
|
||||
openTagModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isActiveTag = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
|
|
@ -34,7 +37,7 @@ const SidebarTags: React.FC<SidebarTagsProps> = ({
|
|||
>
|
||||
<span className="flex items-center">
|
||||
<TagIcon className="h-5 w-5 mr-2" />
|
||||
TAGS
|
||||
{t('sidebar.tags')}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -42,8 +45,8 @@ const SidebarTags: React.FC<SidebarTagsProps> = ({
|
|||
openTagModal(null);
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
aria-label="Add Tag"
|
||||
title="Add Tag"
|
||||
aria-label={t('sidebar.addTagAriaLabel')}
|
||||
title={t('sidebar.addTagTitle')}
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
|
|
@ -8,6 +9,7 @@ interface Tag {
|
|||
}
|
||||
|
||||
const TagDetails: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [tag, setTag] = useState<Tag | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -25,7 +27,7 @@ const TagDetails: React.FC = () => {
|
|||
setError(data.error || 'Failed to fetch tag.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error fetching tag.');
|
||||
setError(t('tags.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -40,7 +42,7 @@ const TagDetails: React.FC = () => {
|
|||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-700 dark:text-gray-300">Loading tag details...</div>;
|
||||
return <div className="text-gray-700 dark:text-gray-300">{t('tags.loading')}</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -48,17 +50,17 @@ const TagDetails: React.FC = () => {
|
|||
}
|
||||
|
||||
if (!tag) {
|
||||
return <div className="text-gray-700 dark:text-gray-300">Tag not found.</div>;
|
||||
return <div className="text-gray-700 dark:text-gray-300">{t('tags.notFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">Tag Details</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">{t('tags.details')}</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Name:</strong> {tag.name}
|
||||
<strong>{t('tags.name')}:</strong> {tag.name}
|
||||
</p>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Status:</strong> {tag.active ? 'Active' : 'Inactive'}
|
||||
<strong>{t('tags.status')}:</strong> {tag.active ? t('tags.active') : t('tags.inactive')}
|
||||
</p>
|
||||
|
||||
{/* "View tasks with this tag" button */}
|
||||
|
|
@ -66,7 +68,7 @@ const TagDetails: React.FC = () => {
|
|||
onClick={handleViewTasks}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
View tasks with this tag
|
||||
{t('tags.viewTasksWithTag')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TagInputProps {
|
||||
initialTags: string[];
|
||||
|
|
@ -8,6 +9,7 @@ interface TagInputProps {
|
|||
}
|
||||
|
||||
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags }) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [tags, setTags] = useState<string[]>(initialTags || []);
|
||||
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
|
||||
|
|
@ -17,6 +19,25 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update internal tags state when initialTags prop changes
|
||||
useEffect(() => {
|
||||
console.log("TagInput received initialTags:", initialTags);
|
||||
|
||||
// Set the tags state with the initial tags
|
||||
if (initialTags && initialTags.length > 0) {
|
||||
// Simply set our internal state to match the initialTags
|
||||
setTags(initialTags);
|
||||
console.log("Set tags to match initialTags:", initialTags);
|
||||
}
|
||||
}, [initialTags]);
|
||||
|
||||
// Clean up effect to notify parent when our tags state changes
|
||||
useEffect(() => {
|
||||
// Notify parent of current state
|
||||
console.log("TagInput internal tags state changed to:", tags);
|
||||
onTagsChange(tags);
|
||||
}, [tags, onTagsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
if (inputValue.trim() === '') {
|
||||
|
|
@ -118,24 +139,28 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
<div className="space-y-2 relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-wrap items-center border border-gray-300 dark:border-gray-900 bg-white dark:bg-gray-900 rounded-md p-2 h-10"
|
||||
className="flex flex-wrap items-center border border-gray-300 dark:border-gray-900 bg-white dark:bg-gray-900 rounded-md p-2 min-h-[40px]"
|
||||
>
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center bg-gray-200 text-gray-700 text-xs font-medium mr-2 px-2.5 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(index)}
|
||||
className="ml-1 text-gray-600 hover:text-gray-800 focus:outline-none"
|
||||
aria-label={`Remove tag ${tag}`}
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex items-center bg-gray-200 text-gray-700 text-xs font-medium mr-2 px-2.5 py-0.5 rounded"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(index)}
|
||||
className="ml-1 text-gray-600 hover:text-gray-800 focus:outline-none"
|
||||
aria-label={`Remove tag ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs"></span>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -143,7 +168,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type to add a tag"
|
||||
placeholder={t('tags.typeToAdd')}
|
||||
className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100"
|
||||
onFocus={() => {
|
||||
if (filteredTags.length > 0) setIsDropdownOpen(true);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||
import { Tag } from '../../entities/Tag';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TagModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -26,6 +27,7 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
|
|
@ -79,7 +81,7 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
showErrorToast('Tag name is required.');
|
||||
showErrorToast(t('errors.tagNameRequired', 'Tag name is required.'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -87,14 +89,14 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
|
||||
try {
|
||||
if (tag) {
|
||||
showSuccessToast('Tag updated successfully!');
|
||||
showSuccessToast(t('success.tagUpdated', 'Tag updated successfully!'));
|
||||
} else {
|
||||
showSuccessToast('Tag created successfully!');
|
||||
showSuccessToast(t('success.tagCreated', 'Tag created successfully!'));
|
||||
}
|
||||
onSave(formData);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
showErrorToast('Failed to save tag.');
|
||||
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -139,7 +141,7 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Enter tag name"
|
||||
placeholder={t('forms.tagNamePlaceholder', 'Enter tag name')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,7 +153,7 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
onClick={handleClose}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none transition duration-150 ease-in-out"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -161,7 +163,11 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : tag ? 'Update Tag' : 'Create Tag'}
|
||||
{isSubmitting
|
||||
? t('modals.submitting', 'Submitting...')
|
||||
: tag
|
||||
? t('modals.updateTag', 'Update Tag')
|
||||
: t('modals.createTag', 'Create Tag')}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useToast } from '../../components/Shared/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface NewTaskProps {
|
||||
|
|
@ -9,6 +10,7 @@ interface NewTaskProps {
|
|||
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
||||
const [taskName, setTaskName] = useState<string>('');
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskName(event.target.value);
|
||||
|
|
@ -19,10 +21,10 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
try {
|
||||
await onTaskCreate(taskName.trim());
|
||||
setTaskName('');
|
||||
showSuccessToast('Task created successfully!');
|
||||
showSuccessToast(t('success.taskCreated', 'Task created successfully!'));
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
showErrorToast('Failed to create task.');
|
||||
showErrorToast(t('errors.taskCreate', 'Failed to create task.'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -38,7 +40,7 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-medium text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none"
|
||||
placeholder="Add New Task"
|
||||
placeholder={t('tasks.addNewTask', 'Προσθήκη Νέας Εργασίας')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
183
app/frontend/components/Task/SimplifiedTaskModal.tsx
Normal file
183
app/frontend/components/Task/SimplifiedTaskModal.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { InboxItem } from "../../entities/InboxItem";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createInboxItemWithStore } from "../../utils/inboxService";
|
||||
|
||||
interface SimplifiedTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (task: Task) => void;
|
||||
initialText?: string;
|
||||
editMode?: boolean;
|
||||
onEdit?: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialText = "",
|
||||
editMode = false,
|
||||
onEdit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputText, setInputText] = useState<string>(initialText);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputText(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!inputText.trim() || isSaving) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (editMode && onEdit) {
|
||||
await onEdit(inputText.trim());
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
return; // Exit early to prevent creating duplicates
|
||||
}
|
||||
|
||||
if (saveMode === 'task') {
|
||||
const newTask: Task = {
|
||||
name: inputText.trim(),
|
||||
status: "not_started",
|
||||
};
|
||||
|
||||
onSave(newTask);
|
||||
showSuccessToast(t('task.createSuccess'));
|
||||
setInputText('');
|
||||
} else {
|
||||
try {
|
||||
const newItem = await createInboxItemWithStore(inputText.trim());
|
||||
|
||||
showSuccessToast(t('inbox.itemAdded'));
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create inbox item:', error);
|
||||
showErrorToast(t('inbox.addError'));
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save:', error);
|
||||
if (editMode) {
|
||||
showErrorToast(t('inbox.updateError'));
|
||||
} else {
|
||||
showErrorToast(saveMode === 'task' ? t('task.createError') : t('inbox.addError'));
|
||||
}
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [inputText, isSaving, editMode, onEdit, saveMode, onSave, showSuccessToast, showErrorToast, t, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
if (!editMode) {
|
||||
setInputText("");
|
||||
setSaveMode('inbox');
|
||||
}
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
}, [onClose, editMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen, handleClose]); // Only depend on isOpen and handleClose
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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 ${
|
||||
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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
} flex flex-col`}
|
||||
>
|
||||
<div className="p-6 px-8 flex items-center">
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
name="text"
|
||||
value={inputText}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="flex-1 text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder={t('inbox.captureThought')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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 ${
|
||||
inputText.trim() && !isSaving
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-blue-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimplifiedTaskModal;
|
||||
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskActionsProps {
|
||||
taskId: number | undefined;
|
||||
|
|
@ -8,6 +9,8 @@ interface TaskActionsProps {
|
|||
}
|
||||
|
||||
const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="p-3 border-t dark:border-gray-700 flex-shrink-0 flex justify-end space-x-2">
|
||||
{taskId && (
|
||||
|
|
@ -16,7 +19,7 @@ const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onC
|
|||
onClick={onDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
{t('common.delete', 'Delete')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
@ -24,14 +27,14 @@ const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onC
|
|||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
Save
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskDueDateProps {
|
||||
dueDate: string;
|
||||
|
|
@ -6,6 +7,7 @@ interface TaskDueDateProps {
|
|||
}
|
||||
|
||||
const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const getDueDateClass = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
|
@ -21,9 +23,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
|
|||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
if (dueDate === today) return 'TODAY';
|
||||
if (dueDate === tomorrow) return 'TOMORROW';
|
||||
if (dueDate === yesterday) return 'YESTERDAY';
|
||||
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
|
||||
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW');
|
||||
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY');
|
||||
|
||||
return new Date(dueDate).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import TagInput from "../Tag/TagInput";
|
|||
import { Project } from "../../entities/Project";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -41,6 +42,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { tagsStore } = useStore();
|
||||
const { tags: availableTags, setTags: setAvailableTags, setLoading: setTagsLoading, setError: setTagsError } = tagsStore;
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(task);
|
||||
|
|
@ -207,12 +209,12 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder="Add Task Name"
|
||||
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
|
||||
/>
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Tags
|
||||
{t('forms.task.labels.tags', 'Tags')}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
|
|
@ -224,11 +226,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
<div className="pb-3 relative">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Project
|
||||
{t('forms.task.labels.project', 'Project')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search or create a project..."
|
||||
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
|
||||
value={newProjectName}
|
||||
onChange={handleProjectSearch}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
|
|
@ -248,7 +250,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-gray-500 dark:text-gray-300">
|
||||
No matching projects
|
||||
{t('forms.task.noMatchingProjects', 'No matching projects')}
|
||||
</div>
|
||||
)}
|
||||
{newProjectName && (
|
||||
|
|
@ -259,8 +261,8 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
{isCreatingProject
|
||||
? "Creating..."
|
||||
: `+ Create "${newProjectName}"`}
|
||||
? t('forms.task.creatingProject', 'Creating...')
|
||||
: t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -269,7 +271,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Status
|
||||
{t('forms.task.labels.status', 'Status')}
|
||||
</label>
|
||||
<StatusDropdown
|
||||
value={formData.status}
|
||||
|
|
@ -280,7 +282,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Priority
|
||||
{t('forms.task.labels.priority', 'Priority')}
|
||||
</label>
|
||||
<PriorityDropdown
|
||||
value={formData.priority || "medium"}
|
||||
|
|
@ -291,7 +293,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Due Date
|
||||
{t('forms.task.labels.dueDate', 'Due Date')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
|
|
@ -305,7 +307,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Note
|
||||
{t('forms.noteContent')}
|
||||
</label>
|
||||
<textarea
|
||||
id={`task_note_${task.id}`}
|
||||
|
|
@ -314,7 +316,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
value={formData.note || ""}
|
||||
onChange={handleChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Add any additional notes here"
|
||||
placeholder={t('forms.noteContentPlaceholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -332,8 +334,8 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
</div>
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="Delete Task"
|
||||
message="Are you sure you want to delete this task? This action cannot be undone."
|
||||
title={t('modals.deleteTask.title', 'Delete Task')}
|
||||
message={t('modals.deleteTask.confirmation', 'Are you sure you want to delete this task? This action cannot be undone.')}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskPriorityIconProps {
|
||||
priority: string | undefined;
|
||||
|
|
@ -7,6 +8,7 @@ interface TaskPriorityIconProps {
|
|||
}
|
||||
|
||||
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status }) => {
|
||||
const { t } = useTranslation();
|
||||
const getIconColor = () => {
|
||||
if (status === 'done') return 'text-green-500';
|
||||
switch (priority) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskStatusBadgeProps {
|
||||
status: string;
|
||||
|
|
@ -7,28 +8,29 @@ interface TaskStatusBadgeProps {
|
|||
}
|
||||
|
||||
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
|
||||
const { t } = useTranslation();
|
||||
let statusIcon, statusLabel;
|
||||
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = 'Not Started';
|
||||
statusLabel = t('status.notStarted', 'Not Started');
|
||||
break;
|
||||
case 'in_progress':
|
||||
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
|
||||
statusLabel = 'In Progress';
|
||||
statusLabel = t('status.inProgress', 'In Progress');
|
||||
break;
|
||||
case 'done':
|
||||
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
|
||||
statusLabel = 'Done';
|
||||
statusLabel = t('status.done', 'Done');
|
||||
break;
|
||||
case 'archived':
|
||||
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = 'Archived';
|
||||
statusLabel = t('status.archived', 'Archived');
|
||||
break;
|
||||
default:
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = 'Unknown';
|
||||
statusLabel = t('status.unknown', 'Unknown');
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { el, enUS, es, ja, uk, de } from "date-fns/locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ClipboardDocumentListIcon,
|
||||
ArrowPathIcon,
|
||||
CalendarDaysIcon,
|
||||
ClockIcon,
|
||||
InboxIcon,
|
||||
FolderIcon,
|
||||
ArchiveBoxIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { fetchProjects } from "../../utils/projectsService";
|
||||
|
|
@ -13,21 +20,37 @@ import { useStore } from "../../store/useStore";
|
|||
import TaskList from "./TaskList";
|
||||
import { Metrics } from "../../entities/Metrics";
|
||||
|
||||
const getLocale = (language: string) => {
|
||||
switch (language) {
|
||||
case 'el':
|
||||
return el;
|
||||
case 'es':
|
||||
return es;
|
||||
case 'jp':
|
||||
return ja;
|
||||
case 'ua':
|
||||
return uk;
|
||||
case 'de':
|
||||
return de;
|
||||
default:
|
||||
return enUS;
|
||||
}
|
||||
};
|
||||
const TasksToday: React.FC = () => {
|
||||
const {
|
||||
tasks,
|
||||
setTasks,
|
||||
setLoading: setTasksLoading,
|
||||
setError: setTasksError,
|
||||
} = useStore((state) => state.tasksStore);
|
||||
const {
|
||||
projects,
|
||||
setProjects,
|
||||
setLoading: setProjectsLoading,
|
||||
setError: setProjectsError,
|
||||
} = useStore((state) => state.projectsStore);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [metrics, setMetrics] = React.useState<Metrics>({
|
||||
// Don't use multiple separate useStore calls - combine them into one
|
||||
const store = useStore();
|
||||
|
||||
// Use local state for data instead of directly using store state
|
||||
// This prevents unnecessary re-renders from store updates
|
||||
const [localTasks, setLocalTasks] = useState<Task[]>([]);
|
||||
const [localProjects, setLocalProjects] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
// Metrics from the API
|
||||
const [metrics, setMetrics] = useState<Metrics>({
|
||||
total_open_tasks: 0,
|
||||
tasks_pending_over_month: 0,
|
||||
tasks_in_progress_count: 0,
|
||||
|
|
@ -36,156 +59,288 @@ const TasksToday: React.FC = () => {
|
|||
suggested_tasks: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// setProjectsLoading(true);
|
||||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
// Track mounting state to prevent state updates after unmount
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
const { tasks: fetchedTasks, metrics } = await fetchTasks("?type=today");
|
||||
setTasks(fetchedTasks);
|
||||
setMetrics(metrics);
|
||||
// Load data once on component mount
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
||||
// Only fetch data once on mount
|
||||
const loadData = async () => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
|
||||
try {
|
||||
// Load projects first
|
||||
const projectsData = await fetchProjects();
|
||||
if (isMounted.current) {
|
||||
setLocalProjects(projectsData);
|
||||
// Also update the store
|
||||
store.projectsStore.setProjects(projectsData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading data:", error);
|
||||
setProjectsError(true);
|
||||
setTasksError(true);
|
||||
console.error("Failed to fetch projects:", error);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Load tasks with metrics
|
||||
const { tasks: fetchedTasks, metrics: fetchedMetrics } = await fetchTasks("?type=today");
|
||||
if (isMounted.current) {
|
||||
setLocalTasks(fetchedTasks);
|
||||
setMetrics(fetchedMetrics);
|
||||
// Also update the store
|
||||
store.tasksStore.setTasks(fetchedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tasks:", error);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
} finally {
|
||||
// setProjectsLoading(false);
|
||||
// setTasksLoading(false);
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [setProjects, setProjectsLoading, setProjectsError, setTasks, setTasksLoading, setTasksError]);
|
||||
|
||||
const handleTaskUpdate = async (updatedTask: Task): Promise<void> => {
|
||||
if (!updatedTask.id) return;
|
||||
// Cleanup function to prevent state updates after unmount
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
// Memoize task handlers to prevent recreating functions on each render
|
||||
const handleTaskUpdate = useCallback(async (updatedTask: Task): Promise<void> => {
|
||||
if (!updatedTask.id || !isMounted.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setTasksLoading(true);
|
||||
await updateTask(updatedTask.id, updatedTask);
|
||||
// Refetch data to ensure consistency
|
||||
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||
setTasks(updatedTasks);
|
||||
setMetrics(metrics);
|
||||
|
||||
if (isMounted.current) {
|
||||
setLocalTasks(updatedTasks);
|
||||
setMetrics(metrics);
|
||||
// Update store
|
||||
store.tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
setTasksError(true);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
} finally {
|
||||
setTasksLoading(false);
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [store.tasksStore]);
|
||||
|
||||
const handleTaskDelete = async (taskId: number): Promise<void> => {
|
||||
const handleTaskDelete = useCallback(async (taskId: number): Promise<void> => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setTasksLoading(true);
|
||||
await deleteTask(taskId);
|
||||
// Refetch data to ensure consistency
|
||||
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||
setTasks(updatedTasks);
|
||||
setMetrics(metrics);
|
||||
|
||||
if (isMounted.current) {
|
||||
setLocalTasks(updatedTasks);
|
||||
setMetrics(metrics);
|
||||
// Update store
|
||||
store.tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
setTasksError(true);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
} finally {
|
||||
setTasksLoading(false);
|
||||
if (isMounted.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [store.tasksStore]);
|
||||
|
||||
const todayDate = format(new Date(), "yyyy-MM-dd");
|
||||
// Get inbox items count from store for the notification
|
||||
const inboxItemsCount = store.inboxStore.inboxItems.length;
|
||||
|
||||
// Show loading state
|
||||
if (isLoading && localTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('common.loading', 'Loading...')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (isError && localTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p className="text-red-500">{t('errors.somethingWentWrong', 'Something went wrong')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<h2 className="text-2xl font-light flex items-center">
|
||||
<CalendarDaysIcon className="h-5 w-5 mr-2" /> Today
|
||||
<CalendarDaysIcon className="h-5 w-5 mr-2" /> {t('tasks.today')}
|
||||
</h2>
|
||||
<span className="ml-4 text-gray-500">
|
||||
{format(new Date(), "EEEE, MMMM d, yyyy")}
|
||||
{format(new Date(), "PPP", { locale: getLocale(i18n.language) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ClipboardDocumentListIcon className="h-8 w-8 text-blue-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Backlog</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.total_open_tasks}
|
||||
</p>
|
||||
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Task Metrics */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-medium mb-3 text-gray-700 dark:text-gray-300">{t('tasks.metrics', 'Tasks')}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left column */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ClipboardDocumentListIcon className="h-6 w-6 text-blue-500 mr-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('tasks.backlog')}</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold">
|
||||
{metrics.total_open_tasks}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-6 w-6 text-green-500 mr-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('tasks.inProgress')}</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold">
|
||||
{metrics.tasks_in_progress_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<CalendarDaysIcon className="h-6 w-6 text-red-500 mr-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('tasks.dueToday')}</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold">
|
||||
{metrics.tasks_due_today.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ClockIcon className="h-6 w-6 text-yellow-500 mr-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('tasks.stale')}</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold">
|
||||
{metrics.tasks_pending_over_month}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ArrowPathIcon className="h-8 w-8 text-green-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">In Progress</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_in_progress_count}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Project Metrics */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-medium mb-3 text-gray-700 dark:text-gray-300">{t('projects.metrics', 'Projects')}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-6 w-6 text-blue-500 mr-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('projects.active')}</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold">
|
||||
{localProjects.filter(project => project.active).length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<CalendarDaysIcon className="h-8 w-8 text-red-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Due Today</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_due_today.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
|
||||
<ClockIcon className="h-8 w-8 text-yellow-500 mr-4" />
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Stale</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{metrics.tasks_pending_over_month}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<ArchiveBoxIcon className="h-6 w-6 text-gray-500 mr-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('projects.inactive')}</p>
|
||||
</div>
|
||||
<p className="text-xl font-semibold">
|
||||
{localProjects.filter(project => !project.active).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inbox Notification */}
|
||||
{inboxItemsCount > 0 && (
|
||||
<div className="mb-6 p-4 bg-white dark:bg-gray-900 border-l-4 border-blue-500 rounded-lg shadow">
|
||||
<Link to="/inbox" className="flex items-center">
|
||||
<InboxIcon className="h-6 w-6 text-blue-500 dark:text-blue-400 mr-3" />
|
||||
<div>
|
||||
<p className="text-gray-700 dark:text-gray-300 font-medium">
|
||||
{t('inbox.unprocessedItems', { count: inboxItemsCount, defaultValue: `You have ${inboxItemsCount} item(s) in your inbox.` })}
|
||||
</p>
|
||||
<p className="text-blue-600 dark:text-blue-400 text-sm">
|
||||
{t('inbox.processNow', 'Process them now')}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{metrics.tasks_due_today.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">Due Today</h3>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">{t('tasks.dueToday')}</h3>
|
||||
<TaskList
|
||||
tasks={metrics.tasks_due_today}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
projects={localProjects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{metrics.tasks_in_progress.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">In Progress</h3>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">{t('tasks.inProgress')}</h3>
|
||||
<TaskList
|
||||
tasks={metrics.tasks_in_progress}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
projects={localProjects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{metrics.suggested_tasks.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">Suggested</h3>
|
||||
<h3 className="text-xl font-medium mt-6 mb-2">{t('tasks.suggested')}</h3>
|
||||
<TaskList
|
||||
tasks={metrics.suggested_tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
projects={localProjects}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
{localTasks.length === 0 && (
|
||||
<p className="text-gray-500 text-center mt-4">
|
||||
No tasks available for today.
|
||||
{t('tasks.noTasksAvailable')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,78 @@
|
|||
import { Project } from "../../entities/Project";
|
||||
|
||||
export const getDescription = (query: URLSearchParams, projects: Project[]): string => {
|
||||
const projectId = query.get('project_id');
|
||||
if (projectId) {
|
||||
const project = projects.find((p) => p.id?.toString() === projectId);
|
||||
return project
|
||||
? `You are currently viewing all tasks associated with the "${project.name}" project. You can organize tasks within this project, set their priority, and track their completion. Use this space to focus on the tasks that belong specifically to this project.`
|
||||
: 'You are viewing tasks for a specific project. Use this space to manage and track tasks associated with this project.';
|
||||
}
|
||||
export const getDescription = (
|
||||
query: URLSearchParams,
|
||||
projects: Project[],
|
||||
t: (key: string, options?: any) => string
|
||||
): string => {
|
||||
try {
|
||||
// Default descriptions as fallbacks in case translation function fails
|
||||
const defaultDescriptions = {
|
||||
project: "Project tasks",
|
||||
today: "Tasks due today or scheduled for immediate attention",
|
||||
inbox: "Uncategorized tasks without project or due date",
|
||||
next: "Tasks that are actionable in the near future",
|
||||
upcoming: "Tasks scheduled for the upcoming week",
|
||||
someday: "Tasks without urgency or specific due date",
|
||||
completed: "Tasks you've completed",
|
||||
allTasks: "All tasks from different projects and priorities"
|
||||
};
|
||||
|
||||
if (query.get('type') === 'today') {
|
||||
return 'These are the tasks that are due today or tasks you’ve scheduled for immediate attention. Use this view to focus on what needs to be completed today. Mark tasks as completed, update their status, or adjust their due dates if needed.';
|
||||
// Check for project_id first
|
||||
const projectId = query.get('project_id');
|
||||
if (projectId) {
|
||||
try {
|
||||
const project = projects.find((p) => p.id?.toString() === projectId);
|
||||
if (project) {
|
||||
return t("taskViews.project.withName", { projectName: project.name });
|
||||
} else {
|
||||
return t("taskViews.project.noName");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Translation error for project description:", e);
|
||||
// Fallback with project name if available
|
||||
const project = projects.find((p) => p.id?.toString() === projectId);
|
||||
return project
|
||||
? `Tasks for project: ${project.name}`
|
||||
: defaultDescriptions.project;
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for type and status parameters
|
||||
try {
|
||||
if (query.get('type') === 'today') {
|
||||
return t("taskViews.today");
|
||||
}
|
||||
if (query.get('type') === 'inbox') {
|
||||
return t("taskViews.inbox");
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return t("taskViews.next");
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return t("taskViews.upcoming");
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return t("taskViews.someday");
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return t("taskViews.completed");
|
||||
}
|
||||
return t("taskViews.allTasks");
|
||||
} catch (e) {
|
||||
console.error("Translation error for task view description:", e);
|
||||
|
||||
// Return appropriate fallback based on type or status
|
||||
if (query.get('type') === 'today') return defaultDescriptions.today;
|
||||
if (query.get('type') === 'inbox') return defaultDescriptions.inbox;
|
||||
if (query.get('type') === 'next') return defaultDescriptions.next;
|
||||
if (query.get('type') === 'upcoming') return defaultDescriptions.upcoming;
|
||||
if (query.get('type') === 'someday') return defaultDescriptions.someday;
|
||||
if (query.get('status') === 'done') return defaultDescriptions.completed;
|
||||
return defaultDescriptions.allTasks;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in getDescription:", error);
|
||||
return "Tasks overview";
|
||||
}
|
||||
if (query.get('type') === 'inbox') {
|
||||
return 'The inbox is where all uncategorized tasks live. Tasks that haven’t been assigned to a project or given a due date will show up here. This is your “brain dump” area where you can quickly jot down tasks and organize them later.';
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return 'This view shows all the tasks that are actionable in the near future. These tasks are ready to be worked on next and don’t have long-term deadlines. It’s a good place to focus when you’re looking to make quick progress on tasks.';
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return 'This view highlights tasks that are scheduled for the upcoming week. It helps you prepare and stay ahead of deadlines by giving you an overview of the work you need to tackle in the near future. Tasks with due dates within the next 7 days will appear here.';
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return 'The “Someday” view is for tasks that aren’t urgent and don’t have a specific due date. These are tasks you may want to get to at some point, but they aren’t a priority right now. Use this section to keep track of ideas or long-term goals.';
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return 'Here you can see all the tasks you’ve completed. It’s a great way to review your accomplishments and reflect on the work you’ve finished. You can also find tasks that may need to be unarchived or referenced in the future.';
|
||||
}
|
||||
return 'You are viewing all tasks. This includes tasks from different projects, tasks without specific due dates, and tasks with varying levels of priority. Use this view for an overall look at everything on your to-do list.';
|
||||
};
|
||||
|
|
@ -10,30 +10,63 @@ import {
|
|||
Bars4Icon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export const getTitleAndIcon = (query: URLSearchParams, projects: Project[]) => {
|
||||
export const getTitleAndIcon = (
|
||||
query: URLSearchParams,
|
||||
projects: Project[],
|
||||
t: (key: string, options?: any) => string
|
||||
) => {
|
||||
try {
|
||||
// Default titles as fallbacks in case translation function fails
|
||||
const defaultTitles = {
|
||||
project: 'Project',
|
||||
today: 'Today',
|
||||
inbox: 'Inbox',
|
||||
next: 'Next Actions',
|
||||
upcoming: 'Upcoming',
|
||||
someday: 'Someday',
|
||||
completed: 'Completed',
|
||||
allTasks: 'All Tasks'
|
||||
};
|
||||
const projectId = query.get('project_id');
|
||||
if (projectId) {
|
||||
const project = projects.find((p) => p.id?.toString() === projectId);
|
||||
return { title: project ? project.name : 'Project', icon: FolderIcon };
|
||||
return { title: project ? project.name : t('sidebar.projects'), icon: FolderIcon };
|
||||
}
|
||||
|
||||
if (query.get('type') === 'today') {
|
||||
return { title: 'Today', icon: CalendarIcon };
|
||||
try {
|
||||
if (query.get('type') === 'today') {
|
||||
return { title: t('tasks.today'), icon: CalendarIcon };
|
||||
}
|
||||
if (query.get('type') === 'inbox') {
|
||||
return { title: t('sidebar.inbox'), icon: InboxIcon };
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return { title: t('sidebar.nextActions'), icon: ArrowRightIcon };
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return { title: t('sidebar.upcoming'), icon: ClockIcon };
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return { title: t('taskViews.someday') || defaultTitles.someday, icon: MoonIcon };
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return { title: t('sidebar.completed'), icon: CheckCircleIcon };
|
||||
}
|
||||
return { title: t('sidebar.allTasks'), icon: Bars4Icon };
|
||||
} catch (e) {
|
||||
console.error("Translation error for task view title:", e);
|
||||
|
||||
// Return appropriate fallback based on type or status
|
||||
if (query.get('type') === 'today') return { title: defaultTitles.today, icon: CalendarIcon };
|
||||
if (query.get('type') === 'inbox') return { title: defaultTitles.inbox, icon: InboxIcon };
|
||||
if (query.get('type') === 'next') return { title: defaultTitles.next, icon: ArrowRightIcon };
|
||||
if (query.get('type') === 'upcoming') return { title: defaultTitles.upcoming, icon: ClockIcon };
|
||||
if (query.get('type') === 'someday') return { title: defaultTitles.someday, icon: MoonIcon };
|
||||
if (query.get('status') === 'done') return { title: defaultTitles.completed, icon: CheckCircleIcon };
|
||||
return { title: defaultTitles.allTasks, icon: Bars4Icon };
|
||||
}
|
||||
if (query.get('type') === 'inbox') {
|
||||
return { title: 'Inbox', icon: InboxIcon };
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return { title: 'Next Actions', icon: ArrowRightIcon };
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return { title: 'Upcoming', icon: ClockIcon };
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return { title: 'Someday', icon: MoonIcon };
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return { title: 'Completed', icon: CheckCircleIcon };
|
||||
}
|
||||
return { title: 'All Tasks', icon: Bars4Icon };
|
||||
} catch (error) {
|
||||
console.error("Error in getTitleAndIcon:", error);
|
||||
return { title: "Tasks", icon: Bars4Icon };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TaskList from "./Task/TaskList";
|
||||
import NewTask from "./Task/NewTask";
|
||||
import { Task } from "../entities/Task";
|
||||
|
|
@ -16,7 +17,22 @@ import {
|
|||
|
||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
// Helper function to get search placeholder by language
|
||||
const getSearchPlaceholder = (language: string): string => {
|
||||
const placeholders: Record<string, string> = {
|
||||
en: 'Search tasks...',
|
||||
el: 'Αναζήτηση εργασιών...',
|
||||
es: 'Buscar tareas...',
|
||||
de: 'Aufgaben suchen...',
|
||||
jp: 'タスクを検索...',
|
||||
ua: 'Пошук завдань...'
|
||||
};
|
||||
|
||||
return placeholders[language] || 'Search tasks...';
|
||||
};
|
||||
|
||||
const Tasks: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
|
@ -34,7 +50,7 @@ const Tasks: React.FC = () => {
|
|||
const { title, icon } =
|
||||
stateTitle && stateIcon
|
||||
? { title: stateTitle, icon: stateIcon }
|
||||
: getTitleAndIcon(query, projects);
|
||||
: getTitleAndIcon(query, projects, t);
|
||||
|
||||
const IconComponent =
|
||||
typeof icon === "string" ? React.createElement(icon) : icon;
|
||||
|
|
@ -194,7 +210,7 @@ const Tasks: React.FC = () => {
|
|||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const description = getDescription(query, projects);
|
||||
const description = getDescription(query, projects, t);
|
||||
|
||||
const isNewTaskAllowed = () => {
|
||||
return status !== "done";
|
||||
|
|
@ -207,7 +223,6 @@ const Tasks: React.FC = () => {
|
|||
return (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
{/* Title and Icon */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4">
|
||||
<div className="flex items-center mb-2 sm:mb-0">
|
||||
{IconComponent && <IconComponent className="h-6 w-6 mr-2" />}
|
||||
|
|
@ -229,7 +244,6 @@ const Tasks: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -240,7 +254,7 @@ const Tasks: React.FC = () => {
|
|||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{" "}
|
||||
{capitalize(orderBy.split(":")[0].replace("_", " "))}
|
||||
{t(`sort.${orderBy.split(":")[0]}`, capitalize(orderBy.split(":")[0].replace("_", " ")))}
|
||||
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
|
|
@ -265,7 +279,7 @@ const Tasks: React.FC = () => {
|
|||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
role="menuitem"
|
||||
>
|
||||
{capitalize(order.split(":")[0].replace("_", " "))}
|
||||
{t(`sort.${order.split(":")[0]}`, capitalize(order.split(":")[0].replace("_", " ")))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -275,18 +289,16 @@ const Tasks: React.FC = () => {
|
|||
</div>
|
||||
|
||||
|
||||
{/* Description */}
|
||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
placeholder={getSearchPlaceholder(i18n.language)}
|
||||
value={taskSearchQuery}
|
||||
onChange={(e) => setTaskSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
|
||||
|
|
@ -294,7 +306,7 @@ const Tasks: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
<p>{t('common.loading', 'Loading...')}</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
|
|
@ -308,7 +320,6 @@ const Tasks: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Task List */}
|
||||
{filteredTasks.length > 0 ? (
|
||||
<TaskList
|
||||
tasks={filteredTasks}
|
||||
|
|
@ -319,7 +330,7 @@ const Tasks: React.FC = () => {
|
|||
/>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center mt-4">
|
||||
No tasks available.
|
||||
{t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
8
app/frontend/entities/InboxItem.ts
Normal file
8
app/frontend/entities/InboxItem.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export interface InboxItem {
|
||||
id?: number;
|
||||
content: string;
|
||||
status?: string; // 'added' | 'processed' | 'deleted'
|
||||
source?: string; // 'tududi' | 'telegram'
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
language: string;
|
||||
appearance: string;
|
||||
timezone: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
299
app/frontend/i18n.ts
Normal file
299
app/frontend/i18n.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// Define required translations for the app to function even if translations fail to load
|
||||
const fallbackResources = {
|
||||
en: {
|
||||
translation: {
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
appLoading: 'Loading application... Please wait.',
|
||||
error: 'Error',
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
},
|
||||
errors: {
|
||||
somethingWentWrong: 'Something went wrong, please try again',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Explicitly add resources for development
|
||||
const devResources = isDevelopment ? {
|
||||
en: {
|
||||
translation: fallbackResources.en.translation,
|
||||
},
|
||||
} : undefined;
|
||||
|
||||
console.log("Initializing i18n...");
|
||||
console.log("Environment:", process.env.NODE_ENV);
|
||||
|
||||
// Create i18n instance
|
||||
const i18nInstance = i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next);
|
||||
|
||||
// Initialize i18n
|
||||
i18nInstance.init({
|
||||
fallbackLng: 'en',
|
||||
debug: isDevelopment,
|
||||
|
||||
// Map language codes with region (e.g., 'en-US') to base language codes (e.g., 'en')
|
||||
load: 'languageOnly',
|
||||
|
||||
// Language mapping to handle specific cases
|
||||
supportedLngs: ['en', 'es', 'el', 'jp', 'ua', 'de'],
|
||||
nonExplicitSupportedLngs: true,
|
||||
|
||||
// Add fallback resources to prevent rendering issues
|
||||
resources: devResources,
|
||||
|
||||
// Language detection options
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'lng',
|
||||
lookupCookie: 'i18next',
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage', 'cookie']
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
|
||||
// Default namespace configuration
|
||||
defaultNS: 'translation',
|
||||
ns: ['translation'],
|
||||
|
||||
// Backend configuration for loading translations
|
||||
backend: {
|
||||
// Always use absolute path for development and production to avoid issues
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
// Add deterministic cache busting parameter based on build timestamp
|
||||
queryStringParams: { v: '1' },
|
||||
requestOptions: {
|
||||
cache: 'default', // Use default browser caching to improve performance
|
||||
credentials: 'same-origin',
|
||||
mode: 'cors'
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
console.log('i18n initialized successfully');
|
||||
console.log('Loaded languages:', i18n.languages);
|
||||
console.log('Current language:', i18n.language);
|
||||
console.log('Available namespaces:', i18n.options.ns);
|
||||
console.log('Has translation bundle:', i18n.hasResourceBundle(i18n.language, 'translation'));
|
||||
|
||||
// Try to load translations directly with both possible paths
|
||||
const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`;
|
||||
console.log(`Attempting to fetch translations from: ${loadPath}`);
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
console.log(`Manual fetch response: ${response.status} from ${loadPath}`);
|
||||
if (!response.ok) {
|
||||
// If first attempt fails and we're in development, try the alternative path
|
||||
if (isDevelopment) {
|
||||
console.log('First fetch attempt failed, trying alternative path');
|
||||
return fetch(`/locales/${i18n.language}/translation.json`);
|
||||
}
|
||||
throw new Error(`Failed to fetch translation: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Translation data fetched manually:', Object.keys(data));
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
console.log('Added resource bundle manually');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error manually fetching translations:', err);
|
||||
|
||||
// As a fallback, try to add translations from the public directory directly using require
|
||||
if (isDevelopment) {
|
||||
try {
|
||||
console.log('Attempting to load translations using a different approach...');
|
||||
setTimeout(() => {
|
||||
fetch(`/locales/${i18n.language}/translation.json`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
mode: 'cors'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
console.log('Added resource bundle via alternative approach');
|
||||
})
|
||||
.catch(e => console.error('Alternative loading approach failed:', e));
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
console.error('All attempts to load translations failed:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('i18n initialization error:', error);
|
||||
});
|
||||
|
||||
// Register event listeners for debugging translation loading
|
||||
i18n.on('initialized', (initialized) => {
|
||||
console.log('i18n initialized event:', initialized);
|
||||
console.log('Current language:', i18n.language);
|
||||
console.log('Available languages:', i18n.languages);
|
||||
console.log('Is initialized:', i18n.isInitialized);
|
||||
});
|
||||
|
||||
i18n.on('loaded', (loaded) => {
|
||||
console.log('Translations loaded event:', loaded);
|
||||
});
|
||||
|
||||
i18n.on('failedLoading', (lng, ns, msg) => {
|
||||
console.error(`Failed loading translation for ${lng}/${ns}: ${msg}`);
|
||||
});
|
||||
|
||||
i18n.on('missingKey', (lngs, namespace, key, res) => {
|
||||
console.warn(`Missing translation key: ${key} in namespace: ${namespace} for languages: ${lngs.join(', ')}`);
|
||||
});
|
||||
|
||||
// Create a custom event for language changes that components can listen for
|
||||
const dispatchLanguageChangeEvent = (lng: string) => {
|
||||
console.log(`Dispatching language change event for: ${lng}`);
|
||||
const event = new CustomEvent('app-language-changed', { detail: { language: lng } });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
console.log(`Language changed to: ${lng}`);
|
||||
|
||||
// Store language in localStorage for persistence
|
||||
localStorage.setItem('i18nextLng', lng);
|
||||
|
||||
// Update HTML lang attribute for accessibility and SEO
|
||||
document.documentElement.lang = lng;
|
||||
|
||||
const handleTranslationsLoaded = () => {
|
||||
// Dispatch a custom event after translations are loaded
|
||||
// This helps components know when to re-render
|
||||
dispatchLanguageChangeEvent(lng);
|
||||
|
||||
// Force update any i18next instances
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
// This triggers internal i18next change notifications
|
||||
const currentNS = i18n.options.defaultNS || 'translation';
|
||||
i18n.reloadResources(lng, currentNS);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure translations are loaded when language changes
|
||||
if (!i18n.hasResourceBundle(lng, 'translation')) {
|
||||
console.log(`Loading translations for language ${lng}`);
|
||||
|
||||
const loadPath = isDevelopment
|
||||
? `./locales/${lng}/translation.json`
|
||||
: `/locales/${lng}/translation.json`;
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch translations for ${lng}: ${response.status}`);
|
||||
// Try alternative path
|
||||
return fetch(`/locales/${lng}/translation.json`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data) {
|
||||
console.log(`Successfully loaded translations for ${lng}`);
|
||||
i18n.addResourceBundle(lng, 'translation', data, true, true);
|
||||
|
||||
// After translations are loaded, dispatch the event
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error loading translations for ${lng}:`, err);
|
||||
// Even if loading fails, we should still dispatch event so UI updates
|
||||
handleTranslationsLoaded();
|
||||
});
|
||||
} else {
|
||||
console.log(`Translations for ${lng} already loaded, skipping fetch`);
|
||||
// If translations are already loaded, dispatch the event immediately
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
});
|
||||
|
||||
// Add a function to manually check translation availability
|
||||
// Add type declaration for the global function and custom events
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'app-language-changed': CustomEvent<{ language: string }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
checkTranslation: (key: string) => void;
|
||||
forceLanguageReload: (lng?: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose a function to manually check translations (helpful for debugging)
|
||||
window.checkTranslation = (key: string) => {
|
||||
try {
|
||||
const translation = i18n.t(key);
|
||||
console.log(`Translation for '${key}': ${translation}`);
|
||||
console.log(`Is key '${key}' available: ${translation !== key}`);
|
||||
return translation;
|
||||
} catch (error) {
|
||||
console.error(`Error checking translation for key '${key}':`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a global function to force language reload
|
||||
window.forceLanguageReload = (lng?: string) => {
|
||||
const targetLng = lng || i18n.language;
|
||||
console.log(`Force reloading language: ${targetLng}`);
|
||||
|
||||
// Force reload the resources for current language
|
||||
i18n.reloadResources(targetLng, 'translation')
|
||||
.then(() => {
|
||||
console.log(`Resources reloaded for ${targetLng}`);
|
||||
|
||||
// To guarantee a reload effect:
|
||||
// 1. First dispatch the event
|
||||
dispatchLanguageChangeEvent(targetLng);
|
||||
|
||||
// 2. Force i18next to refresh its cache and notify all components
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
Object.values(i18n.services.resourceStore.data).forEach(lang => {
|
||||
// Add a proper type guard to check if translation exists and is an object
|
||||
if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) {
|
||||
// Touch the translation object to ensure React detects changes
|
||||
const temp = {...lang.translation as Record<string, unknown>};
|
||||
lang.translation = temp;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Explicitly change language if needed
|
||||
if (lng) {
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(targetLng);
|
||||
}, 50); // Small delay to ensure the DOM has time to update
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error reloading resources: ${err}`);
|
||||
});
|
||||
};
|
||||
|
||||
export default i18n;
|
||||
|
|
@ -1,8 +1,18 @@
|
|||
// Add type declaration for module.hot
|
||||
declare const module: {
|
||||
hot?: {
|
||||
accept: (path: string, callback: () => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./components/Shared/ToastContext";
|
||||
import './i18n'; // Import i18n config to initialize it
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from './i18n'; // Import the i18n instance with its configuration
|
||||
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
|
@ -18,13 +28,37 @@ if (isDarkMode) {
|
|||
|
||||
const container = document.getElementById("root");
|
||||
|
||||
// Store the root outside the if block so it can be accessed by the HMR code
|
||||
let root: any;
|
||||
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Hot Module Replacement (HMR) - Remove this snippet to remove HMR.
|
||||
// Learn more: https://www.webpackjs.com/concepts/hot-module-replacement/
|
||||
if (module.hot) {
|
||||
module.hot.accept('./App', () => {
|
||||
// New version of App component imported
|
||||
if (root) {
|
||||
root.render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Area } from "../entities/Area";
|
|||
import { Note } from "../entities/Note";
|
||||
import { Task } from "../entities/Task";
|
||||
import { Tag } from "../entities/Tag";
|
||||
import { InboxItem } from "../entities/InboxItem";
|
||||
|
||||
interface NotesStore {
|
||||
notes: Note[];
|
||||
|
|
@ -50,12 +51,25 @@ interface TasksStore {
|
|||
setError: (isError: boolean) => void;
|
||||
}
|
||||
|
||||
interface InboxStore {
|
||||
inboxItems: InboxItem[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
setInboxItems: (inboxItems: InboxItem[]) => void;
|
||||
addInboxItem: (inboxItem: InboxItem) => void;
|
||||
updateInboxItem: (inboxItem: InboxItem) => void;
|
||||
removeInboxItem: (id: number) => void;
|
||||
setLoading: (isLoading: boolean) => void;
|
||||
setError: (isError: boolean) => void;
|
||||
}
|
||||
|
||||
interface StoreState {
|
||||
notesStore: NotesStore;
|
||||
areasStore: AreasStore;
|
||||
projectsStore: ProjectsStore;
|
||||
tagsStore: TagsStore;
|
||||
tasksStore: TasksStore;
|
||||
inboxStore: InboxStore;
|
||||
}
|
||||
|
||||
export const useStore = create<StoreState>((set) => ({
|
||||
|
|
@ -99,4 +113,38 @@ export const useStore = create<StoreState>((set) => ({
|
|||
setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })),
|
||||
setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
|
||||
},
|
||||
inboxStore: {
|
||||
inboxItems: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setInboxItems: (inboxItems) => set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, inboxItems }
|
||||
})),
|
||||
addInboxItem: (inboxItem) => set((state) => ({
|
||||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: [...state.inboxStore.inboxItems, inboxItem]
|
||||
}
|
||||
})),
|
||||
updateInboxItem: (inboxItem) => set((state) => ({
|
||||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: state.inboxStore.inboxItems.map(item =>
|
||||
item.id === inboxItem.id ? inboxItem : item
|
||||
)
|
||||
}
|
||||
})),
|
||||
removeInboxItem: (id) => set((state) => ({
|
||||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: state.inboxStore.inboxItems.filter(item => item.id !== id)
|
||||
}
|
||||
})),
|
||||
setLoading: (isLoading) => set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, isLoading }
|
||||
})),
|
||||
setError: (isError) => set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, isError }
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
|
@ -3,6 +3,21 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
.spinner {
|
||||
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border-left-color: #09f;
|
||||
animation: spin 1s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
|
|
|||
115
app/frontend/utils/dateUtils.ts
Normal file
115
app/frontend/utils/dateUtils.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { format, Locale } from 'date-fns';
|
||||
import { enUS } from 'date-fns/locale/en-US';
|
||||
import { es } from 'date-fns/locale/es';
|
||||
import { el } from 'date-fns/locale/el';
|
||||
import i18n from '../i18n';
|
||||
|
||||
/**
|
||||
* Maps i18next language codes to date-fns locale objects
|
||||
*/
|
||||
const localeMap: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
es: es,
|
||||
el: el,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the date-fns locale object based on the current i18next language
|
||||
* Falls back to English if the current language is not supported
|
||||
*/
|
||||
export const getCurrentLocale = (): Locale => {
|
||||
const language = i18n.language || 'en';
|
||||
return localeMap[language] || enUS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date using the current locale from i18next
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @param formatStr - The format string (https://date-fns.org/v2.29.3/docs/format)
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
export const formatLocalizedDate = (date: Date | number, formatStr: string): string => {
|
||||
return format(date, formatStr, {
|
||||
locale: getCurrentLocale(),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the date format pattern from translation file
|
||||
*
|
||||
* @param formatKey - The key for the format in the dateFormats object
|
||||
* @param fallback - Fallback format to use if translation is missing
|
||||
* @returns The format pattern string
|
||||
*/
|
||||
export const getDateFormatPattern = (formatKey: string, fallback: string): string => {
|
||||
const pattern = i18n.t(`dateFormats.${formatKey}`);
|
||||
// If the translation key doesn't exist, it will return the key itself
|
||||
return pattern === `dateFormats.${formatKey}` ? fallback : pattern;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date in a long readable format based on the current locale
|
||||
* Example: "Monday, January 1, 2023" (in English)
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
export const formatLongDate = (date: Date | number): string => {
|
||||
return formatLocalizedDate(date, getDateFormatPattern('long', 'EEEE, MMMM d, yyyy'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date in a short format based on the current locale
|
||||
* Example: "Jan 1, 2023" (in English)
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
export const formatShortDate = (date: Date | number): string => {
|
||||
return formatLocalizedDate(date, getDateFormatPattern('short', 'MMM d, yyyy'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date to show only month and year based on the current locale
|
||||
* Example: "January 2023" (in English)
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
export const formatMonthYear = (date: Date | number): string => {
|
||||
return formatLocalizedDate(date, getDateFormatPattern('monthYear', 'MMMM yyyy'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date to show only day and month based on the current locale
|
||||
* Example: "January 1" (in English)
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
export const formatDayMonth = (date: Date | number): string => {
|
||||
return formatLocalizedDate(date, getDateFormatPattern('dayMonth', 'MMMM d'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date to show only time based on the current locale
|
||||
* Example: "3:30 PM" (in English)
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @returns The formatted time string
|
||||
*/
|
||||
export const formatTime = (date: Date | number): string => {
|
||||
return formatLocalizedDate(date, getDateFormatPattern('time', 'h:mm a'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a date to show date and time based on the current locale
|
||||
* Example: "Jan 1, 2023 3:30 PM" (in English)
|
||||
*
|
||||
* @param date - The date to format
|
||||
* @returns The formatted date and time string
|
||||
*/
|
||||
export const formatDateTime = (date: Date | number): string => {
|
||||
return formatLocalizedDate(date, getDateFormatPattern('dateTime', 'MMM d, yyyy h:mm a'));
|
||||
};
|
||||
165
app/frontend/utils/inboxService.ts
Normal file
165
app/frontend/utils/inboxService.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { InboxItem } from "../entities/InboxItem";
|
||||
import { useStore } from "../store/useStore";
|
||||
|
||||
// API functions
|
||||
export const fetchInboxItems = async (): Promise<InboxItem[]> => {
|
||||
const response = await fetch('/api/inbox');
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch inbox items.');
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
throw new Error('Resulting inbox items are not an array.');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createInboxItem = async (content: string, source: string = 'tududi'): Promise<InboxItem> => {
|
||||
const response = await fetch('/api/inbox', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, source }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create inbox item.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateInboxItem = async (itemId: number, content: string): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update inbox item.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}/process`, {
|
||||
method: 'PATCH',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to process inbox item.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteInboxItem = async (itemId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete inbox item.');
|
||||
};
|
||||
|
||||
// Track last check time to detect new items
|
||||
let lastCheckTimestamp = Date.now();
|
||||
|
||||
// Store-aware functions
|
||||
export const loadInboxItemsToStore = async (): Promise<void> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
// Only show loading for initial load
|
||||
if (inboxStore.inboxItems.length === 0) {
|
||||
inboxStore.setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await fetchInboxItems();
|
||||
|
||||
// Check for new items since last check
|
||||
const currentItemIds = new Set(inboxStore.inboxItems.map(item => item.id));
|
||||
const currentTime = Date.now();
|
||||
|
||||
// New telegram items
|
||||
const newTelegramItems = items.filter(item =>
|
||||
item.id &&
|
||||
!currentItemIds.has(item.id) &&
|
||||
item.source === 'telegram'
|
||||
);
|
||||
|
||||
// Only show notifications if we have detected changes
|
||||
if (inboxStore.inboxItems.length > 0 && newTelegramItems.length > 0) {
|
||||
// Instead of trying to show toast directly (which won't work outside of React components),
|
||||
// dispatch a custom event that the component can listen for and show toasts
|
||||
|
||||
// Get some minimal info about the items for the notification
|
||||
const notificationData = {
|
||||
count: newTelegramItems.length,
|
||||
firstItemContent: newTelegramItems[0].content.substring(0, 30) +
|
||||
(newTelegramItems[0].content.length > 30 ? '...' : '')
|
||||
};
|
||||
|
||||
// Dispatch a custom event with the notification data
|
||||
window.dispatchEvent(new CustomEvent('inboxItemsUpdated', {
|
||||
detail: notificationData
|
||||
}));
|
||||
}
|
||||
|
||||
// Update state and timestamp
|
||||
inboxStore.setInboxItems(items);
|
||||
inboxStore.setError(false);
|
||||
lastCheckTimestamp = currentTime;
|
||||
} catch (error) {
|
||||
console.error('Failed to load inbox items:', error);
|
||||
inboxStore.setError(true);
|
||||
} finally {
|
||||
inboxStore.setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
export const createInboxItemWithStore = async (content: string, source: string = 'tududi'): Promise<InboxItem> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
const newItem = await createInboxItem(content, source);
|
||||
inboxStore.addInboxItem(newItem);
|
||||
return newItem;
|
||||
} catch (error) {
|
||||
console.error('Failed to create inbox item:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateInboxItemWithStore = async (itemId: number, content: string): Promise<InboxItem> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
const updatedItem = await updateInboxItem(itemId, content);
|
||||
inboxStore.updateInboxItem(updatedItem);
|
||||
return updatedItem;
|
||||
} catch (error) {
|
||||
console.error('Failed to update inbox item:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const processInboxItemWithStore = async (itemId: number): Promise<InboxItem> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
const processedItem = await processInboxItem(itemId);
|
||||
inboxStore.removeInboxItem(itemId);
|
||||
return processedItem;
|
||||
} catch (error) {
|
||||
console.error('Failed to process inbox item:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteInboxItemWithStore = async (itemId: number): Promise<void> => {
|
||||
const inboxStore = useStore.getState().inboxStore;
|
||||
|
||||
try {
|
||||
await deleteInboxItem(itemId);
|
||||
inboxStore.removeInboxItem(itemId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete inbox item:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -8,19 +8,29 @@ export const fetchNotes = async (): Promise<Note[]> => {
|
|||
};
|
||||
|
||||
export const createNote = async (noteData: Note): Promise<Note> => {
|
||||
const response = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
try {
|
||||
console.log("Creating note with data:", JSON.stringify(noteData, null, 2));
|
||||
const response = await fetch('/api/note', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create note.');
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error("Error creating note:", errorData);
|
||||
throw new Error(`Failed to create note: ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Exception in createNote:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateNote = async (noteId: number, noteData: Note): Promise<Note> => {
|
||||
const response = await fetch(`/api/notes/${noteId}`, {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(noteData),
|
||||
|
|
@ -32,7 +42,7 @@ export const updateNote = async (noteId: number, noteData: Note): Promise<Note>
|
|||
};
|
||||
|
||||
export const deleteNote = async (noteId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/notes/${noteId}`, {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import { Tag } from "../entities/Tag";
|
||||
|
||||
export const fetchTags = async (): Promise<Tag[]> => {
|
||||
const response = await fetch("/api/tags");
|
||||
if (!response.ok) throw new Error('Failed to fetch tags.');
|
||||
try {
|
||||
const response = await fetch("/api/tags", {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch tags.');
|
||||
|
||||
return await response.json();
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Tags fetch error:", error);
|
||||
// Return empty array to prevent UI from breaking
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createTag = async (tagData: Tag): Promise<Tag> => {
|
||||
|
|
|
|||
71
app/frontend/utils/urlService.ts
Normal file
71
app/frontend/utils/urlService.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Service for URL-related operations like extracting titles from web pages
|
||||
*/
|
||||
|
||||
export interface UrlTitleResult {
|
||||
url: string;
|
||||
title: string | null;
|
||||
found?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the title of a web page from its URL
|
||||
* @param url The URL to extract the title from
|
||||
* @returns Promise resolving to the page title or null if not found
|
||||
*/
|
||||
export const extractUrlTitle = async (url: string): Promise<UrlTitleResult> => {
|
||||
try {
|
||||
const response = await fetch(`/api/url/title?url=${encodeURIComponent(url)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to extract URL title');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL title:', error);
|
||||
return { url, title: null, error: (error as Error).message };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract a URL and its title from arbitrary text
|
||||
* @param text The text that might contain a URL
|
||||
* @returns Promise resolving to the URL and title if found
|
||||
*/
|
||||
export const extractTitleFromText = async (text: string): Promise<UrlTitleResult | null> => {
|
||||
try {
|
||||
const response = await fetch('/api/url/extract-from-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to extract title from text');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.found === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error extracting title from text:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a string is likely a URL
|
||||
* @param text The text to check
|
||||
* @returns True if the text appears to be a URL
|
||||
*/
|
||||
export const isUrl = (text: string): boolean => {
|
||||
// Basic URL validation regex
|
||||
const urlRegex = /^(https?:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/i;
|
||||
return urlRegex.test(text.trim());
|
||||
};
|
||||
22
app/models/inbox_item.rb
Normal file
22
app/models/inbox_item.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
class InboxItem < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
|
||||
enum status: { added: 'added', processed: 'processed', deleted: 'deleted' }
|
||||
enum source: { tududi: 'tududi', telegram: 'telegram' }
|
||||
|
||||
scope :active, -> { where(status: 'added') }
|
||||
scope :processed, -> { where(status: 'processed') }
|
||||
scope :by_source, ->(source) { where(source: source) }
|
||||
|
||||
validates :content, presence: true
|
||||
validates :status, inclusion: { in: statuses.keys }
|
||||
validates :source, inclusion: { in: sources.keys }
|
||||
|
||||
def mark_as_processed!
|
||||
update(status: 'processed')
|
||||
end
|
||||
|
||||
def mark_as_deleted!
|
||||
update(status: 'deleted')
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,6 @@ class Task < ActiveRecord::Base
|
|||
enum priority: { low: 0, medium: 1, high: 2 }
|
||||
enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3, waiting: 4 }
|
||||
|
||||
# Existing scopes
|
||||
scope :complete, -> { where(status: statuses[:done]) }
|
||||
scope :incomplete, -> { where.not(status: statuses[:done]) }
|
||||
scope :due_today, -> { incomplete.where('DATE(due_date) <= ?', Date.today) }
|
||||
|
|
@ -31,7 +30,6 @@ class Task < ActiveRecord::Base
|
|||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
|
||||
# New class method to filter tasks based on params
|
||||
def self.filter_by_params(params, user)
|
||||
tasks = user.tasks.includes(:project, :tags)
|
||||
|
||||
|
|
@ -92,23 +90,22 @@ class Task < ActiveRecord::Base
|
|||
|
||||
# Gather tasks in projects expiring starting today, order by task priority
|
||||
tasks_in_expiring_projects = user.tasks.incomplete
|
||||
.joins(:project)
|
||||
.where('projects.due_date_at >= ?', Date.today)
|
||||
.where(projects: { active: true }) # Only active projects
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC'))
|
||||
.limit(5)
|
||||
.joins(:project)
|
||||
.where('projects.due_date_at >= ?', Date.today)
|
||||
.where(projects: { active: true }) # Only active projects
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC'))
|
||||
.limit(5)
|
||||
|
||||
# Gather tasks not assigned to projects expiring today, ordered by task priority
|
||||
tasks_without_projects = user.tasks.incomplete
|
||||
.where(status: statuses[:not_started], project_id: nil)
|
||||
.or(user.tasks.where(project_id: nil, status: statuses[:not_started]))
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order(priority: :desc)
|
||||
.limit(5)
|
||||
.where(status: statuses[:not_started], project_id: nil)
|
||||
.or(user.tasks.where(project_id: nil, status: statuses[:not_started]))
|
||||
.where.not(id: excluded_task_ids)
|
||||
.order(priority: :desc)
|
||||
.limit(5)
|
||||
|
||||
# Combine both list of suggested tasks
|
||||
|
||||
suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects)
|
||||
{
|
||||
total_open_tasks: total_open_tasks,
|
||||
|
|
@ -130,27 +127,27 @@ class Task < ActiveRecord::Base
|
|||
end
|
||||
|
||||
# Parse or default the project due date
|
||||
project_due_date = if (task.project&.due_date_at).is_a?(String)
|
||||
project_due_date = if task.project&.due_date_at.is_a?(String)
|
||||
Date.parse(task&.project&.due_date_at)
|
||||
else
|
||||
task.project&.due_date_at || Date.new(9999, 12, 31)
|
||||
end
|
||||
|
||||
# Priority in descending order (sorted values should be negative for sort_by)
|
||||
priority_value = -(Task.priorities.fetch(task.priority, -1))
|
||||
priority_value = -Task.priorities.fetch(task.priority, -1)
|
||||
|
||||
# Determine sorting flags based on various criteria
|
||||
is_high_priority_proj_with_due_date = (task.priority == 'high' && task&.project&.due_date_at) ? 0 : 1
|
||||
is_high_priority_with_due_date = (task.priority == 'high' && task.due_date) ? 0 : 1
|
||||
is_high_priority = (task.priority == 'high' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1
|
||||
is_high_priority_proj_with_due_date = task.priority == 'high' && task&.project&.due_date_at ? 0 : 1
|
||||
is_high_priority_with_due_date = task.priority == 'high' && task.due_date ? 0 : 1
|
||||
is_high_priority = task.priority == 'high' && !task.due_date && !task&.project&.due_date_at ? 0 : 1
|
||||
|
||||
is_medium_priority_proj_with_due_date = (task.priority == 'medium' && task&.project&.due_date_at) ? 0 : 1
|
||||
is_medium_priority_with_due_date = (task.priority == 'medium' && task.due_date) ? 0 : 1
|
||||
is_medium_priority = (task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1
|
||||
is_medium_priority_proj_with_due_date = task.priority == 'medium' && task&.project&.due_date_at ? 0 : 1
|
||||
is_medium_priority_with_due_date = task.priority == 'medium' && task.due_date ? 0 : 1
|
||||
is_medium_priority = task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at ? 0 : 1
|
||||
|
||||
is_low_priority_proj_with_due_date = (task.priority == 'low' && task&.project&.due_date_at) ? 0 : 1
|
||||
is_low_priority_with_due_date = (task.priority == 'low' && task.due_date) ? 0 : 1
|
||||
is_low_priority = (task.priority == 'low' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1
|
||||
is_low_priority_proj_with_due_date = task.priority == 'low' && task&.project&.due_date_at ? 0 : 1
|
||||
is_low_priority_with_due_date = task.priority == 'low' && task.due_date ? 0 : 1
|
||||
is_low_priority = task.priority == 'low' && !task.due_date && !task&.project&.due_date_at ? 0 : 1
|
||||
|
||||
# Primary sorting criteria
|
||||
[
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
class User < ActiveRecord::Base
|
||||
has_secure_password
|
||||
|
||||
TASK_SUMMARY_FREQUENCIES = %w[daily weekdays weekly 1h 2h 4h 8h 12h].freeze
|
||||
|
||||
has_many :tasks, dependent: :destroy
|
||||
has_many :projects, dependent: :destroy
|
||||
has_many :areas, dependent: :destroy
|
||||
has_many :notes, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :inbox_items, dependent: :destroy
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true
|
||||
validates :appearance, inclusion: { in: %w[light dark] }
|
||||
validates :language, presence: true
|
||||
validates :timezone, presence: true
|
||||
validates :task_summary_frequency, inclusion: { in: TASK_SUMMARY_FREQUENCIES }, allow_nil: true
|
||||
|
||||
# has_one_attached :avatar_image
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Sinatra::Application
|
|||
content_type :json
|
||||
|
||||
if logged_in?
|
||||
{ user: { email: current_user.email, id: current_user.id } }.to_json
|
||||
{ user: { email: current_user.email, id: current_user.id, language: current_user.language, appearance: current_user.appearance, timezone: current_user.timezone } }.to_json
|
||||
else
|
||||
{ user: nil }.to_json
|
||||
end
|
||||
|
|
@ -30,7 +30,7 @@ class Sinatra::Application
|
|||
if user&.authenticate(password)
|
||||
session[:user_id] = user.id
|
||||
status 200
|
||||
{ user: { email: user.email, id: user.id } }.to_json
|
||||
{ user: { email: user.email, id: user.id, language: user.language, appearance: user.appearance, timezone: user.timezone } }.to_json
|
||||
else
|
||||
halt 401, { errors: ['Invalid credentials'] }.to_json
|
||||
end
|
||||
|
|
|
|||
92
app/routes/inbox_routes.rb
Normal file
92
app/routes/inbox_routes.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
module Sinatra
|
||||
class Application
|
||||
get '/api/inbox' do
|
||||
content_type :json
|
||||
|
||||
items = current_user.inbox_items.where(status: 'added').order(created_at: :desc)
|
||||
items.to_json
|
||||
end
|
||||
|
||||
post '/api/inbox' do
|
||||
content_type :json
|
||||
|
||||
request_body = request.body.read
|
||||
item_data = begin
|
||||
JSON.parse(request_body)
|
||||
rescue JSON::ParserError => e
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
item = current_user.inbox_items.build(
|
||||
content: item_data['content'],
|
||||
source: item_data['source'] || 'tududi'
|
||||
)
|
||||
|
||||
if item.save
|
||||
status 201
|
||||
item.to_json
|
||||
else
|
||||
errors = item.errors.full_messages
|
||||
halt 400, { error: 'There was a problem creating the inbox item.', details: errors }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
patch '/api/inbox/:id' do
|
||||
content_type :json
|
||||
|
||||
item = current_user.inbox_items.find_by(id: params[:id])
|
||||
halt 404, { error: 'Inbox item not found.' }.to_json unless item
|
||||
|
||||
request_body = request.body.read
|
||||
item_data = begin
|
||||
JSON.parse(request_body)
|
||||
rescue JSON::ParserError => e
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
if item.update(content: item_data['content'])
|
||||
item.to_json
|
||||
else
|
||||
errors = item.errors.full_messages
|
||||
halt 400, { error: 'There was a problem updating the inbox item.', details: errors }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
patch '/api/inbox/:id/process' do
|
||||
content_type :json
|
||||
|
||||
item = current_user.inbox_items.find_by(id: params[:id])
|
||||
halt 404, { error: 'Inbox item not found.' }.to_json unless item
|
||||
|
||||
if item.mark_as_processed!
|
||||
item.to_json
|
||||
else
|
||||
halt 400, { error: 'There was a problem processing the inbox item.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Mark an inbox item as deleted
|
||||
delete '/api/inbox/:id' do
|
||||
content_type :json
|
||||
|
||||
item = current_user.inbox_items.find_by(id: params[:id])
|
||||
halt 404, { error: 'Inbox item not found.' }.to_json unless item
|
||||
|
||||
if item.mark_as_deleted!
|
||||
{ message: 'Inbox item successfully deleted' }.to_json
|
||||
else
|
||||
halt 400, { error: 'There was a problem deleting the inbox item.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Get a specific inbox item by ID
|
||||
get '/api/inbox/:id' do
|
||||
content_type :json
|
||||
|
||||
item = current_user.inbox_items.find_by(id: params[:id])
|
||||
halt 404, { error: 'Inbox item not found.' }.to_json unless item
|
||||
|
||||
item.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -60,7 +60,16 @@ class Sinatra::Application
|
|||
end
|
||||
|
||||
if note.save
|
||||
update_note_tags(note, note_data[:tags])
|
||||
# Handle tags array whether it's an array of strings or an array of objects with name property
|
||||
tag_names = if note_data[:tags].is_a?(Array) && note_data[:tags].all? { |t| t.is_a?(String) }
|
||||
note_data[:tags]
|
||||
elsif note_data[:tags].is_a?(Array) && note_data[:tags].all? { |t| t.is_a?(Hash) && t[:name] }
|
||||
note_data[:tags].map { |t| t[:name] }
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
update_note_tags(note, tag_names)
|
||||
status 201
|
||||
note.to_json(include: :tags)
|
||||
else
|
||||
|
|
@ -91,7 +100,18 @@ class Sinatra::Application
|
|||
end
|
||||
|
||||
if note.update(note_attributes)
|
||||
update_note_tags(note, request_data['tags'])
|
||||
# Handle tags array whether it's an array of strings or an array of objects with name property
|
||||
tag_names = if request_data['tags'].is_a?(Array) && request_data['tags'].all? { |t| t.is_a?(String) }
|
||||
request_data['tags']
|
||||
elsif request_data['tags'].is_a?(Array) && request_data['tags'].all? do |t|
|
||||
t.is_a?(Hash) && t['name']
|
||||
end
|
||||
request_data['tags'].map { |t| t['name'] }
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
update_note_tags(note, tag_names)
|
||||
note.to_json(include: :tags)
|
||||
else
|
||||
status 400
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class Sinatra::Application
|
|||
|
||||
if tag.save
|
||||
tag.as_json(only: %i[id name]).to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem updating the tag.' }.to_json
|
||||
end
|
||||
|
|
|
|||
231
app/routes/telegram_poller.rb
Normal file
231
app/routes/telegram_poller.rb
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'json'
|
||||
require 'thread'
|
||||
|
||||
# A class to handle polling for Telegram updates
|
||||
class TelegramPoller
|
||||
@@instance = nil
|
||||
@@mutex = Mutex.new
|
||||
|
||||
attr_reader :running, :thread, :poll_interval, :last_update_id, :users_to_poll
|
||||
|
||||
def initialize
|
||||
@running = false
|
||||
@thread = nil
|
||||
@poll_interval = 5 # seconds
|
||||
@last_update_id = 0
|
||||
@users_to_poll = []
|
||||
|
||||
# Keep a record of which users have active polling
|
||||
@user_status = {}
|
||||
end
|
||||
|
||||
def self.instance
|
||||
@@mutex.synchronize do
|
||||
@@instance ||= new
|
||||
end
|
||||
@@instance
|
||||
end
|
||||
|
||||
# Start polling for a specific user
|
||||
def add_user(user)
|
||||
return false unless user && user.telegram_bot_token
|
||||
|
||||
@users_to_poll << user unless @users_to_poll.any? { |u| u.id == user.id }
|
||||
|
||||
# Start the polling thread if not already running
|
||||
start_polling if @users_to_poll.any? && !@running
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Remove a user from polling
|
||||
def remove_user(user_id)
|
||||
@users_to_poll.reject! { |u| u.id == user_id }
|
||||
|
||||
# Stop polling if no users left
|
||||
stop_polling if @users_to_poll.empty? && @running
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Start the polling thread
|
||||
def start_polling
|
||||
return if @running
|
||||
|
||||
@running = true
|
||||
@thread = Thread.new do
|
||||
while @running
|
||||
begin
|
||||
poll_updates
|
||||
rescue => e
|
||||
puts "Error polling Telegram: #{e.message}"
|
||||
puts e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
sleep @poll_interval
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Stop the polling thread
|
||||
def stop_polling
|
||||
return unless @running
|
||||
|
||||
@running = false
|
||||
@thread.join if @thread
|
||||
@thread = nil
|
||||
end
|
||||
|
||||
# Poll for updates from Telegram
|
||||
def poll_updates
|
||||
@users_to_poll.each do |user|
|
||||
token = user.telegram_bot_token
|
||||
next unless token
|
||||
|
||||
begin
|
||||
# Get updates from Telegram
|
||||
uri = URI.parse("https://api.telegram.org/bot#{token}/getUpdates")
|
||||
|
||||
params = {
|
||||
offset: @user_status[user.id]&.dig(:last_update_id).to_i + 1,
|
||||
timeout: 1 # Short timeout for quick polling
|
||||
}
|
||||
|
||||
uri.query = URI.encode_www_form(params)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = 5
|
||||
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
response = http.request(request)
|
||||
|
||||
if response.code == '200'
|
||||
data = JSON.parse(response.body)
|
||||
|
||||
if data['ok'] && data['result'].is_a?(Array)
|
||||
process_updates(user, data['result'])
|
||||
end
|
||||
else
|
||||
puts "Error polling Telegram for user #{user.id}: #{response.code} #{response.message}"
|
||||
end
|
||||
rescue => e
|
||||
puts "Error getting updates for user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Process updates received from Telegram
|
||||
def process_updates(user, updates)
|
||||
return if updates.empty?
|
||||
|
||||
# Track the highest update_id to avoid processing the same update twice
|
||||
highest_update_id = updates.map { |u| u['update_id'].to_i }.max || 0
|
||||
|
||||
# Save the last update ID for this user
|
||||
@user_status[user.id] ||= {}
|
||||
@user_status[user.id][:last_update_id] = highest_update_id if highest_update_id > (@user_status[user.id][:last_update_id] || 0)
|
||||
|
||||
updates.each do |update|
|
||||
begin
|
||||
# Process message updates
|
||||
if update['message'] && update['message']['text']
|
||||
process_message(user, update)
|
||||
end
|
||||
rescue => e
|
||||
puts "Error processing update #{update['update_id']}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Process a single message
|
||||
def process_message(user, update)
|
||||
message = update['message']
|
||||
text = message['text']
|
||||
chat_id = message['chat']['id'].to_s
|
||||
message_id = message['message_id']
|
||||
|
||||
puts "Processing message from user #{user.id}: #{text}"
|
||||
|
||||
# Save the chat_id if not already saved
|
||||
if user.telegram_chat_id.nil? || user.telegram_chat_id.empty?
|
||||
puts "Updating user's telegram_chat_id to #{chat_id}"
|
||||
user.update(telegram_chat_id: chat_id)
|
||||
end
|
||||
|
||||
# Create an inbox item
|
||||
inbox_item = user.inbox_items.build(
|
||||
content: text,
|
||||
source: 'telegram'
|
||||
)
|
||||
|
||||
if inbox_item.save
|
||||
puts "Created inbox item #{inbox_item.id} from Telegram message"
|
||||
|
||||
# Send confirmation
|
||||
begin
|
||||
send_telegram_message(
|
||||
user.telegram_bot_token,
|
||||
chat_id,
|
||||
"✅ Added to Tududi inbox: \"#{text}\"",
|
||||
message_id
|
||||
)
|
||||
rescue => e
|
||||
puts "Error sending confirmation: #{e.message}"
|
||||
end
|
||||
else
|
||||
puts "Failed to create inbox item: #{inbox_item.errors.full_messages.join(', ')}"
|
||||
|
||||
# Send error message
|
||||
begin
|
||||
send_telegram_message(
|
||||
user.telegram_bot_token,
|
||||
chat_id,
|
||||
"❌ Failed to add to inbox: #{inbox_item.errors.full_messages.join(', ')}",
|
||||
message_id
|
||||
)
|
||||
rescue => e
|
||||
puts "Error sending error message: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Send a message to Telegram
|
||||
def send_telegram_message(token, chat_id, text, reply_to_message_id = nil)
|
||||
uri = URI.parse("https://api.telegram.org/bot#{token}/sendMessage")
|
||||
|
||||
# Prepare message parameters
|
||||
message_params = {
|
||||
chat_id: chat_id,
|
||||
text: text,
|
||||
parse_mode: "MarkdownV2"
|
||||
}
|
||||
|
||||
# Add reply_to_message_id if provided
|
||||
message_params[:reply_to_message_id] = reply_to_message_id if reply_to_message_id
|
||||
|
||||
# Send the request to Telegram API
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
|
||||
request.body = message_params.to_json
|
||||
|
||||
response = http.request(request)
|
||||
return JSON.parse(response.body)
|
||||
end
|
||||
|
||||
# Get status of the poller
|
||||
def status
|
||||
{
|
||||
running: @running,
|
||||
users_count: @users_to_poll.size,
|
||||
poll_interval: @poll_interval,
|
||||
user_status: @user_status
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Initialize the poller when this file is loaded
|
||||
TelegramPoller.instance
|
||||
160
app/routes/telegram_routes.rb
Normal file
160
app/routes/telegram_routes.rb
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'json'
|
||||
require_relative 'telegram_poller'
|
||||
|
||||
module Sinatra
|
||||
class Application
|
||||
# Start polling for a user
|
||||
post '/api/telegram/start-polling' do
|
||||
content_type :json
|
||||
|
||||
# Get the current user's Telegram token
|
||||
user = current_user
|
||||
halt 400, { error: 'Telegram bot token not set.' }.to_json unless user.telegram_bot_token
|
||||
|
||||
# Add the user to the polling list
|
||||
if TelegramPoller.instance.add_user(user)
|
||||
{
|
||||
success: true,
|
||||
message: 'Telegram polling started',
|
||||
status: TelegramPoller.instance.status
|
||||
}.to_json
|
||||
else
|
||||
halt 500, { error: 'Failed to start Telegram polling.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Stop polling for a user
|
||||
post '/api/telegram/stop-polling' do
|
||||
content_type :json
|
||||
|
||||
user = current_user
|
||||
|
||||
# Remove the user from the polling list
|
||||
if TelegramPoller.instance.remove_user(user.id)
|
||||
{
|
||||
success: true,
|
||||
message: 'Telegram polling stopped',
|
||||
status: TelegramPoller.instance.status
|
||||
}.to_json
|
||||
else
|
||||
halt 500, { error: 'Failed to stop Telegram polling.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Get polling status
|
||||
get '/api/telegram/polling-status' do
|
||||
content_type :json
|
||||
|
||||
{
|
||||
success: true,
|
||||
status: TelegramPoller.instance.status,
|
||||
is_polling: TelegramPoller.instance.users_to_poll.any? { |u| u.id == current_user.id }
|
||||
}.to_json
|
||||
end
|
||||
|
||||
# Setup the Telegram bot for a user (save token and start polling)
|
||||
post '/api/telegram/setup' do
|
||||
content_type :json
|
||||
request_body = request.body.read
|
||||
|
||||
begin
|
||||
setup_data = JSON.parse(request_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
token = setup_data['token']
|
||||
halt 400, { error: 'Telegram bot token is required.' }.to_json unless token && !token.empty?
|
||||
|
||||
# Validate the token by making a getMe request to Telegram
|
||||
begin
|
||||
uri = URI.parse("https://api.telegram.org/bot#{token}/getMe")
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
response = http.get(uri.request_uri)
|
||||
json_response = JSON.parse(response.body)
|
||||
|
||||
if json_response['ok']
|
||||
# Token is valid, save it to the user
|
||||
bot_username = json_response['result']['username']
|
||||
current_user.update(telegram_bot_token: token)
|
||||
|
||||
# Start polling for this user
|
||||
TelegramPoller.instance.add_user(current_user)
|
||||
|
||||
# Return success with bot info
|
||||
{
|
||||
success: true,
|
||||
message: 'Telegram bot configured successfully and polling started!',
|
||||
bot: {
|
||||
username: bot_username,
|
||||
polling_status: TelegramPoller.instance.status,
|
||||
chat_url: "https://t.me/#{bot_username}"
|
||||
}
|
||||
}.to_json
|
||||
else
|
||||
halt 400, { error: 'Invalid Telegram bot token.', details: json_response['description'] }.to_json
|
||||
end
|
||||
rescue => e
|
||||
halt 500, { error: 'Error validating Telegram bot token.', details: e.message }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Test endpoint to simulate a Telegram message (for development)
|
||||
post '/api/telegram/test/:user_id' do
|
||||
content_type :json
|
||||
request_body = request.body.read
|
||||
|
||||
begin
|
||||
message_data = JSON.parse(request_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
user_id = params[:user_id]
|
||||
user = User.find_by(id: user_id)
|
||||
halt 404, { error: 'User not found.' }.to_json unless user
|
||||
halt 400, { error: 'User has no Telegram bot token configured.' }.to_json unless user.telegram_bot_token
|
||||
|
||||
text = message_data['text'] || 'Test message from development environment'
|
||||
|
||||
# Create an inbox item directly
|
||||
inbox_item = user.inbox_items.build(
|
||||
content: text,
|
||||
source: 'telegram'
|
||||
)
|
||||
|
||||
if inbox_item.save
|
||||
# Send confirmation to Telegram if the user has a chat_id
|
||||
if user.telegram_chat_id
|
||||
begin
|
||||
# Use the TelegramPoller's send_message method
|
||||
response = TelegramPoller.instance.send_telegram_message(
|
||||
user.telegram_bot_token,
|
||||
user.telegram_chat_id,
|
||||
"✅ Added to Tududi inbox: \"#{text}\""
|
||||
)
|
||||
puts "Test message confirmation sent: #{response}"
|
||||
rescue => e
|
||||
puts "Error sending test confirmation: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
success: true,
|
||||
message: 'Test Telegram message processed successfully!',
|
||||
inbox_item_id: inbox_item.id
|
||||
}.to_json
|
||||
else
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to create inbox item from test message',
|
||||
errors: inbox_item.errors.full_messages
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
app/routes/url_routes.rb
Normal file
43
app/routes/url_routes.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
require_relative '../services/url_title_extractor_service'
|
||||
|
||||
module Sinatra
|
||||
class Application
|
||||
get '/api/url/title' do
|
||||
content_type :json
|
||||
|
||||
url = params[:url]
|
||||
halt 400, { error: 'URL parameter is required' }.to_json unless url
|
||||
|
||||
title = UrlTitleExtractorService.extract_title(url)
|
||||
|
||||
if title
|
||||
{ url: url, title: title }.to_json
|
||||
else
|
||||
{ url: url, title: nil, error: 'Could not extract title' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post '/api/url/extract-from-text' do
|
||||
content_type :json
|
||||
|
||||
request_body = request.body.read
|
||||
|
||||
begin
|
||||
data = JSON.parse(request_body)
|
||||
text = data['text']
|
||||
|
||||
halt 400, { error: 'Text parameter is required' }.to_json unless text
|
||||
|
||||
result = UrlTitleExtractorService.extract_title_from_text(text)
|
||||
|
||||
if result
|
||||
result.to_json
|
||||
else
|
||||
{ found: false }.to_json
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format' }.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ module Sinatra
|
|||
user = current_user
|
||||
|
||||
if user
|
||||
user.to_json(only: %i[id email appearance language timezone avatar_image])
|
||||
user.to_json(only: %i[id email appearance language timezone avatar_image telegram_bot_token telegram_chat_id task_summary_enabled task_summary_frequency])
|
||||
else
|
||||
halt 404, { error: 'Profile not found.' }.to_json
|
||||
end
|
||||
|
|
@ -29,13 +29,140 @@ module Sinatra
|
|||
allowed_params[:language] = request_payload['language'] if request_payload.key?('language')
|
||||
allowed_params[:timezone] = request_payload['timezone'] if request_payload.key?('timezone')
|
||||
allowed_params[:avatar_image] = request_payload['avatar_image'] if request_payload.key?('avatar_image')
|
||||
allowed_params[:telegram_bot_token] = request_payload['telegram_bot_token'] if request_payload.key?('telegram_bot_token')
|
||||
|
||||
if user.update(allowed_params)
|
||||
user.to_json(only: %i[id email appearance language timezone avatar_image])
|
||||
user.to_json(only: %i[id email appearance language timezone avatar_image telegram_bot_token telegram_chat_id])
|
||||
else
|
||||
status 400
|
||||
{ error: 'Failed to update profile.', details: user.errors.full_messages }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post '/api/profile/task-summary/toggle' do
|
||||
content_type :json
|
||||
|
||||
user = current_user
|
||||
halt 404, { error: 'User not found.' }.to_json unless user
|
||||
|
||||
# Toggle the task_summary_enabled flag
|
||||
enabled = !user.task_summary_enabled
|
||||
|
||||
if user.update(task_summary_enabled: enabled)
|
||||
# If enabling, send a test summary to confirm it works
|
||||
if enabled && user.telegram_bot_token && user.telegram_chat_id
|
||||
begin
|
||||
success = TaskSummaryService.send_summary_to_user(user.id)
|
||||
|
||||
if success
|
||||
{
|
||||
success: true,
|
||||
enabled: enabled,
|
||||
message: 'Task summary notifications have been enabled and a test message was sent to your Telegram.'
|
||||
}.to_json
|
||||
else
|
||||
user.update(task_summary_enabled: false)
|
||||
halt 400, {
|
||||
error: 'Failed to send test message to Telegram. Please check your Telegram bot configuration.'
|
||||
}.to_json
|
||||
end
|
||||
rescue => e
|
||||
user.update(task_summary_enabled: false)
|
||||
halt 400, {
|
||||
error: 'Error sending test message to Telegram.',
|
||||
details: e.message
|
||||
}.to_json
|
||||
end
|
||||
else
|
||||
{
|
||||
success: true,
|
||||
enabled: enabled,
|
||||
message: enabled ? 'Task summary notifications have been enabled.' : 'Task summary notifications have been disabled.'
|
||||
}.to_json
|
||||
end
|
||||
else
|
||||
halt 400, {
|
||||
error: 'Failed to update task summary settings.',
|
||||
details: user.errors.full_messages
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post '/api/profile/task-summary/frequency' do
|
||||
content_type :json
|
||||
|
||||
begin
|
||||
request_payload = JSON.parse(request.body.read)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
frequency = request_payload['frequency']
|
||||
halt 400, { error: 'Frequency is required.' }.to_json unless frequency
|
||||
|
||||
# Validate frequency value
|
||||
valid_frequencies = User::TASK_SUMMARY_FREQUENCIES
|
||||
halt 400, { error: 'Invalid frequency value.' }.to_json unless valid_frequencies.include?(frequency)
|
||||
|
||||
user = current_user
|
||||
halt 404, { error: 'User not found.' }.to_json unless user
|
||||
|
||||
if user.update(task_summary_frequency: frequency)
|
||||
{
|
||||
success: true,
|
||||
frequency: frequency,
|
||||
message: "Task summary frequency has been set to #{frequency}."
|
||||
}.to_json
|
||||
else
|
||||
halt 400, {
|
||||
error: 'Failed to update task summary frequency.',
|
||||
details: user.errors.full_messages
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post '/api/profile/task-summary/send-now' do
|
||||
content_type :json
|
||||
|
||||
user = current_user
|
||||
halt 404, { error: 'User not found.' }.to_json unless user
|
||||
|
||||
if user.telegram_bot_token && user.telegram_chat_id
|
||||
begin
|
||||
success = TaskSummaryService.send_summary_to_user(user.id)
|
||||
|
||||
if success
|
||||
{
|
||||
success: true,
|
||||
message: 'Task summary was sent to your Telegram.'
|
||||
}.to_json
|
||||
else
|
||||
halt 400, { error: 'Failed to send message to Telegram.' }.to_json
|
||||
end
|
||||
rescue => e
|
||||
halt 400, {
|
||||
error: 'Error sending message to Telegram.',
|
||||
details: e.message
|
||||
}.to_json
|
||||
end
|
||||
else
|
||||
halt 400, { error: 'Telegram bot is not properly configured.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
get '/api/profile/task-summary/status' do
|
||||
content_type :json
|
||||
|
||||
user = current_user
|
||||
halt 404, { error: 'User not found.' }.to_json unless user
|
||||
|
||||
{
|
||||
success: true,
|
||||
enabled: user.task_summary_enabled,
|
||||
frequency: user.task_summary_frequency,
|
||||
last_run: user.task_summary_last_run,
|
||||
next_run: user.task_summary_next_run
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
288
app/services/task_summary_service.rb
Normal file
288
app/services/task_summary_service.rb
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# app/services/task_summary_service.rb
|
||||
require 'yaml'
|
||||
|
||||
class TaskSummaryService
|
||||
# Helper method to escape special characters for MarkdownV2
|
||||
def self.escape_markdown(text)
|
||||
# Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
|
||||
text.to_s.gsub(/([_*\[\]()~`>#+\-=|{}.!])/, '\\\\\1')
|
||||
end
|
||||
|
||||
def self.generate_summary_for_user(user_id)
|
||||
user = User.find_by(id: user_id)
|
||||
return nil unless user
|
||||
|
||||
# Get today's tasks, in progress tasks, etc.
|
||||
tasks = user.tasks
|
||||
|
||||
today = Date.today
|
||||
due_today = tasks.where('DATE(due_date) = ?', today).where.not(status: 'done')
|
||||
in_progress = tasks.where(status: 'in_progress')
|
||||
completed_today = tasks.where(status: 'done').where('DATE(updated_at) = ?', today)
|
||||
|
||||
# Generate summary message
|
||||
message = "📋 *Today's Task Summary*\n\n"
|
||||
|
||||
# Add a header divider
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
|
||||
# Start Today's Plan section
|
||||
message += "✏️ *Today's Plan*\n\n"
|
||||
|
||||
# Add due today tasks to Today's Plan
|
||||
# Add due today tasks to Today's Plan
|
||||
if due_today.any?
|
||||
message += "🚀 *Tasks Due Today:*\n"
|
||||
due_today.order(:name).each_with_index do |task, index|
|
||||
priority_emoji =
|
||||
case task.priority
|
||||
when 'high' then '🔴'
|
||||
when 'medium' then '🟠'
|
||||
when 'low' then '🟢'
|
||||
else '⚪'
|
||||
end
|
||||
|
||||
# Escape special characters in task name and project name
|
||||
task_name = escape_markdown(task.name)
|
||||
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
|
||||
|
||||
message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}\n"
|
||||
end
|
||||
message += "\n"
|
||||
end
|
||||
# Add in progress tasks to Today's Plan
|
||||
if in_progress.any?
|
||||
message += "⚙️ *In Progress Tasks:*\n"
|
||||
in_progress.order(:name).each_with_index do |task, index|
|
||||
priority_emoji =
|
||||
case task.priority
|
||||
when 'high' then '🔴'
|
||||
when 'medium' then '🟠'
|
||||
when 'low' then '🟢'
|
||||
else '⚪'
|
||||
end
|
||||
|
||||
# Escape special characters in task name and project name
|
||||
task_name = escape_markdown(task.name)
|
||||
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
|
||||
|
||||
message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}\n"
|
||||
end
|
||||
message += "\n"
|
||||
end
|
||||
# Add suggested tasks (not done, not in due today or in progress)
|
||||
suggested_task_ids = due_today.pluck(:id) + in_progress.pluck(:id)
|
||||
|
||||
# Get tasks in expiring projects - same logic as Task.compute_metrics
|
||||
tasks_in_expiring_projects = tasks
|
||||
.where.not(status: 'done')
|
||||
.where.not(id: suggested_task_ids)
|
||||
.joins(:project)
|
||||
.where('projects.due_date_at >= ?', today)
|
||||
.where(projects: { active: true }) # Only active projects
|
||||
.order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC'))
|
||||
|
||||
# Get tasks not assigned to projects - same logic as Task.compute_metrics
|
||||
tasks_without_projects = tasks
|
||||
.where.not(status: 'done')
|
||||
.where.not(id: suggested_task_ids)
|
||||
.where(project_id: nil, status: 'not_started')
|
||||
.order(priority: :desc)
|
||||
|
||||
# Combine both sets of tasks
|
||||
combined_tasks = (tasks_in_expiring_projects + tasks_without_projects)
|
||||
|
||||
# Sort using same logic as Task.sort_suggested_tasks
|
||||
suggested_tasks = combined_tasks.sort_by do |task|
|
||||
# Parse or default the task due date
|
||||
task_due_date = if task.due_date.is_a?(String)
|
||||
Date.parse(task.due_date)
|
||||
else
|
||||
task.due_date || Date.new(9999, 12, 31)
|
||||
end
|
||||
|
||||
# Parse or default the project due date
|
||||
project_due_date = if task.project&.due_date_at.is_a?(String)
|
||||
Date.parse(task&.project&.due_date_at)
|
||||
else
|
||||
task.project&.due_date_at || Date.new(9999, 12, 31)
|
||||
end
|
||||
|
||||
# Priority in descending order (sorted values should be negative for sort_by)
|
||||
priority_value = -Task.priorities.fetch(task.priority, -1)
|
||||
|
||||
# Determine sorting flags based on various criteria
|
||||
is_high_priority_proj_with_due_date = task.priority == 'high' && task.project&.due_date_at ? 0 : 1
|
||||
is_high_priority_with_due_date = task.priority == 'high' && task.due_date ? 0 : 1
|
||||
is_high_priority = task.priority == 'high' && !task.due_date && !task.project&.due_date_at ? 0 : 1
|
||||
|
||||
is_medium_priority_proj_with_due_date = task.priority == 'medium' && task.project&.due_date_at ? 0 : 1
|
||||
is_medium_priority_with_due_date = task.priority == 'medium' && task.due_date ? 0 : 1
|
||||
is_medium_priority = task.priority == 'medium' && !task.due_date && !task.project&.due_date_at ? 0 : 1
|
||||
|
||||
is_low_priority_proj_with_due_date = task.priority == 'low' && task.project&.due_date_at ? 0 : 1
|
||||
is_low_priority_with_due_date = task.priority == 'low' && task.due_date ? 0 : 1
|
||||
is_low_priority = task.priority == 'low' && !task.due_date && !task.project&.due_date_at ? 0 : 1
|
||||
|
||||
# Primary sorting criteria - same as Task.sort_suggested_tasks
|
||||
[
|
||||
is_high_priority_proj_with_due_date,
|
||||
is_high_priority_with_due_date,
|
||||
is_high_priority,
|
||||
|
||||
is_medium_priority_proj_with_due_date,
|
||||
is_medium_priority_with_due_date,
|
||||
is_medium_priority,
|
||||
|
||||
is_low_priority_proj_with_due_date,
|
||||
is_low_priority_with_due_date,
|
||||
is_low_priority,
|
||||
|
||||
task_due_date,
|
||||
project_due_date,
|
||||
priority_value
|
||||
]
|
||||
end.first(5)
|
||||
|
||||
if suggested_tasks.any?
|
||||
message += "💡 *Suggested Tasks \\(Top 3\\):*\n"
|
||||
# Only display the top 3 suggested tasks
|
||||
suggested_tasks.first(5).each_with_index do |task, index|
|
||||
priority_emoji =
|
||||
case task.priority
|
||||
when 'high' then '🔴'
|
||||
when 'medium' then '🟠'
|
||||
when 'low' then '🟢'
|
||||
else '⚪'
|
||||
end
|
||||
|
||||
# Escape special characters in task name and project name
|
||||
task_name = escape_markdown(task.name)
|
||||
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
|
||||
due_date = task.due_date ? " \\(Due: #{escape_markdown(task.due_date.strftime('%b %d'))}\\)" : ''
|
||||
|
||||
message += "#{index + 1}\\. #{priority_emoji} #{task_name}#{project_info}#{due_date}\n"
|
||||
end
|
||||
message += "\n"
|
||||
end
|
||||
|
||||
# Add a section divider
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
|
||||
# Add completed tasks for today if any
|
||||
if completed_today.any?
|
||||
message += "✅ *Completed Today:*\n"
|
||||
completed_today.order(updated_at: :desc).each_with_index do |task, index|
|
||||
# Escape special characters in task name and project name
|
||||
task_name = escape_markdown(task.name)
|
||||
project_info = task.project ? " \\[#{escape_markdown(task.project.name)}\\]" : ''
|
||||
|
||||
message += "#{index + 1}\\. #{task_name}#{project_info}\n"
|
||||
end
|
||||
message += "\n"
|
||||
end
|
||||
|
||||
# Add inbox count if available
|
||||
inbox_items_count = user.inbox_items.where(status: 'added').count
|
||||
if inbox_items_count > 0
|
||||
message += "*Inbox:*\n"
|
||||
message += "• You have #{inbox_items_count} item\\(s\\) in your inbox to process\\.\n\n"
|
||||
end
|
||||
|
||||
# Add a section divider
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||
# Add a motivational note from the YAML file
|
||||
begin
|
||||
quotes_file = Rails.root.join('config', 'quotes.yml')
|
||||
quotes_data = YAML.load_file(quotes_file)['quotes']
|
||||
|
||||
message += "💪 *Today's Motivation:*\n"
|
||||
quote = quotes_data.sample
|
||||
# Escape special characters in the quote
|
||||
message += escape_markdown(quote)
|
||||
rescue StandardError => e
|
||||
# Fallback to default quotes if there's an issue loading from YAML
|
||||
default_quotes = [
|
||||
'Focus on progress, not perfection.',
|
||||
'One task at a time leads to great accomplishments.',
|
||||
"Today's effort is tomorrow's success.",
|
||||
'Small steps every day lead to big results.'
|
||||
]
|
||||
|
||||
message += "💪 *Today's Motivation:*\n"
|
||||
quote = default_quotes.sample
|
||||
# Escape special characters in the quote
|
||||
message += escape_markdown(quote)
|
||||
end
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
def self.send_summary_to_user(user_id)
|
||||
user = User.find_by(id: user_id)
|
||||
return false unless user && user.telegram_bot_token && user.telegram_chat_id
|
||||
|
||||
summary = generate_summary_for_user(user_id)
|
||||
return false unless summary
|
||||
|
||||
# Send the message via Telegram
|
||||
begin
|
||||
TelegramPoller.instance.send_telegram_message(
|
||||
user.telegram_bot_token,
|
||||
user.telegram_chat_id,
|
||||
summary
|
||||
)
|
||||
|
||||
# Update the last run time and calculate the next run time
|
||||
now = Time.now
|
||||
next_run = calculate_next_run_time(user, now)
|
||||
|
||||
# Update the user's tracking fields
|
||||
user.update(
|
||||
task_summary_last_run: now,
|
||||
task_summary_next_run: next_run
|
||||
)
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
puts "Error sending task summary to user #{user_id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate when the next task summary should run based on frequency
|
||||
def self.calculate_next_run_time(user, from_time = Time.now)
|
||||
case user.task_summary_frequency
|
||||
when 'daily'
|
||||
# Next day at 7 AM
|
||||
from_time.tomorrow.change(hour: 7, min: 0, sec: 0)
|
||||
when 'weekdays'
|
||||
# If it's Friday, next is Monday, otherwise next day (if it's a weekday)
|
||||
days_until_next_weekday =
|
||||
if from_time.wday == 5 # Friday
|
||||
3 # Next Monday
|
||||
elsif from_time.wday == 6 # Saturday
|
||||
2 # Next Monday
|
||||
else
|
||||
1 # Next day
|
||||
end
|
||||
from_time.advance(days: days_until_next_weekday).change(hour: 7, min: 0, sec: 0)
|
||||
when 'weekly'
|
||||
# Next week same day, or next Monday if we're being specific
|
||||
from_time.advance(days: 7).change(hour: 7, min: 0, sec: 0)
|
||||
when '1h'
|
||||
from_time + 1.hour
|
||||
when '2h'
|
||||
from_time + 2.hours
|
||||
when '4h'
|
||||
from_time + 4.hours
|
||||
when '8h'
|
||||
from_time + 8.hours
|
||||
when '12h'
|
||||
from_time + 12.hours
|
||||
else
|
||||
# Default to daily at 7 AM
|
||||
from_time.tomorrow.change(hour: 7, min: 0, sec: 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
71
app/services/url_title_extractor_service.rb
Normal file
71
app/services/url_title_extractor_service.rb
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
require 'net/http'
|
||||
require 'uri'
|
||||
require 'nokogiri'
|
||||
|
||||
class UrlTitleExtractorService
|
||||
MAX_BYTES = 50_000
|
||||
TIMEOUT = 5
|
||||
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
|
||||
def self.url?(text)
|
||||
url_regex = %r{^(https?://)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(/.*)?$}i
|
||||
text.strip.match?(url_regex)
|
||||
end
|
||||
|
||||
def self.extract_title(url)
|
||||
url = "http://#{url}" unless url.start_with?('http://') || url.start_with?('https://')
|
||||
|
||||
begin
|
||||
uri = URI.parse(url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
|
||||
http.open_timeout = TIMEOUT
|
||||
http.read_timeout = TIMEOUT
|
||||
|
||||
if uri.scheme == 'https'
|
||||
http.use_ssl = true
|
||||
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
||||
end
|
||||
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
request['User-Agent'] = USER_AGENT
|
||||
request['Accept'] = 'text/html'
|
||||
request['Range'] = "bytes=0-#{MAX_BYTES}"
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
if response.is_a?(Net::HTTPRedirection)
|
||||
redirect_url = response['location']
|
||||
return extract_title(redirect_url)
|
||||
end
|
||||
|
||||
if response.code.to_i.between?(200, 299) && response.body
|
||||
html = Nokogiri::HTML(response.body)
|
||||
|
||||
title = html.at_css('title')&.text&.strip
|
||||
return title if title && !title.empty?
|
||||
|
||||
og_title = html.at_css('meta[property="og:title"]')&.attributes&.[]('content')&.value&.strip
|
||||
return og_title if og_title && !og_title.empty?
|
||||
|
||||
twitter_title = html.at_css('meta[name="twitter:title"]')&.attributes&.[]('content')&.value&.strip
|
||||
return twitter_title if twitter_title && !twitter_title.empty?
|
||||
end
|
||||
|
||||
nil
|
||||
rescue StandardError => e
|
||||
puts "Error extracting title from URL: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.extract_title_from_text(text)
|
||||
text.split(/\s+/).each do |word|
|
||||
if url?(word)
|
||||
title = extract_title(word)
|
||||
return { url: word, title: title } if title
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
184
config/initializers/scheduler.rb
Normal file
184
config/initializers/scheduler.rb
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# config/initializers/scheduler.rb
|
||||
require 'rufus-scheduler'
|
||||
require_relative '../../app/services/task_summary_service'
|
||||
|
||||
# Helper method to update user's summary tracking fields
|
||||
def update_summary_tracking(user, next_time)
|
||||
user.update(
|
||||
task_summary_last_run: Time.now,
|
||||
task_summary_next_run: next_time
|
||||
)
|
||||
end
|
||||
|
||||
# Don't schedule in test environment or when reloading in development
|
||||
if ENV['RACK_ENV'] != 'test' && ENV['DISABLE_SCHEDULER'] != 'true'
|
||||
scheduler = Rufus::Scheduler.singleton
|
||||
|
||||
# Daily schedule at 7 AM (for users with daily frequency)
|
||||
daily_job = scheduler.cron '0 7 * * *' do
|
||||
puts "Running scheduled task: Daily task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: 'daily')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
# Calculate next run time - tomorrow at 7 AM
|
||||
next_run = Time.now.tomorrow.change(hour: 7, min: 0, sec: 0)
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent daily summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending daily summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Weekdays schedule at 7 AM (Monday through Friday)
|
||||
weekday_job = scheduler.cron '0 7 * * 1-5' do
|
||||
puts "Running scheduled task: Weekday task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: 'weekdays')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
# Calculate next run time - next weekday at 7 AM
|
||||
current_day = Time.now.wday
|
||||
days_until_next_weekday = current_day == 5 ? 3 : 1 # If Friday, next is Monday (+3 days), otherwise next day
|
||||
next_run = Time.now.advance(days: days_until_next_weekday).change(hour: 7, min: 0, sec: 0)
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent weekday summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending weekday summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Weekly schedule at 7 AM on Monday
|
||||
weekly_job = scheduler.cron '0 7 * * 1' do
|
||||
puts "Running scheduled task: Weekly task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: 'weekly')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
# Calculate next run time - next Monday at 7 AM
|
||||
next_run = Time.now.advance(days: 7).change(hour: 7, min: 0, sec: 0)
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent weekly summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending weekly summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Hourly schedules for different intervals
|
||||
|
||||
# Every 1 hour
|
||||
hourly_job = scheduler.every '1h' do
|
||||
puts "Running scheduled task: Hourly (1h) task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: '1h')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
next_run = Time.now + 1.hour
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent hourly summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending hourly summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Every 2 hours
|
||||
two_hourly_job = scheduler.every '2h' do
|
||||
puts "Running scheduled task: 2-hour task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: '2h')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
next_run = Time.now + 2.hours
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent 2-hour summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending 2-hour summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Every 4 hours
|
||||
four_hourly_job = scheduler.every '4h' do
|
||||
puts "Running scheduled task: 4-hour task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: '4h')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
next_run = Time.now + 4.hours
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent 4-hour summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending 4-hour summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Every 8 hours
|
||||
eight_hourly_job = scheduler.every '8h' do
|
||||
puts "Running scheduled task: 8-hour task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: '8h')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
next_run = Time.now + 8.hours
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent 8-hour summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending 8-hour summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Every 12 hours
|
||||
twelve_hourly_job = scheduler.every '12h' do
|
||||
puts "Running scheduled task: 12-hour task summary"
|
||||
|
||||
User.where.not(telegram_bot_token: [nil, ''])
|
||||
.where.not(telegram_chat_id: [nil, ''])
|
||||
.where(task_summary_enabled: true)
|
||||
.where(task_summary_frequency: '12h')
|
||||
.each do |user|
|
||||
begin
|
||||
TaskSummaryService.send_summary_to_user(user.id)
|
||||
next_run = Time.now + 12.hours
|
||||
update_summary_tracking(user, next_run)
|
||||
puts "Sent 12-hour summary to user #{user.id}"
|
||||
rescue => e
|
||||
puts "Error sending 12-hour summary to user #{user.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
51
config/initializers/telegram_initializer.rb
Normal file
51
config/initializers/telegram_initializer.rb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env ruby
|
||||
# config/initializers/telegram_initializer.rb
|
||||
require_relative '../../app/routes/telegram_poller'
|
||||
require_relative '../../app/models/user'
|
||||
|
||||
# Create a method to be called after database connection is established
|
||||
def initialize_telegram_polling
|
||||
if ENV['RACK_ENV'] != 'test' && ENV['DISABLE_TELEGRAM'] != 'true'
|
||||
puts "Initializing Telegram polling for configured users..."
|
||||
|
||||
# Get singleton instance of the poller
|
||||
poller = TelegramPoller.instance
|
||||
|
||||
# Make sure we have a database connection
|
||||
begin
|
||||
ActiveRecord::Base.connection_pool.with_connection do |connection|
|
||||
# Check if the users table exists
|
||||
if connection.table_exists?('users')
|
||||
begin
|
||||
# Find users with configured Telegram tokens
|
||||
users_with_telegram = User.where.not(telegram_bot_token: [nil, ''])
|
||||
|
||||
if users_with_telegram.any?
|
||||
puts "Found #{users_with_telegram.count} users with Telegram configuration"
|
||||
|
||||
# Add each user to the polling list
|
||||
users_with_telegram.each do |user|
|
||||
puts "Starting Telegram polling for user #{user.id}"
|
||||
poller.add_user(user)
|
||||
end
|
||||
|
||||
puts "Telegram polling initialized successfully"
|
||||
else
|
||||
puts "No users with Telegram configuration found"
|
||||
end
|
||||
rescue => e
|
||||
puts "Error initializing Telegram polling: #{e.message}"
|
||||
puts e.backtrace.join("\n")
|
||||
end
|
||||
else
|
||||
puts "Users table doesn't exist yet, skipping Telegram initialization"
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
puts "Database connection not available for Telegram initialization: #{e.message}"
|
||||
puts "Telegram polling will be initialized later when the database is available."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Don't run the initializer here - we'll hook it into the Sinatra app after ActiveRecord is initialized
|
||||
22
config/quotes.yml
Normal file
22
config/quotes.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
quotes:
|
||||
- "Believe you can and you're halfway there."
|
||||
- "The only way to do great work is to love what you do."
|
||||
- "Success is not final, failure is not fatal: It is the courage to continue that counts."
|
||||
- "It always seems impossible until it's done."
|
||||
- "Your time is limited, don't waste it living someone else's life."
|
||||
- "The future belongs to those who believe in the beauty of their dreams."
|
||||
- "Don't watch the clock; do what it does. Keep going."
|
||||
- "Quality is not an act, it is a habit."
|
||||
- "The only limit to our realization of tomorrow is our doubts of today."
|
||||
- "Act as if what you do makes a difference. It does."
|
||||
- "The best way to predict the future is to create it."
|
||||
- "Success is walking from failure to failure with no loss of enthusiasm."
|
||||
- "You are never too old to set another goal or to dream a new dream."
|
||||
- "The secret of getting ahead is getting started."
|
||||
- "Don't let yesterday take up too much of today."
|
||||
- "You don't have to be great to start, but you have to start to be great."
|
||||
- "Focus on progress, not perfection."
|
||||
- "One task at a time leads to great accomplishments."
|
||||
- "Today's effort is tomorrow's success."
|
||||
- "Small steps every day lead to big results."
|
||||
|
||||
5
cookies.txt
Normal file
5
cookies.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Netscape HTTP Cookie File
|
||||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1746192262 rack.session W3JvYS7y9hXUKdr6WsdMlrZaPVjCH0GAirPw%2Fmurx6MIJQm8e%2FHnISYGeYeEFrYXHvM52EbBaEatcQz7Fvd4%2F9VMWQvT5WrVrf1w%2F4Lb7abdHbwYJkQiK7o0L4rL%2Bj88ILPQ7ZY4fPqvl%2BFMeGsqO2VGJpwhE%2BU2XCKqBhFS81ejdBT%2BAHlWTzOeEzJ7ElC3Vo%2FBME%2BTEMddkC7lvkYQoWw1BoiRnLTrniQx1kWAb5pdBFf16RsuEBo9Z%2BSw1YryDdPUWfJnVLXT9szA9f45o9D%2Fsqo36VuniodyaDSS--xk78gHip2BjCI4ab--xgh8%2BzQm0bE%2BPLvdjTHLwg%3D%3D
|
||||
11
db/migrate/20250414134722_create_inbox_items.rb
Normal file
11
db/migrate/20250414134722_create_inbox_items.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class CreateInboxItems < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :inbox_items do |t|
|
||||
t.string :content, null: false
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :status, default: 'added'
|
||||
t.string :source, default: 'tududi'
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
6
db/migrate/20250414150330_add_telegram_token_to_users.rb
Normal file
6
db/migrate/20250414150330_add_telegram_token_to_users.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class AddTelegramTokenToUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :users, :telegram_bot_token, :string
|
||||
add_column :users, :telegram_chat_id, :string
|
||||
end
|
||||
end
|
||||
7
db/migrate/20250416231240_add_task_summary_to_users.rb
Normal file
7
db/migrate/20250416231240_add_task_summary_to_users.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class AddTaskSummaryToUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :users, :task_summary_enabled, :boolean, default: false
|
||||
add_column :users, :task_summary_frequency, :string, default: 'daily'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
class AddTaskSummaryRunTrackingToUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :users, :task_summary_last_run, :datetime
|
||||
add_column :users, :task_summary_next_run, :datetime
|
||||
end
|
||||
end
|
||||
|
||||
25
db/schema.rb
25
db/schema.rb
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_04_16_235420) do
|
||||
create_table "areas", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "user_id", null: false
|
||||
|
|
@ -20,6 +20,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do
|
|||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table "inbox_items", force: :cascade do |t|
|
||||
t.string "content", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.string "status", default: "added"
|
||||
t.string "source", default: "tududi"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_inbox_items_on_user_id"
|
||||
end
|
||||
|
||||
create_table "notes", force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.integer "user_id", null: false
|
||||
|
|
@ -87,7 +97,13 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do
|
|||
t.integer "priority"
|
||||
t.text "note"
|
||||
t.integer "status", default: 0
|
||||
t.string "recurrence_type", default: "none"
|
||||
t.integer "recurrence_interval"
|
||||
t.datetime "recurrence_end_date"
|
||||
t.datetime "last_generated_date"
|
||||
t.index ["last_generated_date"], name: "index_tasks_on_last_generated_date"
|
||||
t.index ["project_id"], name: "index_tasks_on_project_id"
|
||||
t.index ["recurrence_type"], name: "index_tasks_on_recurrence_type"
|
||||
t.index ["user_id"], name: "index_tasks_on_user_id"
|
||||
end
|
||||
|
||||
|
|
@ -101,9 +117,16 @@ ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do
|
|||
t.string "language", default: "en", null: false
|
||||
t.string "timezone", default: "UTC", null: false
|
||||
t.string "avatar_image"
|
||||
t.string "telegram_bot_token"
|
||||
t.string "telegram_chat_id"
|
||||
t.boolean "task_summary_enabled", default: false
|
||||
t.string "task_summary_frequency", default: "daily"
|
||||
t.datetime "task_summary_last_run"
|
||||
t.datetime "task_summary_next_run"
|
||||
end
|
||||
|
||||
add_foreign_key "areas", "users"
|
||||
add_foreign_key "inbox_items", "users"
|
||||
add_foreign_key "notes", "projects"
|
||||
add_foreign_key "notes", "users", on_delete: :cascade
|
||||
add_foreign_key "projects", "areas", on_delete: :cascade
|
||||
|
|
|
|||
|
|
@ -550,7 +550,7 @@
|
|||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body data-theme="dark">
|
||||
<header>
|
||||
<div class="container">
|
||||
<nav>
|
||||
|
|
@ -565,7 +565,7 @@
|
|||
<li class="theme-switch">
|
||||
<span class="theme-switch-label"><i class="fas fa-moon"></i></span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="theme-toggle">
|
||||
<input type="checkbox" id="theme-toggle" checked>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</li>
|
||||
|
|
|
|||
151
package-lock.json
generated
151
package-lock.json
generated
|
|
@ -12,8 +12,12 @@
|
|||
"@heroicons/react": "^2.1.5",
|
||||
"@yaireo/tagify": "^4.31.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-tagify": "^1.0.7",
|
||||
"swr": "^2.2.5",
|
||||
|
|
@ -1814,10 +1818,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.25.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.7.tgz",
|
||||
"integrity": "sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==",
|
||||
"dev": true,
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
|
|
@ -3782,9 +3785,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001666",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001666.tgz",
|
||||
"integrity": "sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==",
|
||||
"version": "1.0.30001707",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
|
||||
"integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -4074,6 +4077,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
|
||||
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.6.12"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
|
|
@ -5904,6 +5915,14 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-deceiver": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
|
||||
|
|
@ -5979,6 +5998,52 @@
|
|||
"node": ">=10.18"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "24.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz",
|
||||
"integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.4.tgz",
|
||||
"integrity": "sha512-f3frU3pIxD50/Tz20zx9TD9HobKYg47fmAETb117GKGPrhwcSSPJDoCposXlVycVebQ9GQohC3Efbpq7/nnJ5w==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
|
||||
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
|
||||
"dependencies": {
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
|
|
@ -7055,6 +7120,25 @@
|
|||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||
|
|
@ -7884,6 +7968,27 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz",
|
||||
"integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.2.3",
|
||||
"react": ">= 16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
@ -8066,8 +8171,7 @@
|
|||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||
},
|
||||
"node_modules/regenerator-transform": {
|
||||
"version": "0.15.2",
|
||||
|
|
@ -9220,6 +9324,11 @@
|
|||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tree-dump": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz",
|
||||
|
|
@ -9464,7 +9573,7 @@
|
|||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
|
||||
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -9645,6 +9754,14 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",
|
||||
|
|
@ -9667,6 +9784,11 @@
|
|||
"minimalistic-assert": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.95.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.95.0.tgz",
|
||||
|
|
@ -10005,6 +10127,15 @@
|
|||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "tsc --noEmit && webpack --config webpack.config.js",
|
||||
"start": "tsc --noEmit && NODE_ENV=development webpack serve --config webpack.config.js",
|
||||
"start": "tsc --noEmit && webpack serve --config webpack.config.js",
|
||||
"dev": "webpack serve --config webpack.config.js --hot",
|
||||
"lint": "eslint 'app/frontend/**/*.{js,jsx,ts,tsx}'"
|
||||
},
|
||||
"keywords": [],
|
||||
|
|
@ -49,8 +50,12 @@
|
|||
"@heroicons/react": "^2.1.5",
|
||||
"@yaireo/tagify": "^4.31.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-tagify": "^1.0.7",
|
||||
"swr": "^2.2.5",
|
||||
|
|
|
|||
92
public/js/app_frontend_components_Tasks_tsx.bundle.js
Normal file
92
public/js/app_frontend_components_Tasks_tsx.bundle.js
Normal file
File diff suppressed because one or more lines are too long
22
public/js/app_frontend_utils_urlService_ts.bundle.js
Normal file
22
public/js/app_frontend_utils_urlService_ts.bundle.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
63
public/locales/de/translation.json
Normal file
63
public/locales/de/translation.json
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
"submit": "Absenden",
|
||||
"close": "Schließen",
|
||||
"loading": "Wird geladen..."
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"projects": "Projekte",
|
||||
"tasks": "Aufgaben",
|
||||
"calendar": "Kalender",
|
||||
"notes": "Notizen",
|
||||
"settings": "Einstellungen",
|
||||
"areas": "Bereiche",
|
||||
"tags": "Tags",
|
||||
"today": "Heute",
|
||||
"upcoming": "Demnächst",
|
||||
"nextActions": "Nächste Aktionen",
|
||||
"inbox": "Eingang",
|
||||
"completed": "Abgeschlossen",
|
||||
"allTasks": "Alle Aufgaben"
|
||||
},
|
||||
"forms": {
|
||||
"title": "Titel",
|
||||
"description": "Beschreibung",
|
||||
"tags": "Tags",
|
||||
"required": "Dieses Feld ist erforderlich",
|
||||
"optional": "Optional",
|
||||
"noteTitle": "Notiz-Titel",
|
||||
"noteContent": "Notiz-Inhalt",
|
||||
"noteTitlePlaceholder": "Notiz-Titel eingeben",
|
||||
"noteContentPlaceholder": "Notiz-Inhalt eingeben"
|
||||
},
|
||||
"modals": {
|
||||
"updateNote": "Notiz aktualisieren",
|
||||
"createNote": "Notiz erstellen",
|
||||
"submitting": "Wird übermittelt...",
|
||||
"noteCreation": "Neue Notiz erstellen",
|
||||
"noteEdit": "Notiz bearbeiten"
|
||||
},
|
||||
"errors": {
|
||||
"noteTitleRequired": "Notiz-Titel ist erforderlich.",
|
||||
"failedToLoadTags": "Fehler beim Laden der verfügbaren Tags.",
|
||||
"failedToSaveNote": "Fehler beim Speichern der Notiz."
|
||||
},
|
||||
"success": {
|
||||
"noteUpdated": "Notiz erfolgreich aktualisiert!",
|
||||
"noteCreated": "Notiz erfolgreich erstellt!"
|
||||
},
|
||||
"notes": {
|
||||
"title": "Notizen",
|
||||
"loading": "Notizen werden geladen...",
|
||||
"error": "Fehler beim Laden der Notizen",
|
||||
"noNotesFound": "Keine Notizen gefunden",
|
||||
"searchPlaceholder": "Notizen durchsuchen..."
|
||||
}
|
||||
}
|
||||
|
||||
394
public/locales/el/translation.json
Normal file
394
public/locales/el/translation.json
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
{
|
||||
"common": {
|
||||
"loading": "Φόρτωση...",
|
||||
"save": "Αποθήκευση",
|
||||
"cancel": "Ακύρωση",
|
||||
"delete": "Διαγραφή",
|
||||
"edit": "Επεξεργασία",
|
||||
"create": "Δημιουργία",
|
||||
"submit": "Υποβολή",
|
||||
"close": "Κλείσιμο",
|
||||
"back": "Πίσω",
|
||||
"next": "Επόμενο",
|
||||
"appLoading": "Φόρτωση εφαρμογής... Παρακαλώ περιμένετε.",
|
||||
"completed": "Ολοκληρώθηκε",
|
||||
"error": "Σφάλμα",
|
||||
"success": "Επιτυχία",
|
||||
"area": "Περιοχή",
|
||||
"status": "Κατάσταση",
|
||||
"saving": "Αποθήκευση...",
|
||||
"none": "Κανένα"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Πίνακας Ελέγχου",
|
||||
"projects": "Έργα",
|
||||
"tasks": "Εργασίες",
|
||||
"calendar": "Ημερολόγιο",
|
||||
"notes": "Σημειώσεις",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"areas": "Περιοχές",
|
||||
"tags": "Ετικέτες",
|
||||
"today": "Σήμερα",
|
||||
"upcoming": "Επερχόμενα",
|
||||
"nextActions": "Επόμενες Ενέργειες",
|
||||
"inbox": "Εισερχόμενα",
|
||||
"completed": "Ολοκληρωμένα",
|
||||
"allTasks": "Όλες οι Εργασίες",
|
||||
"addAreaAriaLabel": "Προσθήκη Περιοχής",
|
||||
"addAreaTitle": "Προσθήκη Περιοχής",
|
||||
"addTagAriaLabel": "Προσθήκη Ετικέτας",
|
||||
"addTagTitle": "Προσθήκη Ετικέτας"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Αρχική",
|
||||
"dashboard": "Πίνακας Ελέγχου",
|
||||
"profile": "Προφίλ",
|
||||
"settings": "Ρυθμίσεις",
|
||||
"logout": "Αποσύνδεση"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Σύνδεση",
|
||||
"register": "Εγγραφή",
|
||||
"forgotPassword": "Ξεχάσατε τον Κωδικό",
|
||||
"email": "Email",
|
||||
"password": "Κωδικός",
|
||||
"confirmPassword": "Επιβεβαίωση Κωδικού",
|
||||
"username": "Όνομα Χρήστη",
|
||||
"signup": "Εγγραφή",
|
||||
"signin": "Σύνδεση",
|
||||
"signout": "Αποσύνδεση",
|
||||
"resetPassword": "Επαναφορά Κωδικού",
|
||||
"newPassword": "Νέος Κωδικός",
|
||||
"rememberMe": "Απομνημόνευση",
|
||||
"loginSuccess": "Επιτυχής Σύνδεση",
|
||||
"loginFailed": "Αποτυχία Σύνδεσης",
|
||||
"logoutSuccess": "Επιτυχής Αποσύνδεση"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Ρυθμίσεις Προφίλ",
|
||||
"language": "Γλώσσα",
|
||||
"theme": "Θέμα",
|
||||
"english": "Αγγλικά",
|
||||
"spanish": "Ισπανικά",
|
||||
"greek": "Ελληνικά",
|
||||
"japanese": "Ιαπωνικά",
|
||||
"ukrainian": "Ουκρανικά",
|
||||
"deutsch": "Γερμανικά",
|
||||
"languagePreference": "Προτίμηση Γλώσσας",
|
||||
"personalInfo": "Προσωπικές Πληροφορίες",
|
||||
"notifications": "Ειδοποιήσεις",
|
||||
"appearance": "Εμφάνιση",
|
||||
"lightMode": "Φωτεινό Θέμα",
|
||||
"darkMode": "Σκοτεινό Θέμα",
|
||||
"timezone": "Ζώνη Ώρας",
|
||||
"saveChanges": "Αποθήκευση Αλλαγών",
|
||||
"successMessage": "Το προφίλ ενημερώθηκε με επιτυχία!",
|
||||
"errorMessage": "Αποτυχία ενημέρωσης προφίλ",
|
||||
"languageChangedNote": "Οι αλλαγές γλώσσας εφαρμόζονται αμέσως",
|
||||
"languageChanging": "Αλλαγή γλώσσας...",
|
||||
"telegramIntegration": "Ενσωμάτωση Telegram",
|
||||
"telegramDescription": "Συνδέστε τον λογαριασμό σας στο Tududi με ένα bot του Telegram για να προσθέσετε στοιχεία στα εισερχόμενά σας μέσω μηνυμάτων Telegram.",
|
||||
"telegramBotToken": "Token Bot Telegram",
|
||||
"telegramTokenDescription": "Δημιουργήστε ένα bot με το @BotFather στο Telegram και επικολλήστε το token εδώ.",
|
||||
"telegramConnected": "Ο λογαριασμός σας στο Telegram είναι συνδεδεμένος! Στείλτε μηνύματα στο bot σας για να προσθέσετε στοιχεία στα εισερχόμενά σας στο Tududi.",
|
||||
"setupTelegram": "Ρύθμιση Telegram",
|
||||
"taskSummaryNotifications": "Ειδοποιήσεις Περίληψης Εργασιών",
|
||||
"taskSummaryDescription": "Λάβετε τακτικές περιλήψεις των εργασιών σας μέσω Telegram. Αυτή η λειτουργία απαιτεί να έχει ρυθμιστεί η ενσωμάτωση Telegram.",
|
||||
"enableTaskSummaries": "Ενεργοποίηση Περιλήψεων Εργασιών",
|
||||
"summaryFrequency": "Συχνότητα περίληψης",
|
||||
"summaryFrequencyDescription": "Επιλέξτε πόσο συχνά θέλετε να λαμβάνετε περιλήψεις εργασιών",
|
||||
"sendTestSummary": "Αποστολή δοκιμαστικής περίληψης",
|
||||
"frequency": {
|
||||
"1h": "1 ώρα",
|
||||
"2h": "2 ώρες",
|
||||
"4h": "4 ώρες",
|
||||
"8h": "8 ώρες",
|
||||
"12h": "12 ώρες",
|
||||
"daily": "1 ημέρα",
|
||||
"weekly": "1 εβδομάδα"
|
||||
},
|
||||
"frequencyHelp": "Επιλέξτε πόσο συχνά θέλετε να λαμβάνετε περιλήψεις εργασιών"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Αυτό το πεδίο είναι υποχρεωτικό",
|
||||
"invalidEmail": "Παρακαλώ εισάγετε ένα έγκυρο email",
|
||||
"passwordMismatch": "Οι κωδικοί δεν ταιριάζουν",
|
||||
"somethingWentWrong": "Κάτι πήγε στραβά, παρακαλώ δοκιμάστε ξανά",
|
||||
"taskFetch": "Αποτυχία λήψης εργασιών.",
|
||||
"projectFetch": "Αποτυχία λήψης έργων.",
|
||||
"taskCreate": "Αποτυχία δημιουργίας εργασίας.",
|
||||
"taskUpdate": "Αποτυχία ενημέρωσης εργασίας.",
|
||||
"taskDelete": "Αποτυχία διαγραφής εργασίας.",
|
||||
"noteTitleRequired": "Ο τίτλος της σημείωσης είναι υποχρεωτικός.",
|
||||
"failedToLoadTags": "Αποτυχία φόρτωσης διαθέσιμων ετικετών.",
|
||||
"failedToSaveNote": "Αποτυχία αποθήκευσης σημείωσης.",
|
||||
"tagNameRequired": "Το όνομα ετικέτας είναι υποχρεωτικό.",
|
||||
"failedToSaveTag": "Αποτυχία αποθήκευσης ετικέτας.",
|
||||
"areaNameRequired": "Το όνομα περιοχής είναι υποχρεωτικό.",
|
||||
"failedToSaveArea": "Αποτυχία αποθήκευσης περιοχής.",
|
||||
"projectCreationFailed": "Αποτυχία δημιουργίας έργου."
|
||||
},
|
||||
"dropdown": {
|
||||
"createNew": "Δημιουργία Νέου",
|
||||
"task": "Εργασία",
|
||||
"project": "Έργο",
|
||||
"note": "Σημείωση",
|
||||
"area": "Περιοχή"
|
||||
},
|
||||
"tasks": {
|
||||
"today": "Σήμερα",
|
||||
"backlog": "Εκκρεμότητες",
|
||||
"inProgress": "Σε Εξέλιξη",
|
||||
"dueToday": "Λήγουν Σήμερα",
|
||||
"stale": "Σε αναμομή",
|
||||
"suggested": "Προτεινόμενα",
|
||||
"noTasksAvailable": "Δεν υπάρχουν διαθέσιμες εργασίες.",
|
||||
"searchPlaceholder": "Αναζήτηση εργασιών...",
|
||||
"addNewTask": "Προσθήκη Νέας Εργασίας"
|
||||
},
|
||||
"projects": {
|
||||
"loading": "Φόρτωση έργων...",
|
||||
"error": "Σφάλμα φόρτωσης έργων",
|
||||
"searchPlaceholder": "Αναζήτηση έργων...",
|
||||
"title": "Έργα",
|
||||
"noProjectsFound": "Δεν βρέθηκαν έργα",
|
||||
"cardViewAriaLabel": "Προβολή Καρτών",
|
||||
"listViewAriaLabel": "Προβολή Λίστας",
|
||||
"active": "Ενεργά",
|
||||
"inactive": "Ανενεργά",
|
||||
"metrics": "Έργα",
|
||||
"filters": {
|
||||
"active": "Ενεργά",
|
||||
"inactive": "Ανενεργά",
|
||||
"all": "Όλα",
|
||||
"allAreas": "Όλες οι περιοχές"
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"loading": "Φόρτωση σημειώσεων...",
|
||||
"error": "Σφάλμα φόρτωσης σημειώσεων",
|
||||
"searchPlaceholder": "Αναζήτηση σημειώσεων...",
|
||||
"noNotesFound": "Δεν βρέθηκαν σημειώσεις",
|
||||
"title": "Σημειώσεις",
|
||||
"deleteNoteAriaLabel": "Διαγραφή σημείωσης {{noteTitle}}",
|
||||
"deleteNoteTitle": "Διαγραφή σημείωσης {{noteTitle}}",
|
||||
"editNoteAriaLabel": "Επεξεργασία σημείωσης {{noteTitle}}",
|
||||
"editNoteTitle": "Επεξεργασία σημείωσης {{noteTitle}}"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Επεξεργασία",
|
||||
"delete": "Διαγραφή",
|
||||
"completion": "Ολοκλήρωση",
|
||||
"completionPercentage": "{{percentage}}% ολοκληρωμένο",
|
||||
"toggleDropdownMenu": "Εναλλαγή αναπτυσσόμενου μενού",
|
||||
"projectInitials": "Αρχικά έργου"
|
||||
},
|
||||
"sort": {
|
||||
"due_date": "Ημερομηνία Λήξης",
|
||||
"name": "Όνομα",
|
||||
"priority": "Προτεραιότητα",
|
||||
"status": "Κατάσταση",
|
||||
"created_at": "Ημερομηνία Δημιουργίας"
|
||||
},
|
||||
"modals": {
|
||||
"confirmDelete": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
||||
"taskCreation": "Δημιουργία Νέας Εργασίας",
|
||||
"taskEdit": "Επεξεργασία Εργασίας",
|
||||
"noteCreation": "Δημιουργία Νέας Σημείωσης",
|
||||
"noteEdit": "Επεξεργασία Σημείωσης",
|
||||
"deleteNote": {
|
||||
"title": "Διαγραφή Σημείωσης",
|
||||
"message": "Είστε σίγουροι ότι θέλετε να διαγράψετε τη σημείωση \"{{noteTitle}}\";"
|
||||
},
|
||||
"updateNote": "Ενημέρωση Σημείωσης",
|
||||
"createNote": "Δημιουργία Σημείωσης",
|
||||
"submitting": "Υποβολή...",
|
||||
"deleteTask": {
|
||||
"title": "Διαγραφή Εργασίας",
|
||||
"confirmation": "Είστε σίγουροι ότι θέλετε να διαγράψετε αυτή την εργασία; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
||||
},
|
||||
"areaCreation": "Δημιουργία Νέας Περιοχής",
|
||||
"areaEdit": "Επεξεργασία Περιοχής",
|
||||
"updateArea": "Ενημέρωση Περιοχής",
|
||||
"createArea": "Δημιουργία Περιοχής",
|
||||
"updateTag": "Ενημέρωση Ετικέτας",
|
||||
"createTag": "Δημιουργία Ετικέτας",
|
||||
"deleteProject": {
|
||||
"title": "Διαγραφή Έργου",
|
||||
"message": "Είστε σίγουροι ότι θέλετε να διαγράψετε το έργο \"{{projectName}}\";"
|
||||
},
|
||||
"deleteArea": {
|
||||
"title": "Διαγραφή Περιοχής",
|
||||
"message": "Είστε σίγουροι ότι θέλετε να διαγράψετε την περιοχή \"{{areaName}}\";"
|
||||
},
|
||||
"deleteTag": {
|
||||
"title": "Διαγραφή Ετικέτας",
|
||||
"message": "Είστε σίγουροι ότι θέλετε να διαγράψετε την ετικέτα \"{{tagName}}\";"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"title": "Τίτλος",
|
||||
"description": "Περιγραφή",
|
||||
"dueDate": "Ημερομηνία Λήξης",
|
||||
"priority": "Προτεραιότητα",
|
||||
"status": "Κατάσταση",
|
||||
"assignedTo": "Ανατέθηκε Σε",
|
||||
"category": "Κατηγορία",
|
||||
"tags": "Ετικέτες",
|
||||
"required": "Αυτό το πεδίο είναι υποχρεωτικό",
|
||||
"optional": "Προαιρετικό",
|
||||
"noteTitle": "Τίτλος Σημείωσης",
|
||||
"noteContent": "Περιεχόμενο Σημείωσης",
|
||||
"noteTitlePlaceholder": "Εισάγετε τίτλο σημείωσης",
|
||||
"noteContentPlaceholder": "Εισάγετε περιεχόμενο σημείωσης",
|
||||
"areaName": "Όνομα Περιοχής",
|
||||
"areaDescription": "Περιγραφή Περιοχής",
|
||||
"areaNamePlaceholder": "Εισάγετε όνομα περιοχής",
|
||||
"areaDescriptionPlaceholder": "Εισάγετε περιγραφή περιοχής",
|
||||
"tagName": "Όνομα Ετικέτας",
|
||||
"tagNamePlaceholder": "Εισάγετε όνομα ετικέτας",
|
||||
"tagInputPlaceholder": "Πληκτρολογήστε για προσθήκη ετικέτας",
|
||||
"createTagOption": "+ Δημιουργία \"{{tagName}}\"",
|
||||
"removeTagAriaLabel": "Αφαίρεση ετικέτας {{tagName}}",
|
||||
"task": {
|
||||
"namePlaceholder": "Εισάγετε τίτλο εργασίας",
|
||||
"projectSearchPlaceholder": "Αναζήτηση ή δημιουργία έργου",
|
||||
"noMatchingProjects": "Δεν βρέθηκαν έργα",
|
||||
"creatingProject": "Δημιουργία έργου...",
|
||||
"createProject": "Δημιουργία έργου",
|
||||
"labels": {
|
||||
"tags": "Ετικέτες",
|
||||
"project": "Έργο",
|
||||
"status": "Κατάσταση",
|
||||
"priority": "Προτεραιότητα",
|
||||
"dueDate": "Ημερομηνία Λήξης",
|
||||
"note": "Σημείωση"
|
||||
}
|
||||
}
|
||||
},
|
||||
"project": {
|
||||
"name": "Όνομα Έργου"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Χαμηλή",
|
||||
"medium": "Μεσαία",
|
||||
"high": "Υψηλή"
|
||||
},
|
||||
"status": {
|
||||
"notStarted": "Δεν Ξεκίνησε",
|
||||
"inProgress": "Σε Εξέλιξη",
|
||||
"done": "Ολοκληρώθηκε",
|
||||
"archived": "Αρχειοθετημένο",
|
||||
"unknown": "Άγνωστο"
|
||||
},
|
||||
"task": {
|
||||
"labels": {
|
||||
"tags": "Ετικέτες",
|
||||
"project": "Έργο",
|
||||
"status": "Κατάσταση",
|
||||
"priority": "Προτεραιότητα",
|
||||
"dueDate": "Ημερομηνία Λήξης",
|
||||
"note": "Σημείωση"
|
||||
},
|
||||
"create": "Δημιουργία",
|
||||
"addTaskName": "Προσθήκη ονόματος εργασίας",
|
||||
"createSuccess": "Η εργασία δημιουργήθηκε με επιτυχία",
|
||||
"createError": "Αποτυχία δημιουργίας εργασίας",
|
||||
"saveAsTask": "Αποθήκευση ως Εργασία"
|
||||
},
|
||||
"dateFormats": {
|
||||
"long": "EEEE, d MMMM yyyy",
|
||||
"short": "d MMM yyyy",
|
||||
"monthYear": "MMMM yyyy",
|
||||
"dayMonth": "d MMMM",
|
||||
"time": "H:mm",
|
||||
"dateTime": "d MMM yyyy, H:mm"
|
||||
},
|
||||
"dateIndicators": {
|
||||
"today": "ΣΗΜΕΡΑ",
|
||||
"tomorrow": "ΑΥΡΙΟ",
|
||||
"yesterday": "ΧΘΕΣ"
|
||||
},
|
||||
"taskViews": {
|
||||
"project": {
|
||||
"withName": "Αυτή τη στιγμή βλέπετε όλες τις εργασίες που σχετίζονται με το έργο \"{{projectName}}\". Μπορείτε να οργανώσετε εργασίες σε αυτό το έργο, να καθορίσετε την προτεραιότητά τους και να παρακολουθήσετε την ολοκλήρωσή τους. Χρησιμοποιήστε αυτό το χώρο για να επικεντρωθείτε στις εργασίες που ανήκουν συγκεκριμένα σε αυτό το έργο.",
|
||||
"noName": "Βλέπετε εργασίες για ένα συγκεκριμένο έργο. Χρησιμοποιήστε αυτό το χώρο για να διαχειριστείτε και να παρακολουθήσετε εργασίες που σχετίζονται με αυτό το έργο."
|
||||
},
|
||||
"today": "Αυτές είναι οι εργασίες που πρέπει να ολοκληρωθούν σήμερα ή εργασίες που έχετε προγραμματίσει για άμεση προσοχή. Χρησιμοποιήστε αυτή την προβολή για να επικεντρωθείτε σε ό,τι πρέπει να ολοκληρωθεί σήμερα. Επισημάνετε εργασίες ως ολοκληρωμένες, ενημερώστε την κατάστασή τους ή προσαρμόστε τις ημερομηνίες λήξης τους αν χρειάζεται.",
|
||||
"inbox": "Τα εισερχόμενα είναι όπου βρίσκονται όλες οι μη κατηγοριοποιημένες εργασίες. Εργασίες που δεν έχουν αντιστοιχιστεί σε ένα έργο ή δεν έχουν ημερομηνία λήξης θα εμφανίζονται εδώ. Αυτή είναι η περιοχή \"αποφόρτισης του μυαλού σας\" όπου μπορείτε γρήγορα να σημειώσετε εργασίες και να τις οργανώσετε αργότερα.",
|
||||
"next": "Αυτή η προβολή δείχνει όλες τις εργασίες που είναι εφικτές στο εγγύς μέλλον. Αυτές οι εργασίες είναι έτοιμες να αναληφθούν επόμενες και δεν έχουν μακροπρόθεσμες προθεσμίες. Είναι ένα καλό μέρος για να επικεντρωθείτε όταν θέλετε να κάνετε γρήγορη πρόοδο στις εργασίες.",
|
||||
"upcoming": "Αυτή η προβολή τονίζει εργασίες που είναι προγραμματισμένες για την επόμενη εβδομάδα. Σας βοηθά να προετοιμαστείτε και να είστε μπροστά από τις προθεσμίες δίνοντάς σας μια επισκόπηση της εργασίας που πρέπει να αντιμετωπίσετε στο εγγύς μέλλον. Εργασίες με ημερομηνίες λήξης εντός των επόμενων 7 ημερών θα εμφανίζονται εδώ.",
|
||||
"someday": "Η προβολή \"Κάποια μέρα\" είναι για εργασίες που δεν είναι επείγουσες και δεν έχουν συγκεκριμένη ημερομηνία λήξης. Αυτές είναι εργασίες που μπορεί να θέλετε να αντιμετωπίσετε κάποια στιγμή, αλλά δεν αποτελούν προτεραιότητα αυτή τη στιγμή. Χρησιμοποιήστε αυτή την ενότητα για να παρακολουθείτε ιδέες ή μακροπρόθεσμους στόχους.",
|
||||
"completed": "Εδώ μπορείτε να δείτε όλες τις εργασίες που έχετε ολοκληρώσει. Είναι ένας εξαιρετικός τρόπος να αναθεωρήσετε τα επιτεύγματά σας και να αναλογιστείτε την εργασία που έχετε ολοκληρώσει. Μπορείτε επίσης να βρείτε εργασίες που μπορεί να χρειάζονται απαρχειοθέτηση ή αναφορά στο μέλλον.",
|
||||
"allTasks": "Βλέπετε όλες τις εργασίες. Αυτό περιλαμβάνει εργασίες από διαφορετικά έργα, εργασίες χωρίς συγκεκριμένες ημερομηνίες λήξης και εργασίες με διαφορετικά επίπεδα προτεραιότητας. Χρησιμοποιήστε αυτή την προβολή για μια συνολική ματιά σε όλα τα στοιχεία της λίστας εργασιών σας."
|
||||
},
|
||||
"inbox": {
|
||||
"title": "Εισερχόμενα",
|
||||
"unprocessedItems": "Έχετε {{count}} αντικείμενο(α) στα εισερχόμενά σας.",
|
||||
"processNow": "Επεξεργαστείτε τα τώρα",
|
||||
"captureThought": "Καταγράψτε μια σκέψη",
|
||||
"itemAdded": "Το αντικείμενο προστέθηκε στα εισερχόμενα",
|
||||
"addError": "Σφάλμα προσθήκης αντικειμένου",
|
||||
"updateError": "Σφάλμα ενημέρωσης αντικειμένου",
|
||||
"createTask": "Δημιουργία Εργασίας",
|
||||
"createProject": "Δημιουργία Έργου",
|
||||
"createNote": "Δημιουργία Σημείωσης",
|
||||
"convertTo": "Μετατροπή σε",
|
||||
"deleteConfirmTitle": "Διαγραφή Αντικειμένου",
|
||||
"deleteConfirmMessage": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτό το αντικείμενο από τα εισερχόμενα; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
||||
},
|
||||
"success": {
|
||||
"noteUpdated": "Η σημείωση ενημερώθηκε με επιτυχία!",
|
||||
"noteCreated": "Η σημείωση δημιουργήθηκε με επιτυχία!",
|
||||
"taskCreated": "Η εργασία δημιουργήθηκε με επιτυχία!",
|
||||
"taskUpdated": "Η εργασία ενημερώθηκε με επιτυχία!",
|
||||
"taskDeleted": "Η εργασία διαγράφηκε με επιτυχία!",
|
||||
"areaUpdated": "Η περιοχή ενημερώθηκε με επιτυχία!",
|
||||
"areaCreated": "Η περιοχή δημιουργήθηκε με επιτυχία!",
|
||||
"tagUpdated": "Η ετικέτα ενημερώθηκε με επιτυχία!",
|
||||
"tagCreated": "Η ετικέτα δημιουργήθηκε με επιτυχία!",
|
||||
"projectCreated": "Το έργο δημιουργήθηκε με επιτυχία!"
|
||||
},
|
||||
"areas": {
|
||||
"title": "Περιοχές",
|
||||
"noAreasFound": "Δεν βρέθηκαν περιοχές",
|
||||
"editAreaAriaLabel": "Επεξεργασία περιοχής {{name}}",
|
||||
"editAreaTitle": "Επεξεργασία περιοχής {{name}}",
|
||||
"deleteAreaAriaLabel": "Διαγραφή περιοχής {{name}}",
|
||||
"deleteAreaTitle": "Διαγραφή περιοχής {{name}}",
|
||||
"addArea": "Προσθήκη Περιοχής",
|
||||
"loading": "Φόρτωση λεπτομερειών περιοχής...",
|
||||
"error": "Σφάλμα φόρτωσης λεπτομερειών περιοχής.",
|
||||
"notFound": "Η περιοχή δεν βρέθηκε.",
|
||||
"details": "Λεπτομέρειες Περιοχής",
|
||||
"viewProjects": "Προβολή Έργων στην {{name}}"
|
||||
},
|
||||
"tags": {
|
||||
"loading": "Φόρτωση ετικετών...",
|
||||
"searchPlaceholder": "Αναζήτηση ετικετών...",
|
||||
"title": "Ετικέτες",
|
||||
"noTagsFound": "Δεν βρέθηκαν ετικέτες",
|
||||
"editTagAriaLabel": "Επεξεργασία ετικέτας {{tagName}}",
|
||||
"editTagTitle": "Επεξεργασία ετικέτας {{tagName}}",
|
||||
"deleteTagAriaLabel": "Διαγραφή ετικέτας {{tagName}}",
|
||||
"deleteTagTitle": "Διαγραφή ετικέτας {{tagName}}",
|
||||
"error": "Σφάλμα λήψης ετικέτας.",
|
||||
"notFound": "Η ετικέτα δεν βρέθηκε.",
|
||||
"details": "Λεπτομέρειες Ετικέτας",
|
||||
"name": "Όνομα",
|
||||
"status": "Κατάσταση",
|
||||
"active": "Ενεργή",
|
||||
"inactive": "Ανενεργή",
|
||||
"viewTasksWithTag": "Προβολή εργασιών με αυτή την ετικέτα",
|
||||
"typeToAdd": "Πληκτρολογήστε για να προσθέσετε μια ετικέτα"
|
||||
},
|
||||
"note": {
|
||||
"title": "Τίτλος",
|
||||
"content": "Περιεχόμενο",
|
||||
"titlePlaceholder": "Εισάγετε τίτλο σημείωσης",
|
||||
"contentPlaceholder": "Εισάγετε περιεχόμενο σημείωσης",
|
||||
"project": "Σχετικό Έργο (Προαιρετικό)",
|
||||
"createSuccess": "Η σημείωση δημιουργήθηκε με επιτυχία",
|
||||
"createError": "Αποτυχία δημιουργίας σημείωσης"
|
||||
}
|
||||
}
|
||||
406
public/locales/en/translation.json
Normal file
406
public/locales/en/translation.json
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"submit": "Submit",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"loading": "Loading...",
|
||||
"appLoading": "Loading application... Please wait.",
|
||||
"completed": "Completed",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"saving": "Saving...",
|
||||
"none": "None"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"projects": "Projects",
|
||||
"tasks": "Tasks",
|
||||
"calendar": "Calendar",
|
||||
"notes": "Notes",
|
||||
"settings": "Settings",
|
||||
"areas": "Areas",
|
||||
"tags": "Tags",
|
||||
"addAreaAriaLabel": "Add Area",
|
||||
"addAreaTitle": "Add Area",
|
||||
"addTagAriaLabel": "Add Tag",
|
||||
"addTagTitle": "Add Tag",
|
||||
"today": "Today",
|
||||
"upcoming": "Upcoming",
|
||||
"nextActions": "Next Actions",
|
||||
"inbox": "Inbox",
|
||||
"completed": "Completed",
|
||||
"allTasks": "All Tasks"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"dashboard": "Dashboard",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"tasks": {
|
||||
"today": "Today",
|
||||
"backlog": "Backlog",
|
||||
"inProgress": "In Progress",
|
||||
"dueToday": "Due Today",
|
||||
"stale": "Stale",
|
||||
"suggested": "Suggested",
|
||||
"noTasksAvailable": "No tasks available for today.",
|
||||
"searchPlaceholder": "Search tasks...",
|
||||
"addNewTask": "Add New Task"
|
||||
},
|
||||
"profile": {
|
||||
"settings": "Profile Settings",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"notifications": "Notifications",
|
||||
"english": "English",
|
||||
"spanish": "Spanish",
|
||||
"greek": "Greek",
|
||||
"Japanese": "Japanese",
|
||||
"ukrainian": "Ukrainian",
|
||||
"deutsch": "German",
|
||||
"title": "Profile Settings",
|
||||
"appearance": "Appearance",
|
||||
"lightMode": "Light Mode",
|
||||
"darkMode": "Dark Mode",
|
||||
"timezone": "Timezone",
|
||||
"saveChanges": "Save Changes",
|
||||
"successMessage": "Profile updated successfully!",
|
||||
"languageChangedNote": "Language changes are applied immediately",
|
||||
"languageChanging": "Changing language...",
|
||||
"telegramIntegration": "Telegram Integration",
|
||||
"telegramDescription": "Connect your Tududi account to a Telegram bot to add items to your inbox via Telegram messages.",
|
||||
"telegramBotToken": "Telegram Bot Token",
|
||||
"telegramTokenDescription": "Create a bot with @BotFather on Telegram and paste the token here.",
|
||||
"telegramConnected": "Your Telegram account is connected! Send messages to your bot to add items to your Tududi inbox.",
|
||||
"setupTelegram": "Setup Telegram",
|
||||
"settingUp": "Setting up...",
|
||||
"telegramSetupSuccess": "Telegram bot configured successfully!",
|
||||
"telegramSetupFailed": "Failed to set up Telegram bot.",
|
||||
"invalidTelegramToken": "Invalid Telegram bot token format.",
|
||||
"telegramInstructions": "Go to https://t.me/{{botUsername}} and start chatting with your bot to connect it to your Tududi account.",
|
||||
"botConfigured": "Bot configured successfully!",
|
||||
"botUsername": "Bot Username:",
|
||||
"pollingStatus": "Polling Status:",
|
||||
"pollingActive": "Active - Receiving messages",
|
||||
"pollingInactive": "Inactive - Not receiving messages",
|
||||
"pollingNote": "Polling periodically checks for new messages from Telegram and adds them to your inbox.",
|
||||
"startPolling": "Start Polling",
|
||||
"stopPolling": "Stop Polling",
|
||||
"pollingStarted": "Telegram polling started",
|
||||
"pollingStopped": "Telegram polling stopped",
|
||||
"pollingError": "Error managing Telegram polling",
|
||||
"startPollingFailed": "Failed to start polling",
|
||||
"stopPollingFailed": "Failed to stop polling",
|
||||
"openTelegram": "Open in Telegram",
|
||||
"testTelegramMessage": "Test Telegram",
|
||||
"testMessageSent": "Test message sent successfully!",
|
||||
"testMessageFailed": "Failed to send test message.",
|
||||
"testMessageError": "Error sending test message."
|
||||
},
|
||||
"modals": {
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"taskCreation": "Create New Task",
|
||||
"taskEdit": "Edit Task",
|
||||
"deleteTask": {
|
||||
"title": "Delete Task",
|
||||
"confirmation": "Are you sure you want to delete this task? This action cannot be undone."
|
||||
},
|
||||
"noteCreation": "Create New Note",
|
||||
"noteEdit": "Edit Note",
|
||||
"updateNote": "Update Note",
|
||||
"createNote": "Create Note",
|
||||
"submitting": "Submitting...",
|
||||
"areaCreation": "Create New Area",
|
||||
"areaEdit": "Edit Area",
|
||||
"updateArea": "Update Area",
|
||||
"createArea": "Create Area",
|
||||
"updateTag": "Update Tag",
|
||||
"createTag": "Create Tag",
|
||||
"deleteTag": {
|
||||
"title": "Delete Tag",
|
||||
"message": "Are you sure you want to delete the tag \"{{tagName}}\"?"
|
||||
},
|
||||
"deleteArea": {
|
||||
"title": "Delete Area",
|
||||
"message": "Are you sure you want to delete the area \"{{areaName}}\"?"
|
||||
},
|
||||
"deleteNote": {
|
||||
"title": "Delete Note",
|
||||
"message": "Are you sure you want to delete the note \"{{noteTitle}}\"?"
|
||||
},
|
||||
"deleteProject": {
|
||||
"title": "Delete Project",
|
||||
"message": "Are you sure you want to delete the project \"{{projectName}}\"?"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"dueDate": "Due Date",
|
||||
"priority": "Priority",
|
||||
"status": "Status",
|
||||
"assignedTo": "Assigned To",
|
||||
"category": "Category",
|
||||
"tags": "Tags",
|
||||
"required": "This field is required",
|
||||
"optional": "Optional",
|
||||
"task": {
|
||||
"namePlaceholder": "Add Task Name",
|
||||
"labels": {
|
||||
"tags": "Tags",
|
||||
"project": "Project",
|
||||
"status": "Status",
|
||||
"priority": "Priority",
|
||||
"dueDate": "Due Date",
|
||||
"note": "Note"
|
||||
},
|
||||
"projectSearchPlaceholder": "Search or create a project...",
|
||||
"noMatchingProjects": "No matching projects",
|
||||
"creatingProject": "Creating...",
|
||||
"createProject": "+ Create"
|
||||
},
|
||||
"noteTitle": "Note Title",
|
||||
"noteContent": "Note Content",
|
||||
"noteTitlePlaceholder": "Enter note title",
|
||||
"noteContentPlaceholder": "Enter note content",
|
||||
"areaName": "Area Name",
|
||||
"areaDescription": "Area Description",
|
||||
"areaNamePlaceholder": "Enter area name",
|
||||
"areaDescriptionPlaceholder": "Enter area description",
|
||||
"tagName": "Tag Name",
|
||||
"tagNamePlaceholder": "Enter tag name",
|
||||
"tagInputPlaceholder": "Type to add a tag",
|
||||
"createTagOption": "+ Create \"{{tagName}}\"",
|
||||
"removeTagAriaLabel": "Remove tag {{tagName}}"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"forgotPassword": "Forgot Password",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"username": "Username",
|
||||
"signup": "Sign Up",
|
||||
"signin": "Sign In",
|
||||
"signout": "Sign Out",
|
||||
"resetPassword": "Reset Password",
|
||||
"newPassword": "New Password",
|
||||
"rememberMe": "Remember Me",
|
||||
"loginSuccess": "Login Successful",
|
||||
"loginFailed": "Login Failed",
|
||||
"logoutSuccess": "Logout Successful"
|
||||
},
|
||||
"dropdown": {
|
||||
"createNew": "Create New",
|
||||
"task": "Task",
|
||||
"project": "Project",
|
||||
"note": "Note",
|
||||
"area": "Area"
|
||||
},
|
||||
"sort": {
|
||||
"due_date": "Due Date",
|
||||
"name": "Name",
|
||||
"priority": "Priority",
|
||||
"status": "Status",
|
||||
"created_at": "Created At"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"status": {
|
||||
"notStarted": "Not Started",
|
||||
"inProgress": "In Progress",
|
||||
"done": "Done",
|
||||
"archived": "Archived",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"project": {
|
||||
"name": "Project Name"
|
||||
},
|
||||
"errors": {
|
||||
"required": "This field is required",
|
||||
"invalidEmail": "Invalid email address",
|
||||
"projectCreationFailed": "Failed to create project.",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"minLength": "Minimum length is {{length}} characters",
|
||||
"maxLength": "Maximum length is {{length}} characters",
|
||||
"serverError": "Server error, please try again later",
|
||||
"networkError": "Network error, please check your connection",
|
||||
"somethingWentWrong": "Something went wrong, please try again",
|
||||
"taskFetch": "Failed to fetch tasks.",
|
||||
"projectFetch": "Failed to fetch projects.",
|
||||
"taskCreate": "Failed to create task.",
|
||||
"taskUpdate": "Failed to update task.",
|
||||
"taskDelete": "Failed to delete task.",
|
||||
"noteTitleRequired": "Note title is required.",
|
||||
"failedToLoadTags": "Failed to load available tags.",
|
||||
"failedToSaveNote": "Failed to save note.",
|
||||
"areaNameRequired": "Area name is required.",
|
||||
"failedToSaveArea": "Failed to save area.",
|
||||
"tagNameRequired": "Tag name is required.",
|
||||
"failedToSaveTag": "Failed to save tag."
|
||||
},
|
||||
"inbox": {
|
||||
"title": "Inbox",
|
||||
"empty": "Your inbox is empty",
|
||||
"emptyDescription": "Quickly capture thoughts and ideas using the + button in the bottom right corner",
|
||||
"captureThought": "Capture your thought...",
|
||||
"saveToInbox": "Save to Inbox",
|
||||
"itemAdded": "Item added to inbox",
|
||||
"itemProcessed": "Item processed",
|
||||
"itemDeleted": "Item deleted",
|
||||
"itemUpdated": "Item updated",
|
||||
"newTelegramItem": "New item from Telegram: {{content}}",
|
||||
"newItem": "New inbox item added: {{content}}",
|
||||
"multipleNewItems": "{{count}} more new items added",
|
||||
"loadError": "Failed to load inbox items",
|
||||
"addError": "Failed to add inbox item",
|
||||
"processError": "Failed to process inbox item",
|
||||
"deleteError": "Failed to delete inbox item",
|
||||
"updateError": "Failed to update inbox item",
|
||||
"contentRequired": "Content cannot be empty",
|
||||
"createTask": "Create task",
|
||||
"createProject": "Create project",
|
||||
"createNote": "Create note",
|
||||
"convertTo": "Convert to"
|
||||
},
|
||||
"dateFormats": {
|
||||
"long": "EEEE, MMMM d, yyyy",
|
||||
"short": "MMM d, yyyy",
|
||||
"monthYear": "MMMM yyyy",
|
||||
"dayMonth": "MMMM d",
|
||||
"time": "h:mm a",
|
||||
"dateTime": "MMM d, yyyy h:mm a"
|
||||
},
|
||||
"dateIndicators": {
|
||||
"today": "TODAY",
|
||||
"tomorrow": "TOMORROW",
|
||||
"yesterday": "YESTERDAY"
|
||||
},
|
||||
"taskViews": {
|
||||
"project": {
|
||||
"withName": "You are currently viewing all tasks associated with the \"{{projectName}}\" project. You can organize tasks within this project, set their priority, and track their completion. Use this space to focus on the tasks that belong specifically to this project.",
|
||||
"noName": "You are viewing tasks for a specific project. Use this space to manage and track tasks associated with this project."
|
||||
},
|
||||
"today": "These are the tasks that are due today or tasks you've scheduled for immediate attention. Use this view to focus on what needs to be completed today. Mark tasks as completed, update their status, or adjust their due dates if needed.",
|
||||
"inbox": "The inbox is where all uncategorized tasks live. Tasks that haven't been assigned to a project or given a due date will show up here. This is your \"brain dump\" area where you can quickly jot down tasks and organize them later.",
|
||||
"next": "This view shows all the tasks that are actionable in the near future. These tasks are ready to be worked on next and don't have long-term deadlines. It's a good place to focus when you're looking to make quick progress on tasks.",
|
||||
"upcoming": "This view highlights tasks that are scheduled for the upcoming week. It helps you prepare and stay ahead of deadlines by giving you an overview of the work you need to tackle in the near future. Tasks with due dates within the next 7 days will appear here.",
|
||||
"someday": "The \"Someday\" view is for tasks that aren't urgent and don't have a specific due date. These are tasks you may want to get to at some point, but they aren't a priority right now. Use this section to keep track of ideas or long-term goals.",
|
||||
"completed": "Here you can see all the tasks you've completed. It's a great way to review your accomplishments and reflect on the work you've finished. You can also find tasks that may need to be unarchived or referenced in the future.",
|
||||
"allTasks": "You are viewing all tasks. This includes tasks from different projects, tasks without specific due dates, and tasks with varying levels of priority. Use this view for an overall look at everything on your to-do list."
|
||||
},
|
||||
"success": {
|
||||
"noteUpdated": "Note updated successfully!",
|
||||
"noteCreated": "Note created successfully!",
|
||||
"areaUpdated": "Area updated successfully!",
|
||||
"areaCreated": "Area created successfully!",
|
||||
"tagUpdated": "Tag updated successfully!",
|
||||
"tagCreated": "Tag created successfully!",
|
||||
"projectCreated": "Project created successfully!",
|
||||
"taskCreated": "Task created successfully!",
|
||||
"taskUpdated": "Task updated successfully!",
|
||||
"taskDeleted": "Task deleted successfully!"
|
||||
},
|
||||
"note": {
|
||||
"title": "Title",
|
||||
"content": "Content",
|
||||
"titlePlaceholder": "Enter note title",
|
||||
"contentPlaceholder": "Enter note content",
|
||||
"project": "Related Project (Optional)",
|
||||
"createSuccess": "Note created successfully",
|
||||
"createError": "Failed to create note"
|
||||
},
|
||||
"task": {
|
||||
"labels": {
|
||||
"tags": "Tags",
|
||||
"project": "Project",
|
||||
"status": "Status",
|
||||
"priority": "Priority",
|
||||
"dueDate": "Due Date",
|
||||
"note": "Note"
|
||||
},
|
||||
"create": "Create",
|
||||
"addTaskName": "Add task name",
|
||||
"createSuccess": "Task created successfully",
|
||||
"createError": "Failed to create task",
|
||||
"saveAsTask": "Save as Task"
|
||||
},
|
||||
"projects": {
|
||||
"loading": "Loading projects...",
|
||||
"error": "Error loading projects",
|
||||
"searchPlaceholder": "Search projects...",
|
||||
"title": "Projects",
|
||||
"noProjectsFound": "No projects found",
|
||||
"cardViewAriaLabel": "Card View",
|
||||
"listViewAriaLabel": "List View",
|
||||
"filters": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"all": "All",
|
||||
"allAreas": "All Areas"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"completion": "Completion",
|
||||
"completionPercentage": "{{percentage}}% complete",
|
||||
"toggleDropdownMenu": "Toggle dropdown menu",
|
||||
"projectInitials": "Project initials"
|
||||
},
|
||||
"areas": {
|
||||
"title": "Areas",
|
||||
"noAreasFound": "No areas found",
|
||||
"editAreaAriaLabel": "Edit area {{name}}",
|
||||
"editAreaTitle": "Edit area {{name}}",
|
||||
"deleteAreaAriaLabel": "Delete area {{name}}",
|
||||
"deleteAreaTitle": "Delete area {{name}}",
|
||||
"addArea": "Add Area",
|
||||
"loading": "Loading area details...",
|
||||
"error": "Error loading area details.",
|
||||
"notFound": "Area not found.",
|
||||
"details": "Area Details",
|
||||
"viewProjects": "View Projects in {{name}}"
|
||||
},
|
||||
"notes": {
|
||||
"loading": "Loading notes...",
|
||||
"error": "Error loading notes",
|
||||
"searchPlaceholder": "Search notes...",
|
||||
"noNotesFound": "No notes found",
|
||||
"title": "Notes",
|
||||
"deleteNoteAriaLabel": "Delete note {{noteTitle}}",
|
||||
"deleteNoteTitle": "Delete note {{noteTitle}}",
|
||||
"editNoteAriaLabel": "Edit note {{noteTitle}}",
|
||||
"editNoteTitle": "Edit note {{noteTitle}}"
|
||||
},
|
||||
"tags": {
|
||||
"loading": "Loading tags...",
|
||||
"searchPlaceholder": "Search tags...",
|
||||
"title": "Tags",
|
||||
"noTagsFound": "No tags found",
|
||||
"editTagAriaLabel": "Edit tag {{tagName}}",
|
||||
"editTagTitle": "Edit tag {{tagName}}",
|
||||
"deleteTagAriaLabel": "Delete tag {{tagName}}",
|
||||
"deleteTagTitle": "Delete tag {{tagName}}",
|
||||
"error": "Error fetching tag.",
|
||||
"notFound": "Tag not found.",
|
||||
"details": "Tag Details",
|
||||
"name": "Name",
|
||||
"status": "Status",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"viewTasksWithTag": "View tasks with this tag"
|
||||
}
|
||||
}
|
||||
176
public/locales/es/translation.json
Normal file
176
public/locales/es/translation.json
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
{
|
||||
"common": {
|
||||
"loading": "Cargando...",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"create": "Crear",
|
||||
"submit": "Enviar",
|
||||
"close": "Cerrar",
|
||||
"back": "Atrás",
|
||||
"next": "Siguiente",
|
||||
"completed": "Completado",
|
||||
"error": "Error",
|
||||
"success": "Éxito"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Tablero",
|
||||
"projects": "Proyectos",
|
||||
"tasks": "Tareas",
|
||||
"calendar": "Calendario",
|
||||
"notes": "Notas",
|
||||
"settings": "Ajustes",
|
||||
"areas": "Áreas",
|
||||
"tags": "Etiquetas",
|
||||
"today": "Hoy",
|
||||
"upcoming": "Próximamente",
|
||||
"nextActions": "Próximas Acciones",
|
||||
"inbox": "Bandeja de Entrada",
|
||||
"completed": "Completadas",
|
||||
"allTasks": "Todas las Tareas"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "Inicio",
|
||||
"dashboard": "Tablero",
|
||||
"profile": "Perfil",
|
||||
"settings": "Ajustes",
|
||||
"logout": "Cerrar Sesión"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar Sesión",
|
||||
"register": "Registrarse",
|
||||
"forgotPassword": "Olvidé mi Contraseña",
|
||||
"email": "Correo Electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar Contraseña",
|
||||
"username": "Nombre de Usuario"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Configuración de Perfil",
|
||||
"language": "Idioma",
|
||||
"theme": "Tema",
|
||||
"english": "Inglés",
|
||||
"spanish": "Español",
|
||||
"greek": "Griego",
|
||||
"languagePreference": "Preferencia de Idioma",
|
||||
"personalInfo": "Información Personal",
|
||||
"notifications": "Notificaciones",
|
||||
"saveChanges": "Guardar Cambios",
|
||||
"successMessage": "¡Perfil actualizado con éxito!",
|
||||
"errorMessage": "Error al actualizar el perfil"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Este campo es obligatorio",
|
||||
"invalidEmail": "Por favor, introduce un correo electrónico válido",
|
||||
"passwordMismatch": "Las contraseñas no coinciden",
|
||||
"somethingWentWrong": "Algo salió mal, por favor intenta de nuevo",
|
||||
"taskFetch": "Error al obtener tareas.",
|
||||
"projectFetch": "Error al obtener proyectos.",
|
||||
"taskCreate": "Error al crear la tarea.",
|
||||
"taskUpdate": "Error al actualizar la tarea.",
|
||||
"taskDelete": "Error al eliminar la tarea.",
|
||||
"noteTitleRequired": "El título de la nota es obligatorio.",
|
||||
"failedToLoadTags": "Error al cargar las etiquetas disponibles.",
|
||||
"failedToSaveNote": "Error al guardar la nota."
|
||||
},
|
||||
"dropdown": {
|
||||
"createNew": "Crear Nuevo",
|
||||
"task": "Tarea",
|
||||
"project": "Proyecto",
|
||||
"note": "Nota",
|
||||
"area": "Área"
|
||||
},
|
||||
"tasks": {
|
||||
"today": "Hoy",
|
||||
"backlog": "Pendientes",
|
||||
"inProgress": "En Progreso",
|
||||
"dueToday": "Vence Hoy",
|
||||
"stale": "Atrasados",
|
||||
"suggested": "Sugeridos",
|
||||
"noTasksAvailable": "No hay tareas disponibles para hoy.",
|
||||
"searchPlaceholder": "Buscar tareas...",
|
||||
"addNewTask": "Añadir Nueva Tarea"
|
||||
},
|
||||
"projects": {
|
||||
"loading": "Cargando proyectos...",
|
||||
"error": "Error al cargar proyectos",
|
||||
"searchPlaceholder": "Buscar proyectos...",
|
||||
"title": "Proyectos",
|
||||
"noProjectsFound": "No se encontraron proyectos",
|
||||
"cardViewAriaLabel": "Vista de Tarjetas",
|
||||
"listViewAriaLabel": "Vista de Lista",
|
||||
"filters": {
|
||||
"active": "Activos",
|
||||
"inactive": "Inactivos",
|
||||
"all": "Todos",
|
||||
"allAreas": "Todas las áreas"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"completion": "Finalización",
|
||||
"completionPercentage": "{{percentage}}% completado",
|
||||
"toggleDropdownMenu": "Alternar menú desplegable",
|
||||
"projectInitials": "Iniciales del proyecto"
|
||||
},
|
||||
"sort": {
|
||||
"due_date": "Fecha de Vencimiento",
|
||||
"name": "Nombre",
|
||||
"priority": "Prioridad",
|
||||
"status": "Estado",
|
||||
"created_at": "Fecha de Creación"
|
||||
},
|
||||
"modals": {
|
||||
"confirmDelete": "¿Estás seguro que deseas eliminar?",
|
||||
"taskCreation": "Crear Nueva Tarea",
|
||||
"taskEdit": "Editar Tarea",
|
||||
"noteCreation": "Crear Nueva Nota",
|
||||
"noteEdit": "Editar Nota",
|
||||
"updateNote": "Actualizar Nota",
|
||||
"createNote": "Crear Nota",
|
||||
"submitting": "Enviando..."
|
||||
},
|
||||
"forms": {
|
||||
"title": "Título",
|
||||
"description": "Descripción",
|
||||
"dueDate": "Fecha de Vencimiento",
|
||||
"priority": "Prioridad",
|
||||
"status": "Estado",
|
||||
"assignedTo": "Asignado a",
|
||||
"category": "Categoría",
|
||||
"tags": "Etiquetas",
|
||||
"required": "Este campo es obligatorio",
|
||||
"optional": "Opcional",
|
||||
"noteTitle": "Título de la Nota",
|
||||
"noteContent": "Contenido de la Nota",
|
||||
"noteTitlePlaceholder": "Ingresar título de la nota",
|
||||
"noteContentPlaceholder": "Ingresar contenido de la nota"
|
||||
},
|
||||
"dateFormats": {
|
||||
"long": "EEEE, d 'de' MMMM 'de' yyyy",
|
||||
"short": "d MMM yyyy",
|
||||
"monthYear": "MMMM 'de' yyyy",
|
||||
"dayMonth": "d 'de' MMMM",
|
||||
"time": "H:mm",
|
||||
"dateTime": "d MMM yyyy, H:mm"
|
||||
},
|
||||
"taskViews": {
|
||||
"project": {
|
||||
"withName": "Actualmente estás viendo todas las tareas asociadas con el proyecto \"{{projectName}}\". Puedes organizar tareas dentro de este proyecto, establecer su prioridad y seguir su finalización. Utiliza este espacio para centrarte en las tareas que pertenecen específicamente a este proyecto.",
|
||||
"noName": "Estás viendo tareas para un proyecto específico. Utiliza este espacio para gestionar y seguir las tareas asociadas a este proyecto."
|
||||
},
|
||||
"today": "Estas son las tareas que vencen hoy o tareas que has programado para atención inmediata. Usa esta vista para concentrarte en lo que debe completarse hoy. Marca tareas como completadas, actualiza su estado o ajusta sus fechas de vencimiento si es necesario.",
|
||||
"inbox": "La bandeja de entrada es donde viven todas las tareas sin categorizar. Las tareas que no han sido asignadas a un proyecto o no tienen una fecha de vencimiento aparecerán aquí. Esta es tu área para \"volcar ideas\" donde puedes anotar rápidamente tareas y organizarlas más tarde.",
|
||||
"next": "Esta vista muestra todas las tareas que son accionables en un futuro cercano. Estas tareas están listas para ser trabajadas a continuación y no tienen plazos a largo plazo. Es un buen lugar para concentrarte cuando buscas hacer un progreso rápido en las tareas.",
|
||||
"upcoming": "Esta vista destaca las tareas programadas para la próxima semana. Te ayuda a prepararte y adelantarte a los plazos, dándote una visión general del trabajo que necesitas abordar en el futuro cercano. Las tareas con fechas de vencimiento dentro de los próximos 7 días aparecerán aquí.",
|
||||
"someday": "La vista \"Algún día\" es para tareas que no son urgentes y no tienen una fecha de vencimiento específica. Estas son tareas que tal vez quieras hacer en algún momento, pero no son una prioridad ahora mismo. Utiliza esta sección para realizar un seguimiento de ideas u objetivos a largo plazo.",
|
||||
"completed": "Aquí puedes ver todas las tareas que has completado. Es una excelente manera de revisar tus logros y reflexionar sobre el trabajo que has terminado. También puedes encontrar tareas que pueden necesitar ser desarchivadas o referenciadas en el futuro.",
|
||||
"allTasks": "Estás viendo todas las tareas. Esto incluye tareas de diferentes proyectos, tareas sin fechas de vencimiento específicas y tareas con diferentes niveles de prioridad. Utiliza esta vista para una mirada general a todo en tu lista de pendientes."
|
||||
},
|
||||
"success": {
|
||||
"noteUpdated": "¡Nota actualizada con éxito!",
|
||||
"noteCreated": "¡Nota creada con éxito!"
|
||||
}
|
||||
}
|
||||
229
public/locales/jp/translation.json
Normal file
229
public/locales/jp/translation.json
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"delete": "削除",
|
||||
"edit": "編集",
|
||||
"create": "作成",
|
||||
"submit": "送信",
|
||||
"close": "閉じる",
|
||||
"back": "戻る",
|
||||
"next": "次へ",
|
||||
"loading": "読み込み中...",
|
||||
"completed": "完了",
|
||||
"error": "エラー",
|
||||
"success": "成功"
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"dashboard": "ダッシュボード",
|
||||
"projects": "プロジェクト",
|
||||
"tasks": "タスク",
|
||||
"calendar": "カレンダー",
|
||||
"notes": "ノート",
|
||||
"settings": "設定",
|
||||
"areas": "エリア",
|
||||
"tags": "タグ",
|
||||
"today": "今日",
|
||||
"upcoming": "近日",
|
||||
"nextActions": "次のアクション",
|
||||
"inbox": "受信箱",
|
||||
"completed": "完了",
|
||||
"allTasks": "すべてのタスク"
|
||||
},
|
||||
"navigation": {
|
||||
"home": "ホーム",
|
||||
"dashboard": "ダッシュボード",
|
||||
"profile": "プロフィール",
|
||||
"settings": "設定",
|
||||
"logout": "ログアウト"
|
||||
},
|
||||
"tasks": {
|
||||
"today": "今日",
|
||||
"backlog": "バックログ",
|
||||
"inProgress": "進行中",
|
||||
"dueToday": "本日の期限",
|
||||
"stale": "古い",
|
||||
"suggested": "おすすめ",
|
||||
"noTasksAvailable": "今日のタスクはありません。",
|
||||
"searchPlaceholder": "タスクを検索...",
|
||||
"addNewTask": "新しいタスクを追加"
|
||||
},
|
||||
"profile": {
|
||||
"settings": "プロフィール設定",
|
||||
"language": "言語",
|
||||
"theme": "テーマ",
|
||||
"notifications": "通知",
|
||||
"english": "英語",
|
||||
"spanish": "スペイン語",
|
||||
"greek": "ギリシャ語",
|
||||
"Japanese": "日本語"
|
||||
},
|
||||
"modals": {
|
||||
"confirmDelete": "本当に削除してよろしいですか?",
|
||||
"taskCreation": "新しいタスクの作成",
|
||||
"taskEdit": "タスクの編集",
|
||||
"noteCreation": "新しいノートの作成",
|
||||
"noteEdit": "ノートの編集",
|
||||
"updateNote": "ノートを更新",
|
||||
"createNote": "ノートを作成",
|
||||
"submitting": "送信中...",
|
||||
"deleteTag": {
|
||||
"title": "タグの削除",
|
||||
"message": "\"{{tagName}}\" タグを削除してもよろしいですか?"
|
||||
},
|
||||
"deleteArea": {
|
||||
"title": "エリアの削除",
|
||||
"message": "\"{{areaName}}\" エリアを削除してもよろしいですか?"
|
||||
},
|
||||
"deleteNote": {
|
||||
"title": "ノートの削除",
|
||||
"message": "\"{{noteTitle}}\" ノートを削除してもよろしいですか?"
|
||||
},
|
||||
"deleteProject": {
|
||||
"title": "プロジェクトの削除",
|
||||
"message": "\"{{projectName}}\" プロジェクトを削除してもよろしいですか?"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"title": "タイトル",
|
||||
"description": "説明",
|
||||
"dueDate": "期限",
|
||||
"priority": "優先度",
|
||||
"status": "状態",
|
||||
"assignedTo": "担当者",
|
||||
"category": "カテゴリー",
|
||||
"tags": "タグ",
|
||||
"required": "この項目は必須です",
|
||||
"optional": "任意",
|
||||
"noteTitle": "ノートタイトル",
|
||||
"noteContent": "ノート内容",
|
||||
"noteTitlePlaceholder": "ノートのタイトルを入力",
|
||||
"noteContentPlaceholder": "ノートの内容を入力"
|
||||
},
|
||||
"auth": {
|
||||
"login": "ログイン",
|
||||
"register": "登録",
|
||||
"forgotPassword": "パスワードをお忘れですか?",
|
||||
"email": "メール",
|
||||
"password": "パスワード",
|
||||
"confirmPassword": "パスワードの確認",
|
||||
"username": "ユーザー名",
|
||||
"signup": "サインアップ",
|
||||
"signin": "サインイン",
|
||||
"signout": "サインアウト",
|
||||
"resetPassword": "パスワードのリセット",
|
||||
"newPassword": "新しいパスワード",
|
||||
"rememberMe": "ログイン状態を保持する",
|
||||
"loginSuccess": "ログイン成功",
|
||||
"loginFailed": "ログイン失敗",
|
||||
"logoutSuccess": "ログアウト成功"
|
||||
},
|
||||
"dropdown": {
|
||||
"createNew": "新規作成",
|
||||
"task": "タスク",
|
||||
"project": "プロジェクト",
|
||||
"note": "ノート",
|
||||
"area": "エリア"
|
||||
},
|
||||
"sort": {
|
||||
"due_date": "期限日",
|
||||
"name": "名前",
|
||||
"priority": "優先度",
|
||||
"status": "状態",
|
||||
"created_at": "作成日時"
|
||||
},
|
||||
"errors": {
|
||||
"required": "この項目は必須です",
|
||||
"invalidEmail": "無効なメールアドレス",
|
||||
"passwordMismatch": "パスワードが一致しません",
|
||||
"minLength": "最小文字数は {{length}} 文字です",
|
||||
"maxLength": "最大文字数は {{length}} 文字です",
|
||||
"serverError": "サーバーエラーが発生しました。しばらくしてから再試行してください",
|
||||
"networkError": "ネットワークエラー。接続を確認してください",
|
||||
"somethingWentWrong": "問題が発生しました。再試行してください",
|
||||
"taskFetch": "タスクの取得に失敗しました。",
|
||||
"projectFetch": "プロジェクトの取得に失敗しました。",
|
||||
"taskCreate": "タスクの作成に失敗しました。",
|
||||
"taskUpdate": "タスクの更新に失敗しました。",
|
||||
"taskDelete": "タスクの削除に失敗しました。",
|
||||
"noteTitleRequired": "ノートのタイトルは必須です。",
|
||||
"failedToLoadTags": "利用可能なタグの読み込みに失敗しました。",
|
||||
"failedToSaveNote": "ノートの保存に失敗しました。"
|
||||
},
|
||||
"success": {
|
||||
"noteUpdated": "ノートが正常に更新されました!",
|
||||
"noteCreated": "ノートが正常に作成されました!"
|
||||
},
|
||||
"dateFormats": {
|
||||
"long": "EEEE, MMMM d, yyyy",
|
||||
"short": "MMM d, yyyy",
|
||||
"monthYear": "MMMM yyyy",
|
||||
"dayMonth": "MMMM d",
|
||||
"time": "h:mm a",
|
||||
"dateTime": "MMM d, yyyy h:mm a"
|
||||
},
|
||||
"taskViews": {
|
||||
"project": {
|
||||
"withName": "「{{projectName}}」プロジェクトに関連するすべてのタスクを表示しています。このプロジェクト内でタスクを整理し、優先順位を設定し、完了状況を追跡できます。このスペースを使って、特定のプロジェクトに属するタスクに集中してください。",
|
||||
"noName": "特定のプロジェクトのタスクを表示しています。このスペースでプロジェクトに関連するタスクを管理してください。"
|
||||
},
|
||||
"today": "本日期限のタスクまたは即時対応のためのタスクが表示されます。今日完了すべきタスクに集中してください。ステータスの更新や完了マークが可能です。",
|
||||
"inbox": "受信トレイは、カテゴリ未割り当てのタスクが一覧表示されます。プロジェクトや期限が設定されていないタスクはここに集まります。アイデアやタスクをすぐに記録する場所としてご利用ください。",
|
||||
"next": "近日中に実行可能なタスクを表示します。すぐに取り掛かるべきタスクにフォーカスしてください。",
|
||||
"upcoming": "今後1週間以内に期限が来るタスクを表示します。今後の予定を把握し、締め切りに備えるのに役立ちます。",
|
||||
"someday": "「いつか」実施するタスクです。今は優先度が低いですが、将来的に取りかかる可能性のあるタスクを管理します。",
|
||||
"completed": "完了したタスク一覧です。過去の達成を振り返ることができます。",
|
||||
"allTasks": "すべてのタスクが表示されます。各プロジェクトのタスク、期限未設定のタスク、優先度の異なるタスクなどを含みます。全体を把握するためにご利用ください。"
|
||||
},
|
||||
"projects": {
|
||||
"loading": "プロジェクトを読み込み中...",
|
||||
"error": "プロジェクトの読み込みエラー",
|
||||
"searchPlaceholder": "プロジェクトを検索...",
|
||||
"title": "プロジェクト",
|
||||
"noProjectsFound": "プロジェクトが見つかりません",
|
||||
"cardViewAriaLabel": "カードビュー",
|
||||
"listViewAriaLabel": "リストビュー",
|
||||
"filters": {
|
||||
"active": "有効",
|
||||
"inactive": "無効",
|
||||
"all": "すべて",
|
||||
"allAreas": "すべてのエリア"
|
||||
}
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "編集",
|
||||
"delete": "削除",
|
||||
"completion": "完了度",
|
||||
"completionPercentage": "{{percentage}}% 完了",
|
||||
"toggleDropdownMenu": "ドロップダウンメニューを切り替え",
|
||||
"projectInitials": "プロジェクトの頭文字"
|
||||
},
|
||||
"areas": {
|
||||
"title": "エリア",
|
||||
"noAreasFound": "エリアが見つかりませんでした",
|
||||
"editAreaAriaLabel": "エリア {{name}} を編集",
|
||||
"editAreaTitle": "エリア {{name}} の編集",
|
||||
"deleteAreaAriaLabel": "エリア {{name}} を削除",
|
||||
"deleteAreaTitle": "エリア {{name}} の削除"
|
||||
},
|
||||
"notes": {
|
||||
"loading": "ノートを読み込み中...",
|
||||
"noNotesFound": "ノートが見つかりませんでした",
|
||||
"title": "ノート",
|
||||
"deleteNoteAriaLabel": "ノート {{noteTitle}} を削除",
|
||||
"deleteNoteTitle": "ノート {{noteTitle}} の削除",
|
||||
"editNoteAriaLabel": "ノート {{noteTitle}} を編集",
|
||||
"editNoteTitle": "ノート {{noteTitle}} の編集"
|
||||
},
|
||||
"tags": {
|
||||
"loading": "タグを読み込み中...",
|
||||
"searchPlaceholder": "タグを検索...",
|
||||
"title": "タグ",
|
||||
"noTagsFound": "タグが見つかりませんでした",
|
||||
"editTagAriaLabel": "タグ {{tagName}} を編集",
|
||||
"editTagTitle": "タグ {{tagName}} の編集",
|
||||
"deleteTagAriaLabel": "タグ {{tagName}} を削除",
|
||||
"deleteTagTitle": "タグ {{tagName}} の削除"
|
||||
}
|
||||
}
|
||||
111
public/locales/ua/translation.json
Normal file
111
public/locales/ua/translation.json
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Зберегти",
|
||||
"cancel": "Скасувати",
|
||||
"delete": "Видалити",
|
||||
"edit": "Редагувати",
|
||||
"create": "Створити",
|
||||
"submit": "Надіслати",
|
||||
"close": "Закрити",
|
||||
"back": "Назад",
|
||||
"next": "Далі",
|
||||
"loading": "Завантаження...",
|
||||
"completed": "Завершено",
|
||||
"error": "Помилка",
|
||||
"success": "Успішно"
|
||||
},
|
||||
|
||||
"sidebar": {
|
||||
"dashboard": "Дашборд",
|
||||
"projects": "Проекти",
|
||||
"tasks": "Завдання",
|
||||
"calendar": "Календар",
|
||||
"notes": "Нотатки",
|
||||
"settings": "Налаштування",
|
||||
"areas": "Області",
|
||||
"tags": "Теги",
|
||||
"today": "Сьогодні",
|
||||
"upcoming": "Майбутні",
|
||||
"nextActions": "Наступні дії",
|
||||
"inbox": "Вхідні",
|
||||
"completed": "Завершені",
|
||||
"allTasks": "Всі завдання"
|
||||
},
|
||||
|
||||
"tasks": {
|
||||
"today": "Сьогодні",
|
||||
"backlog": "Відкладені",
|
||||
"inProgress": "В процесі",
|
||||
"dueToday": "Термін сьогодні",
|
||||
"stale": "Прострочені",
|
||||
"suggested": "Запропоновані",
|
||||
"noTasksAvailable": "Немає доступних завдань.",
|
||||
"searchPlaceholder": "Пошук завдань...",
|
||||
"addNewTask": "Додати нове завдання"
|
||||
},
|
||||
|
||||
"projects": {
|
||||
"loading": "Завантаження проектів...",
|
||||
"error": "Помилка завантаження проектів",
|
||||
"searchPlaceholder": "Пошук проектів...",
|
||||
"title": "Проекти",
|
||||
"noProjectsFound": "Проектів не знайдено",
|
||||
"cardViewAriaLabel": "Вигляд картками",
|
||||
"listViewAriaLabel": "Вигляд списком",
|
||||
"filters": {
|
||||
"active": "Активні",
|
||||
"inactive": "Неактивні",
|
||||
"all": "Всі",
|
||||
"allAreas": "Всі області"
|
||||
}
|
||||
},
|
||||
|
||||
"projectItem": {
|
||||
"edit": "Редагувати",
|
||||
"delete": "Видалити",
|
||||
"completion": "Завершення",
|
||||
"completionPercentage": "{{percentage}}% завершено",
|
||||
"toggleDropdownMenu": "Перемкнути випадаюче меню",
|
||||
"projectInitials": "Ініціали проекту"
|
||||
},
|
||||
"forms": {
|
||||
"noteTitle": "Заголовок нотатки",
|
||||
"noteContent": "Вміст нотатки",
|
||||
"noteTitlePlaceholder": "Введіть заголовок нотатки",
|
||||
"noteContentPlaceholder": "Введіть вміст нотатки",
|
||||
"tags": "Теги",
|
||||
"required": "Це поле обов'язкове",
|
||||
"optional": "Необов'язково"
|
||||
},
|
||||
|
||||
"modals": {
|
||||
"updateNote": "Оновити нотатку",
|
||||
"createNote": "Створити нотатку",
|
||||
"submitting": "Надсилання...",
|
||||
"noteCreation": "Створити нову нотатку",
|
||||
"noteEdit": "Редагувати нотатку"
|
||||
},
|
||||
|
||||
"errors": {
|
||||
"noteTitleRequired": "Заголовок нотатки обов'язковий.",
|
||||
"failedToLoadTags": "Не вдалося завантажити доступні теги.",
|
||||
"failedToSaveNote": "Не вдалося зберегти нотатку."
|
||||
},
|
||||
|
||||
"success": {
|
||||
"noteUpdated": "Нотатку успішно оновлено!",
|
||||
"noteCreated": "Нотатку успішно створено!"
|
||||
},
|
||||
|
||||
"notes": {
|
||||
"loading": "Завантаження нотаток...",
|
||||
"error": "Помилка завантаження нотаток",
|
||||
"searchPlaceholder": "Пошук нотаток...",
|
||||
"noNotesFound": "Нотаток не знайдено",
|
||||
"title": "Нотатки",
|
||||
"deleteNoteAriaLabel": "Видалити нотатку {{noteTitle}}",
|
||||
"deleteNoteTitle": "Видалити нотатку {{noteTitle}}",
|
||||
"editNoteAriaLabel": "Редагувати нотатку {{noteTitle}}",
|
||||
"editNoteTitle": "Редагувати нотатку {{noteTitle}}"
|
||||
}
|
||||
}
|
||||
79
public/translation-test.html
Normal file
79
public/translation-test.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Translation Test</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
button {
|
||||
padding: 10px 15px;
|
||||
margin: 10px 0;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Translation File Direct Test</h1>
|
||||
<p>This page tests direct access to translation files</p>
|
||||
|
||||
<div>
|
||||
<h2>Test Translation Files</h2>
|
||||
<button id="testEnglish">Test English Translation</button>
|
||||
<button id="testSpanish">Test Spanish Translation</button>
|
||||
<button id="testGerman">Test German Translation</button>
|
||||
</div>
|
||||
|
||||
<h3>Results:</h3>
|
||||
<pre id="results">Click a button to test...</pre>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
const testTranslation = async (language) => {
|
||||
resultsEl.innerHTML = `Testing ${language} translation file...`;
|
||||
try {
|
||||
// Create the URL using window.location.origin to ensure proper base path
|
||||
const url = `${window.location.origin}/locales/${language}/translation.json`;
|
||||
resultsEl.innerHTML += `\nFetching from: ${url}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
resultsEl.innerHTML = `<span class="success">✅ Successfully loaded ${language} translation</span>\n\nURL: ${url}\n\nData:\n${JSON.stringify(data, null, 2)}`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
resultsEl.innerHTML = `<span class="error">❌ Error loading ${language} translation: ${error.message}</span>`;
|
||||
console.error(`Error fetching ${language} translation:`, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('testEnglish').addEventListener('click', () => testTranslation('en'));
|
||||
document.getElementById('testSpanish').addEventListener('click', () => testTranslation('es'));
|
||||
document.getElementById('testGerman').addEventListener('click', () => testTranslation('de'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
35
run.sh
35
run.sh
|
|
@ -1,4 +1,39 @@
|
|||
#! /bin/bash
|
||||
|
||||
export TUDUDI_SESSION_SECRET=7e9ca5868791e1e2da76b46deb760e7536967de380984ae30836433d212a94d362b500507e07f9c9f6e7e99cba0befd02925e378546565783de3c1648503aaf9
|
||||
|
||||
# Ensure database directory exists
|
||||
mkdir -p db
|
||||
|
||||
# Check if database exists, if not create it
|
||||
if [ ! -f "db/development.sqlite3" ]; then
|
||||
echo "Creating development database..."
|
||||
bundle exec rake db:setup
|
||||
fi
|
||||
|
||||
# Check database connection and retry if needed
|
||||
MAX_RETRIES=3
|
||||
RETRY_COUNT=0
|
||||
DB_CONNECTED=false
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ "$DB_CONNECTED" = false ]; do
|
||||
echo "Testing database connection (attempt $(($RETRY_COUNT + 1))/${MAX_RETRIES})..."
|
||||
if bundle exec ruby -e "require 'sqlite3'; begin; SQLite3::Database.new('db/development.sqlite3'); puts 'Database connection successful'; exit 0; rescue => e; puts \"Database error: #{e.message}\"; exit 1; end"; then
|
||||
DB_CONNECTED=true
|
||||
else
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
|
||||
echo "Connection failed, waiting 3 seconds before retry..."
|
||||
sleep 3
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DB_CONNECTED" = false ]; then
|
||||
echo "Failed to connect to database after ${MAX_RETRIES} attempts. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run puma server
|
||||
echo "Starting puma server..."
|
||||
puma -C app/config/puma.rb
|
||||
0
src/app/frontend/components/Inbox/InboxItemDetail.tsx
Normal file
0
src/app/frontend/components/Inbox/InboxItemDetail.tsx
Normal file
48
test-i18n.html
Normal file
48
test-i18n.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>i18n Test</title>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<h1>i18n Test Page</h1>
|
||||
<div>
|
||||
<h2>Direct link test</h2>
|
||||
<p>Click to test if translation files are accessible:</p>
|
||||
<ul>
|
||||
<li><a href="/locales/en/translation.json" target="_blank">English translation file</a></li>
|
||||
<li><a href="/locales/es/translation.json" target="_blank">Spanish translation file</a></li>
|
||||
<li><a href="/locales/de/translation.json" target="_blank">German translation file</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Fetch API test</h2>
|
||||
<button id="testFetch">Test fetch API</button>
|
||||
<pre id="result" style="background-color: #f5f5f5; padding: 10px; max-height: 300px; overflow: auto;"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('testFetch').addEventListener('click', async () => {
|
||||
const resultElement = document.getElementById('result');
|
||||
try {
|
||||
// Try to fetch the English translation file
|
||||
resultElement.textContent = 'Fetching English translation...';
|
||||
const response = await fetch('/locales/en/translation.json');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
resultElement.textContent = 'Success! Translation file content:\n\n' +
|
||||
JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
resultElement.textContent = `Error fetching translation file: ${error.message}`;
|
||||
console.error('Fetch error:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1
test.js
Normal file
1
test.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
import i18next from 'i18next'; console.log('Current language:', i18next.language);
|
||||
92
translation.json
Normal file
92
translation.json
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"submit": "Submit",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"loading": "Loading...",
|
||||
"completed": "Completed",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"success": {
|
||||
"taskCreated": "Task created successfully",
|
||||
"taskUpdated": "Task updated successfully",
|
||||
"taskDeleted": "Task deleted successfully",
|
||||
"projectCreated": "Project created successfully"
|
||||
},
|
||||
"error": {
|
||||
"projectCreationFailed": "Failed to create project"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"projects": "Projects",
|
||||
"tasks": "Tasks",
|
||||
"calendar": "Calendar",
|
||||
"notes": "Notes",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"tasks": {
|
||||
"today": "Today",
|
||||
"backlog": "Backlog",
|
||||
"inProgress": "In Progress",
|
||||
"dueToday": "Due Today",
|
||||
"stale": "Stale",
|
||||
"suggested": "Suggested",
|
||||
"noTasksAvailable": "No tasks available for today."
|
||||
},
|
||||
"profile": {
|
||||
"settings": "Profile Settings",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"notifications": "Notifications",
|
||||
"english": "English",
|
||||
"spanish": "Spanish",
|
||||
"greek": "Greek",
|
||||
"japanese": "Japanese"
|
||||
},
|
||||
"modals": {
|
||||
"confirmDelete": "Are you sure you want to delete?",
|
||||
"taskCreation": "Create New Task",
|
||||
"taskEdit": "Edit Task",
|
||||
"noteCreation": "Create New Note",
|
||||
"noteEdit": "Edit Note",
|
||||
"deleteTask": {
|
||||
"title": "Delete Task",
|
||||
"confirmation": "Are you sure you want to delete this task?"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"title": "Title",
|
||||
"description": "Description",
|
||||
"dueDate": "Due Date",
|
||||
"priority": "Priority",
|
||||
"status": "Status",
|
||||
"assignedTo": "Assigned To",
|
||||
"category": "Category",
|
||||
"tags": "Tags",
|
||||
"required": "This field is required",
|
||||
"optional": "Optional",
|
||||
"task": {
|
||||
"namePlaceholder": "Enter task name",
|
||||
"projectSearchPlaceholder": "Search or create project",
|
||||
"noMatchingProjects": "No matching projects",
|
||||
"creatingProject": "Creating project...",
|
||||
"createProject": "Create project",
|
||||
"labels": {
|
||||
"tags": "Tags",
|
||||
"project": "Project",
|
||||
"status": "Status",
|
||||
"priority": "Priority",
|
||||
"dueDate": "Due Date",
|
||||
"note": "Note"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue