This commit is contained in:
Chris 2025-06-09 07:30:00 +03:00 committed by GitHub
parent 912cfacb70
commit 5c427ef314
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 9413 additions and 702 deletions

3
.gitignore vendored
View file

@ -8,4 +8,5 @@ certs/
node_modules
.env
public/js/bundle.js
public/js/bundle.js
.aider*

10
Gemfile
View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -1,7 +1,7 @@
# config/database.yml
default: &default
adapter: sqlite3
pool: 5
pool: 15
timeout: 5000
development:

View file

@ -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";
@ -20,15 +20,51 @@ import NoteDetails from "./components/Note/NoteDetails";
import ProfileSettings from "./components/Profile/ProfileSettings";
import Layout from "./Layout";
import { User } from "./entities/User";
import TasksToday from "./components/Task/TasksToday";
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>
);
};

View file

@ -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 && (

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>

View 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;

View 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;

View file

@ -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>

View file

@ -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>
)}

View file

@ -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>

View file

@ -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)}
/>

View file

@ -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]) {
@ -66,6 +323,125 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
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', '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();
@ -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"
@ -174,34 +565,282 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
<option value="Asia/Tokyo">Asia/Tokyo</option>
</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 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>
)}
</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>

File diff suppressed because it is too large Load diff

View 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;

View file

@ -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>

View file

@ -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>

View file

@ -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)}
/>

View file

@ -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>

View 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;

View file

@ -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>

View file

@ -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);

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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,21 +50,26 @@ 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>
);
};
export default SidebarNav;
export default SidebarNav;

View file

@ -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) => {

View file

@ -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) => {

View file

@ -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>

View file

@ -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>
);

View file

@ -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"
>
&times;
</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}`}
>
&times;
</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);

View file

@ -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>

View file

@ -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 {
@ -8,7 +9,8 @@ interface NewTaskProps {
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const [taskName, setTaskName] = useState<string>('');
const { showSuccessToast, showErrorToast } = useToast();
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>
);

View 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;

View file

@ -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>
);

View file

@ -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',

View file

@ -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)}
/>

View file

@ -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) {

View file

@ -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 (

View file

@ -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 [metrics, setMetrics] = React.useState<Metrics>({
const { t } = useTranslation();
// 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: [],
});
// Track mounting state to prevent state updates after unmount
const isMounted = React.useRef(false);
// 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 {
// setProjectsLoading(true);
// Load projects first
const projectsData = await fetchProjects();
setProjects(projectsData);
const { tasks: fetchedTasks, metrics } = await fetchTasks("?type=today");
setTasks(fetchedTasks);
setMetrics(metrics);
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]);
// Cleanup function to prevent state updates after unmount
return () => {
isMounted.current = false;
};
}, []); // Empty dependency array - only run once on mount
const handleTaskUpdate = async (updatedTask: Task): Promise<void> => {
if (!updatedTask.id) return;
// 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>
<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>
{/* 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="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>
@ -193,4 +348,4 @@ const TasksToday: React.FC = () => {
);
};
export default TasksToday;
export default TasksToday;

View file

@ -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"
};
// 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;
}
}
if (query.get('type') === 'today') {
return 'These are the tasks that are due today or tasks youve 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.';
// 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 havent 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 dont have long-term deadlines. Its a good place to focus when youre 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 arent urgent and dont have a specific due date. These are tasks you may want to get to at some point, but they arent 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 youve completed. Its a great way to review your accomplishments and reflect on the work youve 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.';
};

View file

@ -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 };
}
};

View file

@ -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>
)}
</>

View 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;
}

View file

@ -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
View 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;

View file

@ -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>
);
}
});
}

View file

@ -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 }
})),
},
}));

View file

@ -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;

View 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'));
};

View 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;
}
};

View file

@ -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',
});

View file

@ -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> => {

View 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
View 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

View file

@ -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
[

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View 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

View 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
View 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

View file

@ -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

View 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

View 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

View 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

View 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
View 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
View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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>
@ -795,4 +795,4 @@
});
</script>
</body>
</html>
</html>

151
package-lock.json generated
View file

@ -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",

View file

@ -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",

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

File diff suppressed because one or more lines are too long

View 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..."
}
}

View 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": "Αποτυχία δημιουργίας σημείωσης"
}
}

View 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"
}
}

View 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!"
}
}

View 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}} の削除"
}
}

View 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}}"
}
}

View 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
View file

@ -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

48
test-i18n.html Normal file
View 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
View file

@ -0,0 +1 @@
import i18next from 'i18next'; console.log('Current language:', i18next.language);

92
translation.json Normal file
View 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