Lint frontend (#131)

* Add lint-fix npm target

* Sync eslint+plugins with backend

* Add prettier

* Ignore no-explicit-any lint rule for now

* Silence eslint react warning

* Format frontend via prettier

* Lint frontend.

---------

Co-authored-by: antanst <>
This commit is contained in:
Antonis Anastasiadis 2025-07-09 12:23:55 +03:00 committed by GitHub
parent f433dbffe3
commit 220bc92b4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 30271 additions and 48239 deletions

6
.prettierrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}

View file

@ -10,4 +10,14 @@ export default [
pluginJs.configs.recommended, pluginJs.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
pluginReact.configs.flat.recommended, pluginReact.configs.flat.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "off"
},
settings: {
react: {
version: "18"
}
}
}
]; ];

View file

@ -1,35 +1,30 @@
import React, { useEffect, useState, Suspense, lazy } from "react"; import React, { useEffect, useState, Suspense, lazy } from 'react';
import { import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
Routes, import { useTranslation } from 'react-i18next';
Route, import Login from './components/Login';
Navigate, import NotFound from './components/Shared/NotFound';
Outlet import ProjectDetails from './components/Project/ProjectDetails';
} from "react-router-dom"; import Projects from './components/Projects';
import { useTranslation } from "react-i18next"; import AreaDetails from './components/Area/AreaDetails';
import Login from "./components/Login"; import Areas from './components/Areas';
import NotFound from "./components/Shared/NotFound"; import TagDetails from './components/Tag/TagDetails';
import ProjectDetails from "./components/Project/ProjectDetails"; import Tags from './components/Tags';
import Projects from "./components/Projects"; import Notes from './components/Notes';
import AreaDetails from "./components/Area/AreaDetails"; import NoteDetails from './components/Note/NoteDetails';
import Areas from "./components/Areas"; import Calendar from './components/Calendar';
import TagDetails from "./components/Tag/TagDetails"; import ProfileSettings from './components/Profile/ProfileSettings';
import Tags from "./components/Tags"; import About from './components/About';
import Notes from "./components/Notes"; import Layout from './Layout';
import NoteDetails from "./components/Note/NoteDetails"; import { User } from './entities/User';
import Calendar from "./components/Calendar"; import TasksToday from './components/Task/TasksToday';
import ProfileSettings from "./components/Profile/ProfileSettings"; import TaskView from './components/Task/TaskView';
import About from "./components/About"; import LoadingScreen from './components/Shared/LoadingScreen';
import Layout from "./Layout"; import InboxItems from './components/Inbox/InboxItems';
import { User } from "./entities/User";
import TasksToday from "./components/Task/TasksToday";
import TaskView from "./components/Task/TaskView";
import LoadingScreen from "./components/Shared/LoadingScreen";
import InboxItems from "./components/Inbox/InboxItems";
// Lazy load Tasks component to prevent issues with tags loading // Lazy load Tasks component to prevent issues with tags loading
const Tasks = lazy(() => import("./components/Tasks")); const Tasks = lazy(() => import('./components/Tasks'));
const App: React.FC = () => { const App: React.FC = () => {
const { t, i18n } = useTranslation(); const { i18n } = useTranslation();
if (!i18n.isInitialized) { if (!i18n.isInitialized) {
return <LoadingScreen />; return <LoadingScreen />;
@ -40,10 +35,10 @@ const App: React.FC = () => {
const fetchCurrentUser = async () => { const fetchCurrentUser = async () => {
try { try {
const response = await fetch("/api/current_user", { const response = await fetch('/api/current_user', {
credentials: "include", credentials: 'include',
headers: { headers: {
Accept: "application/json", Accept: 'application/json',
}, },
}); });
@ -61,7 +56,7 @@ const App: React.FC = () => {
} else { } else {
setCurrentUser(null); setCurrentUser(null);
} }
} catch (err) { } catch {
setCurrentUser(null); setCurrentUser(null);
} finally { } finally {
setLoading(false); setLoading(false);
@ -80,59 +75,77 @@ const App: React.FC = () => {
setCurrentUser(user); setCurrentUser(user);
}; };
window.addEventListener('userLoggedIn', handleUserLoggedIn as EventListener); window.addEventListener(
return () => window.removeEventListener('userLoggedIn', handleUserLoggedIn as EventListener); 'userLoggedIn',
handleUserLoggedIn as EventListener
);
return () =>
window.removeEventListener(
'userLoggedIn',
handleUserLoggedIn as EventListener
);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (i18n.isInitialized) { if (i18n.isInitialized) {
fetch(`/locales/${i18n.language}/translation.json`) fetch(`/locales/${i18n.language}/translation.json`)
.then(response => { .then((response) => {
return response.json(); return response.json();
}) })
.then(data => { .then((data) => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true); i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
}) })
.catch(error => { .catch((error) => {
console.error("Error manually fetching translation file:", error); console.error(
'Error manually fetching translation file:',
error
);
}); });
} }
}, [i18n.isInitialized]); }, [i18n.isInitialized]);
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => { const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const storedPreference = localStorage.getItem("isDarkMode"); const storedPreference = localStorage.getItem('isDarkMode');
return storedPreference !== null return storedPreference !== null
? storedPreference === "true" ? storedPreference === 'true'
: window.matchMedia("(prefers-color-scheme: dark)").matches; : window.matchMedia('(prefers-color-scheme: dark)').matches;
}); });
const toggleDarkMode = () => { const toggleDarkMode = () => {
const newValue = !isDarkMode; const newValue = !isDarkMode;
setIsDarkMode(newValue); setIsDarkMode(newValue);
localStorage.setItem("isDarkMode", JSON.stringify(newValue)); localStorage.setItem('isDarkMode', JSON.stringify(newValue));
}; };
useEffect(() => { useEffect(() => {
const updateTheme = () => { const updateTheme = () => {
document.documentElement.classList.toggle("dark", isDarkMode); document.documentElement.classList.toggle('dark', isDarkMode);
}; };
updateTheme(); updateTheme();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaListener = (e: MediaQueryListEvent) => { const mediaListener = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("isDarkMode")) { if (!localStorage.getItem('isDarkMode')) {
setIsDarkMode(e.matches); setIsDarkMode(e.matches);
} }
}; };
mediaQuery.addEventListener("change", mediaListener); mediaQuery.addEventListener('change', mediaListener);
return () => mediaQuery.removeEventListener("change", mediaListener); return () => mediaQuery.removeEventListener('change', mediaListener);
}, [isDarkMode]); }, [isDarkMode]);
const LoadingComponent = () => ( const LoadingComponent = () => (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900"> <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"> <div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{i18n.t('common.loading', 'Loading application... Please wait.')} {i18n.t(
'common.loading',
'Loading application... Please wait.'
)}
</div> </div>
</div> </div>
); );
@ -158,28 +171,55 @@ const App: React.FC = () => {
</Layout> </Layout>
} }
> >
<Route index element={<Navigate to="/today" replace />} /> <Route
index
element={<Navigate to="/today" replace />}
/>
<Route path="/today" element={<TasksToday />} /> <Route path="/today" element={<TasksToday />} />
<Route path="/task/:uuid" element={<TaskView />} /> <Route path="/task/:uuid" element={<TaskView />} />
<Route <Route
path="/tasks" path="/tasks"
element={ element={
<Suspense fallback={<div className="p-4">{i18n.t('common.loading', 'Loading...')}</div>}> <Suspense
fallback={
<div className="p-4">
{i18n.t(
'common.loading',
'Loading...'
)}
</div>
}
>
<Tasks /> <Tasks />
</Suspense> </Suspense>
} }
/> />
<Route path="/inbox" element={<InboxItems />} /> <Route path="/inbox" element={<InboxItems />} />
<Route path="/projects" element={<Projects />} /> <Route path="/projects" element={<Projects />} />
<Route path="/project/:id" element={<ProjectDetails />} /> <Route
path="/project/:id"
element={<ProjectDetails />}
/>
<Route path="/areas" element={<Areas />} /> <Route path="/areas" element={<Areas />} />
<Route path="/area/:id" element={<AreaDetails />} /> <Route path="/area/:id" element={<AreaDetails />} />
<Route path="/tags" element={<Tags />} /> <Route path="/tags" element={<Tags />} />
<Route path="/tag/:identifier" element={<TagDetails />} /> <Route
path="/tag/:identifier"
element={<TagDetails />}
/>
<Route path="/notes" element={<Notes />} /> <Route path="/notes" element={<Notes />} />
<Route path="/note/:id" element={<NoteDetails />} /> <Route path="/note/:id" element={<NoteDetails />} />
<Route path="/calendar" element={<Calendar />} /> <Route path="/calendar" element={<Calendar />} />
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />} /> <Route
path="/profile"
element={
<ProfileSettings
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
/>
}
/>
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Route> </Route>
@ -187,8 +227,14 @@ const App: React.FC = () => {
) : ( ) : (
<> <>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/login" replace />} /> <Route
<Route path="*" element={<Navigate to="/login" replace />} /> path="/"
element={<Navigate to="/login" replace />}
/>
<Route
path="*"
element={<Navigate to="/login" replace />}
/>
</> </>
)} )}
</Routes> </Routes>

View file

@ -1,28 +1,32 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import { useToast } from "./components/Shared/ToastContext"; import { useToast } from './components/Shared/ToastContext';
import Navbar from "./components/Navbar"; import Navbar from './components/Navbar';
import Sidebar from "./components/Sidebar"; import Sidebar from './components/Sidebar';
import "./styles/tailwind.css"; import './styles/tailwind.css';
import ProjectModal from "./components/Project/ProjectModal"; import ProjectModal from './components/Project/ProjectModal';
import NoteModal from "./components/Note/NoteModal"; import NoteModal from './components/Note/NoteModal';
import AreaModal from "./components/Area/AreaModal"; import AreaModal from './components/Area/AreaModal';
import TagModal from "./components/Tag/TagModal"; import TagModal from './components/Tag/TagModal';
import InboxModal from "./components/Inbox/InboxModal"; import InboxModal from './components/Inbox/InboxModal';
import TaskModal from "./components/Task/TaskModal"; import TaskModal from './components/Task/TaskModal';
import { Note } from "./entities/Note"; import { Note } from './entities/Note';
import { Area } from "./entities/Area"; import { Area } from './entities/Area';
import { Tag } from "./entities/Tag"; import { Tag } from './entities/Tag';
import { Project } from "./entities/Project"; import { Project } from './entities/Project';
import { Task } from "./entities/Task"; import { Task } from './entities/Task';
import { User } from "./entities/User"; import { User } from './entities/User';
import { useStore } from "./store/useStore"; import { useStore } from './store/useStore';
import { fetchNotes, createNote, updateNote } from "./utils/notesService"; import { fetchNotes, createNote, updateNote } from './utils/notesService';
import { fetchAreas, createArea, updateArea } from "./utils/areasService"; import { fetchAreas, createArea, updateArea } from './utils/areasService';
import { fetchTags, createTag, updateTag } from "./utils/tagsService"; import { fetchTags, createTag, updateTag } from './utils/tagsService';
import { fetchProjects, createProject, updateProject } from "./utils/projectsService"; import {
import { createTask, updateTask } from "./utils/tasksService"; fetchProjects,
import { isAuthError } from "./utils/authUtils"; createProject,
updateProject,
} from './utils/projectsService';
import { createTask, updateTask } from './utils/tasksService';
import { isAuthError } from './utils/authUtils';
interface LayoutProps { interface LayoutProps {
currentUser: User; currentUser: User;
@ -41,18 +45,21 @@ const Layout: React.FC<LayoutProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024); const [isSidebarOpen, setIsSidebarOpen] = useState(
window.innerWidth >= 1024
);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false); const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
const [isTagModalOpen, setIsTagModalOpen] = useState(false); const [isTagModalOpen, setIsTagModalOpen] = useState(false);
const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>('simplified'); const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>(
'simplified'
);
const [selectedNote, setSelectedNote] = useState<Note | null>(null); const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [selectedArea, setSelectedArea] = useState<Area | null>(null); const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null); const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [newTask, setNewTask] = useState<Task | null>(null);
const { const {
notesStore: { notesStore: {
@ -71,12 +78,7 @@ const Layout: React.FC<LayoutProps> = ({
isLoading: isAreasLoading, isLoading: isAreasLoading,
isError: isAreasError, isError: isAreasError,
}, },
tasksStore: { tasksStore: { isLoading: isTasksLoading, isError: isTasksError },
setLoading: setTasksLoading,
setError: setTasksError,
isLoading: isTasksLoading,
isError: isTasksError,
},
projectsStore: { projectsStore: {
projects, projects,
setProjects, setProjects,
@ -100,14 +102,12 @@ const Layout: React.FC<LayoutProps> = ({
setTaskModalType(type); setTaskModalType(type);
}; };
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
setIsSidebarOpen(window.innerWidth >= 1024); setIsSidebarOpen(window.innerWidth >= 1024);
}; };
window.addEventListener("resize", handleResize); window.addEventListener('resize', handleResize);
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
const loadNotes = async () => { const loadNotes = async () => {
@ -116,7 +116,7 @@ const Layout: React.FC<LayoutProps> = ({
const notesData = await fetchNotes(); const notesData = await fetchNotes();
setNotes(notesData); setNotes(notesData);
} catch (error) { } catch (error) {
console.error("Error fetching notes:", error); console.error('Error fetching notes:', error);
setNotesError(true); setNotesError(true);
} finally { } finally {
setNotesLoading(false); setNotesLoading(false);
@ -129,7 +129,7 @@ const Layout: React.FC<LayoutProps> = ({
const areasData = await fetchAreas(); const areasData = await fetchAreas();
setAreas(areasData); setAreas(areasData);
} catch (error) { } catch (error) {
console.error("Error fetching areas:", error); console.error('Error fetching areas:', error);
setAreasError(true); setAreasError(true);
} finally { } finally {
setAreasLoading(false); setAreasLoading(false);
@ -142,7 +142,7 @@ const Layout: React.FC<LayoutProps> = ({
const tagsData = await fetchTags(); const tagsData = await fetchTags();
setTags(tagsData); setTags(tagsData);
} catch (error) { } catch (error) {
console.error("Error fetching tags:", error); console.error('Error fetching tags:', error);
setTagsError(true); setTagsError(true);
} finally { } finally {
setTagsLoading(false); setTagsLoading(false);
@ -155,7 +155,7 @@ const Layout: React.FC<LayoutProps> = ({
const projectsData = await fetchProjects(); const projectsData = await fetchProjects();
setProjects(projectsData); setProjects(projectsData);
} catch (error) { } catch (error) {
console.error("Error fetching projects:", error); console.error('Error fetching projects:', error);
setProjectsError(true); setProjectsError(true);
} finally { } finally {
setProjectsLoading(false); setProjectsLoading(false);
@ -181,7 +181,6 @@ const Layout: React.FC<LayoutProps> = ({
const closeTaskModal = () => { const closeTaskModal = () => {
setIsTaskModalOpen(false); setIsTaskModalOpen(false);
setNewTask(null);
}; };
const openProjectModal = () => { const openProjectModal = () => {
@ -222,7 +221,7 @@ const Layout: React.FC<LayoutProps> = ({
loadNotes(); loadNotes();
closeNoteModal(); closeNoteModal();
} catch (error: any) { } catch (error: any) {
console.error("Error saving note:", error); console.error('Error saving note:', error);
// Don't close modal if there's an auth error (user will be redirected) // Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) { if (isAuthError(error)) {
return; return;
@ -237,7 +236,14 @@ const Layout: React.FC<LayoutProps> = ({
await updateTask(taskData.id, taskData); await updateTask(taskData.id, taskData);
const taskLink = ( const taskLink = (
<span> <span>
{t('task.updated', 'Task')} <a href="/tasks" className="text-green-200 underline hover:text-green-100">{taskData.name}</a> {t('task.updatedSuccessfully', 'updated successfully!')} {t('task.updated', 'Task')}{' '}
<a
href="/tasks"
className="text-green-200 underline hover:text-green-100"
>
{taskData.name}
</a>{' '}
{t('task.updatedSuccessfully', 'updated successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
@ -245,7 +251,14 @@ const Layout: React.FC<LayoutProps> = ({
const createdTask = await createTask(taskData); const createdTask = await createTask(taskData);
const taskLink = ( const taskLink = (
<span> <span>
{t('task.created', 'Task')} <a href="/tasks" className="text-green-200 underline hover:text-green-100">{createdTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')} {t('task.created', 'Task')}{' '}
<a
href="/tasks"
className="text-green-200 underline hover:text-green-100"
>
{createdTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
@ -254,7 +267,7 @@ const Layout: React.FC<LayoutProps> = ({
// This prevents unnecessary re-renders and race conditions // This prevents unnecessary re-renders and race conditions
closeTaskModal(); closeTaskModal();
} catch (error: any) { } catch (error: any) {
console.error("Error saving task:", error); console.error('Error saving task:', error);
// Don't close modal if there's an auth error (user will be redirected) // Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) { if (isAuthError(error)) {
return; return;
@ -273,7 +286,7 @@ const Layout: React.FC<LayoutProps> = ({
}); });
return newProject; return newProject;
} catch (error) { } catch (error) {
console.error("Error creating project:", error); console.error('Error creating project:', error);
throw error; throw error;
} }
}; };
@ -289,7 +302,7 @@ const Layout: React.FC<LayoutProps> = ({
setProjects(projectsData); setProjects(projectsData);
closeProjectModal(); closeProjectModal();
} catch (error: any) { } catch (error: any) {
console.error("Error saving project:", error); console.error('Error saving project:', error);
// Don't close modal if there's an auth error (user will be redirected) // Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) { if (isAuthError(error)) {
return; return;
@ -298,7 +311,6 @@ const Layout: React.FC<LayoutProps> = ({
} }
}; };
const handleSaveArea = async (areaData: Partial<Area>) => { const handleSaveArea = async (areaData: Partial<Area>) => {
try { try {
if (areaData.id) { if (areaData.id) {
@ -309,7 +321,7 @@ const Layout: React.FC<LayoutProps> = ({
loadAreas(); loadAreas();
closeAreaModal(); closeAreaModal();
} catch (error: any) { } catch (error: any) {
console.error("Error saving area:", error); console.error('Error saving area:', error);
// Don't close modal if there's an auth error (user will be redirected) // Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) { if (isAuthError(error)) {
return; return;
@ -329,7 +341,7 @@ const Layout: React.FC<LayoutProps> = ({
setTags(tagsData); setTags(tagsData);
closeTagModal(); closeTagModal();
} catch (error: any) { } catch (error: any) {
console.error("Error saving tag:", error); console.error('Error saving tag:', error);
// Don't close modal if there's an auth error (user will be redirected) // Don't close modal if there's an auth error (user will be redirected)
if (isAuthError(error)) { if (isAuthError(error)) {
return; return;
@ -338,24 +350,7 @@ const Layout: React.FC<LayoutProps> = ({
} }
}; };
const handleLogout = async () => { const mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0';
try {
const response = await fetch('/api/logout', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
setCurrentUser(null);
} else {
console.error('Logout failed:', await response.json());
}
} catch (error) {
console.error('Error during logout:', error);
}
};
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
const isLoading = const isLoading =
isNotesLoading || isNotesLoading ||
@ -372,7 +367,7 @@ const Layout: React.FC<LayoutProps> = ({
if (isLoading) { if (isLoading) {
return ( return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}> <div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
<Navbar <Navbar
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode} toggleDarkMode={toggleDarkMode}
@ -410,7 +405,7 @@ const Layout: React.FC<LayoutProps> = ({
if (isError) { if (isError) {
return ( return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}> <div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
<Navbar <Navbar
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode} toggleDarkMode={toggleDarkMode}
@ -438,14 +433,16 @@ const Layout: React.FC<LayoutProps> = ({
<div <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}`} 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">{t('errors.somethingWentWrong')}</div> <div className="text-xl text-red-500">
{t('errors.somethingWentWrong')}
</div>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}> <div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
<Navbar <Navbar
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode} toggleDarkMode={toggleDarkMode}
@ -476,14 +473,15 @@ const Layout: React.FC<LayoutProps> = ({
> >
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto"> <div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
<div className="flex-grow py-6 px-2 md:px-6 pt-24"> <div className="flex-grow py-6 px-2 md:px-6 pt-24">
<div className="w-full max-w-5xl mx-auto">{children}</div> <div className="w-full max-w-5xl mx-auto">
{children}
</div>
</div> </div>
</div> </div>
</div> </div>
{isTaskModalOpen &&
{isTaskModalOpen && ( (taskModalType === 'simplified' ? (
taskModalType === 'simplified' ? (
<InboxModal <InboxModal
isOpen={isTaskModalOpen} isOpen={isTaskModalOpen}
onClose={closeTaskModal} onClose={closeTaskModal}
@ -494,16 +492,15 @@ const Layout: React.FC<LayoutProps> = ({
isOpen={isTaskModalOpen} isOpen={isTaskModalOpen}
onClose={closeTaskModal} onClose={closeTaskModal}
task={{ task={{
name: "", name: '',
status: "not_started", status: 'not_started',
}} }}
onSave={handleSaveTask} onSave={handleSaveTask}
onDelete={async () => {}} onDelete={async () => {}}
projects={projects} projects={projects}
onCreateProject={handleCreateProject} onCreateProject={handleCreateProject}
/> />
) ))}
)}
{isProjectModalOpen && ( {isProjectModalOpen && (
<ProjectModal <ProjectModal
@ -512,7 +509,9 @@ const Layout: React.FC<LayoutProps> = ({
onSave={handleSaveProject} onSave={handleSaveProject}
onDelete={async (projectId) => { onDelete={async (projectId) => {
try { try {
const { deleteProject } = await import('./utils/projectsService'); const { deleteProject } = await import(
'./utils/projectsService'
);
await deleteProject(projectId); await deleteProject(projectId);
loadProjects(); loadProjects();
closeProjectModal(); closeProjectModal();
@ -531,7 +530,9 @@ const Layout: React.FC<LayoutProps> = ({
onSave={handleSaveNote} onSave={handleSaveNote}
onDelete={async (noteId) => { onDelete={async (noteId) => {
try { try {
const { deleteNote } = await import('./utils/notesService'); const { deleteNote } = await import(
'./utils/notesService'
);
await deleteNote(noteId); await deleteNote(noteId);
loadNotes(); loadNotes();
closeNoteModal(); closeNoteModal();

View file

@ -4,18 +4,18 @@ import { HeartIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
const About: React.FC = () => { const About: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [version, setVersion] = useState<string>("0.3"); const [version, setVersion] = useState<string>('0.3');
useEffect(() => { useEffect(() => {
// Fetch version from the deployed app // Fetch version from the deployed app
fetch('/api/version') fetch('/api/version')
.then(response => response.json()) .then((response) => response.json())
.then(data => { .then((data) => {
if (data.version) { if (data.version) {
setVersion(data.version); setVersion(data.version);
} }
}) })
.catch(error => { .catch((error) => {
console.error('Error fetching version:', error); console.error('Error fetching version:', error);
// Keep default version if fetch fails // Keep default version if fetch fails
}); });
@ -26,7 +26,9 @@ const About: React.FC = () => {
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<InformationCircleIcon className="h-6 w-6 mr-2" /> <InformationCircleIcon className="h-6 w-6 mr-2" />
<h2 className="text-2xl font-light">{t('about.title', 'About')}</h2> <h2 className="text-2xl font-light">
{t('about.title', 'About')}
</h2>
</div> </div>
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
@ -43,7 +45,10 @@ const About: React.FC = () => {
{/* Description */} {/* Description */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-8"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-6 mb-8">
<p className="text-gray-700 dark:text-gray-300 text-center leading-relaxed"> <p className="text-gray-700 dark:text-gray-300 text-center leading-relaxed">
{t('about.description', 'Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration. Built with love for productivity enthusiasts.')} {t(
'about.description',
'Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration. Built with love for productivity enthusiasts.'
)}
</p> </p>
</div> </div>
@ -56,14 +61,20 @@ const About: React.FC = () => {
</span> </span>
</div> </div>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed"> <p className="text-gray-600 dark:text-gray-400 leading-relaxed">
{t('about.appreciation', 'Thank you for using tududi! Your support helps keep this project alive and growing. If you find it useful, consider supporting the development.')} {t(
'about.appreciation',
'Thank you for using tududi! Your support helps keep this project alive and growing. If you find it useful, consider supporting the development.'
)}
</p> </p>
</div> </div>
{/* Support Links */} {/* Support Links */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 mb-8"> <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 text-center"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 text-center">
{t('about.supportDevelopment', 'Support Development')} {t(
'about.supportDevelopment',
'Support Development'
)}
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<a <a
@ -72,7 +83,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors duration-200 font-medium" className="flex items-center justify-center px-4 py-3 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors duration-200 font-medium"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M0 .5h4.219v23H0V.5zM15.384.5c4.767 0 8.616 3.718 8.616 8.313 0 4.596-3.85 8.313-8.616 8.313-4.767 0-8.615-3.717-8.615-8.313C6.769 4.218 10.617.5 15.384.5z" /> <path d="M0 .5h4.219v23H0V.5zM15.384.5c4.767 0 8.616 3.718 8.616 8.313 0 4.596-3.85 8.313-8.616 8.313-4.767 0-8.615-3.717-8.615-8.313C6.769 4.218 10.617.5 15.384.5z" />
</svg> </svg>
Patreon Patreon
@ -83,7 +98,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors duration-200 font-medium" className="flex items-center justify-center px-4 py-3 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors duration-200 font-medium"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-.766-1.623a4.44 4.44 0 0 0-1.209-.982c-.621-.37-1.294-.646-1.975-.804-.681-.158-1.375-.158-2.056 0-.682.158-1.354.434-1.975.804a4.44 4.44 0 0 0-1.209.982c-.378.46-.647 1.025-.766 1.623l-.132.666a.75.75 0 0 0 .735.885h8.568a.75.75 0 0 0 .735-.885zM11.5 9.5h1v8h-1v-8zM9 9.5h1v8H9v-8zM14 9.5h1v8h-1v-8z" /> <path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-.766-1.623a4.44 4.44 0 0 0-1.209-.982c-.621-.37-1.294-.646-1.975-.804-.681-.158-1.375-.158-2.056 0-.682.158-1.354.434-1.975.804a4.44 4.44 0 0 0-1.209.982c-.378.46-.647 1.025-.766 1.623l-.132.666a.75.75 0 0 0 .735.885h8.568a.75.75 0 0 0 .735-.885zM11.5 9.5h1v8h-1v-8zM9 9.5h1v8H9v-8zM14 9.5h1v8h-1v-8z" />
</svg> </svg>
Buy Me a Coffee Buy Me a Coffee
@ -94,7 +113,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-white dark:bg-gray-800 border-2 border-gray-800 dark:border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-300 rounded-lg transition-colors duration-200 font-medium" className="flex items-center justify-center px-4 py-3 bg-white dark:bg-gray-800 border-2 border-gray-800 dark:border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-300 rounded-lg transition-colors duration-200 font-medium"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /> <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg> </svg>
GitHub Sponsors GitHub Sponsors
@ -105,7 +128,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200 font-medium" className="flex items-center justify-center px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200 font-medium"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.13-.657c-.55-2.29-2.04-3.26-5.45-3.26H9.326L7.18 15.857h2.19c4.298 0 7.664-1.747 8.647-6.797.03-.149.054-.294.077-.437.206-1.314.064-2.285-.872-2.706z" /> <path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.13-.657c-.55-2.29-2.04-3.26-5.45-3.26H9.326L7.18 15.857h2.19c4.298 0 7.664-1.747 8.647-6.797.03-.149.054-.294.077-.437.206-1.314.064-2.285-.872-2.706z" />
</svg> </svg>
PayPal PayPal
@ -125,7 +152,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors duration-200 font-medium" className="flex items-center justify-center px-4 py-3 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors duration-200 font-medium"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg> </svg>
Official Website Official Website
@ -136,7 +167,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 font-medium" className="flex items-center justify-center px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 font-medium"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z" /> <path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z" />
</svg> </svg>
Reddit Reddit
@ -147,7 +182,11 @@ const About: React.FC = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors duration-200 font-medium col-span-2" className="flex items-center justify-center px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors duration-200 font-medium col-span-2"
> >
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor"> <svg
className="w-5 h-5 mr-2"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z" /> <path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z" />
</svg> </svg>
Discord Discord
@ -165,12 +204,19 @@ const About: React.FC = () => {
className="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors duration-200" className="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors duration-200"
> >
{t('about.viewOnGitHub', 'View on GitHub')} {t('about.viewOnGitHub', 'View on GitHub')}
<svg className="w-4 h-4 ml-1" fill="currentColor" viewBox="0 0 24 24"> <svg
className="w-4 h-4 ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
</a> </a>
<span className="block text-sm text-gray-500 dark:text-gray-400"> <span className="block text-sm text-gray-500 dark:text-gray-400">
{t('about.license', 'Licensed for personal use')} {t(
'about.license',
'Licensed for personal use'
)}
</span> </span>
</div> </div>
</div> </div>
@ -178,7 +224,15 @@ const About: React.FC = () => {
{/* Footer */} {/* Footer */}
<div className="text-center mt-12 pt-8 border-t border-gray-200 dark:border-gray-700"> <div className="text-center mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{t('about.builtBy', 'Built by')} <a href="https://github.com/chrisvel" target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">Chris Veleris</a> {t('about.builtBy', 'Built by')}{' '}
<a
href="https://github.com/chrisvel"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Chris Veleris
</a>
</p> </p>
</div> </div>
</div> </div>

View file

@ -48,7 +48,9 @@ const AreaDetails: React.FC = () => {
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('areas.details')}: {area?.name} {t('areas.details')}: {area?.name}
</h2> </h2>
<p className="text-md text-gray-700 dark:text-gray-300">{area?.description}</p> <p className="text-md text-gray-700 dark:text-gray-300">
{area?.description}
</p>
<Link <Link
to={`/projects?area_id=${area?.id}`} to={`/projects?area_id=${area?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block" className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"

View file

@ -12,7 +12,13 @@ interface AreaModalProps {
area?: Area | null; area?: Area | null;
} }
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, onDelete }) => { const AreaModal: React.FC<AreaModalProps> = ({
isOpen,
onClose,
area,
onSave,
onDelete,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [formData, setFormData] = useState<Area>({ const [formData, setFormData] = useState<Area>({
id: area?.id || 0, id: area?.id || 0,
@ -92,7 +98,11 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
try { try {
await onSave(formData); await onSave(formData);
showSuccessToast(formData.id ? t('success.areaUpdated') : t('success.areaCreated')); showSuccessToast(
formData.id
? t('success.areaUpdated')
: t('success.areaCreated')
);
handleClose(); handleClose();
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
@ -114,11 +124,15 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
if (formData.id && formData.id !== 0 && onDelete) { if (formData.id && formData.id !== 0 && onDelete) {
try { try {
await onDelete(formData.id); await onDelete(formData.id);
showSuccessToast(t('success.areaDeleted', 'Area deleted successfully!')); showSuccessToast(
t('success.areaDeleted', 'Area deleted successfully!')
);
handleClose(); handleClose();
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
showErrorToast(t('errors.failedToDeleteArea', 'Failed to delete area.')); showErrorToast(
t('errors.failedToDeleteArea', 'Failed to delete area.')
);
} }
} }
}; };
@ -128,21 +142,24 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
return ( return (
<div <div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${ className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? "opacity-0" : "opacity-100" isClosing ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
<div className="h-full flex items-center justify-center sm:px-4 sm:py-4"> <div className="h-full flex items-center justify-center sm:px-4 sm:py-4">
<div <div
ref={modalRef} ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md transform transition-transform duration-300 ${ className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-md transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100" isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`} } h-full sm:h-auto sm:my-4`}
> >
<div className="flex flex-col h-full sm:min-h-[400px] sm:max-h-[90vh]"> <div className="flex flex-col h-full sm:min-h-[400px] sm:max-h-[90vh]">
{/* Main Form Section */} {/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800"> <div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
<div className="flex-1 relative"> <div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}> <div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<form className="h-full"> <form className="h-full">
<fieldset className="h-full flex flex-col"> <fieldset className="h-full flex flex-col">
{/* Area Title Section - Always Visible */} {/* Area Title Section - Always Visible */}
@ -155,7 +172,9 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
onChange={handleChange} onChange={handleChange}
required required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2" className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t('forms.areaNamePlaceholder')} placeholder={t(
'forms.areaNamePlaceholder'
)}
/> />
</div> </div>
@ -167,13 +186,21 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
value={formData.description} value={formData.description}
onChange={handleChange} onChange={handleChange}
className="block w-full h-full border border-gray-300 dark:border-gray-600 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 resize-none" className="block w-full h-full border border-gray-300 dark:border-gray-600 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 resize-none"
placeholder={t('forms.areaDescriptionPlaceholder')} placeholder={t(
style={{ minHeight: '150px' }} 'forms.areaDescriptionPlaceholder'
)}
style={{
minHeight: '150px',
}}
/> />
</div> </div>
{/* Error Message */} {/* Error Message */}
{error && <div className="text-red-500 px-4 mb-4">{error}</div>} {error && (
<div className="text-red-500 px-4 mb-4">
{error}
</div>
)}
</fieldset> </fieldset>
</form> </form>
</div> </div>
@ -183,12 +210,18 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between"> <div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */} {/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{(area && area.id && area.id !== 0 && onDelete) && ( {area &&
area.id &&
area.id !== 0 &&
onDelete && (
<button <button
type="button" type="button"
onClick={handleDeleteArea} onClick={handleDeleteArea}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out" className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')} title={t(
'common.delete',
'Delete'
)}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
</button> </button>
@ -208,7 +241,9 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
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 text-sm ${ 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 text-sm ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : '' isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`} }`}
> >
{isSubmitting {isSubmitting

View file

@ -9,16 +9,24 @@ import {
import ConfirmDialog from './Shared/ConfirmDialog'; import ConfirmDialog from './Shared/ConfirmDialog';
import AreaModal from './Area/AreaModal'; import AreaModal from './Area/AreaModal';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
import { fetchAreas, createArea, updateArea, deleteArea } from '../utils/areasService'; import {
fetchAreas,
createArea,
updateArea,
deleteArea,
} from '../utils/areasService';
import { Area } from '../entities/Area'; import { Area } from '../entities/Area';
const Areas: React.FC = () => { const Areas: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { areas, setAreas, setLoading, setError } = useStore((state) => state.areasStore); const { areas, setAreas, setLoading, setError } = useStore(
(state) => state.areasStore
);
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false); const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
const [selectedArea, setSelectedArea] = useState<Area | null>(null); const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null); const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
const [hoveredAreaId, setHoveredAreaId] = useState<number | null>(null); const [hoveredAreaId, setHoveredAreaId] = useState<number | null>(null);
@ -67,11 +75,6 @@ const Areas: React.FC = () => {
setIsAreaModalOpen(true); setIsAreaModalOpen(true);
}; };
const handleCreateArea = () => {
setSelectedArea(null);
setIsAreaModalOpen(true);
};
const openConfirmDialog = (area: Area) => { const openConfirmDialog = (area: Area) => {
setAreaToDelete(area); setAreaToDelete(area);
setIsConfirmDialogOpen(true); setIsConfirmDialogOpen(true);
@ -115,14 +118,18 @@ const Areas: React.FC = () => {
{/* Areas List */} {/* Areas List */}
{areas.length === 0 ? ( {areas.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">{t('areas.noAreasFound')}</p> <p className="text-gray-700 dark:text-gray-300">
{t('areas.noAreasFound')}
</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{areas.map((area) => ( {areas.map((area) => (
<li <li
key={area.id} key={area.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 p-4 flex justify-between items-center"
onMouseEnter={() => setHoveredAreaId(area.id || null)} onMouseEnter={() =>
setHoveredAreaId(area.id || null)
}
onMouseLeave={() => setHoveredAreaId(null)} onMouseLeave={() => setHoveredAreaId(null)}
> >
{/* Area Content */} {/* Area Content */}
@ -145,20 +152,34 @@ const Areas: React.FC = () => {
<button <button
onClick={() => handleEditArea(area)} onClick={() => handleEditArea(area)}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${ className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${
hoveredAreaId === area.id ? 'opacity-100' : 'opacity-0' hoveredAreaId === area.id
? 'opacity-100'
: 'opacity-0'
}`} }`}
aria-label={t('areas.editAreaAriaLabel', { name: area.name })} aria-label={t(
title={t('areas.editAreaTitle', { name: area.name })} 'areas.editAreaAriaLabel',
{ name: area.name }
)}
title={t('areas.editAreaTitle', {
name: area.name,
})}
> >
<PencilSquareIcon className="h-5 w-5" /> <PencilSquareIcon className="h-5 w-5" />
</button> </button>
<button <button
onClick={() => openConfirmDialog(area)} onClick={() => openConfirmDialog(area)}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${ className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${
hoveredAreaId === area.id ? 'opacity-100' : 'opacity-0' hoveredAreaId === area.id
? 'opacity-100'
: 'opacity-0'
}`} }`}
aria-label={t('areas.deleteAreaAriaLabel', { name: area.name })} aria-label={t(
title={t('areas.deleteAreaTitle', { name: area.name })} 'areas.deleteAreaAriaLabel',
{ name: area.name }
)}
title={t('areas.deleteAreaTitle', {
name: area.name,
})}
> >
<TrashIcon className="h-5 w-5" /> <TrashIcon className="h-5 w-5" />
</button> </button>
@ -194,7 +215,9 @@ const Areas: React.FC = () => {
{isConfirmDialogOpen && areaToDelete && ( {isConfirmDialogOpen && areaToDelete && (
<ConfirmDialog <ConfirmDialog
title={t('modals.deleteArea.title')} title={t('modals.deleteArea.title')}
message={t('modals.deleteArea.message', { name: areaToDelete.name })} message={t('modals.deleteArea.message', {
name: areaToDelete.name,
})}
onConfirm={handleDeleteArea} onConfirm={handleDeleteArea}
onCancel={closeConfirmDialog} onCancel={closeConfirmDialog}
/> />

View file

@ -8,9 +8,8 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
CalendarIcon, CalendarIcon,
PlusIcon,
XMarkIcon, XMarkIcon,
ArrowTopRightOnSquareIcon ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { format, addWeeks, addDays } from 'date-fns'; import { format, addWeeks, addDays } from 'date-fns';
import { el, enUS, es, ja, uk, de } from 'date-fns/locale'; import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
@ -20,12 +19,18 @@ import CalendarDayView from './Calendar/CalendarDayView';
const getLocale = (language: string) => { const getLocale = (language: string) => {
switch (language) { switch (language) {
case 'el': return el; case 'el':
case 'es': return es; return el;
case 'jp': return ja; case 'es':
case 'ua': return uk; return es;
case 'de': return de; case 'jp':
default: return enUS; return ja;
case 'ua':
return uk;
case 'de':
return de;
default:
return enUS;
} }
}; };
@ -47,7 +52,9 @@ const Calendar: React.FC = () => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState<'month' | 'week' | 'day'>('month'); const [view, setView] = useState<'month' | 'week' | 'day'>('month');
const [googleStatus, setGoogleStatus] = useState<GoogleCalendarStatus>({ connected: false }); const [googleStatus, setGoogleStatus] = useState<GoogleCalendarStatus>({
connected: false,
});
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [isDemoMode, setIsDemoMode] = useState(false); const [isDemoMode, setIsDemoMode] = useState(false);
const [events, setEvents] = useState<CalendarEvent[]>([]); const [events, setEvents] = useState<CalendarEvent[]>([]);
@ -70,18 +77,25 @@ const Calendar: React.FC = () => {
// Check URL parameters for demo mode // Check URL parameters for demo mode
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('demo') === 'true' && urlParams.get('connected') === 'true') { if (
urlParams.get('demo') === 'true' &&
urlParams.get('connected') === 'true'
) {
setGoogleStatus({ connected: true, email: 'demo@example.com' }); setGoogleStatus({ connected: true, email: 'demo@example.com' });
setIsDemoMode(true); setIsDemoMode(true);
// Clean up URL // Clean up URL
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState(
{},
document.title,
window.location.pathname
);
} }
}, []); }, []);
const checkGoogleCalendarStatus = async () => { const checkGoogleCalendarStatus = async () => {
try { try {
const response = await fetch('/api/calendar/status', { const response = await fetch('/api/calendar/status', {
credentials: 'include' credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
const status = await response.json(); const status = await response.json();
@ -97,7 +111,7 @@ const Calendar: React.FC = () => {
setIsLoadingTasks(true); setIsLoadingTasks(true);
try { try {
const response = await fetch('/api/tasks', { const response = await fetch('/api/tasks', {
credentials: 'include' credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@ -138,8 +152,7 @@ const Calendar: React.FC = () => {
return []; return [];
} }
tasks.forEach((task, index) => { tasks.forEach((task) => {
// Add tasks with due dates // Add tasks with due dates
if (task.due_date) { if (task.due_date) {
const dueDate = new Date(task.due_date); const dueDate = new Date(task.due_date);
@ -149,7 +162,7 @@ const Calendar: React.FC = () => {
start: dueDate, start: dueDate,
end: new Date(dueDate.getTime() + 60 * 60 * 1000), // 1 hour duration end: new Date(dueDate.getTime() + 60 * 60 * 1000), // 1 hour duration
type: 'task' as const, type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#ef4444' // Green if completed, red if not color: task.completed_at ? '#22c55e' : '#ef4444', // Green if completed, red if not
}; };
taskEvents.push(taskEvent); taskEvents.push(taskEvent);
} }
@ -167,7 +180,7 @@ const Calendar: React.FC = () => {
start: createdDate, start: createdDate,
end: new Date(createdDate.getTime() + 30 * 60 * 1000), // 30 min duration end: new Date(createdDate.getTime() + 30 * 60 * 1000), // 30 min duration
type: 'task' as const, type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#3b82f6' // Green if completed, blue if not color: task.completed_at ? '#22c55e' : '#3b82f6', // Green if completed, blue if not
}; };
taskEvents.push(taskEvent); taskEvents.push(taskEvent);
} }
@ -181,7 +194,7 @@ const Calendar: React.FC = () => {
start: new Date(), // Today start: new Date(), // Today
end: new Date(Date.now() + 30 * 60 * 1000), // 30 min duration end: new Date(Date.now() + 30 * 60 * 1000), // 30 min duration
type: 'task' as const, type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#8b5cf6' // Green if completed, purple if not color: task.completed_at ? '#22c55e' : '#8b5cf6', // Green if completed, purple if not
}; };
taskEvents.push(taskEvent); taskEvents.push(taskEvent);
} }
@ -193,7 +206,7 @@ const Calendar: React.FC = () => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const response = await fetch('/api/projects', { const response = await fetch('/api/projects', {
credentials: 'include' credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
const projectsData = await response.json(); const projectsData = await response.json();
@ -210,13 +223,16 @@ const Calendar: React.FC = () => {
setIsConnecting(true); setIsConnecting(true);
try { try {
const response = await fetch('/api/calendar/auth', { const response = await fetch('/api/calendar/auth', {
credentials: 'include' credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
if (result.demo) { if (result.demo) {
// Demo mode - simulate connection // Demo mode - simulate connection
setGoogleStatus({ connected: true, email: 'demo@example.com' }); setGoogleStatus({
connected: true,
email: 'demo@example.com',
});
setIsDemoMode(true); setIsDemoMode(true);
} else { } else {
// Real Google OAuth - redirect to auth URL // Real Google OAuth - redirect to auth URL
@ -245,7 +261,7 @@ const Calendar: React.FC = () => {
// Real disconnect API call // Real disconnect API call
const response = await fetch('/api/calendar/disconnect', { const response = await fetch('/api/calendar/disconnect', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
setGoogleStatus({ connected: false }); setGoogleStatus({ connected: false });
@ -259,7 +275,7 @@ const Calendar: React.FC = () => {
}; };
const navigate = (direction: 'prev' | 'next') => { const navigate = (direction: 'prev' | 'next') => {
setCurrentDate(prev => { setCurrentDate((prev) => {
if (view === 'month') { if (view === 'month') {
const newDate = new Date(prev); const newDate = new Date(prev);
if (direction === 'prev') { if (direction === 'prev') {
@ -269,9 +285,14 @@ const Calendar: React.FC = () => {
} }
return newDate; return newDate;
} else if (view === 'week') { } else if (view === 'week') {
return direction === 'prev' ? addWeeks(prev, -1) : addWeeks(prev, 1); return direction === 'prev'
} else { // day ? addWeeks(prev, -1)
return direction === 'prev' ? addDays(prev, -1) : addDays(prev, 1); : addWeeks(prev, 1);
} else {
// day
return direction === 'prev'
? addDays(prev, -1)
: addDays(prev, 1);
} }
}); });
}; };
@ -285,12 +306,11 @@ const Calendar: React.FC = () => {
}; };
const handleEventClick = (event: CalendarEvent) => { const handleEventClick = (event: CalendarEvent) => {
// Handle task events // Handle task events
if (event.type === 'task') { if (event.type === 'task') {
// Extract task ID from event ID // Extract task ID from event ID
const taskId = event.id.replace(/^task(-created|-fallback)?-/, ''); const taskId = event.id.replace(/^task(-created|-fallback)?-/, '');
const task = allTasks.find(t => t.id.toString() === taskId); const task = allTasks.find((t) => t.id.toString() === taskId);
if (task) { if (task) {
// Convert task to proper Task entity format for TaskModal // Convert task to proper Task entity format for TaskModal
@ -305,7 +325,7 @@ const Calendar: React.FC = () => {
due_date: task.due_date, due_date: task.due_date,
created_at: task.created_at, created_at: task.created_at,
completed_at: task.completed_at, completed_at: task.completed_at,
project_id: task.project_id project_id: task.project_id,
}; };
setSelectedTask(taskEntity); setSelectedTask(taskEntity);
@ -325,7 +345,9 @@ const Calendar: React.FC = () => {
const handleTaskSave = (updatedTask: Task) => { const handleTaskSave = (updatedTask: Task) => {
// Update the task in allTasks // Update the task in allTasks
setAllTasks(prev => prev.map(t => t.id === updatedTask.id ? updatedTask : t)); setAllTasks((prev) =>
prev.map((t) => (t.id === updatedTask.id ? updatedTask : t))
);
// Refresh calendar // Refresh calendar
loadTasks(); loadTasks();
// Close modal // Close modal
@ -337,7 +359,7 @@ const Calendar: React.FC = () => {
try { try {
await deleteTask(taskId); await deleteTask(taskId);
// Remove task from allTasks // Remove task from allTasks
setAllTasks(prev => prev.filter(t => t.id !== taskId)); setAllTasks((prev) => prev.filter((t) => t.id !== taskId));
// Refresh calendar // Refresh calendar
loadTasks(); loadTasks();
// Close modal // Close modal
@ -354,12 +376,12 @@ const Calendar: React.FC = () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ name, description: '' }) body: JSON.stringify({ name, description: '' }),
}); });
if (response.ok) { if (response.ok) {
const newProject = await response.json(); const newProject = await response.json();
setProjects(prev => [...prev, newProject]); setProjects((prev) => [...prev, newProject]);
return newProject; return newProject;
} else { } else {
throw new Error('Failed to create project'); throw new Error('Failed to create project');
@ -391,7 +413,11 @@ const Calendar: React.FC = () => {
{['month', 'week', 'day'].map((viewType) => ( {['month', 'week', 'day'].map((viewType) => (
<button <button
key={viewType} key={viewType}
onClick={() => setView(viewType as 'month' | 'week' | 'day')} onClick={() =>
setView(
viewType as 'month' | 'week' | 'day'
)
}
className={`px-3 py-1 text-sm font-medium capitalize ${ className={`px-3 py-1 text-sm font-medium capitalize ${
view === viewType view === viewType
? 'bg-blue-500 text-white' ? 'bg-blue-500 text-white'
@ -434,7 +460,6 @@ const Calendar: React.FC = () => {
</div> </div>
)} )}
{/* Calendar view */} {/* Calendar view */}
{view === 'month' && ( {view === 'month' && (
<CalendarMonthView <CalendarMonthView
@ -474,18 +499,20 @@ const Calendar: React.FC = () => {
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{isDemoMode {isDemoMode
? 'Demo mode: Google Calendar integration simulated for testing purposes.' ? 'Demo mode: Google Calendar integration simulated for testing purposes.'
: t('calendar.googleDescription') : t('calendar.googleDescription')}
}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-500"> <p className="text-xs text-gray-500 dark:text-gray-500">
{t('calendar.googleStatus')}: {t('calendar.googleStatus')}:
{googleStatus.connected ? ( {googleStatus.connected ? (
<span className="text-green-500 ml-1"> <span className="text-green-500 ml-1">
{t('calendar.connected')} {t('calendar.connected')}
{googleStatus.email && ` (${googleStatus.email})`} {googleStatus.email &&
` (${googleStatus.email})`}
</span> </span>
) : ( ) : (
<span className="text-red-500 ml-1">{t('calendar.notConnected')}</span> <span className="text-red-500 ml-1">
{t('calendar.notConnected')}
</span>
)} )}
</p> </p>
</div> </div>
@ -502,7 +529,9 @@ const Calendar: React.FC = () => {
disabled={isConnecting} disabled={isConnecting}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50" className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
> >
{isConnecting ? t('calendar.connecting') : t('calendar.connectGoogle')} {isConnecting
? t('calendar.connecting')
: t('calendar.connectGoogle')}
</button> </button>
)} )}
</div> </div>
@ -549,7 +578,12 @@ interface TaskEventModalProps {
onEditTask: () => void; onEditTask: () => void;
} }
const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose, onEditTask }) => { const TaskEventModal: React.FC<TaskEventModalProps> = ({
isOpen,
task,
onClose,
onEditTask,
}) => {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const locale = getLocale(i18n.language); const locale = getLocale(i18n.language);
@ -587,12 +621,16 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
{t('calendar.status')} {t('calendar.status')}
</label> </label>
<div className="flex items-center"> <div className="flex items-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
task.completed_at task.completed_at
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' : 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}> }`}
{task.completed_at ? `${t('calendar.completed')}` : `${t('calendar.pending')}`} >
{task.completed_at
? `${t('calendar.completed')}`
: `${t('calendar.pending')}`}
</span> </span>
</div> </div>
</div> </div>
@ -604,7 +642,9 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
{t('calendar.dueDate')} {t('calendar.dueDate')}
</label> </label>
<p className="text-gray-900 dark:text-gray-100"> <p className="text-gray-900 dark:text-gray-100">
{format(new Date(task.due_date), 'PPP', { locale: locale })} {format(new Date(task.due_date), 'PPP', {
locale: locale,
})}
</p> </p>
</div> </div>
)} )}
@ -615,13 +655,15 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('calendar.priority')} {t('calendar.priority')}
</label> </label>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
task.priority === 'high' task.priority === 'high'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: task.priority === 'medium' : task.priority === 'medium'
? 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' ? 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
}`}> }`}
>
{t(`calendar.${task.priority}`)} {t(`calendar.${task.priority}`)}
</span> </span>
</div> </div>
@ -660,7 +702,9 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
{t('calendar.created')} {t('calendar.created')}
</label> </label>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{format(new Date(task.created_at), 'PPp', { locale: locale })} {format(new Date(task.created_at), 'PPp', {
locale: locale,
})}
</p> </p>
</div> </div>
)} )}

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { format, addHours, isToday } from 'date-fns'; import { format, addHours, isToday } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CalendarEvent { interface CalendarEvent {
id: string; id: string;
@ -22,14 +21,12 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
currentDate, currentDate,
events, events,
onEventClick, onEventClick,
onTimeSlotClick onTimeSlotClick,
}) => { }) => {
const { t } = useTranslation();
const hours = Array.from({ length: 24 }, (_, i) => i); const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForTimeSlot = (hour: number) => { const getEventsForTimeSlot = (hour: number) => {
return events.filter(event => { return events.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd'); const eventDay = format(event.start, 'yyyy-MM-dd');
const currentDay = format(currentDate, 'yyyy-MM-dd'); const currentDay = format(currentDate, 'yyyy-MM-dd');
const eventHour = event.start.getHours(); const eventHour = event.start.getHours();
@ -67,14 +64,22 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
{/* Header */} {/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> <div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="text-center"> <div className="text-center">
<div className={`text-lg font-medium ${ <div
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100' className={`text-lg font-medium ${
}`}> isToday(currentDate)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{format(currentDate, 'EEEE')} {format(currentDate, 'EEEE')}
</div> </div>
<div className={`text-2xl font-bold ${ <div
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400' className={`text-2xl font-bold ${
}`}> isToday(currentDate)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{format(currentDate, 'd')} {format(currentDate, 'd')}
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400"> <div className="text-sm text-gray-500 dark:text-gray-400">
@ -85,27 +90,41 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
{/* All day events */} {/* All day events */}
<div className="p-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800"> <div className="p-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">All day</div> <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">
All day
</div>
<div className="space-y-1"> <div className="space-y-1">
{events {events
.filter(event => { .filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd'); const eventDay = format(event.start, 'yyyy-MM-dd');
const currentDay = format(currentDate, 'yyyy-MM-dd'); const currentDay = format(
currentDate,
'yyyy-MM-dd'
);
// Check if it's an all-day event (spans 24 hours or more) // Check if it's an all-day event (spans 24 hours or more)
const duration = event.end.getTime() - event.start.getTime(); const duration =
return eventDay === currentDay && duration >= 24 * 60 * 60 * 1000; event.end.getTime() - event.start.getTime();
return (
eventDay === currentDay &&
duration >= 24 * 60 * 60 * 1000
);
}) })
.map(event => ( .map((event) => (
<div <div
key={event.id} key={event.id}
onClick={(e) => handleEventClick(event, e)} onClick={(e) => handleEventClick(event, e)}
className={`text-xs p-2 rounded text-white cursor-pointer hover:opacity-80 transition-opacity ${ className={`text-xs p-2 rounded text-white cursor-pointer hover:opacity-80 transition-opacity ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : '' event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`} }`}
style={{ backgroundColor: event.color || '#3b82f6' }} style={{
backgroundColor: event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`} title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
> >
{event.type === 'task' && '📋 '}{event.title} {event.type === 'task' && '📋 '}
{event.title}
</div> </div>
))} ))}
</div> </div>
@ -113,15 +132,24 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
{/* Time slots */} {/* Time slots */}
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{hours.map(hour => { {hours.map((hour) => {
const timeSlotEvents = getEventsForTimeSlot(hour); const timeSlotEvents = getEventsForTimeSlot(hour);
return ( return (
<div key={hour} className="relative border-b border-gray-100 dark:border-gray-800"> <div
key={hour}
className="relative border-b border-gray-100 dark:border-gray-800"
>
<div className="flex"> <div className="flex">
{/* Time column */} {/* Time column */}
<div className="w-16 p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700"> <div className="w-16 p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700">
{format(addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm')} {format(
addHours(
new Date().setHours(hour, 0, 0, 0),
0
),
'HH:mm'
)}
</div> </div>
{/* Event area */} {/* Event area */}
@ -129,23 +157,36 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
onClick={() => handleTimeSlotClick(hour)} onClick={() => handleTimeSlotClick(hour)}
className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative" className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative"
> >
{timeSlotEvents.map(event => ( {timeSlotEvents.map((event) => (
<div <div
key={event.id} key={event.id}
onClick={(e) => handleEventClick(event, e)} onClick={(e) =>
handleEventClick(event, e)
}
className={`absolute left-1 right-1 text-xs p-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity z-10 ${ className={`absolute left-1 right-1 text-xs p-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity z-10 ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : '' event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`} }`}
style={{ style={{
backgroundColor: event.color || '#3b82f6', backgroundColor:
top: calculateEventPosition(event), event.color || '#3b82f6',
height: calculateEventHeight(event) top: calculateEventPosition(
event
),
height: calculateEventHeight(
event
),
}} }}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`} title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`}
> >
<div className="font-medium">{event.type === 'task' && '📋 '}{event.title}</div> <div className="font-medium">
{event.type === 'task' && '📋 '}
{event.title}
</div>
<div className="text-xs opacity-90"> <div className="text-xs opacity-90">
{format(event.start, 'HH:mm')} - {format(event.end, 'HH:mm')} {format(event.start, 'HH:mm')} -{' '}
{format(event.end, 'HH:mm')}
</div> </div>
</div> </div>
))} ))}

View file

@ -1,5 +1,14 @@
import React from 'react'; import React from 'react';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, startOfWeek, endOfWeek } from 'date-fns'; import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameMonth,
isToday,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface CalendarEvent { interface CalendarEvent {
@ -22,7 +31,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
currentDate, currentDate,
events, events,
onDateClick, onDateClick,
onEventClick onEventClick,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -33,7 +42,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
const days = eachDayOfInterval({ const days = eachDayOfInterval({
start: calendarStart, start: calendarStart,
end: calendarEnd end: calendarEnd,
}); });
const weekDays = [ const weekDays = [
@ -43,7 +52,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
t('weekdays.thursday', 'Thu'), t('weekdays.thursday', 'Thu'),
t('weekdays.friday', 'Fri'), t('weekdays.friday', 'Fri'),
t('weekdays.saturday', 'Sat'), t('weekdays.saturday', 'Sat'),
t('weekdays.sunday', 'Sun') t('weekdays.sunday', 'Sun'),
]; ];
const handleDateClick = (date: Date) => { const handleDateClick = (date: Date) => {
@ -63,8 +72,11 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
{/* Week days header */} {/* Week days header */}
<div className="grid grid-cols-7 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> <div className="grid grid-cols-7 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
{weekDays.map(day => ( {weekDays.map((day) => (
<div key={day} className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400"> <div
key={day}
className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400"
>
{day} {day}
</div> </div>
))} ))}
@ -72,9 +84,11 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
{/* Calendar grid */} {/* Calendar grid */}
<div className="grid grid-cols-7"> <div className="grid grid-cols-7">
{days.map(day => { {days.map((day) => {
const dayEvents = events.filter(event => const dayEvents = events.filter(
format(event.start, 'yyyy-MM-dd') === format(day, 'yyyy-MM-dd') (event) =>
format(event.start, 'yyyy-MM-dd') ===
format(day, 'yyyy-MM-dd')
); );
const isCurrentMonth = isSameMonth(day, currentDate); const isCurrentMonth = isSameMonth(day, currentDate);
@ -90,11 +104,13 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
: 'bg-white dark:bg-gray-900' : 'bg-white dark:bg-gray-900'
} ${isTodayDate ? 'bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600' : ''}`} } ${isTodayDate ? 'bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600' : ''}`}
> >
<div className={`text-sm mb-2 ${ <div
className={`text-sm mb-2 ${
!isCurrentMonth !isCurrentMonth
? 'text-gray-400 dark:text-gray-600' ? 'text-gray-400 dark:text-gray-600'
: 'text-gray-900 dark:text-gray-100' : 'text-gray-900 dark:text-gray-100'
} ${isTodayDate ? 'font-bold text-blue-600 dark:text-blue-400' : ''}`}> } ${isTodayDate ? 'font-bold text-blue-600 dark:text-blue-400' : ''}`}
>
{isTodayDate && ( {isTodayDate && (
<span className="inline-flex items-center justify-center w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full"> <span className="inline-flex items-center justify-center w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full">
{format(day, 'd')} {format(day, 'd')}
@ -105,17 +121,25 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
{/* Events */} {/* Events */}
<div className="space-y-1"> <div className="space-y-1">
{dayEvents.slice(0, 3).map(event => ( {dayEvents.slice(0, 3).map((event) => (
<div <div
key={event.id} key={event.id}
onClick={(e) => handleEventClick(event, e)} onClick={(e) =>
handleEventClick(event, e)
}
className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity ${ className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : '' event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`} }`}
style={{ backgroundColor: event.color || '#3b82f6' }} style={{
backgroundColor:
event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`} title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
> >
{event.type === 'task' && '📋 '}{event.title} {event.type === 'task' && '📋 '}
{event.title}
</div> </div>
))} ))}
{dayEvents.length > 3 && ( {dayEvents.length > 3 && (

View file

@ -1,6 +1,12 @@
import React from 'react'; import React from 'react';
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isToday, addHours } from 'date-fns'; import {
import { useTranslation } from 'react-i18next'; format,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isToday,
addHours,
} from 'date-fns';
interface CalendarEvent { interface CalendarEvent {
id: string; id: string;
@ -22,12 +28,9 @@ interface CalendarWeekViewProps {
const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
currentDate, currentDate,
events, events,
onDateClick,
onEventClick, onEventClick,
onTimeSlotClick onTimeSlotClick,
}) => { }) => {
const { t } = useTranslation();
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 }); const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 }); const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
const weekDays = eachDayOfInterval({ start: weekStart, end: weekEnd }); const weekDays = eachDayOfInterval({ start: weekStart, end: weekEnd });
@ -35,7 +38,7 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
const hours = Array.from({ length: 24 }, (_, i) => i); const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForTimeSlot = (day: Date, hour: number) => { const getEventsForTimeSlot = (day: Date, hour: number) => {
return events.filter(event => { return events.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd'); const eventDay = format(event.start, 'yyyy-MM-dd');
const slotDay = format(day, 'yyyy-MM-dd'); const slotDay = format(day, 'yyyy-MM-dd');
const eventHour = event.start.getHours(); const eventHour = event.start.getHours();
@ -64,18 +67,29 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
<div className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400"> <div className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
Time Time
</div> </div>
{weekDays.map(day => ( {weekDays.map((day) => (
<div key={day.toString()} className={`p-3 text-center border-l border-gray-200 dark:border-gray-700 ${ <div
key={day.toString()}
className={`p-3 text-center border-l border-gray-200 dark:border-gray-700 ${
isToday(day) ? 'bg-blue-50 dark:bg-blue-900/20' : '' isToday(day) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}> }`}
<div className={`text-sm font-medium ${ >
isToday(day) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100' <div
}`}> className={`text-sm font-medium ${
isToday(day)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{format(day, 'EEE')} {format(day, 'EEE')}
</div> </div>
<div className={`text-lg ${ <div
isToday(day) ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-gray-600 dark:text-gray-400' className={`text-lg ${
}`}> isToday(day)
? 'text-blue-600 dark:text-blue-400 font-bold'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{isToday(day) ? ( {isToday(day) ? (
<span className="inline-flex items-center justify-center w-8 h-8 bg-blue-600 text-white text-sm font-bold rounded-full"> <span className="inline-flex items-center justify-center w-8 h-8 bg-blue-600 text-white text-sm font-bold rounded-full">
{format(day, 'd')} {format(day, 'd')}
@ -90,36 +104,57 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
{/* Time slots */} {/* Time slots */}
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{hours.map(hour => ( {hours.map((hour) => (
<div key={hour} className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800"> <div
key={hour}
className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800"
>
{/* Time column */} {/* Time column */}
<div className="p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700"> <div className="p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700">
{format(addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm')} {format(
addHours(new Date().setHours(hour, 0, 0, 0), 0),
'HH:mm'
)}
</div> </div>
{/* Day columns */} {/* Day columns */}
{weekDays.map(day => { {weekDays.map((day) => {
const timeSlotEvents = getEventsForTimeSlot(day, hour); const timeSlotEvents = getEventsForTimeSlot(
day,
hour
);
return ( return (
<div <div
key={`${day.toString()}-${hour}`} key={`${day.toString()}-${hour}`}
onClick={() => handleTimeSlotClick(day, hour)} onClick={() =>
handleTimeSlotClick(day, hour)
}
className={`h-12 p-1 border-l border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative ${ className={`h-12 p-1 border-l border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative ${
isToday(day) ? 'bg-blue-50/30 dark:bg-blue-900/10' : '' isToday(day)
? 'bg-blue-50/30 dark:bg-blue-900/10'
: ''
}`} }`}
> >
{timeSlotEvents.map(event => ( {timeSlotEvents.map((event) => (
<div <div
key={event.id} key={event.id}
onClick={(e) => handleEventClick(event, e)} onClick={(e) =>
handleEventClick(event, e)
}
className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity absolute inset-1 ${ className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity absolute inset-1 ${
event.type === 'task' ? 'border-l-2 border-l-white/50' : '' event.type === 'task'
? 'border-l-2 border-l-white/50'
: ''
}`} }`}
style={{ backgroundColor: event.color || '#3b82f6' }} style={{
backgroundColor:
event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`} title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`}
> >
{event.type === 'task' && '📋 '}{event.title} {event.type === 'task' && '📋 '}
{event.title}
</div> </div>
))} ))}
</div> </div>

View file

@ -1,11 +1,17 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { InboxItem } from '../../entities/InboxItem'; import { InboxItem } from '../../entities/InboxItem';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TrashIcon, PencilIcon, DocumentTextIcon, FolderIcon, ClipboardDocumentListIcon, TagIcon } from '@heroicons/react/24/outline'; import {
TrashIcon,
PencilIcon,
DocumentTextIcon,
FolderIcon,
ClipboardDocumentListIcon,
TagIcon,
} from '@heroicons/react/24/outline';
import { Task } from '../../entities/Task'; import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project'; import { Project } from '../../entities/Project';
import { Note } from '../../entities/Note'; import { Note } from '../../entities/Note';
import { useToast } from '../Shared/ToastContext';
import ConfirmDialog from '../Shared/ConfirmDialog'; import ConfirmDialog from '../Shared/ConfirmDialog';
import { useStore } from '../../store/useStore'; import { useStore } from '../../store/useStore';
@ -21,16 +27,17 @@ interface InboxItemDetailProps {
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
item, item,
onProcess, onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars
onDelete, onDelete,
onUpdate, onUpdate,
openTaskModal, openTaskModal,
openProjectModal, openProjectModal,
openNoteModal openNoteModal,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast(); const {
const { tagsStore: { tags } } = useStore(); tagsStore: { tags },
} = useStore();
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -39,16 +46,18 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
const parseHashtags = (text: string): string[] => { const parseHashtags = (text: string): string[] => {
const hashtagRegex = /#([a-zA-Z0-9_]+)/g; const hashtagRegex = /#([a-zA-Z0-9_]+)/g;
const matches = text.match(hashtagRegex); const matches = text.match(hashtagRegex);
return matches ? matches.map(tag => tag.substring(1)) : []; return matches ? matches.map((tag) => tag.substring(1)) : [];
}; };
const hashtags = parseHashtags(item.content); const hashtags = parseHashtags(item.content);
const handleConvertToTask = () => { const handleConvertToTask = () => {
// Convert hashtags to Tag objects // Convert hashtags to Tag objects
const taskTags = hashtags.map(hashtagName => { const taskTags = hashtags.map((hashtagName) => {
// Find existing tag or create a placeholder for new tag // Find existing tag or create a placeholder for new tag
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase()); const existingTag = tags.find(
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName }; return existingTag || { name: hashtagName };
}); });
@ -56,7 +65,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
name: item.content, name: item.content,
status: 'not_started', status: 'not_started',
priority: 'medium', priority: 'medium',
tags: taskTags tags: taskTags,
}; };
if (item.id !== undefined) { if (item.id !== undefined) {
@ -68,9 +77,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
const handleConvertToProject = () => { const handleConvertToProject = () => {
// Convert hashtags to Tag objects // Convert hashtags to Tag objects
const projectTags = hashtags.map(hashtagName => { const projectTags = hashtags.map((hashtagName) => {
// Find existing tag or create a placeholder for new tag // Find existing tag or create a placeholder for new tag
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase()); const existingTag = tags.find(
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName }; return existingTag || { name: hashtagName };
}); });
@ -78,7 +89,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
name: item.content, name: item.content,
description: '', description: '',
active: true, active: true,
tags: projectTags tags: projectTags,
}; };
if (item.id !== undefined) { if (item.id !== undefined) {
@ -89,25 +100,32 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
}; };
const handleConvertToNote = async () => { const handleConvertToNote = async () => {
let title = item.content.split('\n')[0] || item.content.substring(0, 50); let title =
item.content.split('\n')[0] || item.content.substring(0, 50);
let content = item.content; let content = item.content;
let isBookmark = false; let isBookmark = false;
try { try {
const { isUrl, extractUrlTitle } = await import("../../utils/urlService"); const { isUrl, extractUrlTitle } = await import(
'../../utils/urlService'
);
if (isUrl(item.content.trim())) { if (isUrl(item.content.trim())) {
setLoading(true); setLoading(true);
try { try {
// Add a timeout to prevent infinite loading // Add a timeout to prevent infinite loading
const timeoutPromise = new Promise((_, reject) => const timeoutPromise = new Promise(
setTimeout(() => reject(new Error('Timeout')), 10000) // 10 second timeout (_, reject) =>
setTimeout(
() => reject(new Error('Timeout')),
10000
) // 10 second timeout
); );
const result = await Promise.race([ const result = (await Promise.race([
extractUrlTitle(item.content.trim()), extractUrlTitle(item.content.trim()),
timeoutPromise timeoutPromise,
]) as any; ])) as any;
if (result && result.title) { if (result && result.title) {
title = result.title; title = result.title;
@ -115,7 +133,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
isBookmark = true; isBookmark = true;
} }
} catch (titleError) { } catch (titleError) {
console.error("Error extracting URL title:", titleError); console.error('Error extracting URL title:', titleError);
// Continue with default title if URL title extraction fails // Continue with default title if URL title extraction fails
// Still mark as bookmark if it's a URL // Still mark as bookmark if it's a URL
isBookmark = true; isBookmark = true;
@ -124,25 +142,27 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
} }
} }
} catch (error) { } catch (error) {
console.error("Error checking URL or extracting title:", error); console.error('Error checking URL or extracting title:', error);
setLoading(false); setLoading(false);
} }
// Convert hashtags to Tag objects and include bookmark tag if needed // Convert hashtags to Tag objects and include bookmark tag if needed
const hashtagTags = hashtags.map(hashtagName => { const hashtagTags = hashtags.map((hashtagName) => {
// Find existing tag or create a placeholder for new tag // Find existing tag or create a placeholder for new tag
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase()); const existingTag = tags.find(
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
);
return existingTag || { name: hashtagName }; return existingTag || { name: hashtagName };
}); });
// Combine hashtag tags with bookmark tag if it's a URL // Combine hashtag tags with bookmark tag if it's a URL
const bookmarkTag = isBookmark ? [{ name: "bookmark" }] : []; const bookmarkTag = isBookmark ? [{ name: 'bookmark' }] : [];
const tagObjects = [...hashtagTags, ...bookmarkTag]; const tagObjects = [...hashtagTags, ...bookmarkTag];
const newNote: Note = { const newNote: Note = {
title: title, title: title,
content: content, content: content,
tags: tagObjects tags: tagObjects,
}; };
if (item.id !== undefined) { if (item.id !== undefined) {
@ -152,7 +172,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
} }
}; };
const handleDelete = () => { const handleDelete = () => {
setShowConfirmDialog(true); setShowConfirmDialog(true);
}; };
@ -241,7 +260,10 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{showConfirmDialog && ( {showConfirmDialog && (
<ConfirmDialog <ConfirmDialog
title={t('inbox.deleteConfirmTitle', 'Delete Item')} 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.')} message={t(
'inbox.deleteConfirmMessage',
'Are you sure you want to delete this inbox item? This action cannot be undone.'
)}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setShowConfirmDialog(false)} onCancel={() => setShowConfirmDialog(false)}
/> />

View file

@ -6,7 +6,7 @@ import {
loadInboxItemsToStore, loadInboxItemsToStore,
processInboxItemWithStore, processInboxItemWithStore,
deleteInboxItemWithStore, deleteInboxItemWithStore,
updateInboxItemWithStore updateInboxItemWithStore,
} from '../../utils/inboxService'; } from '../../utils/inboxService';
import InboxItemDetail from './InboxItemDetail'; import InboxItemDetail from './InboxItemDetail';
import { useToast } from '../Shared/ToastContext'; import { useToast } from '../Shared/ToastContext';
@ -29,7 +29,7 @@ const InboxItems: React.FC = () => {
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
// Access store data // Access store data
const { inboxItems, isLoading } = useStore(state => state.inboxStore); const { inboxItems, isLoading } = useStore((state) => state.inboxStore);
// Modal states // Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
@ -43,7 +43,9 @@ const InboxItems: React.FC = () => {
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null); const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
// Track the current inbox item ID being converted (for task/project/note conversion) // Track the current inbox item ID being converted (for task/project/note conversion)
const [currentConversionItemId, setCurrentConversionItemId] = useState<number | null>(null); const [currentConversionItemId, setCurrentConversionItemId] = useState<
number | null
>(null);
// Track the current inbox item being edited // Track the current inbox item being edited
const [itemToEdit, setItemToEdit] = useState<number | null>(null); const [itemToEdit, setItemToEdit] = useState<number | null>(null);
@ -69,19 +71,33 @@ const InboxItems: React.FC = () => {
}; };
// Handler for the inboxItemsUpdated custom event // Handler for the inboxItemsUpdated custom event
const handleInboxItemsUpdated = (event: CustomEvent<{count: number, firstItemContent: string}>) => { const handleInboxItemsUpdated = (
event: CustomEvent<{ count: number; firstItemContent: string }>
) => {
// Show toast notifications for new items // Show toast notifications for new items
if (event.detail.count > 0) { if (event.detail.count > 0) {
// Show notification for the first new item // Show notification for the first new item
showSuccessToast(t('inbox.newTelegramItem', 'New item from Telegram: {{content}}', { showSuccessToast(
content: event.detail.firstItemContent t(
})); 'inbox.newTelegramItem',
'New item from Telegram: {{content}}',
{
content: event.detail.firstItemContent,
}
)
);
// If multiple new items, show a summary notification as well // If multiple new items, show a summary notification as well
if (event.detail.count > 1) { if (event.detail.count > 1) {
showSuccessToast(t('inbox.multipleNewItems', '{{count}} more new items added', { showSuccessToast(
count: event.detail.count - 1 t(
})); 'inbox.multipleNewItems',
'{{count}} more new items added',
{
count: event.detail.count - 1,
}
)
);
} }
} }
}; };
@ -94,12 +110,18 @@ const InboxItems: React.FC = () => {
// Add event listeners // Add event listeners
window.addEventListener('forceInboxReload', handleForceReload); window.addEventListener('forceInboxReload', handleForceReload);
window.addEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener); window.addEventListener(
'inboxItemsUpdated',
handleInboxItemsUpdated as EventListener
);
return () => { return () => {
clearInterval(pollInterval); clearInterval(pollInterval);
window.removeEventListener('forceInboxReload', handleForceReload); window.removeEventListener('forceInboxReload', handleForceReload);
window.removeEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener); window.removeEventListener(
'inboxItemsUpdated',
handleInboxItemsUpdated as EventListener
);
}; };
}, [refreshInboxItems]); }, [refreshInboxItems]);
@ -165,7 +187,10 @@ const InboxItems: React.FC = () => {
setIsTaskModalOpen(true); setIsTaskModalOpen(true);
}; };
const handleOpenProjectModal = (project: Project | null, inboxItemId?: number) => { const handleOpenProjectModal = (
project: Project | null,
inboxItemId?: number
) => {
setProjectToEdit(project); setProjectToEdit(project);
if (inboxItemId) { if (inboxItemId) {
@ -175,7 +200,10 @@ const InboxItems: React.FC = () => {
setIsProjectModalOpen(true); setIsProjectModalOpen(true);
}; };
const handleOpenNoteModal = async (note: Note | null, inboxItemId?: number) => { const handleOpenNoteModal = async (
note: Note | null,
inboxItemId?: number
) => {
// Load projects first before opening the modal // Load projects first before opening the modal
try { try {
const projectData = await fetchProjects(); const projectData = await fetchProjects();
@ -191,7 +219,7 @@ const InboxItems: React.FC = () => {
if (note && note.content && isUrl(note.content.trim())) { if (note && note.content && isUrl(note.content.trim())) {
if (!note.tags) { if (!note.tags) {
note.tags = [{ name: 'bookmark' }]; note.tags = [{ name: 'bookmark' }];
} else if (!note.tags.some(tag => tag.name === 'bookmark')) { } else if (!note.tags.some((tag) => tag.name === 'bookmark')) {
note.tags.push({ name: 'bookmark' }); note.tags.push({ name: 'bookmark' });
} }
} }
@ -210,7 +238,14 @@ const InboxItems: React.FC = () => {
const createdTask = await createTask(task); const createdTask = await createTask(task);
const taskLink = ( const taskLink = (
<span> <span>
{t('task.created', 'Task')} <a href={`/task/${createdTask.uuid}`} className="text-green-200 underline hover:text-green-100">{createdTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')} {t('task.created', 'Task')}{' '}
<a
href={`/task/${createdTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{createdTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
@ -246,7 +281,6 @@ const InboxItems: React.FC = () => {
} }
}; };
const handleSaveNote = async (note: Note) => { const handleSaveNote = async (note: Note) => {
try { try {
// Check if the content appears to be a URL and add the bookmark tag // Check if the content appears to be a URL and add the bookmark tag
@ -259,14 +293,19 @@ const InboxItems: React.FC = () => {
} }
// Add a bookmark tag if content is a URL and doesn't already have the tag // 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')) { if (
isBookmarkContent &&
!note.tags.some((tag) => tag.name === 'bookmark')
) {
// Use spread operator to create a new array with the bookmark tag added // Use spread operator to create a new array with the bookmark tag added
note.tags = [...note.tags, { name: 'bookmark' }]; note.tags = [...note.tags, { name: 'bookmark' }];
} }
// Create the note with proper tags // Create the note with proper tags
await createNote(note); await createNote(note);
showSuccessToast(t('note.createSuccess', 'Note created successfully')); showSuccessToast(
t('note.createSuccess', 'Note created successfully')
);
// Process the inbox item after successful note creation // Process the inbox item after successful note creation
if (currentConversionItemId !== null) { if (currentConversionItemId !== null) {
@ -315,7 +354,10 @@ const InboxItems: React.FC = () => {
</div> </div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400"> <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.')} {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> </p>
<div className="space-y-2"> <div className="space-y-2">
@ -344,7 +386,13 @@ const InboxItems: React.FC = () => {
setIsTaskModalOpen(false); setIsTaskModalOpen(false);
setTaskToEdit(null); setTaskToEdit(null);
}} }}
task={taskToEdit || { name: '', status: 'not_started', priority: 'medium' }} task={
taskToEdit || {
name: '',
status: 'not_started',
priority: 'medium',
}
}
onSave={handleSaveTask} onSave={handleSaveTask}
onDelete={async () => {}} // No need to delete since it's a new task onDelete={async () => {}} // No need to delete since it's a new task
projects={Array.isArray(projects) ? projects : []} projects={Array.isArray(projects) ? projects : []}
@ -358,7 +406,8 @@ const InboxItems: React.FC = () => {
})()} })()}
{/* Project Modal - Only render when needed to prevent infinite loops */} {/* Project Modal - Only render when needed to prevent infinite loops */}
{isProjectModalOpen && (() => { {isProjectModalOpen &&
(() => {
try { try {
return ( return (
<ProjectModal <ProjectModal
@ -409,7 +458,10 @@ const InboxItems: React.FC = () => {
setItemToEdit(null); setItemToEdit(null);
}} }}
onSave={async () => {}} // Not used in edit mode onSave={async () => {}} // Not used in edit mode
initialText={inboxItems.find(item => item.id === itemToEdit)?.content || ""} initialText={
inboxItems.find((item) => item.id === itemToEdit)
?.content || ''
}
editMode={true} editMode={true}
onEdit={handleSaveEditedItem} onEdit={handleSaveEditedItem}
/> />

View file

@ -1,14 +1,14 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Task } from "../../entities/Task"; import { Task } from '../../entities/Task';
import { Tag } from "../../entities/Tag"; import { Tag } from '../../entities/Tag';
import { useToast } from "../Shared/ToastContext"; import { useToast } from '../Shared/ToastContext';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import { createInboxItemWithStore } from "../../utils/inboxService"; import { createInboxItemWithStore } from '../../utils/inboxService';
import { isAuthError } from "../../utils/authUtils"; import { isAuthError } from '../../utils/authUtils';
import { createTag } from "../../utils/tagsService"; import { createTag } from '../../utils/tagsService';
import { XMarkIcon, TagIcon } from "@heroicons/react/24/outline"; import { XMarkIcon, TagIcon } from '@heroicons/react/24/outline';
import { useStore } from "../../store/useStore"; import { useStore } from '../../store/useStore';
import { Link } from "react-router-dom"; import { Link } from 'react-router-dom';
// import UrlPreview from "../Shared/UrlPreview"; // import UrlPreview from "../Shared/UrlPreview";
// import { UrlTitleResult } from "../../utils/urlService"; // import { UrlTitleResult } from "../../utils/urlService";
@ -25,7 +25,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
isOpen, isOpen,
onClose, onClose,
onSave, onSave,
initialText = "", initialText = '',
editMode = false, editMode = false,
onEdit, onEdit,
}) => { }) => {
@ -37,12 +37,17 @@ const InboxModal: React.FC<InboxModalProps> = ({
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
const nameInputRef = useRef<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox'); const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
const { tagsStore: { tags, setTags } } = useStore(); const {
tagsStore: { tags, setTags },
} = useStore();
const [showTagSuggestions, setShowTagSuggestions] = useState(false); const [showTagSuggestions, setShowTagSuggestions] = useState(false);
const [filteredTags, setFilteredTags] = useState<Tag[]>([]); const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
const [cursorPosition, setCursorPosition] = useState(0); const [cursorPosition, setCursorPosition] = useState(0);
const [currentHashtagQuery, setCurrentHashtagQuery] = useState(''); const [, setCurrentHashtagQuery] = useState('');
const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0 }); const [dropdownPosition, setDropdownPosition] = useState({
left: 0,
top: 0,
});
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null); // const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
// Dispatch global modal events to hide floating + button // Dispatch global modal events to hide floating + button
@ -51,7 +56,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
const parseHashtags = (text: string): string[] => { const parseHashtags = (text: string): string[] => {
const hashtagRegex = /#([a-zA-Z0-9_]+)/g; const hashtagRegex = /#([a-zA-Z0-9_]+)/g;
const matches = text.match(hashtagRegex); const matches = text.match(hashtagRegex);
return matches ? matches.map(tag => tag.substring(1)) : []; return matches ? matches.map((tag) => tag.substring(1)) : [];
}; };
// Helper function to get current hashtag query at cursor position // Helper function to get current hashtag query at cursor position
@ -62,31 +67,36 @@ const InboxModal: React.FC<InboxModalProps> = ({
}; };
// Helper function to render text with clickable hashtags // Helper function to render text with clickable hashtags
const renderTextWithHashtags = (text: string) => { // const renderTextWithHashtags = (text: string) => {
const parts = text.split(/(#[a-zA-Z0-9_]+)/g); // const parts = text.split(/(#[a-zA-Z0-9_]+)/g);
return parts.map((part, index) => { // return parts.map((part, index) => {
if (part.startsWith('#')) { // if (part.startsWith('#')) {
const tagName = part.substring(1); // const tagName = part.substring(1);
const tag = tags.find(t => t.name.toLowerCase() === tagName.toLowerCase()); // const tag = tags.find(
if (tag) { // (t) => t.name.toLowerCase() === tagName.toLowerCase()
return ( // );
<Link // if (tag) {
key={index} // return (
to={`/tag/${encodeURIComponent(tag.name)}`} // <Link
className="text-blue-600 dark:text-blue-400 hover:underline" // key={index}
onClick={(e) => e.stopPropagation()} // to={`/tag/${encodeURIComponent(tag.name)}`}
> // className="text-blue-600 dark:text-blue-400 hover:underline"
{part} // onClick={(e) => e.stopPropagation()}
</Link> // >
); // {part}
} // </Link>
} // );
return <span key={index}>{part}</span>; // }
}); // }
}; // return <span key={index}>{part}</span>;
// });
// };
// Helper function to calculate dropdown position based on cursor // Helper function to calculate dropdown position based on cursor
const calculateDropdownPosition = (input: HTMLInputElement, cursorPos: number) => { const calculateDropdownPosition = (
input: HTMLInputElement,
cursorPos: number
) => {
// Create a temporary element to measure text width // Create a temporary element to measure text width
const temp = document.createElement('span'); const temp = document.createElement('span');
temp.style.visibility = 'hidden'; temp.style.visibility = 'hidden';
@ -122,7 +132,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
return { return {
left: hashtagOffset, left: hashtagOffset,
top: input.offsetHeight top: input.offsetHeight,
}; };
} }
@ -162,12 +172,19 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) { if (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) {
// Filter tags based on current query // Filter tags based on current query
const filtered = tags.filter(tag => const filtered = tags
tag.name.toLowerCase().startsWith(hashtagQuery.toLowerCase()) .filter((tag) =>
).slice(0, 5); // Limit to 5 suggestions tag.name
.toLowerCase()
.startsWith(hashtagQuery.toLowerCase())
)
.slice(0, 5); // Limit to 5 suggestions
// Calculate dropdown position // Calculate dropdown position
const position = calculateDropdownPosition(e.target, newCursorPosition); const position = calculateDropdownPosition(
e.target,
newCursorPosition
);
setDropdownPosition(position); setDropdownPosition(position);
setFilteredTags(filtered); setFilteredTags(filtered);
@ -185,7 +202,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/); const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/);
if (hashtagMatch) { if (hashtagMatch) {
const newText = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) + afterCursor; const newText =
beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) +
afterCursor;
setInputText(newText); setInputText(newText);
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
@ -194,8 +213,14 @@ const InboxModal: React.FC<InboxModalProps> = ({
setTimeout(() => { setTimeout(() => {
if (nameInputRef.current) { if (nameInputRef.current) {
nameInputRef.current.focus(); nameInputRef.current.focus();
const newCursorPos = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`).length; const newCursorPos = beforeCursor.replace(
nameInputRef.current.setSelectionRange(newCursorPos, newCursorPos); /#([a-zA-Z0-9_]*)$/,
`#${tagName}`
).length;
nameInputRef.current.setSelectionRange(
newCursorPos,
newCursorPos
);
} }
}, 0); }, 0);
} }
@ -204,9 +229,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
// Create missing tags automatically // Create missing tags automatically
const createMissingTags = async (text: string): Promise<void> => { const createMissingTags = async (text: string): Promise<void> => {
const hashtagsInText = parseHashtags(text); const hashtagsInText = parseHashtags(text);
const existingTagNames = tags.map(tag => tag.name.toLowerCase()); const existingTagNames = tags.map((tag) => tag.name.toLowerCase());
const missingTags = hashtagsInText.filter(tagName => const missingTags = hashtagsInText.filter(
!existingTagNames.includes(tagName.toLowerCase()) (tagName) => !existingTagNames.includes(tagName.toLowerCase())
); );
for (const tagName of missingTags) { for (const tagName of missingTags) {
@ -243,7 +268,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (saveMode === 'task') { if (saveMode === 'task') {
const newTask: Task = { const newTask: Task = {
name: inputText.trim(), name: inputText.trim(),
status: "not_started", status: 'not_started',
}; };
try { try {
@ -276,19 +301,36 @@ const InboxModal: React.FC<InboxModalProps> = ({
if (editMode) { if (editMode) {
showErrorToast(t('inbox.updateError')); showErrorToast(t('inbox.updateError'));
} else { } else {
showErrorToast(saveMode === 'task' ? t('task.createError') : t('inbox.addError')); showErrorToast(
saveMode === 'task'
? t('task.createError')
: t('inbox.addError')
);
} }
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [inputText, isSaving, editMode, onEdit, saveMode, onSave, showSuccessToast, showErrorToast, t, onClose, tags, setTags]); }, [
inputText,
isSaving,
editMode,
onEdit,
saveMode,
onSave,
showSuccessToast,
showErrorToast,
t,
onClose,
tags,
setTags,
]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setIsClosing(true); setIsClosing(true);
setTimeout(() => { setTimeout(() => {
onClose(); onClose();
if (!editMode) { if (!editMode) {
setInputText(""); setInputText('');
setSaveMode('inbox'); setSaveMode('inbox');
} }
setIsClosing(false); setIsClosing(false);
@ -297,7 +339,10 @@ const InboxModal: React.FC<InboxModalProps> = ({
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) { if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
if (showTagSuggestions) { if (showTagSuggestions) {
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
@ -307,16 +352,16 @@ const InboxModal: React.FC<InboxModalProps> = ({
} }
}; };
if (isOpen) { if (isOpen) {
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
} }
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, [isOpen, showTagSuggestions, handleClose]); }, [isOpen, showTagSuggestions, handleClose]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === 'Escape') {
if (showTagSuggestions) { if (showTagSuggestions) {
setShowTagSuggestions(false); setShowTagSuggestions(false);
setFilteredTags([]); setFilteredTags([]);
@ -326,10 +371,10 @@ const InboxModal: React.FC<InboxModalProps> = ({
} }
}; };
if (isOpen) { if (isOpen) {
document.addEventListener("keydown", handleKeyDown); document.addEventListener('keydown', handleKeyDown);
} }
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen, showTagSuggestions, handleClose]); }, [isOpen, showTagSuggestions, handleClose]);
@ -338,13 +383,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
return ( return (
<div <div
className={`fixed top-16 left-0 right-0 bottom-0 sm:top-16 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-[45] transition-opacity duration-300 ${ className={`fixed top-16 left-0 right-0 bottom-0 sm:top-16 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-[45] transition-opacity duration-300 ${
isClosing ? "opacity-0" : "opacity-100" isClosing ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
<div <div
ref={modalRef} ref={modalRef}
className={`relative bg-white dark:bg-gray-800 border-0 sm:border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full h-full sm:h-auto sm:max-w-2xl md:max-w-3xl transform transition-transform duration-300 ${ className={`relative bg-white dark:bg-gray-800 border-0 sm:border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full h-full sm:h-auto sm:max-w-2xl md:max-w-3xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100" isClosing ? 'scale-95' : 'scale-100'
} flex flex-col`} } flex flex-col`}
> >
{/* Close button - only visible on mobile */} {/* Close button - only visible on mobile */}
@ -367,29 +412,44 @@ const InboxModal: React.FC<InboxModalProps> = ({
value={inputText} value={inputText}
onChange={handleChange} onChange={handleChange}
onSelect={(e) => { onSelect={(e) => {
const pos = e.currentTarget.selectionStart || 0; const pos =
e.currentTarget.selectionStart || 0;
setCursorPosition(pos); setCursorPosition(pos);
// Update dropdown position if showing suggestions // Update dropdown position if showing suggestions
if (showTagSuggestions) { if (showTagSuggestions) {
const position = calculateDropdownPosition(e.currentTarget, pos); const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position); setDropdownPosition(position);
} }
}} }}
onKeyUp={(e) => { onKeyUp={(e) => {
const pos = e.currentTarget.selectionStart || 0; const pos =
e.currentTarget.selectionStart || 0;
setCursorPosition(pos); setCursorPosition(pos);
// Update dropdown position if showing suggestions // Update dropdown position if showing suggestions
if (showTagSuggestions) { if (showTagSuggestions) {
const position = calculateDropdownPosition(e.currentTarget, pos); const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position); setDropdownPosition(position);
} }
}} }}
onClick={(e) => { onClick={(e) => {
const pos = e.currentTarget.selectionStart || 0; const pos =
e.currentTarget.selectionStart || 0;
setCursorPosition(pos); setCursorPosition(pos);
// Update dropdown position if showing suggestions // Update dropdown position if showing suggestions
if (showTagSuggestions) { if (showTagSuggestions) {
const position = calculateDropdownPosition(e.currentTarget, pos); const position =
calculateDropdownPosition(
e.currentTarget,
pos
);
setDropdownPosition(position); setDropdownPosition(position);
} }
}} }}
@ -397,7 +457,12 @@ const InboxModal: React.FC<InboxModalProps> = ({
className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2" className="w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white focus:outline-none shadow-sm py-2"
placeholder={t('inbox.captureThought')} placeholder={t('inbox.captureThought')}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !isSaving && !showTagSuggestions) { if (
e.key === 'Enter' &&
!e.shiftKey &&
!isSaving &&
!showTagSuggestions
) {
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
} }
@ -405,54 +470,85 @@ const InboxModal: React.FC<InboxModalProps> = ({
/> />
{/* Tags display like TaskItem */} {/* Tags display like TaskItem */}
{inputText && parseHashtags(inputText).length > 0 && ( {inputText &&
parseHashtags(inputText).length > 0 && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1"> <div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span> <span>
{parseHashtags(inputText).map((tagName, index) => { {parseHashtags(inputText).map(
const tag = tags.find(t => t.name.toLowerCase() === tagName.toLowerCase()); (tagName, index) => {
const isLast = index === parseHashtags(inputText).length - 1; const tag = tags.find(
(t) =>
t.name.toLowerCase() ===
tagName.toLowerCase()
);
const isLast =
index ===
parseHashtags(
inputText
).length -
1;
if (tag) { if (tag) {
return ( return (
<span key={index}> <span
key={index}
>
<Link <Link
to={`/tag/${encodeURIComponent(tag.name)}`} to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-blue-600 dark:text-blue-400 hover:underline" className="text-blue-600 dark:text-blue-400 hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(
e
) =>
e.stopPropagation()
}
> >
{tagName} {
tagName
}
</Link> </Link>
{!isLast && ', '} {!isLast &&
', '}
</span> </span>
); );
} else { } else {
return ( return (
<span key={index} className="text-orange-500 dark:text-orange-400"> <span
{tagName}{!isLast && ', '} key={index}
className="text-orange-500 dark:text-orange-400"
>
{tagName}
{!isLast &&
', '}
</span> </span>
); );
} }
})} }
)}
</span> </span>
</div> </div>
)} )}
{/* Tag Suggestions Dropdown */} {/* Tag Suggestions Dropdown */}
{showTagSuggestions && filteredTags.length > 0 && ( {showTagSuggestions &&
filteredTags.length > 0 && (
<div <div
className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50" className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50"
style={{ style={{
left: `${dropdownPosition.left}px`, left: `${dropdownPosition.left}px`,
top: `${dropdownPosition.top + 4}px`, top: `${dropdownPosition.top + 4}px`,
minWidth: '120px', minWidth: '120px',
maxWidth: '200px' maxWidth: '200px',
}} }}
> >
{filteredTags.map((tag, index) => ( {filteredTags.map((tag, index) => (
<button <button
key={tag.id || index} key={tag.id || index}
onClick={() => handleTagSelect(tag.name)} onClick={() =>
handleTagSelect(
tag.name
)
}
className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md" className="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md"
> >
#{tag.name} #{tag.name}
@ -467,11 +563,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
disabled={!inputText.trim() || isSaving} disabled={!inputText.trim() || isSaving}
className={`mt-4 sm:mt-0 sm:ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${ className={`mt-4 sm:mt-0 sm:ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${
inputText.trim() && !isSaving inputText.trim() && !isSaving
? "bg-blue-600 hover:bg-blue-700" ? 'bg-blue-600 hover:bg-blue-700'
: "bg-blue-400 cursor-not-allowed" : 'bg-blue-400 cursor-not-allowed'
}`} }`}
> >
{isSaving ? t('common.saving') : t('common.save')} {isSaving
? t('common.saving')
: t('common.save')}
</button> </button>
</div> </div>
{/* URL Preview disabled */} {/* URL Preview disabled */}

View file

@ -20,7 +20,7 @@ const Login: React.FC = () => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
credentials: 'include' credentials: 'include',
}); });
const data = await response.json(); const data = await response.json();
@ -30,7 +30,9 @@ const Login: React.FC = () => {
await i18n.changeLanguage(data.user.language); await i18n.changeLanguage(data.user.language);
} }
window.dispatchEvent(new CustomEvent('userLoggedIn', { detail: data.user })); window.dispatchEvent(
new CustomEvent('userLoggedIn', { detail: data.user })
);
navigate('/today'); navigate('/today');
} else { } else {
@ -44,14 +46,10 @@ const Login: React.FC = () => {
return ( return (
<div className="bg-gray-100 flex flex-col items-center justify-center min-h-screen px-4"> <div className="bg-gray-100 flex flex-col items-center justify-center min-h-screen px-4">
<h1 className="text-5xl font-bold text-gray-300 mb-6"> <h1 className="text-5xl font-bold text-gray-300 mb-6">tududi</h1>
tududi
</h1>
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm"> <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
{error && ( {error && (
<div className="mb-4 text-center text-red-500"> <div className="mb-4 text-center text-red-500">{error}</div>
{error}
</div>
)} )}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="mb-4"> <div className="mb-4">

View file

@ -1,8 +1,13 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from 'react-router-dom';
import { UserIcon, Bars3Icon, BoltIcon, InboxIcon } from "@heroicons/react/24/solid"; import {
import { useTranslation } from "react-i18next"; UserIcon,
import PomodoroTimer from "./Shared/PomodoroTimer"; Bars3Icon,
BoltIcon,
InboxIcon,
} from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import PomodoroTimer from './Shared/PomodoroTimer';
interface NavbarProps { interface NavbarProps {
isDarkMode: boolean; isDarkMode: boolean;
@ -18,8 +23,6 @@ interface NavbarProps {
} }
const Navbar: React.FC<NavbarProps> = ({ const Navbar: React.FC<NavbarProps> = ({
isDarkMode,
toggleDarkMode,
currentUser, currentUser,
setCurrentUser, setCurrentUser,
isSidebarOpen, isSidebarOpen,
@ -41,9 +44,9 @@ const Navbar: React.FC<NavbarProps> = ({
setIsDropdownOpen(false); setIsDropdownOpen(false);
} }
}; };
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, []); }, []);
@ -52,11 +55,15 @@ const Navbar: React.FC<NavbarProps> = ({
const fetchProfile = async () => { const fetchProfile = async () => {
try { try {
const response = await fetch('/api/profile', { const response = await fetch('/api/profile', {
credentials: 'include' credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
const profile = await response.json(); const profile = await response.json();
setPomodoroEnabled(profile.pomodoro_enabled !== undefined ? profile.pomodoro_enabled : true); setPomodoroEnabled(
profile.pomodoro_enabled !== undefined
? profile.pomodoro_enabled
: true
);
} }
} catch (error) { } catch (error) {
console.error('Error fetching profile:', error); console.error('Error fetching profile:', error);
@ -71,10 +78,16 @@ const Navbar: React.FC<NavbarProps> = ({
setPomodoroEnabled(event.detail.enabled); setPomodoroEnabled(event.detail.enabled);
}; };
window.addEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener); window.addEventListener(
'pomodoroSettingChanged',
handlePomodoroSettingChange as EventListener
);
return () => { return () => {
window.removeEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener); window.removeEventListener(
'pomodoroSettingChanged',
handlePomodoroSettingChange as EventListener
);
}; };
}, []); }, []);
@ -107,7 +120,11 @@ const Navbar: React.FC<NavbarProps> = ({
<button <button
onClick={() => setIsSidebarOpen(!isSidebarOpen)} onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="flex items-center focus:outline-none text-gray-500 dark:text-gray-500" className="flex items-center focus:outline-none text-gray-500 dark:text-gray-500"
aria-label={isSidebarOpen ? "Collapse Sidebar" : "Expand Sidebar"} aria-label={
isSidebarOpen
? 'Collapse Sidebar'
: 'Expand Sidebar'
}
> >
<Bars3Icon className="h-6 mt-1 w-6 mr-2" /> <Bars3Icon className="h-6 mt-1 w-6 mr-2" />
</button> </button>
@ -160,7 +177,10 @@ const Navbar: React.FC<NavbarProps> = ({
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => setIsDropdownOpen(false)} onClick={() => setIsDropdownOpen(false)}
> >
{t('navigation.profileSettings', 'Profile Settings')} {t(
'navigation.profileSettings',
'Profile Settings'
)}
</Link> </Link>
<Link <Link
to="/about" to="/about"

View file

@ -1,17 +1,27 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid'; import {
PencilSquareIcon,
TrashIcon,
TagIcon,
DocumentTextIcon,
} from '@heroicons/react/24/solid';
import ConfirmDialog from '../Shared/ConfirmDialog'; import ConfirmDialog from '../Shared/ConfirmDialog';
import NoteModal from './NoteModal'; import NoteModal from './NoteModal';
import MarkdownRenderer from '../Shared/MarkdownRenderer'; import MarkdownRenderer from '../Shared/MarkdownRenderer';
import { Note } from '../../entities/Note'; import { Note } from '../../entities/Note';
import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService'; import {
fetchNotes,
deleteNote as apiDeleteNote,
updateNote as apiUpdateNote,
} from '../../utils/notesService';
const NoteDetails: React.FC = () => { const NoteDetails: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [note, setNote] = useState<Note | null>(null); const [note, setNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null); const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@ -52,10 +62,13 @@ const NoteDetails: React.FC = () => {
const handleSaveNote = async (updatedNote: Note) => { const handleSaveNote = async (updatedNote: Note) => {
try { try {
if (updatedNote.id !== undefined) { if (updatedNote.id !== undefined) {
const savedNote = await apiUpdateNote(updatedNote.id, updatedNote); const savedNote = await apiUpdateNote(
updatedNote.id,
updatedNote
);
setNote(savedNote); setNote(savedNote);
} else { } else {
console.error("Error: Note ID is undefined."); console.error('Error: Note ID is undefined.');
} }
} catch (err) { } catch (err) {
console.error('Error saving note:', err); console.error('Error saving note:', err);
@ -86,7 +99,9 @@ const NoteDetails: React.FC = () => {
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg"> <div className="text-red-500 text-lg">
{isError ? 'Error loading note details.' : 'Note not found.'} {isError
? 'Error loading note details.'
: 'Note not found.'}
</div> </div>
</div> </div>
); );
@ -124,26 +139,38 @@ const NoteDetails: React.FC = () => {
</div> </div>
</div> </div>
{/* Tags and Project */} {/* Tags and Project */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) || note.project || note.Project ? ( {(note.tags && note.tags.length > 0) ||
(note.Tags && note.Tags.length > 0) ||
note.project ||
note.Project ? (
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6"> <div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
{/* Note Tags */} {/* Note Tags */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && ( {((note.tags && note.tags.length > 0) ||
(note.Tags && note.Tags.length > 0)) && (
<div className="mb-4"> <div className="mb-4">
<div className="flex items-start"> <div className="flex items-start">
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" /> <TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" />
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Tags:</span> <span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Tags:
</span>
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex flex-wrap gap-2 mt-1">
{(note.tags || note.Tags || []).map((tag) => ( {(note.tags || note.Tags || []).map(
(tag) => (
<button <button
key={tag.id} key={tag.id}
onClick={() => navigate(`/tag/${encodeURIComponent(tag.name)}`)} onClick={() =>
navigate(
`/tag/${encodeURIComponent(tag.name)}`
)
}
className="flex items-center space-x-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors text-xs" className="flex items-center space-x-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors text-xs"
> >
<TagIcon className="h-3 w-3" /> <TagIcon className="h-3 w-3" />
<span>{tag.name}</span> <span>{tag.name}</span>
</button> </button>
))} )
)}
</div> </div>
</div> </div>
</div> </div>
@ -151,7 +178,14 @@ const NoteDetails: React.FC = () => {
)} )}
{/* Note Project */} {/* Note Project */}
{(note.project || note.Project) && ( {(note.project || note.Project) && (
<div className={((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) ? "mt-4" : ""}> <div
className={
(note.tags && note.tags.length > 0) ||
(note.Tags && note.Tags.length > 0)
? 'mt-4'
: ''
}
>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Project Project
</h3> </h3>

View file

@ -1,4 +1,10 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from 'react';
import { Note } from '../../entities/Note'; import { Note } from '../../entities/Note';
import { Project } from '../../entities/Project'; import { Project } from '../../entities/Project';
import { useToast } from '../Shared/ToastContext'; import { useToast } from '../Shared/ToastContext';
@ -7,7 +13,13 @@ import MarkdownRenderer from '../Shared/MarkdownRenderer';
import { Tag } from '../../entities/Tag'; import { Tag } from '../../entities/Tag';
import { fetchTags } from '../../utils/tagsService'; import { fetchTags } from '../../utils/tagsService';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EyeIcon, PencilIcon, FolderIcon, TagIcon, TrashIcon } from '@heroicons/react/24/outline'; import {
EyeIcon,
PencilIcon,
FolderIcon,
TagIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
interface NoteModalProps { interface NoteModalProps {
isOpen: boolean; isOpen: boolean;
@ -19,7 +31,15 @@ interface NoteModalProps {
onCreateProject?: (name: string) => Promise<Project>; onCreateProject?: (name: string) => Promise<Project>;
} }
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, onDelete, projects = [], onCreateProject }) => { const NoteModal: React.FC<NoteModalProps> = ({
isOpen,
onClose,
note,
onSave,
onDelete,
projects = [],
onCreateProject,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [formData, setFormData] = useState<Note>({ const [formData, setFormData] = useState<Note>({
id: note?.id, id: note?.id,
@ -27,7 +47,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
content: note?.content || '', content: note?.content || '',
tags: note?.tags || [], tags: note?.tags || [],
}); });
const [tags, setTags] = useState<string[]>(note?.tags?.map((tag) => tag.name) || []); const [tags, setTags] = useState<string[]>(
note?.tags?.map((tag) => tag.name) || []
);
const [availableTags, setAvailableTags] = useState<Tag[]>([]); const [availableTags, setAvailableTags] = useState<Tag[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
@ -37,7 +59,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
// Project-related state // Project-related state
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]); const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
const [newProjectName, setNewProjectName] = useState<string>(""); const [newProjectName, setNewProjectName] = useState<string>('');
const [isCreatingProject, setIsCreatingProject] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@ -88,7 +110,10 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
setError(null); setError(null);
// Initialize project name from note - exactly like TaskModal // Initialize project name from note - exactly like TaskModal
const currentProject = memoizedProjects.find((project) => project.id === (note?.project?.id || note?.Project?.id)); const currentProject = memoizedProjects.find(
(project) =>
project.id === (note?.project?.id || note?.Project?.id)
);
setNewProjectName(currentProject ? currentProject.name : ''); setNewProjectName(currentProject ? currentProject.name : '');
} }
}, [isOpen, note, memoizedProjects]); }, [isOpen, note, memoizedProjects]);
@ -152,21 +177,24 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
})); }));
}, []); }, []);
const toggleSection = useCallback((section: keyof typeof expandedSections) => { const toggleSection = useCallback(
setExpandedSections(prev => { (section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
const newExpanded = { const newExpanded = {
...prev, ...prev,
[section]: !prev[section] [section]: !prev[section],
}; };
// Auto-scroll to show the expanded section // Auto-scroll to show the expanded section
if (newExpanded[section]) { if (newExpanded[section]) {
setTimeout(() => { setTimeout(() => {
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto'); const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: 'smooth' behavior: 'smooth',
}); });
} }
}, 100); // Small delay to ensure DOM is updated }, 100); // Small delay to ensure DOM is updated
@ -174,7 +202,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
return newExpanded; return newExpanded;
}); });
}, []); },
[]
);
const handleProjectSearch = (e: React.ChangeEvent<HTMLInputElement>) => { const handleProjectSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
@ -194,24 +224,24 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
}; };
const handleProjectSelection = (project: Project) => { const handleProjectSelection = (project: Project) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
project: { id: project.id!, name: project.name }, project: { id: project.id!, name: project.name },
project_id: project.id project_id: project.id,
})); }));
setNewProjectName(project.name); setNewProjectName(project.name);
setDropdownOpen(false); setDropdownOpen(false);
}; };
const handleCreateProject = async () => { const handleCreateProject = async () => {
if (newProjectName.trim() !== "" && onCreateProject) { if (newProjectName.trim() !== '' && onCreateProject) {
setIsCreatingProject(true); setIsCreatingProject(true);
try { try {
const newProject = await onCreateProject(newProjectName.trim()); const newProject = await onCreateProject(newProjectName.trim());
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
project: { id: newProject.id!, name: newProject.name }, project: { id: newProject.id!, name: newProject.name },
project_id: newProject.id project_id: newProject.id,
})); }));
setFilteredProjects([...filteredProjects, newProject]); setFilteredProjects([...filteredProjects, newProject]);
setNewProjectName(newProject.name); setNewProjectName(newProject.name);
@ -219,7 +249,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
showSuccessToast(t('success.projectCreated')); showSuccessToast(t('success.projectCreated'));
} catch (error) { } catch (error) {
showErrorToast(t('errors.projectCreationFailed')); showErrorToast(t('errors.projectCreationFailed'));
console.error("Error creating project:", error); console.error('Error creating project:', error);
} finally { } finally {
setIsCreatingProject(false); setIsCreatingProject(false);
} }
@ -237,13 +267,17 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
try { try {
// Convert string tags to tag objects // Convert string tags to tag objects
const noteTags: Tag[] = tags.map(tagName => ({ name: tagName })); const noteTags: Tag[] = tags.map((tagName) => ({ name: tagName }));
// Create final form data with the tags // Create final form data with the tags
const finalFormData = { ...formData, tags: noteTags }; const finalFormData = { ...formData, tags: noteTags };
await onSave(finalFormData); await onSave(finalFormData);
showSuccessToast(formData.id && formData.id !== 0 ? t('success.noteUpdated') : t('success.noteCreated')); showSuccessToast(
formData.id && formData.id !== 0
? t('success.noteUpdated')
: t('success.noteCreated')
);
handleClose(); handleClose();
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
@ -272,21 +306,26 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<> <>
<div <div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${ className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? "opacity-0" : "opacity-100" isClosing ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
<div className="h-full flex items-start justify-center sm:px-4 sm:py-4"> <div className="h-full flex items-start justify-center sm:px-4 sm:py-4">
<div <div
ref={modalRef} ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${ className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100" isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`} } h-full sm:h-auto sm:my-4`}
> >
<div className="flex flex-col h-full sm:min-h-[600px] sm:max-h-[90vh]"> <div className="flex flex-col h-full sm:min-h-[600px] sm:max-h-[90vh]">
{/* Main Form Section */} {/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800"> <div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
<div className="flex-1 relative"> <div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}> <div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{
WebkitOverflowScrolling: 'touch',
}}
>
<form className="h-full"> <form className="h-full">
<fieldset className="h-full flex flex-col"> <fieldset className="h-full flex flex-col">
{/* Note Title Section - Always Visible */} {/* Note Title Section - Always Visible */}
@ -299,7 +338,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
onChange={handleChange} onChange={handleChange}
required required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2" className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t('forms.noteTitlePlaceholder')} placeholder={t(
'forms.noteTitlePlaceholder'
)}
/> />
</div> </div>
@ -307,32 +348,52 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('forms.noteContent')} <span className="text-gray-500">(Markdown supported)</span> {t(
'forms.noteContent'
)}{' '}
<span className="text-gray-500">
(Markdown
supported)
</span>
</label> </label>
<div className="flex space-x-1"> <div className="flex space-x-1">
<button <button
type="button" type="button"
onClick={() => setActiveTab('edit')} onClick={() =>
setActiveTab(
'edit'
)
}
className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${ className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${
activeTab === 'edit' activeTab ===
'edit'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`} }`}
> >
<PencilIcon className="h-3 w-3" /> <PencilIcon className="h-3 w-3" />
<span>Edit</span> <span>
Edit
</span>
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('preview')} onClick={() =>
setActiveTab(
'preview'
)
}
className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${ className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${
activeTab === 'preview' activeTab ===
'preview'
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600' : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`} }`}
> >
<EyeIcon className="h-3 w-3" /> <EyeIcon className="h-3 w-3" />
<span>Preview</span> <span>
Preview
</span>
</button> </button>
</div> </div>
</div> </div>
@ -341,19 +402,39 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<textarea <textarea
id="noteContent" id="noteContent"
name="content" name="content"
value={formData.content} value={
onChange={handleChange} formData.content
}
onChange={
handleChange
}
rows={15} rows={15}
className="block w-full border border-gray-300 dark:border-gray-600 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 resize-none" className="block w-full border border-gray-300 dark:border-gray-600 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 resize-none"
placeholder="Write your content using Markdown formatting...&#10;&#10;Examples:&#10;# Heading&#10;**Bold text**&#10;*Italic text*&#10;- List item&#10;```code```" placeholder="Write your content using Markdown formatting...&#10;&#10;Examples:&#10;# Heading&#10;**Bold text**&#10;*Italic text*&#10;- List item&#10;```code```"
/> />
) : ( ) : (
<div className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-gray-50 dark:bg-gray-800 overflow-y-auto" style={{ minHeight: '300px', maxHeight: '400px' }}> <div
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm p-3 text-sm bg-gray-50 dark:bg-gray-800 overflow-y-auto"
style={{
minHeight:
'300px',
maxHeight:
'400px',
}}
>
{formData.content ? ( {formData.content ? (
<MarkdownRenderer content={formData.content} /> <MarkdownRenderer
content={
formData.content
}
/>
) : ( ) : (
<p className="text-gray-500 dark:text-gray-400 italic"> <p className="text-gray-500 dark:text-gray-400 italic">
No content to preview. Switch to Edit tab to add content. No content
to preview.
Switch to
Edit tab to
add content.
</p> </p>
)} )}
</div> </div>
@ -367,9 +448,13 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{t('forms.tags')} {t('forms.tags')}
</h3> </h3>
<TagInput <TagInput
onTagsChange={handleTagsChange} onTagsChange={
handleTagsChange
}
initialTags={tags} initialTags={tags}
availableTags={availableTags} availableTags={
availableTags
}
/> />
</div> </div>
)} )}
@ -377,45 +462,84 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{expandedSections.project && ( {expandedSections.project && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.labels.project', 'Project')} {t(
'forms.task.labels.project',
'Project'
)}
</h3> </h3>
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')} placeholder={t(
value={newProjectName} 'forms.task.projectSearchPlaceholder',
onChange={handleProjectSearch} 'Search or create a project...'
)}
value={
newProjectName
}
onChange={
handleProjectSearch
}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
/> />
{dropdownOpen && newProjectName && ( {dropdownOpen &&
newProjectName && (
<div className="absolute mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md w-full z-50 border border-gray-200 dark:border-gray-700"> <div className="absolute mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md w-full z-50 border border-gray-200 dark:border-gray-700">
{filteredProjects.length > 0 && ( {filteredProjects.length >
filteredProjects.map((project) => ( 0 &&
filteredProjects.map(
(
project
) => (
<button <button
key={project.id} key={
project.id
}
type="button" type="button"
onClick={() => handleProjectSelection(project)} onClick={() =>
handleProjectSelection(
project
)
}
className="block w-full text-gray-700 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" className="block w-full text-gray-700 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
> >
{project.name} {
project.name
}
</button> </button>
)) )
)} )}
{filteredProjects.length === 0 && ( {filteredProjects.length ===
0 && (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400"> <div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{t('forms.task.noMatchingProjects', 'No matching projects')} {t(
'forms.task.noMatchingProjects',
'No matching projects'
)}
</div> </div>
)} )}
{newProjectName.trim() && onCreateProject && ( {newProjectName.trim() &&
onCreateProject && (
<button <button
type="button" type="button"
onClick={handleCreateProject} onClick={
disabled={isCreatingProject} handleCreateProject
}
disabled={
isCreatingProject
}
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors" className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors"
> >
{isCreatingProject {isCreatingProject
? t('forms.task.creatingProject', 'Creating...') ? t(
: t('forms.task.createProject', '+ Create') + ` "${newProjectName.trim()}"`} 'forms.task.creatingProject',
'Creating...'
)
: t(
'forms.task.createProject',
'+ Create'
) +
` "${newProjectName.trim()}"`}
</button> </button>
)} )}
</div> </div>
@ -424,7 +548,11 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
</div> </div>
)} )}
{error && <div className="text-red-500 px-4 mb-4">{error}</div>} {error && (
<div className="text-red-500 px-4 mb-4">
{error}
</div>
)}
</fieldset> </fieldset>
</form> </form>
</div> </div>
@ -438,7 +566,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{/* Tags Toggle */} {/* Tags Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('tags')} onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
@ -447,7 +577,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
title={t('forms.tags', 'Tags')} title={t('forms.tags', 'Tags')}
> >
<TagIcon className="h-5 w-5" /> <TagIcon className="h-5 w-5" />
{formData.tags && formData.tags.length > 0 && ( {formData.tags &&
formData.tags.length >
0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span> <span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)} )}
</button> </button>
@ -455,13 +587,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
{/* Project Toggle */} {/* Project Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('project')} onClick={() =>
toggleSection('project')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.project expandedSections.project
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.task.labels.project', 'Project')} title={t(
'forms.task.labels.project',
'Project'
)}
> >
<FolderIcon className="h-5 w-5" /> <FolderIcon className="h-5 w-5" />
{formData.project && ( {formData.project && (
@ -476,12 +613,15 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between"> <div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */} {/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{(note && note.id && onDelete) && ( {note && note.id && onDelete && (
<button <button
type="button" type="button"
onClick={handleDeleteNote} onClick={handleDeleteNote}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out" className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')} title={t(
'common.delete',
'Delete'
)}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
</button> </button>
@ -501,7 +641,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
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 text-sm ${ 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 text-sm ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : '' isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`} }`}
> >
{isSubmitting {isSubmitting

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
BookOpenIcon, BookOpenIcon,
@ -25,7 +25,6 @@ const Notes: React.FC = () => {
console.log('Notes component rendering...'); console.log('Notes component rendering...');
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null); const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
@ -41,8 +40,13 @@ const Notes: React.FC = () => {
// Memoize projects to ensure stable reference // Memoize projects to ensure stable reference
const memoizedProjects = useMemo(() => projects || [], [projects]); const memoizedProjects = useMemo(() => projects || [], [projects]);
console.log('Notes component render - projects:', { projectsLength: projects?.length, projects: projects?.map(p => p.name) }); console.log('Notes component render - projects:', {
console.log('Memoized projects:', { memoizedLength: memoizedProjects?.length }); projectsLength: projects?.length,
projects: projects?.map((p) => p.name),
});
console.log('Memoized projects:', {
memoizedLength: memoizedProjects?.length,
});
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null); const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
@ -67,13 +71,20 @@ const Notes: React.FC = () => {
// Load projects if not available - force load every time for debugging // Load projects if not available - force load every time for debugging
useEffect(() => { useEffect(() => {
const loadProjectsIfNeeded = async () => { const loadProjectsIfNeeded = async () => {
console.log('useEffect triggered - projects length:', projects?.length); console.log(
'useEffect triggered - projects length:',
projects?.length
);
console.log('Force loading projects in Notes component...'); console.log('Force loading projects in Notes component...');
try { try {
// Fetch all projects (active and inactive) // Fetch all projects (active and inactive)
const fetchedProjects = await fetchProjects("all", ""); const fetchedProjects = await fetchProjects('all', '');
console.log('Raw API response:', fetchedProjects); console.log('Raw API response:', fetchedProjects);
console.log('Projects loaded:', fetchedProjects.length, fetchedProjects.map(p => p.name)); console.log(
'Projects loaded:',
fetchedProjects.length,
fetchedProjects.map((p) => p.name)
);
setProjects(fetchedProjects); setProjects(fetchedProjects);
console.log('setProjects called'); console.log('setProjects called');
} catch (error) { } catch (error) {
@ -88,7 +99,9 @@ const Notes: React.FC = () => {
if (!noteToDelete) return; if (!noteToDelete) return;
try { try {
await apiDeleteNote(noteToDelete.id!); await apiDeleteNote(noteToDelete.id!);
setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id)); setNotes((prev) =>
prev.filter((note) => note.id !== noteToDelete.id)
);
setIsConfirmDialogOpen(false); setIsConfirmDialogOpen(false);
setNoteToDelete(null); setNoteToDelete(null);
} catch (err) { } catch (err) {
@ -101,7 +114,7 @@ const Notes: React.FC = () => {
projectsLength: projects?.length, projectsLength: projects?.length,
memoizedLength: memoizedProjects?.length, memoizedLength: memoizedProjects?.length,
projectsExist: !!projects, projectsExist: !!projects,
memoizedExist: !!memoizedProjects memoizedExist: !!memoizedProjects,
}); });
setSelectedNote(note); setSelectedNote(note);
setIsNoteModalOpen(true); setIsNoteModalOpen(true);
@ -129,7 +142,10 @@ const Notes: React.FC = () => {
const handleCreateProject = async (name: string) => { const handleCreateProject = async (name: string) => {
try { try {
const newProject = await createProject({ name, priority: 'medium' }); const newProject = await createProject({
name,
priority: 'medium',
});
return newProject; return newProject;
} catch (error) { } catch (error) {
console.error('Error creating project:', error); console.error('Error creating project:', error);
@ -143,7 +159,6 @@ const Notes: React.FC = () => {
note.content.toLowerCase().includes(searchQuery.toLowerCase()) note.content.toLowerCase().includes(searchQuery.toLowerCase())
); );
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
@ -191,14 +206,18 @@ const Notes: React.FC = () => {
{/* Notes List */} {/* Notes List */}
{filteredNotes.length === 0 ? ( {filteredNotes.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">{t('notes.noNotesFound')}</p> <p className="text-gray-700 dark:text-gray-300">
{t('notes.noNotesFound')}
</p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">
{filteredNotes.map((note) => ( {filteredNotes.map((note) => (
<li <li
key={note.id} key={note.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center" className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
onMouseEnter={() => setHoveredNoteId(note.id || null)} onMouseEnter={() =>
setHoveredNoteId(note.id || null)
}
onMouseLeave={() => setHoveredNoteId(null)} onMouseLeave={() => setHoveredNoteId(null)}
> >
<div className="flex-grow overflow-hidden pr-4"> <div className="flex-grow overflow-hidden pr-4">
@ -210,21 +229,57 @@ const Notes: React.FC = () => {
{note.title} {note.title}
</Link> </Link>
{/* Project and Tags */} {/* Project and Tags */}
{((note.project || note.Project) || ((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0))) && ( {(note.project ||
note.Project ||
(note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length > 0)) && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400"> <div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
{(note.project || note.Project) && ( {(note.project ||
note.Project) && (
<div className="flex items-center"> <div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" /> <FolderIcon className="h-3 w-3 mr-1" />
<span>{(note.project || note.Project)?.name}</span> <span>
{
(
note.project ||
note.Project
)?.name
}
</span>
</div> </div>
)} )}
{(note.project || note.Project) && ((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && ( {(note.project ||
<span className="mx-2"></span> note.Project) &&
((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length >
0)) && (
<span className="mx-2">
</span>
)} )}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && ( {((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length >
0)) && (
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span>{(note.tags || note.Tags || []).map(tag => tag.name).join(', ')}</span> <span>
{(
note.tags ||
note.Tags ||
[]
)
.map(
(tag) =>
tag.name
)
.join(', ')}
</span>
</div> </div>
)} )}
</div> </div>
@ -235,8 +290,13 @@ const Notes: React.FC = () => {
<button <button
onClick={() => handleEditNote(note)} onClick={() => handleEditNote(note)}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`} className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={t('notes.editNoteAriaLabel', { noteTitle: note.title })} aria-label={t(
title={t('notes.editNoteTitle', { noteTitle: note.title })} 'notes.editNoteAriaLabel',
{ noteTitle: note.title }
)}
title={t('notes.editNoteTitle', {
noteTitle: note.title,
})}
> >
<PencilSquareIcon className="h-5 w-5" /> <PencilSquareIcon className="h-5 w-5" />
</button> </button>
@ -246,8 +306,13 @@ const Notes: React.FC = () => {
setIsConfirmDialogOpen(true); setIsConfirmDialogOpen(true);
}} }}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`} className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={t('notes.deleteNoteAriaLabel', { noteTitle: note.title })} aria-label={t(
title={t('notes.deleteNoteTitle', { noteTitle: note.title })} 'notes.deleteNoteAriaLabel',
{ noteTitle: note.title }
)}
title={t('notes.deleteNoteTitle', {
noteTitle: note.title,
})}
> >
<TrashIcon className="h-5 w-5" /> <TrashIcon className="h-5 w-5" />
</button> </button>
@ -262,14 +327,18 @@ const Notes: React.FC = () => {
<NoteModal <NoteModal
isOpen={isNoteModalOpen} isOpen={isNoteModalOpen}
onClose={() => { onClose={() => {
console.log('Closing modal, projects at close:', { projectsLength: projects?.length }); console.log('Closing modal, projects at close:', {
projectsLength: projects?.length,
});
setIsNoteModalOpen(false); setIsNoteModalOpen(false);
}} }}
onSave={handleSaveNote} onSave={handleSaveNote}
onDelete={async (noteId) => { onDelete={async (noteId) => {
try { try {
await apiDeleteNote(noteId); await apiDeleteNote(noteId);
setNotes((prev) => prev.filter((note) => note.id !== noteId)); setNotes((prev) =>
prev.filter((note) => note.id !== noteId)
);
setIsNoteModalOpen(false); setIsNoteModalOpen(false);
setSelectedNote(null); setSelectedNote(null);
} catch (err) { } catch (err) {
@ -277,20 +346,35 @@ const Notes: React.FC = () => {
} }
}} }}
note={selectedNote} note={selectedNote}
projects={projects?.length > 0 ? projects : [ projects={
{ id: 1, name: 'Test Project 1', active: true, priority: 'medium' }, projects?.length > 0
{ id: 2, name: 'tududi', active: true, priority: 'high' } ? projects
] as any} : ([
{
id: 1,
name: 'Test Project 1',
active: true,
priority: 'medium',
},
{
id: 2,
name: 'tududi',
active: true,
priority: 'high',
},
] as any)
}
onCreateProject={handleCreateProject} onCreateProject={handleCreateProject}
/> />
)} )}
{/* ConfirmDialog */} {/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && ( {isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog <ConfirmDialog
title={t('modals.deleteNote.title')} title={t('modals.deleteNote.title')}
message={t('modals.deleteNote.message', { noteTitle: noteToDelete.title })} message={t('modals.deleteNote.message', {
noteTitle: noteToDelete.title,
})}
onConfirm={handleDeleteNote} onConfirm={handleDeleteNote}
onCancel={() => setIsConfirmDialogOpen(false)} onCancel={() => setIsConfirmDialogOpen(false)}
/> />

View file

@ -7,18 +7,28 @@ import {
ClockIcon, ClockIcon,
FolderIcon, FolderIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon ChevronRightIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Task } from '../../entities/Task'; import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project'; import { Project } from '../../entities/Project';
import TaskModal from '../Task/TaskModal'; import TaskModal from '../Task/TaskModal';
import { fetchTaskById, updateTask, deleteTask } from '../../utils/tasksService'; import {
fetchTaskById,
updateTask,
deleteTask,
} from '../../utils/tasksService';
import { fetchProjects, createProject } from '../../utils/projectsService'; import { fetchProjects, createProject } from '../../utils/projectsService';
import { useToast } from '../Shared/ToastContext'; import { useToast } from '../Shared/ToastContext';
import { getVagueTasks } from '../../utils/taskIntelligenceService'; import { getVagueTasks } from '../../utils/taskIntelligenceService';
interface ProductivityInsight { interface ProductivityInsight {
type: 'stalled_projects' | 'completed_no_next' | 'tasks_are_projects' | 'vague_tasks' | 'overdue_tasks' | 'stuck_projects'; type:
| 'stalled_projects'
| 'completed_no_next'
| 'tasks_are_projects'
| 'vague_tasks'
| 'overdue_tasks'
| 'stuck_projects';
title: string; title: string;
description: string; description: string;
items: (Task | Project)[]; items: (Task | Project)[];
@ -31,14 +41,19 @@ interface ProductivityAssistantProps {
projects: Project[]; projects: Project[];
} }
const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, projects }) => { const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
tasks,
projects,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [insights, setInsights] = useState<ProductivityInsight[]>([]); const [insights, setInsights] = useState<ProductivityInsight[]>([]);
const [expandedInsights, setExpandedInsights] = useState<Set<number>>(new Set()); const [expandedInsights, setExpandedInsights] = useState<Set<number>>(
new Set()
);
// Modal states // Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
@ -46,7 +61,18 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
const [allProjects, setAllProjects] = useState<Project[]>(projects); const [allProjects, setAllProjects] = useState<Project[]>(projects);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const PROJECT_VERBS = ['plan', 'organize', 'set up', 'setup', 'fix', 'review', 'implement', 'create', 'build', 'develop']; const PROJECT_VERBS = [
'plan',
'organize',
'set up',
'setup',
'fix',
'review',
'implement',
'create',
'build',
'develop',
];
const OVERDUE_THRESHOLD_DAYS = 30; const OVERDUE_THRESHOLD_DAYS = 30;
useEffect(() => { useEffect(() => {
@ -54,30 +80,48 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
const newInsights: ProductivityInsight[] = []; const newInsights: ProductivityInsight[] = [];
// Filter to only include non-completed tasks // Filter to only include non-completed tasks
const activeTasks = tasks.filter(task => task.status !== 'done' && task.status !== 'archived'); const activeTasks = tasks.filter(
(task) => task.status !== 'done' && task.status !== 'archived'
);
// 1. Stalled Projects (no tasks/actions) // 1. Stalled Projects (no tasks/actions)
const stalledProjects = projects.filter(project => const stalledProjects = projects.filter(
project.active && !activeTasks.some(task => task.project_id === project.id) (project) =>
project.active &&
!activeTasks.some((task) => task.project_id === project.id)
); );
if (stalledProjects.length > 0) { if (stalledProjects.length > 0) {
newInsights.push({ newInsights.push({
type: 'stalled_projects', type: 'stalled_projects',
title: t('productivity.stalledProjects', 'Stalled Projects'), title: t(
description: t('productivity.stalledProjectsDesc', 'These projects have no tasks or actions'), 'productivity.stalledProjects',
'Stalled Projects'
),
description: t(
'productivity.stalledProjectsDesc',
'These projects have no tasks or actions'
),
items: stalledProjects, items: stalledProjects,
icon: FolderIcon, icon: FolderIcon,
color: 'text-red-500' color: 'text-red-500',
}); });
} }
// 2. Projects with completed tasks but no next action // 2. Projects with completed tasks but no next action
const projectsNeedingNextAction = projects.filter(project => { const projectsNeedingNextAction = projects.filter((project) => {
const projectTasks = tasks.filter(task => task.project_id === project.id); const projectTasks = tasks.filter(
const hasCompletedTasks = projectTasks.some(task => task.status === 'done' || task.status === 'archived'); (task) => task.project_id === project.id
const hasNextAction = activeTasks.some(task => );
task.project_id === project.id && (task.status === 'not_started' || task.status === 'in_progress') const hasCompletedTasks = projectTasks.some(
(task) =>
task.status === 'done' || task.status === 'archived'
);
const hasNextAction = activeTasks.some(
(task) =>
task.project_id === project.id &&
(task.status === 'not_started' ||
task.status === 'in_progress')
); );
return project.active && hasCompletedTasks && !hasNextAction; return project.active && hasCompletedTasks && !hasNextAction;
}); });
@ -85,29 +129,43 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
if (projectsNeedingNextAction.length > 0) { if (projectsNeedingNextAction.length > 0) {
newInsights.push({ newInsights.push({
type: 'completed_no_next', type: 'completed_no_next',
title: t('productivity.needsNextAction', 'Projects Need Next Action'), title: t(
description: t('productivity.needsNextActionDesc', 'These projects have completed tasks but no next action'), 'productivity.needsNextAction',
'Projects Need Next Action'
),
description: t(
'productivity.needsNextActionDesc',
'These projects have completed tasks but no next action'
),
items: projectsNeedingNextAction, items: projectsNeedingNextAction,
icon: ExclamationTriangleIcon, icon: ExclamationTriangleIcon,
color: 'text-yellow-500' color: 'text-yellow-500',
}); });
} }
// 3. Tasks that are actually projects // 3. Tasks that are actually projects
const tasksAreProjects = activeTasks.filter(task => { const tasksAreProjects = activeTasks.filter((task) => {
const taskName = task.name.toLowerCase(); const taskName = task.name.toLowerCase();
return PROJECT_VERBS.some(verb => taskName.includes(verb)) && return (
taskName.length > 30; // Longer tasks are more likely to be projects PROJECT_VERBS.some((verb) => taskName.includes(verb)) &&
taskName.length > 30
); // Longer tasks are more likely to be projects
}); });
if (tasksAreProjects.length > 0) { if (tasksAreProjects.length > 0) {
newInsights.push({ newInsights.push({
type: 'tasks_are_projects', type: 'tasks_are_projects',
title: t('productivity.tasksAreProjects', 'Tasks That Look Like Projects'), title: t(
description: t('productivity.tasksAreProjectsDesc', 'These tasks might need to be broken down'), 'productivity.tasksAreProjects',
'Tasks That Look Like Projects'
),
description: t(
'productivity.tasksAreProjectsDesc',
'These tasks might need to be broken down'
),
items: tasksAreProjects, items: tasksAreProjects,
icon: AcademicCapIcon, icon: AcademicCapIcon,
color: 'text-blue-500' color: 'text-blue-500',
}); });
} }
@ -117,21 +175,31 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
if (vagueTasks.length > 0) { if (vagueTasks.length > 0) {
newInsights.push({ newInsights.push({
type: 'vague_tasks', type: 'vague_tasks',
title: t('productivity.vagueTasks', 'Tasks Without Clear Action'), title: t(
description: t('productivity.vagueTasksDesc', 'These tasks need clearer action verbs'), 'productivity.vagueTasks',
'Tasks Without Clear Action'
),
description: t(
'productivity.vagueTasksDesc',
'These tasks need clearer action verbs'
),
items: vagueTasks, items: vagueTasks,
icon: ExclamationTriangleIcon, icon: ExclamationTriangleIcon,
color: 'text-orange-500' color: 'text-orange-500',
}); });
} }
// 5. Overdue or stale tasks // 5. Overdue or stale tasks
const now = new Date(); const now = new Date();
const thresholdDate = new Date(now.getTime() - (OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000)); const thresholdDate = new Date(
now.getTime() - OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
);
const staleTasks = activeTasks.filter(task => { const staleTasks = activeTasks.filter((task) => {
// Only use created_at since updated_at doesn't exist in the interface // Only use created_at since updated_at doesn't exist in the interface
const taskDate = task.created_at ? new Date(task.created_at) : null; const taskDate = task.created_at
? new Date(task.created_at)
: null;
return taskDate && taskDate < thresholdDate; return taskDate && taskDate < thresholdDate;
}); });
@ -140,28 +208,39 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
newInsights.push({ newInsights.push({
type: 'overdue_tasks', type: 'overdue_tasks',
title: t('productivity.staleTasks', 'Stale Tasks'), title: t('productivity.staleTasks', 'Stale Tasks'),
description: t('productivity.staleTasksDesc', 'Tasks not updated in {{days}} days', { days: OVERDUE_THRESHOLD_DAYS }), description: t(
'productivity.staleTasksDesc',
'Tasks not updated in {{days}} days',
{ days: OVERDUE_THRESHOLD_DAYS }
),
items: staleTasks, items: staleTasks,
icon: ClockIcon, icon: ClockIcon,
color: 'text-gray-500' color: 'text-gray-500',
}); });
} }
// 6. Stuck projects (not updated in a month) // 6. Stuck projects (not updated in a month)
const stuckProjects = projects.filter(project => { const stuckProjects = projects.filter((project) => {
if (!project.active) return false; if (!project.active) return false;
// Projects don't have date fields in the interface, so we'll check if they have recent tasks // Projects don't have date fields in the interface, so we'll check if they have recent tasks
const projectTasks = activeTasks.filter(task => task.project_id === project.id); const projectTasks = activeTasks.filter(
(task) => task.project_id === project.id
);
if (projectTasks.length === 0) return false; // Empty projects are handled by "stalled projects" if (projectTasks.length === 0) return false; // Empty projects are handled by "stalled projects"
// Find the most recent task date for this project // Find the most recent task date for this project
const mostRecentTaskDate = projectTasks.reduce((latest, task) => { const mostRecentTaskDate = projectTasks.reduce(
const taskDate = task.created_at ? new Date(task.created_at) : null; (latest, task) => {
const taskDate = task.created_at
? new Date(task.created_at)
: null;
if (!taskDate) return latest; if (!taskDate) return latest;
return !latest || taskDate > latest ? taskDate : latest; return !latest || taskDate > latest ? taskDate : latest;
}, null as Date | null); },
null as Date | null
);
return mostRecentTaskDate && mostRecentTaskDate < thresholdDate; return mostRecentTaskDate && mostRecentTaskDate < thresholdDate;
}); });
@ -170,10 +249,13 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
newInsights.push({ newInsights.push({
type: 'stuck_projects', type: 'stuck_projects',
title: t('productivity.stuckProjects', 'Stuck Projects'), title: t('productivity.stuckProjects', 'Stuck Projects'),
description: t('productivity.stuckProjectsDesc', 'Projects not updated recently'), description: t(
'productivity.stuckProjectsDesc',
'Projects not updated recently'
),
items: stuckProjects, items: stuckProjects,
icon: FolderIcon, icon: FolderIcon,
color: 'text-purple-500' color: 'text-purple-500',
}); });
} }
@ -183,7 +265,10 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
generateInsights(); generateInsights();
}, [tasks, projects, t]); }, [tasks, projects, t]);
const totalIssues = insights.reduce((sum, insight) => sum + insight.items.length, 0); const totalIssues = insights.reduce(
(sum, insight) => sum + insight.items.length,
0
);
const toggleInsightExpansion = (index: number) => { const toggleInsightExpansion = (index: number) => {
const newExpanded = new Set(expandedInsights); const newExpanded = new Set(expandedInsights);
@ -207,7 +292,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
setIsTaskModalOpen(true); setIsTaskModalOpen(true);
} catch (error) { } catch (error) {
console.error('Failed to fetch task:', error); console.error('Failed to fetch task:', error);
showErrorToast(t('errors.failedToLoadTask', 'Failed to load task')); showErrorToast(
t('errors.failedToLoadTask', 'Failed to load task')
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -223,7 +310,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
await updateTask(updatedTask.id, updatedTask); await updateTask(updatedTask.id, updatedTask);
setIsTaskModalOpen(false); setIsTaskModalOpen(false);
setSelectedTask(null); setSelectedTask(null);
showSuccessToast(t('task.updateSuccess', 'Task updated successfully')); showSuccessToast(
t('task.updateSuccess', 'Task updated successfully')
);
// Optionally refresh the parent component data // Optionally refresh the parent component data
} }
} catch (error) { } catch (error) {
@ -238,7 +327,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
await deleteTask(selectedTask.id); await deleteTask(selectedTask.id);
setIsTaskModalOpen(false); setIsTaskModalOpen(false);
setSelectedTask(null); setSelectedTask(null);
showSuccessToast(t('task.deleteSuccess', 'Task deleted successfully')); showSuccessToast(
t('task.deleteSuccess', 'Task deleted successfully')
);
// Optionally refresh the parent component data // Optionally refresh the parent component data
} }
} catch (error) { } catch (error) {
@ -250,7 +341,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
const handleCreateProject = async (name: string): Promise<Project> => { const handleCreateProject = async (name: string): Promise<Project> => {
try { try {
const project = await createProject({ name, active: true }); const project = await createProject({ name, active: true });
setAllProjects(prev => [...prev, project]); setAllProjects((prev) => [...prev, project]);
return project; return project;
} catch (error) { } catch (error) {
console.error('Failed to create project:', error); console.error('Failed to create project:', error);
@ -289,10 +380,17 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
<ExclamationTriangleIcon className="h-6 w-6 text-yellow-500 dark:text-yellow-400 mr-3" /> <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500 dark:text-yellow-400 mr-3" />
<div className="flex-1 text-left"> <div className="flex-1 text-left">
<p className="text-gray-700 dark:text-gray-300 font-medium"> <p className="text-gray-700 dark:text-gray-300 font-medium">
{t('productivity.issuesFound', 'Found {{count}} productivity issue(s) that need attention', { count: totalIssues })} {t(
'productivity.issuesFound',
'Found {{count}} productivity issue(s) that need attention',
{ count: totalIssues }
)}
</p> </p>
<p className="text-yellow-600 dark:text-yellow-400 text-sm"> <p className="text-yellow-600 dark:text-yellow-400 text-sm">
{t('productivity.reviewItems', 'Click to review and improve your workflow')} {t(
'productivity.reviewItems',
'Click to review and improve your workflow'
)}
</p> </p>
</div> </div>
{isExpanded ? ( {isExpanded ? (
@ -306,22 +404,38 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="space-y-4"> <div className="space-y-4">
{insights.map((insight, index) => ( {insights.map((insight, index) => (
<div key={index} className="border-l-4 border-gray-200 dark:border-gray-600 pl-4"> <div
key={index}
className="border-l-4 border-gray-200 dark:border-gray-600 pl-4"
>
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<insight.icon className={`h-5 w-5 mt-0.5 ${insight.color}`} /> <insight.icon
className={`h-5 w-5 mt-0.5 ${insight.color}`}
/>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-gray-100"> <h4 className="font-medium text-gray-900 dark:text-gray-100">
{insight.title} ({insight.items.length}) {insight.title} (
{insight.items.length})
</h4> </h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{insight.description} {insight.description}
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{(expandedInsights.has(index) ? insight.items : insight.items.slice(0, 3)).map((item, itemIndex) => { {(expandedInsights.has(index)
? insight.items
: insight.items.slice(0, 3)
).map((item, itemIndex) => {
return ( return (
<div key={itemIndex} className="text-sm"> <div
key={itemIndex}
className="text-sm"
>
<button <button
onClick={() => handleItemClick(item)} onClick={() =>
handleItemClick(
item
)
}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline text-left" className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline text-left"
disabled={loading} disabled={loading}
> >
@ -332,13 +446,16 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
})} })}
{insight.items.length > 3 && ( {insight.items.length > 3 && (
<button <button
onClick={() => toggleInsightExpansion(index)} onClick={() =>
toggleInsightExpansion(
index
)
}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline cursor-pointer" className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 underline cursor-pointer"
> >
{expandedInsights.has(index) {expandedInsights.has(index)
? '... show less' ? '... show less'
: `... and ${insight.items.length - 3} more items` : `... and ${insight.items.length - 3} more items`}
}
</button> </button>
)} )}
</div> </div>
@ -350,7 +467,10 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
{t('productivity.suggestion', 'Click on any item above to open it and make improvements.')} {t(
'productivity.suggestion',
'Click on any item above to open it and make improvements.'
)}
</p> </p>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import { useToast } from "../Shared/ToastContext"; import { useToast } from '../Shared/ToastContext';
interface AutoSuggestNextActionBoxProps { interface AutoSuggestNextActionBoxProps {
onAddAction: (actionDescription: string) => void; onAddAction: (actionDescription: string) => void;
@ -11,9 +11,9 @@ interface AutoSuggestNextActionBoxProps {
const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
onAddAction, onAddAction,
onDismiss, onDismiss,
projectName, projectName, // eslint-disable-line @typescript-eslint/no-unused-vars
}) => { }) => {
const [actionDescription, setActionDescription] = useState(""); const [actionDescription, setActionDescription] = useState('');
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -32,12 +32,12 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
if (actionDescription.trim()) { if (actionDescription.trim()) {
onAddAction(actionDescription.trim()); onAddAction(actionDescription.trim());
showSuccessToast(t('success.nextActionAdded')); showSuccessToast(t('success.nextActionAdded'));
setActionDescription(""); setActionDescription('');
} }
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === 'Escape') {
onDismiss(); onDismiss();
} }
}; };
@ -63,7 +63,10 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-1"> <h3 className="text-lg font-semibold text-blue-800 dark:text-blue-200 mb-1">
{t('profile.nextActionPrompt', 'What\'s the very next physical action for this project?')} {t(
'profile.nextActionPrompt',
"What's the very next physical action for this project?"
)}
</h3> </h3>
</div> </div>
</div> </div>
@ -72,8 +75,18 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 transition-colors" className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 transition-colors"
aria-label="Dismiss" aria-label="Dismiss"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
</div> </div>
@ -88,7 +101,10 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={t('profile.nextActionPlaceholder', 'e.g., Call John to schedule meeting, Research competitors online, Create project folder...')} placeholder={t(
'profile.nextActionPlaceholder',
'e.g., Call John to schedule meeting, Research competitors online, Create project folder...'
)}
className={`w-full px-4 py-3 border rounded-lg shadow-sm transition-all duration-200 focus:outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 ${ className={`w-full px-4 py-3 border rounded-lg shadow-sm transition-all duration-200 focus:outline-none bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 ${
isFocused isFocused
? 'border-blue-400 ring-2 ring-blue-100 dark:ring-blue-900/50' ? 'border-blue-400 ring-2 ring-blue-100 dark:ring-blue-900/50'
@ -124,11 +140,24 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
<div className="mt-4 p-3 bg-blue-100/50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700"> <div className="mt-4 p-3 bg-blue-100/50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-700">
<p className="text-xs text-blue-700 dark:text-blue-300 flex items-center"> <p className="text-xs text-blue-700 dark:text-blue-300 flex items-center">
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="w-4 h-4 mr-2 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span> <span>
{t('profile.nextActionHint', 'Think of the smallest, most concrete step you can take right now to move this project forward.')} {t(
'profile.nextActionHint',
'Think of the smallest, most concrete step you can take right now to move this project forward.'
)}
</span> </span>
</p> </p>
</div> </div>

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from "react-router-dom"; import { useParams, useNavigate, Link } from 'react-router-dom';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import { useToast } from "../Shared/ToastContext"; import { useToast } from '../Shared/ToastContext';
import { import {
PencilSquareIcon, PencilSquareIcon,
TrashIcon, TrashIcon,
@ -9,23 +9,34 @@ import {
Squares2X2Icon, Squares2X2Icon,
BookOpenIcon, BookOpenIcon,
TagIcon, TagIcon,
ListBulletIcon ListBulletIcon,
} from "@heroicons/react/24/outline"; } from '@heroicons/react/24/outline';
import TaskList from "../Task/TaskList"; import TaskList from '../Task/TaskList';
import ProjectModal from "../Project/ProjectModal"; import ProjectModal from '../Project/ProjectModal';
import ConfirmDialog from "../Shared/ConfirmDialog"; import ConfirmDialog from '../Shared/ConfirmDialog';
import { useStore } from "../../store/useStore"; import { useStore } from '../../store/useStore';
import NewTask from "../Task/NewTask"; import NewTask from '../Task/NewTask';
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
import { PriorityType, Task } from "../../entities/Task"; import { PriorityType, Task } from '../../entities/Task';
import { Note } from "../../entities/Note"; import { Note } from '../../entities/Note';
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService"; import {
import { createTask, deleteTask, toggleTaskToday } from "../../utils/tasksService"; fetchProjectById,
import { fetchAreas } from "../../utils/areasService"; updateProject,
import { isAuthError } from "../../utils/authUtils"; deleteProject,
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid"; } from '../../utils/projectsService';
import { getAutoSuggestNextActionsEnabled } from "../../utils/profileService"; import {
import AutoSuggestNextActionBox from "./AutoSuggestNextActionBox"; createTask,
deleteTask,
toggleTaskToday,
} from '../../utils/tasksService';
import { fetchAreas } from '../../utils/areasService';
import { isAuthError } from '../../utils/authUtils';
import {
CalendarDaysIcon,
InformationCircleIcon,
} from '@heroicons/react/24/solid';
import { getAutoSuggestNextActionsEnabled } from '../../utils/profileService';
import AutoSuggestNextActionBox from './AutoSuggestNextActionBox';
type PriorityStyles = Record<PriorityType, string> & { default: string }; type PriorityStyles = Record<PriorityType, string> & { default: string };
@ -48,7 +59,7 @@ const ProjectDetails: React.FC = () => {
const [tasks, setTasks] = useState<Task[]>([]); const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [showCompleted, setShowCompleted] = useState(false); const [showCompleted, setShowCompleted] = useState(false);
@ -59,7 +70,7 @@ const ProjectDetails: React.FC = () => {
useEffect(() => { useEffect(() => {
const loadProjectData = async () => { const loadProjectData = async () => {
if (!id) { if (!id) {
console.error("Project ID is missing."); console.error('Project ID is missing.');
return; return;
} }
@ -69,13 +80,15 @@ const ProjectDetails: React.FC = () => {
const projectData = await fetchProjectById(id); const projectData = await fetchProjectById(id);
setProject(projectData); setProject(projectData);
// Handle both 'tasks' and 'Tasks' property names // Handle both 'tasks' and 'Tasks' property names
const projectTasks = projectData.tasks || projectData.Tasks || []; const projectTasks =
projectData.tasks || projectData.Tasks || [];
setTasks(projectTasks); setTasks(projectTasks);
// Handle project notes // Handle project notes
const projectNotes = projectData.notes || projectData.Notes || []; const projectNotes =
projectData.notes || projectData.Notes || [];
setNotes(projectNotes); setNotes(projectNotes);
} catch (error) { } catch (error) {
console.error("Error fetching project data:", error); console.error('Error fetching project data:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -88,7 +101,8 @@ const ProjectDetails: React.FC = () => {
useEffect(() => { useEffect(() => {
const checkAutoSuggest = async () => { const checkAutoSuggest = async () => {
if (project && tasks.length === 0 && !loading) { if (project && tasks.length === 0 && !loading) {
const autoSuggestEnabled = await getAutoSuggestNextActionsEnabled(); const autoSuggestEnabled =
await getAutoSuggestNextActionsEnabled();
if (autoSuggestEnabled) { if (autoSuggestEnabled) {
setShowAutoSuggestForm(true); setShowAutoSuggestForm(true);
} }
@ -100,14 +114,14 @@ const ProjectDetails: React.FC = () => {
const handleTaskCreate = async (taskName: string) => { const handleTaskCreate = async (taskName: string) => {
if (!project) { if (!project) {
console.error("Cannot create task: Project is missing"); console.error('Cannot create task: Project is missing');
throw new Error("Cannot create task: Project is missing"); throw new Error('Cannot create task: Project is missing');
} }
try { try {
const newTask = await createTask({ const newTask = await createTask({
name: taskName, name: taskName,
status: "not_started", status: 'not_started',
project_id: project.id, project_id: project.id,
}); });
setTasks((prevTasks) => [...prevTasks, newTask]); setTasks((prevTasks) => [...prevTasks, newTask]);
@ -115,12 +129,19 @@ const ProjectDetails: React.FC = () => {
// Show success toast with task link // Show success toast with task link
const taskLink = ( const taskLink = (
<span> <span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')} {t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
} catch (err: any) { } catch (err: any) {
console.error("Error creating task:", err); console.error('Error creating task:', err);
// Check if it's an authentication error // Check if it's an authentication error
if (isAuthError(err)) { if (isAuthError(err)) {
return; return;
@ -131,22 +152,22 @@ const ProjectDetails: React.FC = () => {
const handleTaskUpdate = async (updatedTask: Task) => { const handleTaskUpdate = async (updatedTask: Task) => {
if (!updatedTask.id) { if (!updatedTask.id) {
console.error("Cannot update task: Task ID is missing"); console.error('Cannot update task: Task ID is missing');
return; return;
} }
try { try {
// Use direct fetch call like Tasks.tsx to ensure proper tag saving // Use direct fetch call like Tasks.tsx to ensure proper tag saving
const response = await fetch(`/api/task/${updatedTask.id}`, { const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH", method: 'PATCH',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
credentials: "include", credentials: 'include',
body: JSON.stringify(updatedTask), body: JSON.stringify(updatedTask),
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
console.error("Failed to update task:", errorData.error); console.error('Failed to update task:', errorData.error);
throw new Error("Failed to update task"); throw new Error('Failed to update task');
} }
const savedTask = await response.json(); const savedTask = await response.json();
@ -156,20 +177,22 @@ const ProjectDetails: React.FC = () => {
) )
); );
} catch (err) { } catch (err) {
console.error("Error updating task:", err); console.error('Error updating task:', err);
} }
}; };
const handleTaskDelete = async (taskId: number | undefined) => { const handleTaskDelete = async (taskId: number | undefined) => {
if (!taskId) { if (!taskId) {
console.error("Cannot delete task: Task ID is missing"); console.error('Cannot delete task: Task ID is missing');
return; return;
} }
try { try {
await deleteTask(taskId); await deleteTask(taskId);
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId)); setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} catch (err) { } catch (err) {
console.error("Error deleting task:", err); console.error('Error deleting task:', err);
} }
}; };
@ -177,13 +200,19 @@ const ProjectDetails: React.FC = () => {
try { try {
const updatedTask = await toggleTaskToday(taskId); const updatedTask = await toggleTaskToday(taskId);
// Update the task in the local state immediately to avoid UI flashing // Update the task in the local state immediately to avoid UI flashing
setTasks(prevTasks => setTasks((prevTasks) =>
prevTasks.map(task => prevTasks.map((task) =>
task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count } : task task.id === taskId
? {
...task,
today: updatedTask.today,
today_move_count: updatedTask.today_move_count,
}
: task
) )
); );
} catch (error) { } catch (error) {
console.error("Error toggling task today status:", error); console.error('Error toggling task today status:', error);
// Optionally refetch data on error to ensure consistency // Optionally refetch data on error to ensure consistency
if (id) { if (id) {
try { try {
@ -191,7 +220,10 @@ const ProjectDetails: React.FC = () => {
setProject(updatedProject); setProject(updatedProject);
setTasks(updatedProject.tasks || []); setTasks(updatedProject.tasks || []);
} catch (refetchError) { } catch (refetchError) {
console.error("Error refetching project data:", refetchError); console.error(
'Error refetching project data:',
refetchError
);
} }
} }
} }
@ -203,60 +235,72 @@ const ProjectDetails: React.FC = () => {
const handleSaveProject = async (updatedProject: Project) => { const handleSaveProject = async (updatedProject: Project) => {
if (!updatedProject.id) { if (!updatedProject.id) {
console.error("Cannot save project: Project ID is missing"); console.error('Cannot save project: Project ID is missing');
return; return;
} }
try { try {
const savedProject = await updateProject(updatedProject.id, updatedProject); const savedProject = await updateProject(
updatedProject.id,
updatedProject
);
setProject(savedProject); setProject(savedProject);
setIsModalOpen(false); setIsModalOpen(false);
} catch (err) { } catch (err) {
console.error("Error saving project:", err); console.error('Error saving project:', err);
} }
}; };
const handleCreateNextAction = async (projectId: number, actionDescription: string) => { const handleCreateNextAction = async (
projectId: number,
actionDescription: string
) => {
try { try {
const newTask = await createTask({ const newTask = await createTask({
name: actionDescription, name: actionDescription,
status: "not_started", status: 'not_started',
project_id: projectId, project_id: projectId,
priority: "medium" priority: 'medium',
}); });
// Update the tasks list to include the new task // Update the tasks list to include the new task
setTasks(prevTasks => [...prevTasks, newTask]); setTasks((prevTasks) => [...prevTasks, newTask]);
setShowAutoSuggestForm(false); setShowAutoSuggestForm(false);
// Show success toast with task link // Show success toast with task link
const taskLink = ( const taskLink = (
<span> <span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')} {t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
} catch (error) { } catch (error) {
console.error("Error creating next action:", error); console.error('Error creating next action:', error);
} }
}; };
const handleSkipNextAction = () => { const handleSkipNextAction = () => {
setShowAutoSuggestForm(false); setShowAutoSuggestForm(false);
}; };
const handleDeleteProject = async () => { const handleDeleteProject = async () => {
if (!project?.id) { if (!project?.id) {
console.error("Cannot delete project: Project ID is missing"); console.error('Cannot delete project: Project ID is missing');
return; return;
} }
try { try {
await deleteProject(project.id); await deleteProject(project.id);
navigate("/projects"); navigate('/projects');
} catch (err) { } catch (err) {
console.error("Error deleting project:", err); console.error('Error deleting project:', err);
} }
}; };
@ -286,14 +330,21 @@ const ProjectDetails: React.FC = () => {
); );
} }
const activeTasks = tasks?.filter((task) => { const activeTasks =
return typeof task.status === 'number' ? task.status !== 2 : task.status !== 'done'; tasks?.filter((task) => {
return typeof task.status === 'number'
? task.status !== 2
: task.status !== 'done';
}) || []; //TODO: Also add archived }) || []; //TODO: Also add archived
const completedTasks = tasks?.filter((task) => { const completedTasks = tasks?.filter((task) => {
return typeof task.status === 'number' ? task.status === 2 : task.status === 'done'; return typeof task.status === 'number'
? task.status === 2
: task.status === 'done';
}); });
const displayTasks = showCompleted ? [...activeTasks, ...completedTasks] : activeTasks; const displayTasks = showCompleted
? [...activeTasks, ...completedTasks]
: activeTasks;
const formatProjectDueDate = (dateString: string) => { const formatProjectDueDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@ -303,7 +354,7 @@ const ProjectDetails: React.FC = () => {
const formatOptions: Intl.DateTimeFormatOptions = { const formatOptions: Intl.DateTimeFormatOptions = {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric',
}; };
return date.toLocaleDateString(currentLang, formatOptions); return date.toLocaleDateString(currentLang, formatOptions);
@ -327,12 +378,13 @@ const ProjectDetails: React.FC = () => {
</h1> </h1>
</div> </div>
{/* Priority Indicator on Image */} {/* Priority Indicator on Image */}
{project.priority !== undefined && project.priority !== null && ( {project.priority !== undefined &&
project.priority !== null && (
<div className="absolute top-3 left-3"> <div className="absolute top-3 left-3">
<div <div
className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${ className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${getPriorityStyle(
getPriorityStyle(project.priority) project.priority
}`} )}`}
title={`Priority: ${priorityLabel(project.priority)}`} title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`} aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div> ></div>
@ -357,14 +409,19 @@ const ProjectDetails: React.FC = () => {
)} )}
{/* Project Metadata Box */} {/* Project Metadata Box */}
{(project.description || project.area || project.due_date_at || (project.tags && project.tags.length > 0)) && ( {(project.description ||
project.area ||
project.due_date_at ||
(project.tags && project.tags.length > 0)) && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="grid gap-3"> <div className="grid gap-3">
{project.description && ( {project.description && (
<div className="flex items-start"> <div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5 flex-shrink-0" /> <InformationCircleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5 flex-shrink-0" />
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Description:</span> <span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Description:
</span>
<p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed mt-1"> <p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed mt-1">
{project.description} {project.description}
</p> </p>
@ -375,7 +432,9 @@ const ProjectDetails: React.FC = () => {
{project.area && ( {project.area && (
<div className="flex items-center"> <div className="flex items-center">
<Squares2X2Icon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" /> <Squares2X2Icon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Area:</span> <span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Area:
</span>
<span className="text-sm text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded"> <span className="text-sm text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{project.area.name} {project.area.name}
</span> </span>
@ -385,9 +444,13 @@ const ProjectDetails: React.FC = () => {
{project.due_date_at && ( {project.due_date_at && (
<div className="flex items-center"> <div className="flex items-center">
<CalendarDaysIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" /> <CalendarDaysIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Due Date:</span> <span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Due Date:
</span>
<span className="text-sm text-gray-900 dark:text-gray-100"> <span className="text-sm text-gray-900 dark:text-gray-100">
{formatProjectDueDate(project.due_date_at)} {formatProjectDueDate(
project.due_date_at
)}
</span> </span>
</div> </div>
)} )}
@ -395,17 +458,30 @@ const ProjectDetails: React.FC = () => {
{project.tags && project.tags.length > 0 && ( {project.tags && project.tags.length > 0 && (
<div className="flex items-start"> <div className="flex items-start">
<div className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5"> <div className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5">
<svg fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" /> fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg> </svg>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Tags:</span> <span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">
Tags:
</span>
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{project.tags.map((tag, index) => ( {project.tags.map((tag, index) => (
<button <button
key={index} key={index}
onClick={() => navigate(`/tag/${encodeURIComponent(tag.name)}`)} onClick={() =>
navigate(
`/tag/${encodeURIComponent(tag.name)}`
)
}
className="inline-block px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors" className="inline-block px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
> >
{tag.name} {tag.name}
@ -428,11 +504,12 @@ const ProjectDetails: React.FC = () => {
{project.name} {project.name}
</h2> </h2>
{/* Show priority indicator only when no image */} {/* Show priority indicator only when no image */}
{project.priority !== undefined && project.priority !== null && ( {project.priority !== undefined &&
project.priority !== null && (
<div <div
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${ className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${getPriorityStyle(
getPriorityStyle(project.priority) project.priority
}`} )}`}
title={`Priority: ${priorityLabel(project.priority)}`} title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`} aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div> ></div>
@ -459,24 +536,38 @@ const ProjectDetails: React.FC = () => {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center"> <div className="flex items-center">
<ListBulletIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" /> <ListBulletIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">{t('sidebar.tasks', 'Tasks')}</h3> <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{t('sidebar.tasks', 'Tasks')}
</h3>
</div> </div>
{completedTasks.length > 0 && ( {completedTasks.length > 0 && (
<label className="flex items-center space-x-2 cursor-pointer"> <label className="flex items-center space-x-2 cursor-pointer">
<span className="text-sm text-gray-600 dark:text-gray-400">Show completed</span> <span className="text-sm text-gray-600 dark:text-gray-400">
Show completed
</span>
<div className="relative flex items-center"> <div className="relative flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={showCompleted} checked={showCompleted}
onChange={(e) => setShowCompleted(e.target.checked)} onChange={(e) =>
setShowCompleted(e.target.checked)
}
className="sr-only" className="sr-only"
/> />
<div className={`w-10 h-5 rounded-full transition-colors ${ <div
showCompleted ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600' className={`w-10 h-5 rounded-full transition-colors ${
}`}> showCompleted
<div className={`w-4 h-4 bg-white rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${ ? 'bg-blue-500'
showCompleted ? 'translate-x-5' : 'translate-x-0.5' : 'bg-gray-300 dark:bg-gray-600'
} translate-y-0.5`}></div> }`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow-md transform transition-transform duration-200 ease-in-out ${
showCompleted
? 'translate-x-5'
: 'translate-x-0.5'
} translate-y-0.5`}
></div>
</div> </div>
</div> </div>
</label> </label>
@ -502,14 +593,19 @@ const ProjectDetails: React.FC = () => {
<AutoSuggestNextActionBox <AutoSuggestNextActionBox
onAddAction={(actionDescription) => { onAddAction={(actionDescription) => {
if (project?.id) { if (project?.id) {
handleCreateNextAction(project.id, actionDescription); handleCreateNextAction(
project.id,
actionDescription
);
} }
}} }}
onDismiss={handleSkipNextAction} onDismiss={handleSkipNextAction}
projectName={project?.name || ""} projectName={project?.name || ''}
/> />
) : ( ) : (
<p className="text-gray-500 dark:text-gray-400">No tasks.</p> <p className="text-gray-500 dark:text-gray-400">
No tasks.
</p>
)} )}
</div> </div>
@ -533,20 +629,34 @@ const ProjectDetails: React.FC = () => {
to={`/note/${note.id}`} to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline" className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
> >
{note.title || t('notes.untitled', 'Untitled Note')} {note.title ||
t(
'notes.untitled',
'Untitled Note'
)}
</Link> </Link>
{note.content && ( {note.content && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2"> <p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
{note.content.length > 150 {note.content.length > 150
? note.content.substring(0, 150) + '...' ? note.content.substring(
: note.content 0,
} 150
) + '...'
: note.content}
</p> </p>
)} )}
{note.tags && note.tags.length > 0 && ( {note.tags &&
note.tags.length > 0 && (
<div className="flex items-center mt-2 text-xs text-gray-500 dark:text-gray-400"> <div className="flex items-center mt-2 text-xs text-gray-500 dark:text-gray-400">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span>{note.tags.map(tag => tag.name).join(', ')}</span> <span>
{note.tags
.map(
(tag) =>
tag.name
)
.join(', ')}
</span>
</div> </div>
)} )}
</div> </div>
@ -555,7 +665,9 @@ const ProjectDetails: React.FC = () => {
))} ))}
</div> </div>
) : ( ) : (
<p className="text-gray-500 dark:text-gray-400">{t('notes.noNotes', 'No notes for this project.')}</p> <p className="text-gray-500 dark:text-gray-400">
{t('notes.noNotes', 'No notes for this project.')}
</p>
)} )}
</div> </div>
@ -575,7 +687,6 @@ const ProjectDetails: React.FC = () => {
onCancel={() => setIsConfirmDialogOpen(false)} onCancel={() => setIsConfirmDialogOpen(false)}
/> />
)} )}
</div> </div>
</div> </div>
); );
@ -583,7 +694,8 @@ const ProjectDetails: React.FC = () => {
const priorityLabel = (priority: PriorityType | number) => { const priorityLabel = (priority: PriorityType | number) => {
// Handle both string and numeric priorities // Handle both string and numeric priorities
const normalizedPriority = typeof priority === 'number' const normalizedPriority =
typeof priority === 'number'
? (['low', 'medium', 'high'][priority] as PriorityType) ? (['low', 'medium', 'high'][priority] as PriorityType)
: priority; : priority;
@ -601,7 +713,8 @@ const priorityLabel = (priority: PriorityType | number) => {
const getPriorityStyle = (priority: PriorityType | number) => { const getPriorityStyle = (priority: PriorityType | number) => {
// Handle both string and numeric priorities // Handle both string and numeric priorities
const normalizedPriority = typeof priority === 'number' const normalizedPriority =
typeof priority === 'number'
? (['low', 'medium', 'high'][priority] as PriorityType) ? (['low', 'medium', 'high'][priority] as PriorityType)
: priority; : priority;

View file

@ -1,12 +1,12 @@
import React from "react"; import React from 'react';
import { Link } from "react-router-dom"; import { Link } from 'react-router-dom';
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid"; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
interface ProjectItemProps { interface ProjectItemProps {
project: Project; project: Project;
viewMode: "cards" | "list"; viewMode: 'cards' | 'list';
color: string; color: string;
getCompletionPercentage: () => number; getCompletionPercentage: () => number;
activeDropdown: number | null; activeDropdown: number | null;
@ -19,12 +19,12 @@ interface ProjectItemProps {
const getProjectInitials = (name: string) => { const getProjectInitials = (name: string) => {
const words = name const words = name
.trim() .trim()
.split(" ") .split(' ')
.filter((word) => word.length > 0); .filter((word) => word.length > 0);
if (words.length === 1) { if (words.length === 1) {
return name.toUpperCase(); return name.toUpperCase();
} }
return words.map((word) => word[0].toUpperCase()).join(""); return words.map((word) => word[0].toUpperCase()).join('');
}; };
const ProjectItem: React.FC<ProjectItemProps> = ({ const ProjectItem: React.FC<ProjectItemProps> = ({
@ -42,19 +42,19 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
return ( return (
<div <div
className={`${ className={`${
viewMode === "cards" viewMode === 'cards'
? "bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col" ? 'bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col'
: "bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-row items-center p-4" : 'bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-row items-center p-4'
}`} }`}
style={{ style={{
minHeight: viewMode === "cards" ? "250px" : "auto", minHeight: viewMode === 'cards' ? '250px' : 'auto',
maxHeight: viewMode === "cards" ? "250px" : "auto", maxHeight: viewMode === 'cards' ? '250px' : 'auto',
}} }}
> >
{viewMode === "cards" && ( {viewMode === 'cards' && (
<div <div
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg relative" className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg relative"
style={{ height: "140px" }} style={{ height: '140px' }}
> >
{project.image_url ? ( {project.image_url ? (
<img <img
@ -65,7 +65,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
) : ( ) : (
<span <span
className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20" className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20"
aria-label={t("projectItem.projectInitials")} aria-label={t('projectItem.projectInitials')}
> >
{getProjectInitials(project.name)} {getProjectInitials(project.name)}
</span> </span>
@ -76,7 +76,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
</div> </div>
)} )}
{viewMode === "list" && project.image_url && ( {viewMode === 'list' && project.image_url && (
<div className="w-16 h-16 mr-4 flex-shrink-0"> <div className="w-16 h-16 mr-4 flex-shrink-0">
<img <img
src={project.image_url} src={project.image_url}
@ -88,19 +88,21 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
<div <div
className={`flex justify-between items-start ${ className={`flex justify-between items-start ${
viewMode === "cards" ? "p-4 flex-1" : "flex-1" viewMode === 'cards' ? 'p-4 flex-1' : 'flex-1'
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
{viewMode === "list" && !project.image_url && ( {viewMode === 'list' && !project.image_url && (
<div className={`w-3 h-3 rounded-full ${color} mr-3 flex-shrink-0`}></div> <div
className={`w-3 h-3 rounded-full ${color} mr-3 flex-shrink-0`}
></div>
)} )}
<Link <Link
to={`/project/${project.id}`} to={`/project/${project.id}`}
className={`${ className={`${
viewMode === "cards" viewMode === 'cards'
? "text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2" ? 'text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2'
: "text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline" : 'text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline'
}`} }`}
> >
{project.name} {project.name}
@ -111,10 +113,12 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none" className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none"
onClick={() => onClick={() =>
setActiveDropdown( setActiveDropdown(
activeDropdown === project.id ? null : project.id ?? null activeDropdown === project.id
? null
: (project.id ?? null)
) )
} }
aria-label={t("projectItem.toggleDropdownMenu")} aria-label={t('projectItem.toggleDropdownMenu')}
> >
<EllipsisVerticalIcon className="h-5 w-5" /> <EllipsisVerticalIcon className="h-5 w-5" />
</button> </button>
@ -125,7 +129,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
onClick={() => handleEditProject(project)} 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" 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"
> >
{t("projectItem.edit")} {t('projectItem.edit')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@ -135,19 +139,21 @@ 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" 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"
> >
{t("projectItem.delete")} {t('projectItem.delete')}
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
{viewMode === "cards" && ( {viewMode === 'cards' && (
<div className="absolute bottom-4 left-0 right-0 px-4"> <div className="absolute bottom-4 left-0 right-0 px-4">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2" className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"
title={t("projectItem.completionPercentage", { percentage: getCompletionPercentage() })} title={t('projectItem.completionPercentage', {
percentage: getCompletionPercentage(),
})}
> >
<div <div
className="bg-blue-500 h-2 rounded-full" className="bg-blue-500 h-2 rounded-full"

View file

@ -1,15 +1,23 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Area } from "../../entities/Area"; import { Area } from '../../entities/Area';
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
import ConfirmDialog from "../Shared/ConfirmDialog"; import ConfirmDialog from '../Shared/ConfirmDialog';
import { useToast } from "../Shared/ToastContext"; import { useToast } from '../Shared/ToastContext';
import TagInput from "../Tag/TagInput"; import TagInput from '../Tag/TagInput';
import PriorityDropdown from "../Shared/PriorityDropdown"; import PriorityDropdown from '../Shared/PriorityDropdown';
import { PriorityType } from "../../entities/Task"; import { PriorityType } from '../../entities/Task';
import Switch from "../Shared/Switch"; import Switch from '../Shared/Switch';
import { useStore } from "../../store/useStore"; import { useStore } from '../../store/useStore';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import { TagIcon, FolderIcon, Cog6ToothIcon, TrashIcon, CameraIcon, CalendarIcon, ExclamationTriangleIcon, PowerIcon } from '@heroicons/react/24/outline'; import {
TagIcon,
FolderIcon,
TrashIcon,
CameraIcon,
CalendarIcon,
ExclamationTriangleIcon,
PowerIcon,
} from '@heroicons/react/24/outline';
interface ProjectModalProps { interface ProjectModalProps {
isOpen: boolean; isOpen: boolean;
@ -30,14 +38,14 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}) => { }) => {
const [formData, setFormData] = useState<Project>( const [formData, setFormData] = useState<Project>(
project || { project || {
name: "", name: '',
description: "", description: '',
area_id: null, area_id: null,
active: true, active: true,
tags: [], tags: [],
priority: "low", priority: 'low',
due_date_at: "", due_date_at: '',
image_url: "", image_url: '',
} }
); );
@ -45,7 +53,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
project?.tags?.map((tag) => tag.name) || [] project?.tags?.map((tag) => tag.name) || []
); );
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>(project?.image_url || ""); const [imagePreview, setImagePreview] = useState<string>(
project?.image_url || ''
);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const { tagsStore } = useStore(); const { tagsStore } = useStore();
@ -67,7 +77,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
active: false, active: false,
}); });
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
@ -75,24 +85,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
setFormData({ setFormData({
...project, ...project,
tags: project.tags || [], tags: project.tags || [],
due_date_at: project.due_date_at || "", due_date_at: project.due_date_at || '',
image_url: project.image_url || "", image_url: project.image_url || '',
}); });
setTags(project.tags?.map((tag) => tag.name) || []); setTags(project.tags?.map((tag) => tag.name) || []);
setImagePreview(project.image_url || ""); setImagePreview(project.image_url || '');
} else { } else {
setFormData({ setFormData({
name: "", name: '',
description: "", description: '',
area_id: null, area_id: null,
active: true, active: true,
tags: [], tags: [],
priority: "low", priority: 'low',
due_date_at: "", due_date_at: '',
image_url: "", image_url: '',
}); });
setTags([]); setTags([]);
setImagePreview(""); setImagePreview('');
} }
setImageFile(null); setImageFile(null);
setError(null); setError(null);
@ -109,7 +119,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
// Check if click is on priority dropdown (which is portaled to document.body) // Check if click is on priority dropdown (which is portaled to document.body)
const clickedElement = target as Element; const clickedElement = target as Element;
if (clickedElement && clickedElement.closest && clickedElement.closest('.fixed.z-50.bg-white, .fixed.z-50.bg-gray-700')) { if (
clickedElement &&
clickedElement.closest &&
clickedElement.closest(
'.fixed.z-50.bg-white, .fixed.z-50.bg-gray-700'
)
) {
return; return;
} }
@ -117,24 +133,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}; };
if (isOpen) { if (isOpen) {
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
} }
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === 'Escape') {
handleClose(); handleClose();
} }
}; };
if (isOpen) { if (isOpen) {
document.addEventListener("keydown", handleKeyDown); document.addEventListener('keydown', handleKeyDown);
} }
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen]); }, [isOpen]);
@ -151,7 +167,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
setError(null); setError(null);
} }
if (type === "checkbox") { if (type === 'checkbox') {
if (target instanceof HTMLInputElement) { if (target instanceof HTMLInputElement) {
const checked = target.checked; const checked = target.checked;
setFormData((prev) => ({ setFormData((prev) => ({
@ -219,20 +235,22 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const handleRemoveImage = () => { const handleRemoveImage = () => {
setImageFile(null); setImageFile(null);
setImagePreview(""); setImagePreview('');
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
image_url: "", image_url: '',
})); }));
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ""; fileInputRef.current.value = '';
} }
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
// Validate required fields // Validate required fields
if (!formData.name.trim()) { if (!formData.name.trim()) {
setError(t('errors.projectNameRequired', 'Project name is required')); setError(
t('errors.projectNameRequired', 'Project name is required')
);
return; return;
} }
@ -250,7 +268,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const projectData = { const projectData = {
...formData, ...formData,
image_url: imageUrl, image_url: imageUrl,
tags: tags.map((name) => ({ name })) tags: tags.map((name) => ({ name })),
}; };
// Save the project // Save the project
@ -258,8 +276,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
showSuccessToast( showSuccessToast(
project project
? "Project updated successfully!" ? 'Project updated successfully!'
: "Project created successfully!" : 'Project created successfully!'
); );
handleClose(); handleClose();
@ -297,21 +315,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
})); }));
}; };
const toggleSection = useCallback((section: keyof typeof expandedSections) => { const toggleSection = useCallback(
setExpandedSections(prev => { (section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
const newExpanded = { const newExpanded = {
...prev, ...prev,
[section]: !prev[section] [section]: !prev[section],
}; };
// Auto-scroll to show the expanded section // Auto-scroll to show the expanded section
if (newExpanded[section]) { if (newExpanded[section]) {
setTimeout(() => { setTimeout(() => {
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto'); const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: 'smooth' behavior: 'smooth',
}); });
} }
}, 100); // Small delay to ensure DOM is updated }, 100); // Small delay to ensure DOM is updated
@ -319,8 +340,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
return newExpanded; return newExpanded;
}); });
}, []); },
[]
);
if (!isOpen) return null; if (!isOpen) return null;
@ -328,20 +350,23 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<> <>
<div <div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${ className={`fixed top-16 left-0 right-0 bottom-0 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" isClosing ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
<div <div
ref={modalRef} ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${ className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100" isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`} } h-full sm:h-auto sm:my-4`}
> >
<div className="flex flex-col h-full sm:min-h-[500px] sm:max-h-[80vh]"> <div className="flex flex-col h-full sm:min-h-[500px] sm:max-h-[80vh]">
{/* Main Form Section */} {/* Main Form Section */}
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800"> <div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
<div className="flex-1 relative"> <div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}> <div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<form className="h-full"> <form className="h-full">
<fieldset className="h-full flex flex-col"> <fieldset className="h-full flex flex-col">
{/* Project Title Section - Always Visible */} {/* Project Title Section - Always Visible */}
@ -354,7 +379,10 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onChange={handleChange} onChange={handleChange}
required required
className={`block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2`} className={`block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2`}
placeholder={t('project.name', 'Enter project name')} placeholder={t(
'project.name',
'Enter project name'
)}
/> />
{error && ( {error && (
<div className="mt-2 text-red-500 text-sm font-medium"> <div className="mt-2 text-red-500 text-sm font-medium">
@ -368,11 +396,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<textarea <textarea
id="projectDescription" id="projectDescription"
name="description" name="description"
value={formData.description || ""} value={
formData.description ||
''
}
onChange={handleChange} onChange={handleChange}
className="block w-full h-full border border-gray-300 dark:border-gray-600 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 resize-none" className="block w-full h-full border border-gray-300 dark:border-gray-600 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 resize-none"
placeholder={t('forms.areaDescriptionPlaceholder', 'Enter project description (optional)')} placeholder={t(
style={{ minHeight: '200px' }} 'forms.areaDescriptionPlaceholder',
'Enter project description (optional)'
)}
style={{
minHeight: '200px',
}}
/> />
</div> </div>
@ -381,18 +417,28 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.active && ( {expandedSections.active && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('projects.active', 'Status')} {t(
'projects.active',
'Status'
)}
</h3> </h3>
<div className="flex items-center"> <div className="flex items-center">
<Switch <Switch
isChecked={formData.active} isChecked={
onToggle={handleToggleActive} formData.active
}
onToggle={
handleToggleActive
}
/> />
<label <label
htmlFor="active" htmlFor="active"
className="ml-2 block text-sm text-gray-700 dark:text-gray-300" className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
> >
{t('projects.active', 'Active')} {t(
'projects.active',
'Active'
)}
</label> </label>
</div> </div>
</div> </div>
@ -401,12 +447,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.tags && ( {expandedSections.tags && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.tags', 'Tags')} {t(
'forms.tags',
'Tags'
)}
</h3> </h3>
<TagInput <TagInput
onTagsChange={handleTagsChange} onTagsChange={
handleTagsChange
}
initialTags={tags} initialTags={tags}
availableTags={availableTags} availableTags={
availableTags
}
/> />
</div> </div>
)} )}
@ -414,18 +467,32 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.area && ( {expandedSections.area && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('common.area', 'Area')} {t(
'common.area',
'Area'
)}
</h3> </h3>
<select <select
id="projectArea" id="projectArea"
name="area_id" name="area_id"
value={formData.area_id || ""} value={
formData.area_id ||
''
}
onChange={handleChange} onChange={handleChange}
className="block w-full border border-gray-300 dark:border-gray-600 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" className="block w-full border border-gray-300 dark:border-gray-600 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="">{t('common.none', 'No Area')}</option> <option value="">
{t(
'common.none',
'No Area'
)}
</option>
{areas.map((area) => ( {areas.map((area) => (
<option key={area.id} value={area.id}> <option
key={area.id}
value={area.id}
>
{area.name} {area.name}
</option> </option>
))} ))}
@ -436,20 +503,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.image && ( {expandedSections.image && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('project.projectImage', 'Project Image')} {t(
'project.projectImage',
'Project Image'
)}
</h3> </h3>
{imagePreview ? ( {imagePreview ? (
<div className="mb-3"> <div className="mb-3">
<div className="relative inline-block"> <div className="relative inline-block">
<img <img
src={imagePreview} src={
imagePreview
}
alt="Project preview" alt="Project preview"
className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600" className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600"
/> />
<button <button
type="button" type="button"
onClick={handleRemoveImage} onClick={
handleRemoveImage
}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600" className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
> >
× ×
@ -462,21 +536,41 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleImageSelect} onChange={
handleImageSelect
}
className="hidden" className="hidden"
/> />
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() =>
fileInputRef.current?.click()
}
className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors" className="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
> >
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg> </svg>
{t('project.browseImage', 'Browse Image')} {t(
'project.browseImage',
'Browse Image'
)}
</button> </button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('project.uploadImageHint', 'Upload an image for your project (max 5MB)')} {t(
'project.uploadImageHint',
'Upload an image for your project (max 5MB)'
)}
</p> </p>
</div> </div>
)} )}
@ -484,12 +578,23 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.priority && ( {expandedSections.priority && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.priority', 'Priority')} {t(
'forms.priority',
'Priority'
)}
</h3> </h3>
<PriorityDropdown <PriorityDropdown
value={formData.priority || "medium"} value={
onChange={(value: PriorityType) => formData.priority ||
setFormData({ ...formData, priority: value }) 'medium'
}
onChange={(
value: PriorityType
) =>
setFormData({
...formData,
priority: value,
})
} }
/> />
</div> </div>
@ -498,12 +603,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{expandedSections.dueDate && ( {expandedSections.dueDate && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.dueDate', 'Due Date')} {t(
'forms.dueDate',
'Due Date'
)}
</h3> </h3>
<input <input
type="date" type="date"
name="due_date_at" name="due_date_at"
value={formData.due_date_at || ""} value={
formData.due_date_at ||
''
}
onChange={handleChange} onChange={handleChange}
className="block w-full border border-gray-300 dark:border-gray-600 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" className="block w-full border border-gray-300 dark:border-gray-600 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"
/> />
@ -522,13 +633,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Active Status Toggle - First */} {/* Active Status Toggle - First */}
<button <button
type="button" type="button"
onClick={() => toggleSection('active')} onClick={() =>
toggleSection('active')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.active expandedSections.active
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('projects.active', 'Status')} title={t(
'projects.active',
'Status'
)}
> >
<PowerIcon className="h-5 w-5" /> <PowerIcon className="h-5 w-5" />
{!formData.active && ( {!formData.active && (
@ -539,7 +655,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Tags Toggle */} {/* Tags Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('tags')} onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
@ -548,7 +666,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
title={t('forms.tags', 'Tags')} title={t('forms.tags', 'Tags')}
> >
<TagIcon className="h-5 w-5" /> <TagIcon className="h-5 w-5" />
{formData.tags && formData.tags.length > 0 && ( {formData.tags &&
formData.tags.length > 0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span> <span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)} )}
</button> </button>
@ -556,7 +675,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Area Toggle */} {/* Area Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('area')} onClick={() =>
toggleSection('area')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.area expandedSections.area
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
@ -573,13 +694,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Project Image Toggle */} {/* Project Image Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('image')} onClick={() =>
toggleSection('image')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.image expandedSections.image
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('project.projectImage', 'Project Image')} title={t(
'project.projectImage',
'Project Image'
)}
> >
<CameraIcon className="h-5 w-5" /> <CameraIcon className="h-5 w-5" />
{formData.image_url && ( {formData.image_url && (
@ -590,13 +716,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Priority Toggle */} {/* Priority Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('priority')} onClick={() =>
toggleSection('priority')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.priority expandedSections.priority
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.priority', 'Priority')} title={t(
'forms.priority',
'Priority'
)}
> >
<ExclamationTriangleIcon className="h-5 w-5" /> <ExclamationTriangleIcon className="h-5 w-5" />
{formData.priority !== 'medium' && ( {formData.priority !== 'medium' && (
@ -607,13 +738,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
{/* Due Date Toggle */} {/* Due Date Toggle */}
<button <button
type="button" type="button"
onClick={() => toggleSection('dueDate')} onClick={() =>
toggleSection('dueDate')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.dueDate expandedSections.dueDate
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.dueDate', 'Due Date')} title={t(
'forms.dueDate',
'Due Date'
)}
> >
<CalendarIcon className="h-5 w-5" /> <CalendarIcon className="h-5 w-5" />
{formData.due_date_at && ( {formData.due_date_at && (
@ -628,7 +764,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between"> <div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */} {/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{(project && project.id && onDelete) && ( {project && project.id && onDelete && (
<button <button
type="button" type="button"
onClick={handleDeleteClick} onClick={handleDeleteClick}
@ -653,10 +789,22 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onClick={handleSubmit} onClick={handleSubmit}
disabled={isUploading} disabled={isUploading}
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 text-sm ${ 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 text-sm ${
isUploading ? 'opacity-50 cursor-not-allowed' : '' isUploading
? 'opacity-50 cursor-not-allowed'
: ''
}`} }`}
> >
{isUploading ? 'Uploading...' : (project ? t('modals.updateProject', 'Update Project') : t('modals.createProject', 'Create Project'))} {isUploading
? 'Uploading...'
: project
? t(
'modals.updateProject',
'Update Project'
)
: t(
'modals.createProject',
'Create Project'
)}
</button> </button>
</div> </div>
</div> </div>
@ -672,7 +820,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onCancel={() => setShowConfirmDialog(false)} onCancel={() => setShowConfirmDialog(false)}
/> />
)} )}
</> </>
); );
}; };

View file

@ -1,56 +1,73 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from 'react';
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
FolderIcon, FolderIcon,
Squares2X2Icon, Squares2X2Icon,
Bars3Icon, Bars3Icon,
} from "@heroicons/react/24/solid"; } from '@heroicons/react/24/solid';
import ConfirmDialog from "./Shared/ConfirmDialog"; import ConfirmDialog from './Shared/ConfirmDialog';
import ProjectModal from "./Project/ProjectModal"; import ProjectModal from './Project/ProjectModal';
import { useStore } from "../store/useStore"; import { useStore } from '../store/useStore';
import { fetchGroupedProjects, createProject, updateProject, deleteProject } from "../utils/projectsService"; import {
import { fetchAreas } from "../utils/areasService"; fetchGroupedProjects,
import { useTranslation } from "react-i18next"; createProject,
updateProject,
import { Project } from "../entities/Project"; deleteProject,
import { PriorityType } from "../entities/Task"; } from '../utils/projectsService';
import { useSearchParams } from "react-router-dom"; import { fetchAreas } from '../utils/areasService';
import ProjectItem from "./Project/ProjectItem"; import { useTranslation } from 'react-i18next';
import { Project } from '../entities/Project';
import { PriorityType } from '../entities/Task';
import { useSearchParams } from 'react-router-dom';
import ProjectItem from './Project/ProjectItem';
const getPriorityStyles = (priority: PriorityType) => { const getPriorityStyles = (priority: PriorityType) => {
switch (priority) { switch (priority) {
case "low": case 'low':
return { color: "bg-green-500" }; return { color: 'bg-green-500' };
case "medium": case 'medium':
return { color: "bg-yellow-500" }; return { color: 'bg-yellow-500' };
case "high": case 'high':
return { color: "bg-red-500" }; return { color: 'bg-red-500' };
default: default:
return { color: "bg-gray-500" }; return { color: 'bg-gray-500' };
} }
}; };
const Projects: React.FC = () => { const Projects: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore); const {
const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore); areas,
setAreas,
setError: setAreasError,
} = useStore((state) => state.areasStore);
const {
projects,
setProjects,
setLoading: setProjectsLoading,
setError: setProjectsError,
} = useStore((state) => state.projectsStore);
const { isLoading, isError } = useStore((state) => state.projectsStore); const { isLoading, isError } = useStore((state) => state.projectsStore);
const [groupedProjects, setGroupedProjects] = useState<Record<string, Project[]>>({}); const [groupedProjects, setGroupedProjects] = useState<
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false); Record<string, Project[]>
>({});
const [isProjectModalOpen, setIsProjectModalOpen] =
useState<boolean>(false);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null); const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null); const [projectToDelete, setProjectToDelete] = useState<Project | null>(
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false); null
);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState<string>(""); const [searchQuery, setSearchQuery] = useState<string>('');
const [viewMode, setViewMode] = useState<"cards" | "list">("cards"); const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const activeFilter = searchParams.get("active") || "all"; const activeFilter = searchParams.get('active') || 'all';
const areaFilter = searchParams.get('area_id') || '';
// Dispatch global modal events
const areaFilter = searchParams.get("area_id") || "";
useEffect(() => { useEffect(() => {
const loadAreas = async () => { const loadAreas = async () => {
@ -58,7 +75,7 @@ useEffect(() => {
const areasData = await fetchAreas(); const areasData = await fetchAreas();
setAreas(areasData); setAreas(areasData);
} catch (error) { } catch (error) {
console.error("Failed to fetch areas:", error); console.error('Failed to fetch areas:', error);
setAreasError(true); setAreasError(true);
} }
}; };
@ -69,10 +86,13 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter); const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData); setGroupedProjects(groupedProjectsData);
} catch (error) { } catch (error) {
console.error("Failed to fetch projects:", error); console.error('Failed to fetch projects:', error);
setProjectsError(true); setProjectsError(true);
} }
}; };
@ -88,10 +108,13 @@ useEffect(() => {
} else { } else {
await createProject(project); await createProject(project);
} }
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter); const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData); setGroupedProjects(groupedProjectsData);
} catch (error) { } catch (error) {
console.error("Error saving project:", error); console.error('Error saving project:', error);
setProjectsError(true); setProjectsError(true);
} finally { } finally {
setProjectsLoading(false); setProjectsLoading(false);
@ -99,7 +122,6 @@ useEffect(() => {
} }
}; };
const handleEditProject = (project: Project) => { const handleEditProject = (project: Project) => {
setProjectToEdit(project); setProjectToEdit(project);
setIsProjectModalOpen(true); setIsProjectModalOpen(true);
@ -112,13 +134,16 @@ useEffect(() => {
if (projectToDelete.id !== undefined) { if (projectToDelete.id !== undefined) {
setProjectsLoading(true); setProjectsLoading(true);
await deleteProject(projectToDelete.id); await deleteProject(projectToDelete.id);
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter); const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData); setGroupedProjects(groupedProjectsData);
} else { } else {
console.error("Cannot delete project: ID is undefined."); console.error('Cannot delete project: ID is undefined.');
} }
} catch (error) { } catch (error) {
console.error("Error deleting project:", error); console.error('Error deleting project:', error);
setProjectsError(true); setProjectsError(true);
} finally { } finally {
setProjectsLoading(false); setProjectsLoading(false);
@ -132,39 +157,47 @@ useEffect(() => {
return (project as any).completion_percentage || 0; return (project as any).completion_percentage || 0;
}; };
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleActiveFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>
) => {
const newActiveFilter = e.target.value; const newActiveFilter = e.target.value;
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
if (newActiveFilter === "all") { if (newActiveFilter === 'all') {
params.delete("active"); params.delete('active');
} else { } else {
params.set("active", newActiveFilter); params.set('active', newActiveFilter);
} }
setSearchParams(params); setSearchParams(params);
}; };
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => { const handleAreaFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>
) => {
const newAreaFilter = e.target.value; const newAreaFilter = e.target.value;
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
if (newAreaFilter === "") { if (newAreaFilter === '') {
params.delete("area_id"); params.delete('area_id');
} else { } else {
params.set("area_id", newAreaFilter); params.set('area_id', newAreaFilter);
} }
setSearchParams(params); setSearchParams(params);
}; };
// Apply search filter to the grouped projects from backend // Apply search filter to the grouped projects from backend
const searchFilteredGroupedProjects = Object.keys(groupedProjects).reduce<Record<string, Project[]>>( const searchFilteredGroupedProjects = Object.keys(groupedProjects).reduce<
(acc, areaName) => { Record<string, Project[]>
>((acc, areaName) => {
const projectsInArea = groupedProjects[areaName]; const projectsInArea = groupedProjects[areaName];
// Defensive check: ensure projectsInArea is an array // Defensive check: ensure projectsInArea is an array
if (!Array.isArray(projectsInArea)) { if (!Array.isArray(projectsInArea)) {
console.warn(`Projects for area "${areaName}" is not an array:`, projectsInArea); console.warn(
`Projects for area "${areaName}" is not an array:`,
projectsInArea
);
return acc; return acc;
} }
@ -175,9 +208,7 @@ useEffect(() => {
acc[areaName] = filteredProjects; acc[areaName] = filteredProjects;
} }
return acc; return acc;
}, }, {});
{}
);
if (isLoading) { if (isLoading) {
return ( return (
@ -192,7 +223,9 @@ useEffect(() => {
if (isError) { if (isError) {
return ( return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">{t('projects.error')}</div> <div className="text-red-500 text-lg">
{t('projects.error')}
</div>
</div> </div>
); );
} }
@ -211,25 +244,25 @@ useEffect(() => {
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 space-y-4 md:space-y-0"> <div className="flex flex-col md:flex-row md:items-center justify-between mb-6 space-y-4 md:space-y-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={() => setViewMode("cards")} onClick={() => setViewMode('cards')}
className={`p-2 rounded-md focus:outline-none ${ className={`p-2 rounded-md focus:outline-none ${
viewMode === "cards" viewMode === 'cards'
? "bg-blue-500 text-white" ? 'bg-blue-500 text-white'
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300" : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
aria-label={t("projects.cardViewAriaLabel")} aria-label={t('projects.cardViewAriaLabel')}
> >
<Squares2X2Icon className="h-5 w-5" /> <Squares2X2Icon className="h-5 w-5" />
</button> </button>
<button <button
onClick={() => setViewMode("list")} onClick={() => setViewMode('list')}
className={`p-2 rounded-md focus:outline-none ${ className={`p-2 rounded-md focus:outline-none ${
viewMode === "list" viewMode === 'list'
? "bg-blue-500 text-white" ? 'bg-blue-500 text-white'
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300" : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
aria-label={t("projects.listViewAriaLabel")} aria-label={t('projects.listViewAriaLabel')}
> >
<Bars3Icon className="h-5 w-5" /> <Bars3Icon className="h-5 w-5" />
</button> </button>
@ -249,9 +282,15 @@ useEffect(() => {
onChange={handleActiveFilterChange} 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" 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">{t('projects.filters.active')}</option> <option value="true">
<option value="false">{t('projects.filters.inactive')}</option> {t('projects.filters.active')}
<option value="all">{t('projects.filters.all')}</option> </option>
<option value="false">
{t('projects.filters.inactive')}
</option>
<option value="all">
{t('projects.filters.all')}
</option>
</select> </select>
</div> </div>
@ -268,9 +307,14 @@ useEffect(() => {
onChange={handleAreaFilterChange} 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" 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="">{t('projects.filters.allAreas')}</option> <option value="">
{t('projects.filters.allAreas')}
</option>
{areas.map((area) => ( {areas.map((area) => (
<option key={area.id} value={area.id?.toString()}> <option
key={area.id}
value={area.id?.toString()}
>
{area.name} {area.name}
</option> </option>
))} ))}
@ -296,9 +340,9 @@ useEffect(() => {
{/* Projects Grid/List */} {/* Projects Grid/List */}
<div <div
className={`${ className={`${
viewMode === "cards" viewMode === 'cards'
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
: "flex flex-col space-y-1" : 'flex flex-col space-y-1'
}`} }`}
> >
{Object.keys(searchFilteredGroupedProjects).length === 0 ? ( {Object.keys(searchFilteredGroupedProjects).length === 0 ? (
@ -306,34 +350,54 @@ useEffect(() => {
{t('projects.noProjectsFound')} {t('projects.noProjectsFound')}
</div> </div>
) : ( ) : (
Object.keys(searchFilteredGroupedProjects).map((areaName) => ( Object.keys(searchFilteredGroupedProjects).map(
(areaName) => (
<React.Fragment key={areaName}> <React.Fragment key={areaName}>
<h3 className={`${ <h3
viewMode === "cards" className={`${
? "col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-2 mt-6" viewMode === 'cards'
: "text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 mt-6 border-b border-gray-300 dark:border-gray-600 pb-2" ? 'col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-2 mt-6'
}`}> : 'text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 mt-6 border-b border-gray-300 dark:border-gray-600 pb-2'
}`}
>
{areaName} {areaName}
</h3> </h3>
{searchFilteredGroupedProjects[areaName].map((project) => { {searchFilteredGroupedProjects[
const { color } = getPriorityStyles(project.priority || "low"); areaName
].map((project) => {
const { color } = getPriorityStyles(
project.priority || 'low'
);
return ( return (
<ProjectItem <ProjectItem
key={project.id} key={project.id}
project={project} project={project}
viewMode={viewMode} viewMode={viewMode}
color={color} color={color}
getCompletionPercentage={() => getCompletionPercentage(project)} getCompletionPercentage={() =>
getCompletionPercentage(
project
)
}
activeDropdown={activeDropdown} activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown} setActiveDropdown={
handleEditProject={handleEditProject} setActiveDropdown
setProjectToDelete={setProjectToDelete} }
setIsConfirmDialogOpen={setIsConfirmDialogOpen} handleEditProject={
handleEditProject
}
setProjectToDelete={
setProjectToDelete
}
setIsConfirmDialogOpen={
setIsConfirmDialogOpen
}
/> />
); );
})} })}
</React.Fragment> </React.Fragment>
)) )
)
)} )}
</div> </div>
</div> </div>
@ -349,7 +413,11 @@ useEffect(() => {
onDelete={async (projectId) => { onDelete={async (projectId) => {
try { try {
await deleteProject(projectId); await deleteProject(projectId);
setProjects(projects.filter((p: Project) => p.id !== projectId)); setProjects(
projects.filter(
(p: Project) => p.id !== projectId
)
);
setIsProjectModalOpen(false); setIsProjectModalOpen(false);
setProjectToEdit(null); setProjectToEdit(null);
} catch (error) { } catch (error) {
@ -364,7 +432,9 @@ useEffect(() => {
{isConfirmDialogOpen && ( {isConfirmDialogOpen && (
<ConfirmDialog <ConfirmDialog
title={t('modals.deleteProject.title')} title={t('modals.deleteProject.title')}
message={t('modals.deleteProject.message', { projectName: projectToDelete?.name })} message={t('modals.deleteProject.message', {
projectName: projectToDelete?.name,
})}
onConfirm={handleDeleteProject} onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)} onCancel={() => setIsConfirmDialogOpen(false)}
/> />

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface CollapsibleSectionProps { interface CollapsibleSectionProps {
title: string; title: string;
@ -14,10 +14,12 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
isExpanded, isExpanded,
onToggle, onToggle,
children, children,
className = "" className = '',
}) => { }) => {
return ( return (
<div className={`border-b border-gray-200 dark:border-gray-700 ${className}`}> <div
className={`border-b border-gray-200 dark:border-gray-700 ${className}`}
>
<button <button
type="button" type="button"
onClick={onToggle} onClick={onToggle}
@ -33,12 +35,14 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
)} )}
</button> </button>
<div className={`transition-all duration-300 ease-in-out ${ <div
isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden' className={`transition-all duration-300 ease-in-out ${
}`}> isExpanded
<div className="px-4 pb-4"> ? 'max-h-[500px] opacity-100'
{children} : 'max-h-0 opacity-0 overflow-hidden'
</div> }`}
>
<div className="px-4 pb-4">{children}</div>
</div> </div>
</div> </div>
); );

View file

@ -8,14 +8,23 @@ interface ConfirmDialogProps {
onCancel: () => void; onCancel: () => void;
} }
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => { const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
title,
message,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50"> <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-8 rounded-lg shadow-xl w-full max-w-lg mx-4"> <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> <h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
<p className="text-gray-700 dark:text-gray-300 mb-8">{message}</p> {title}
</h3>
<p className="text-gray-700 dark:text-gray-300 mb-8">
{message}
</p>
<div className="flex justify-end space-x-4"> <div className="flex justify-end space-x-4">
<button <button
onClick={onCancel} onClick={onCancel}

View file

@ -1,14 +1,14 @@
import { MoonIcon, SunIcon } from "@heroicons/react/24/solid"; import { MoonIcon, SunIcon } from '@heroicons/react/24/solid';
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
const DarkModeToggle: React.FC = () => { const DarkModeToggle: React.FC = () => {
const [darkMode, setDarkMode] = useState<boolean>(() => { const [darkMode, setDarkMode] = useState<boolean>(() => {
return localStorage.getItem("darkMode") === "true"; return localStorage.getItem('darkMode') === 'true';
}); });
useEffect(() => { useEffect(() => {
document.body.classList.toggle("dark-mode", darkMode); document.body.classList.toggle('dark-mode', darkMode);
localStorage.setItem("darkMode", darkMode.toString()); localStorage.setItem('darkMode', darkMode.toString());
}, [darkMode]); }, [darkMode]);
return ( return (

View file

@ -1,6 +1,10 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CalendarDaysIcon } from '@heroicons/react/24/outline'; import {
ChevronLeftIcon,
ChevronRightIcon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline';
interface DatePickerProps { interface DatePickerProps {
value: string; value: string;
@ -8,7 +12,6 @@ interface DatePickerProps {
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
label?: string;
} }
const DatePicker: React.FC<DatePickerProps> = ({ const DatePicker: React.FC<DatePickerProps> = ({
@ -17,17 +20,31 @@ const DatePicker: React.FC<DatePickerProps> = ({
placeholder = 'Select date', placeholder = 'Select date',
disabled = false, disabled = false,
className = '', className = '',
label
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false }); const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const [currentMonth, setCurrentMonth] = useState(new Date()); const [currentMonth, setCurrentMonth] = useState(new Date());
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const months = [ const months = [
'January', 'February', 'March', 'April', 'May', 'June', 'January',
'July', 'August', 'September', 'October', 'November', 'December' 'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]; ];
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@ -50,7 +67,7 @@ const DatePicker: React.FC<DatePickerProps> = ({
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric',
}); });
}; };
@ -81,7 +98,10 @@ const DatePicker: React.FC<DatePickerProps> = ({
openUpward = true; openUpward = true;
top = Math.max(padding, rect.top - menuHeight - 8); top = Math.max(padding, rect.top - menuHeight - 8);
} else { } else {
top = Math.min(window.innerHeight - menuHeight - padding, rect.bottom + 8); top = Math.min(
window.innerHeight - menuHeight - padding,
rect.bottom + 8
);
} }
} }
@ -95,25 +115,37 @@ const DatePicker: React.FC<DatePickerProps> = ({
top, top,
left, left,
width: Math.max(rect.width, 280), // Minimum width for calendar width: Math.max(rect.width, 280), // Minimum width for calendar
openUpward openUpward,
}); });
// Set current month based on selected date or today // Set current month based on selected date or today
if (value) { if (value) {
const selectedDate = parseDate(value); const selectedDate = parseDate(value);
if (selectedDate && !isNaN(selectedDate.getTime())) { if (selectedDate && !isNaN(selectedDate.getTime())) {
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); setCurrentMonth(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
1
)
);
} }
} else { } else {
setCurrentMonth(new Date(new Date().getFullYear(), new Date().getMonth(), 1)); setCurrentMonth(
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
);
} }
} }
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && if (
menuRef.current && !menuRef.current.contains(event.target as Node)) { dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
@ -135,7 +167,7 @@ const DatePicker: React.FC<DatePickerProps> = ({
}; };
const navigateMonth = (direction: 'prev' | 'next') => { const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth(prev => { setCurrentMonth((prev) => {
const newMonth = new Date(prev); const newMonth = new Date(prev);
if (direction === 'prev') { if (direction === 'prev') {
newMonth.setMonth(newMonth.getMonth() - 1); newMonth.setMonth(newMonth.getMonth() - 1);
@ -177,7 +209,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
const isSelected = (date: Date) => { const isSelected = (date: Date) => {
if (!value) return false; if (!value) return false;
const selectedDate = parseDate(value); const selectedDate = parseDate(value);
return selectedDate && date.toDateString() === selectedDate.toDateString(); return (
selectedDate && date.toDateString() === selectedDate.toDateString()
);
}; };
useEffect(() => { useEffect(() => {
@ -193,11 +227,16 @@ const DatePicker: React.FC<DatePickerProps> = ({
}, [isOpen]); }, [isOpen]);
return ( return (
<div ref={dropdownRef} className={`relative inline-block text-left w-full ${className}`}> <div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button <button
type="button" type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${ className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800' disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`} }`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -206,7 +245,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
}} }}
disabled={disabled} disabled={disabled}
> >
<span className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}> <span
className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{formatDisplayDate(value)} {formatDisplayDate(value)}
</span> </span>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
@ -223,7 +264,8 @@ const DatePicker: React.FC<DatePickerProps> = ({
</div> </div>
</button> </button>
{isOpen && createPortal( {isOpen &&
createPortal(
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 date-picker-menu" className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 date-picker-menu"
@ -244,7 +286,8 @@ const DatePicker: React.FC<DatePickerProps> = ({
<ChevronLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" /> <ChevronLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button> </button>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{months[currentMonth.getMonth()]} {currentMonth.getFullYear()} {months[currentMonth.getMonth()]}{' '}
{currentMonth.getFullYear()}
</span> </span>
<button <button
type="button" type="button"
@ -259,8 +302,11 @@ const DatePicker: React.FC<DatePickerProps> = ({
<div className="p-3"> <div className="p-3">
{/* Day Headers */} {/* Day Headers */}
<div className="grid grid-cols-7 gap-1 mb-2"> <div className="grid grid-cols-7 gap-1 mb-2">
{days.map(day => ( {days.map((day) => (
<div key={day} className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1"> <div
key={day}
className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1"
>
{day} {day}
</div> </div>
))} ))}
@ -273,7 +319,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
{date && ( {date && (
<button <button
type="button" type="button"
onClick={() => handleDateSelect(date)} onClick={() =>
handleDateSelect(date)
}
className={`w-full h-full text-xs rounded transition-colors ${ className={`w-full h-full text-xs rounded transition-colors ${
isSelected(date) isSelected(date)
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'

View file

@ -7,4 +7,3 @@ const LoadingScreen: React.FC = () => (
); );
export default LoadingScreen; export default LoadingScreen;

View file

@ -9,11 +9,29 @@ interface MarkdownRendererProps {
className?: string; className?: string;
} }
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => { const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = '',
}) => {
useEffect(() => { useEffect(() => {
// Configure highlight.js // Configure highlight.js
hljs.configure({ hljs.configure({
languages: ['javascript', 'typescript', 'python', 'java', 'css', 'html', 'json', 'bash', 'sql', 'yaml', 'xml', 'dockerfile', 'nginx', 'apache'] languages: [
'javascript',
'typescript',
'python',
'java',
'css',
'html',
'json',
'bash',
'sql',
'yaml',
'xml',
'dockerfile',
'nginx',
'apache',
],
}); });
// Manual highlighting for any missed code blocks // Manual highlighting for any missed code blocks
@ -31,64 +49,183 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className
<div className={`markdown-content ${className}`}> <div className={`markdown-content ${className}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]} rehypePlugins={[
[rehypeHighlight, { detect: true, ignoreMissing: true }],
]}
components={{ components={{
// Customize heading styles // Customize heading styles
h1: ({...props}) => <h1 className="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100" {...props} />, h1: ({ ...props }) => (
h2: ({...props}) => <h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-gray-100" {...props} />, <h1
h3: ({...props}) => <h3 className="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />, className="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100"
h4: ({...props}) => <h4 className="text-lg font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />, {...props}
h5: ({...props}) => <h5 className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />, />
h6: ({...props}) => <h6 className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />, ),
h2: ({ ...props }) => (
<h2
className="text-2xl font-semibold mb-3 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h3: ({ ...props }) => (
<h3
className="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h4: ({ ...props }) => (
<h4
className="text-lg font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h5: ({ ...props }) => (
<h5
className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
h6: ({ ...props }) => (
<h6
className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100"
{...props}
/>
),
// Customize paragraph styles // Customize paragraph styles
p: ({...props}) => <p className="mb-3 text-gray-700 dark:text-gray-300 leading-relaxed" {...props} />, p: ({ ...props }) => (
<p
className="mb-3 text-gray-700 dark:text-gray-300 leading-relaxed"
{...props}
/>
),
// Customize list styles // Customize list styles
ul: ({...props}) => <ul className="mb-3 list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300" {...props} />, ul: ({ ...props }) => (
ol: ({...props}) => <ol className="mb-3 list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300" {...props} />, <ul
className="mb-3 list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="mb-3 list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300"
{...props}
/>
),
li: ({ ...props }) => <li className="ml-4" {...props} />, li: ({ ...props }) => <li className="ml-4" {...props} />,
// Customize link styles // Customize link styles
a: ({...props}) => <a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />, a: ({ ...props }) => (
<a
className="text-blue-600 dark:text-blue-400 hover:underline"
{...props}
/>
),
// Customize code styles // Customize code styles
code: ({ className, children, ...props }) => { code: ({ className, children, ...props }) => {
// Check if this is a code block (has language class) or inline code // Check if this is a code block (has language class) or inline code
const isCodeBlock = className && className.startsWith('language-'); const isCodeBlock =
className && className.startsWith('language-');
if (isCodeBlock) { if (isCodeBlock) {
// This is a code block - add hljs class to ensure our styles apply // This is a code block - add hljs class to ensure our styles apply
return <code className={`${className} hljs`} {...props}>{children}</code>; return (
<code
className={`${className} hljs`}
{...props}
>
{children}
</code>
);
} else { } else {
// This is inline code - apply our custom styling // This is inline code - apply our custom styling
// Check if parent is a pre element - if so, this might be a code block without language // Check if parent is a pre element - if so, this might be a code block without language
const parentIsPre = (props as any).node?.parent?.tagName === 'pre'; // eslint-disable-next-line react/prop-types
const node = (props as any).node;
// eslint-disable-next-line react/prop-types
const parentIsPre = node?.parent?.tagName === 'pre';
if (parentIsPre) { if (parentIsPre) {
return <code className="hljs" {...props}>{children}</code>; return (
<code className="hljs" {...props}>
{children}
</code>
);
} }
return <code className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100" {...props}>{children}</code>; return (
<code
className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100"
{...props}
>
{children}
</code>
);
} }
}, },
pre: ({...props}) => <pre className="mb-4 rounded-lg overflow-x-auto" {...props} />, pre: ({ ...props }) => (
<pre
className="mb-4 rounded-lg overflow-x-auto"
{...props}
/>
),
// Customize blockquote styles // Customize blockquote styles
blockquote: ({...props}) => <blockquote className="mb-4 pl-4 border-l-4 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400" {...props} />, blockquote: ({ ...props }) => (
<blockquote
className="mb-4 pl-4 border-l-4 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400"
{...props}
/>
),
// Customize table styles // Customize table styles
table: ({...props}) => <table className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600" {...props} />, table: ({ ...props }) => (
thead: ({...props}) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />, <table
th: ({...props}) => <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-semibold text-gray-900 dark:text-gray-100" {...props} />, className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600"
td: ({...props}) => <td className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-gray-700 dark:text-gray-300" {...props} />, {...props}
/>
),
thead: ({ ...props }) => (
<thead
className="bg-gray-100 dark:bg-gray-800"
{...props}
/>
),
th: ({ ...props }) => (
<th
className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-semibold text-gray-900 dark:text-gray-100"
{...props}
/>
),
td: ({ ...props }) => (
<td
className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-gray-700 dark:text-gray-300"
{...props}
/>
),
// Customize horizontal rule // Customize horizontal rule
hr: ({...props}) => <hr className="my-6 border-gray-300 dark:border-gray-600" {...props} />, hr: ({ ...props }) => (
<hr
className="my-6 border-gray-300 dark:border-gray-600"
{...props}
/>
),
// Customize strong/bold text // Customize strong/bold text
strong: ({...props}) => <strong className="font-semibold text-gray-900 dark:text-gray-100" {...props} />, strong: ({ ...props }) => (
<strong
className="font-semibold text-gray-900 dark:text-gray-100"
{...props}
/>
),
// Customize italic text // Customize italic text
em: ({...props}) => <em className="italic text-gray-700 dark:text-gray-300" {...props} /> em: ({ ...props }) => (
<em
className="italic text-gray-700 dark:text-gray-300"
{...props}
/>
),
}} }}
> >
{content} {content}

View file

@ -4,7 +4,7 @@ const NotFound: React.FC = () => {
return ( return (
<div> <div>
<h1>404 - Page Not Found</h1> <h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p> <p>The page you&apos;re looking for doesn&apos;t exist.</p>
</div> </div>
); );
}; };

View file

@ -19,10 +19,15 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
max = 99, max = 99,
placeholder = 'Select number', placeholder = 'Select number',
disabled = false, disabled = false,
className = '' className = '',
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false }); const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -41,21 +46,26 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
const spaceAbove = rect.top; const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight; const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({ setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8, top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left, left: rect.left,
width: rect.width, width: rect.width,
openUpward openUpward,
}); });
} }
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && if (
menuRef.current && !menuRef.current.contains(event.target as Node)) { dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
@ -82,14 +92,19 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
}; };
}, [isOpen]); }, [isOpen]);
const selectedOption = options.find(option => option.value === value); const selectedOption = options.find((option) => option.value === value);
return ( return (
<div ref={dropdownRef} className={`relative inline-block text-left w-full ${className}`}> <div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button <button
type="button" type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${ className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800' disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`} }`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -101,10 +116,13 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
<span className="truncate"> <span className="truncate">
{selectedOption ? selectedOption.label : placeholder} {selectedOption ? selectedOption.label : placeholder}
</span> </span>
<ChevronDownIcon className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDownIcon
className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button> </button>
{isOpen && createPortal( {isOpen &&
createPortal(
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-48 overflow-y-auto number-dropdown-menu" className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-48 overflow-y-auto number-dropdown-menu"
@ -132,7 +150,9 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
> >
<span>{option.label}</span> <span>{option.label}</span>
{option.value === value && ( {option.value === value && (
<span className="text-blue-600 dark:text-blue-400"></span> <span className="text-blue-600 dark:text-blue-400">
</span>
)} )}
</button> </button>
))} ))}

View file

@ -1,5 +1,10 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { PlayIcon, PauseIcon, ArrowPathIcon, XMarkIcon } from '@heroicons/react/24/outline'; import {
PlayIcon,
PauseIcon,
ArrowPathIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface PomodoroTimerProps { interface PomodoroTimerProps {
@ -37,8 +42,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
// If timer was running, calculate how much time has passed // If timer was running, calculate how much time has passed
if (state.isRunning && state.startTime) { if (state.isRunning && state.startTime) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000); const elapsed = Math.floor(
const newTimeLeft = Math.max(0, state.timeLeft - elapsed); (Date.now() - state.startTime) / 1000
);
const newTimeLeft = Math.max(
0,
state.timeLeft - elapsed
);
setTimeLeft(newTimeLeft); setTimeLeft(newTimeLeft);
if (newTimeLeft > 0) { if (newTimeLeft > 0) {
setIsRunning(true); setIsRunning(true);
@ -59,7 +69,9 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
isActive, isActive,
timeLeft, timeLeft,
isRunning, isRunning,
startTime: isRunning ? Date.now() - (DEFAULT_TIME - timeLeft) * 1000 : undefined startTime: isRunning
? Date.now() - (DEFAULT_TIME - timeLeft) * 1000
: undefined,
}; };
localStorage.setItem(POMODORO_STORAGE_KEY, JSON.stringify(state)); localStorage.setItem(POMODORO_STORAGE_KEY, JSON.stringify(state));
}, [isActive, timeLeft, isRunning]); }, [isActive, timeLeft, isRunning]);
@ -67,7 +79,7 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
useEffect(() => { useEffect(() => {
if (isRunning && timeLeft > 0) { if (isRunning && timeLeft > 0) {
intervalRef.current = setInterval(() => { intervalRef.current = setInterval(() => {
setTimeLeft(prev => { setTimeLeft((prev) => {
if (prev <= 1) { if (prev <= 1) {
setIsRunning(false); setIsRunning(false);
setShowCompletionMessage(true); setShowCompletionMessage(true);
@ -157,7 +169,10 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
if (!isActive) { if (!isActive) {
return ( return (
<div className={`flex items-center ${className}`} onClick={handleTomatoClick}> <div
className={`flex items-center ${className}`}
onClick={handleTomatoClick}
>
<TomatoIcon /> <TomatoIcon />
</div> </div>
); );
@ -173,7 +188,9 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
<button <button
onClick={handlePlayPause} onClick={handlePlayPause}
className="flex items-center justify-center p-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" className="flex items-center justify-center p-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
aria-label={isRunning ? t('pomodoro.pause') : t('pomodoro.play')} aria-label={
isRunning ? t('pomodoro.pause') : t('pomodoro.play')
}
> >
{isRunning ? ( {isRunning ? (
<PauseIcon className="h-3 w-3" /> <PauseIcon className="h-3 w-3" />
@ -203,9 +220,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
{showCompletionMessage && ( {showCompletionMessage && (
<div className="absolute top-full mt-2 right-0 bg-green-100 dark:bg-green-900 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200 px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap"> <div className="absolute top-full mt-2 right-0 bg-green-100 dark:bg-green-900 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200 px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap">
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
<span className="text-sm font-medium">🍅 {t('pomodoro.complete')}</span> <span className="text-sm font-medium">
🍅 {t('pomodoro.complete')}
</span>
</div> </div>
<p className="text-xs mb-3">{t('pomodoro.completeMessage')}</p> <p className="text-xs mb-3">
{t('pomodoro.completeMessage')}
</p>
<button <button
onClick={() => { onClick={() => {
setShowCompletionMessage(false); setShowCompletionMessage(false);

View file

@ -1,6 +1,11 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline'; import {
ChevronDownIcon,
ArrowDownIcon,
ArrowUpIcon,
FireIcon,
} from '@heroicons/react/24/outline';
import { PriorityType } from '../../entities/Task'; import { PriorityType } from '../../entities/Task';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,16 +14,42 @@ interface PriorityDropdownProps {
onChange: (value: PriorityType) => void; onChange: (value: PriorityType) => void;
} }
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => { const PriorityDropdown: React.FC<PriorityDropdownProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const priorities = [ 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: 'low',
{ value: 'high', label: t('priority.high', 'High'), icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> } 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 [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false }); const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -29,13 +60,14 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
const spaceAbove = rect.top; const spaceAbove = rect.top;
const menuHeight = 120; const menuHeight = 120;
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight; const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({ setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8, top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left, left: rect.left,
width: rect.width, width: rect.width,
openUpward openUpward,
}); });
} }
setIsOpen(!isOpen); setIsOpen(!isOpen);
@ -69,10 +101,13 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
}; };
}, [isOpen]); }, [isOpen]);
const selectedPriority = priorities.find(p => p.value === value); const selectedPriority = priorities.find((p) => p.value === value);
return ( return (
<div ref={dropdownRef} className="relative inline-block text-left w-full"> <div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button <button
type="button" type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none" className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
@ -80,12 +115,17 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">
{selectedPriority ? selectedPriority.icon : ''} {selectedPriority ? selectedPriority.icon : ''}
<span>{selectedPriority ? selectedPriority.label : t('forms.priority', 'Select Priority')}</span> <span>
{selectedPriority
? selectedPriority.label
: t('forms.priority', 'Select Priority')}
</span>
</span> </span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" /> <ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button> </button>
{isOpen && createPortal( {isOpen &&
createPortal(
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600" className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600"
@ -98,11 +138,14 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
{priorities.map((priority) => ( {priorities.map((priority) => (
<button <button
key={priority.value} key={priority.value}
onClick={() => handleSelect(priority.value as PriorityType)} onClick={() =>
handleSelect(priority.value as PriorityType)
}
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full first:rounded-t-md last:rounded-b-md" className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full first:rounded-t-md last:rounded-b-md"
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">
{priority.icon} <span>{priority.label}</span> {priority.icon}{' '}
<span>{priority.label}</span>
</span> </span>
</button> </button>
))} ))}

View file

@ -1,6 +1,11 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronDownIcon, ArrowPathIcon, CalendarDaysIcon, ClockIcon } from '@heroicons/react/24/outline'; import {
ChevronDownIcon,
ArrowPathIcon,
CalendarDaysIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import { RecurrenceType } from '../../entities/Task'; import { RecurrenceType } from '../../entities/Task';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,20 +14,64 @@ interface RecurrenceDropdownProps {
onChange: (value: RecurrenceType) => void; onChange: (value: RecurrenceType) => void;
} }
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange }) => { const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const recurrenceOptions = [ const recurrenceOptions = [
{ value: 'none', label: t('recurrence.none', 'No repeat'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, {
{ value: 'daily', label: t('recurrence.daily', 'Daily'), icon: <ArrowPathIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, value: 'none',
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, label: t('recurrence.none', 'No repeat'),
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, icon: (
{ value: 'monthly_weekday', label: t('recurrence.monthlyWeekday', 'Monthly on weekday'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
{ value: 'monthly_last_day', label: t('recurrence.monthlyLastDay', 'Monthly on last day'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> } ),
},
{
value: 'daily',
label: t('recurrence.daily', 'Daily'),
icon: (
<ArrowPathIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'weekly',
label: t('recurrence.weekly', 'Weekly'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'monthly',
label: t('recurrence.monthly', 'Monthly'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'monthly_weekday',
label: t('recurrence.monthlyWeekday', 'Monthly on weekday'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'monthly_last_day',
label: t('recurrence.monthlyLastDay', 'Monthly on last day'),
icon: (
<CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
]; ];
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false }); const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -33,20 +82,24 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
const spaceAbove = rect.top; const spaceAbove = rect.top;
const menuHeight = 240; const menuHeight = 240;
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight; const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({ setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8, top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left, left: rect.left,
width: rect.width, width: rect.width,
openUpward openUpward,
}); });
} }
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
@ -68,10 +121,13 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
}; };
}, [isOpen]); }, [isOpen]);
const selectedRecurrence = recurrenceOptions.find(r => r.value === value); const selectedRecurrence = recurrenceOptions.find((r) => r.value === value);
return ( return (
<div ref={dropdownRef} className="relative inline-block text-left w-full"> <div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button <button
type="button" type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none" className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
@ -79,12 +135,20 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">
{selectedRecurrence ? selectedRecurrence.icon : ''} {selectedRecurrence ? selectedRecurrence.icon : ''}
<span>{selectedRecurrence ? selectedRecurrence.label : t('forms.task.labels.recurrenceType', 'Select Recurrence')}</span> <span>
{selectedRecurrence
? selectedRecurrence.label
: t(
'forms.task.labels.recurrenceType',
'Select Recurrence'
)}
</span>
</span> </span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" /> <ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button> </button>
{isOpen && createPortal( {isOpen &&
createPortal(
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600" className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600"
@ -97,11 +161,16 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
{recurrenceOptions.map((recurrence) => ( {recurrenceOptions.map((recurrence) => (
<button <button
key={recurrence.value} key={recurrence.value}
onClick={() => handleSelect(recurrence.value as RecurrenceType)} onClick={() =>
handleSelect(
recurrence.value as RecurrenceType
)
}
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full first:rounded-t-md last:rounded-b-md" className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full first:rounded-t-md last:rounded-b-md"
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">
{recurrence.icon} <span>{recurrence.label}</span> {recurrence.icon}{' '}
<span>{recurrence.label}</span>
</span> </span>
</button> </button>
))} ))}

View file

@ -22,10 +22,15 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
options, options,
placeholder = 'Select option', placeholder = 'Select option',
disabled = false, disabled = false,
className = '' className = '',
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false }); const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -38,21 +43,26 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
const spaceAbove = rect.top; const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight; const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({ setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8, top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left, left: rect.left,
width: rect.width, width: rect.width,
openUpward openUpward,
}); });
} }
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && if (
menuRef.current && !menuRef.current.contains(event.target as Node)) { dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
@ -79,14 +89,19 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
}; };
}, [isOpen]); }, [isOpen]);
const selectedOption = options.find(option => option.value === value); const selectedOption = options.find((option) => option.value === value);
return ( return (
<div ref={dropdownRef} className={`relative inline-block text-left w-full ${className}`}> <div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
<button <button
type="button" type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${ className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50 dark:hover:bg-gray-800' disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`} }`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@ -98,10 +113,13 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
<span className="truncate"> <span className="truncate">
{selectedOption ? selectedOption.label : placeholder} {selectedOption ? selectedOption.label : placeholder}
</span> </span>
<ChevronDownIcon className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> <ChevronDownIcon
className={`w-5 h-5 text-gray-500 dark:text-gray-300 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button> </button>
{isOpen && createPortal( {isOpen &&
createPortal(
<div <div
ref={menuRef} ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-48 overflow-y-auto recurrence-dropdown-menu" className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-48 overflow-y-auto recurrence-dropdown-menu"
@ -129,7 +147,9 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
> >
<span>{option.label}</span> <span>{option.label}</span>
{option.value === value && ( {option.value === value && (
<span className="text-blue-600 dark:text-blue-400"></span> <span className="text-blue-600 dark:text-blue-400">
</span>
)} )}
</button> </button>
))} ))}

View file

@ -1,5 +1,11 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'; import {
ChevronDownIcon,
MinusIcon,
ClockIcon,
CheckCircleIcon,
ArchiveBoxIcon,
} from '@heroicons/react/24/outline';
import { StatusType } from '../../entities/Task'; import { StatusType } from '../../entities/Task';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -12,10 +18,34 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const statuses = [ 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: 'not_started',
{ value: 'done', label: t('status.done', 'Done'), icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, label: t('status.notStarted', 'Not Started'),
{ value: 'archived', label: t('status.archived', 'Archived'), icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }, 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 [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -25,7 +55,10 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
}; };
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false); setIsOpen(false);
} }
}; };
@ -47,10 +80,13 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
}; };
}, [isOpen]); }, [isOpen]);
const selectedStatus = statuses.find(s => s.value === value); const selectedStatus = statuses.find((s) => s.value === value);
return ( return (
<div ref={dropdownRef} className="relative inline-block text-left w-full"> <div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button <button
type="button" type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none" className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
@ -58,7 +94,11 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">
{selectedStatus ? selectedStatus.icon : ''} {selectedStatus ? selectedStatus.icon : ''}
<span>{selectedStatus ? selectedStatus.label : 'Select Status'}</span> <span>
{selectedStatus
? selectedStatus.label
: 'Select Status'}
</span>
</span> </span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" /> <ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button> </button>
@ -68,7 +108,9 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
{statuses.map((status) => ( {statuses.map((status) => (
<button <button
key={status.value} key={status.value}
onClick={() => handleSelect(status.value as StatusType)} onClick={() =>
handleSelect(status.value as StatusType)
}
className="flex items-center justify-between space-x-2 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full" className="flex items-center justify-between space-x-2 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
> >
<span className="flex items-center space-x-2"> <span className="flex items-center space-x-2">

View file

@ -7,9 +7,7 @@ interface SwitchProps {
const Switch: React.FC<SwitchProps> = ({ isChecked, onToggle }) => { const Switch: React.FC<SwitchProps> = ({ isChecked, onToggle }) => {
return ( return (
<div <div className="flex items-center space-x-2">
className="flex items-center space-x-2"
>
<div <div
className={`w-12 h-6 flex items-center rounded-full p-1 cursor-pointer transition-all duration-300 ${ className={`w-12 h-6 flex items-center rounded-full p-1 cursor-pointer transition-all duration-300 ${
isChecked ? 'bg-blue-600' : 'bg-gray-300' isChecked ? 'bg-blue-600' : 'bg-gray-300'

View file

@ -7,15 +7,22 @@ interface ToastContextProps {
const ToastContext = createContext<ToastContextProps | undefined>(undefined); const ToastContext = createContext<ToastContextProps | undefined>(undefined);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
const [toastMessage, setToastMessage] = useState<string | React.ReactNode | null>(null); children,
}) => {
const [toastMessage, setToastMessage] = useState<
string | React.ReactNode | null
>(null);
const [toastType, setToastType] = useState<'success' | 'error'>('success'); const [toastType, setToastType] = useState<'success' | 'error'>('success');
const showSuccessToast = useCallback((message: string | React.ReactNode) => { const showSuccessToast = useCallback(
(message: string | React.ReactNode) => {
setToastMessage(message); setToastMessage(message);
setToastType('success'); setToastType('success');
setTimeout(() => setToastMessage(null), 4000); setTimeout(() => setToastMessage(null), 4000);
}, []); },
[]
);
const showErrorToast = useCallback((message: string | React.ReactNode) => { const showErrorToast = useCallback((message: string | React.ReactNode) => {
setToastMessage(message); setToastMessage(message);
@ -26,7 +33,13 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
return ( return (
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}> <ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
{children} {children}
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />} {toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={() => setToastMessage(null)}
/>
)}
</ToastContext.Provider> </ToastContext.Provider>
); );
}; };
@ -39,7 +52,11 @@ export const useToast = () => {
return context; return context;
}; };
const Toast: React.FC<{ message: string | React.ReactNode; type: 'success' | 'error'; onClose: () => void }> = ({ message, type, onClose }) => { const Toast: React.FC<{
message: string | React.ReactNode;
type: 'success' | 'error';
onClose: () => void;
}> = ({ message, type, onClose }) => {
return ( return (
<div <div
className={`fixed top-20 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${ className={`fixed top-20 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${
@ -48,7 +65,10 @@ const Toast: React.FC<{ message: string | React.ReactNode; type: 'success' | 'er
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-1">{message}</div> <div className="flex-1">{message}</div>
<button onClick={onClose} className="ml-4 text-xl leading-none hover:opacity-75"> <button
onClick={onClose}
className="ml-4 text-xl leading-none hover:opacity-75"
>
&times; &times;
</button> </button>
</div> </div>

View file

@ -15,7 +15,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
label, label,
description, description,
disabled = false, disabled = false,
className = '' className = '',
}) => { }) => {
const handleToggle = () => { const handleToggle = () => {
if (!disabled) { if (!disabled) {
@ -33,7 +33,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${checked ${
checked
? 'bg-blue-600 dark:bg-blue-500' ? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-600' : 'bg-gray-200 dark:bg-gray-600'
} }
@ -64,11 +65,13 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
{label} {label}
</label> </label>
{description && ( {description && (
<p className={`text-xs mt-1 ${ <p
className={`text-xs mt-1 ${
disabled disabled
? 'text-gray-400 dark:text-gray-500' ? 'text-gray-400 dark:text-gray-500'
: 'text-gray-500 dark:text-gray-400' : 'text-gray-500 dark:text-gray-400'
}`}> }`}
>
{description} {description}
</p> </p>
)} )}

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { extractTitleFromText, UrlTitleResult } from '../../utils/urlService'; import { extractTitleFromText, UrlTitleResult } from '../../utils/urlService';
import { LinkIcon, XMarkIcon, PhotoIcon } from '@heroicons/react/24/outline'; import { XMarkIcon, PhotoIcon } from '@heroicons/react/24/outline';
interface UrlPreviewProps { interface UrlPreviewProps {
text: string; text: string;
@ -58,7 +58,9 @@ const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600"> <div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
<span className="text-sm text-gray-600 dark:text-gray-300">Loading preview...</span> <span className="text-sm text-gray-600 dark:text-gray-300">
Loading preview...
</span>
</div> </div>
</div> </div>
); );
@ -94,21 +96,27 @@ const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
)} )}
</div> </div>
<div className="flex-1 min-w-0 pr-6"> <div className="flex-1 min-w-0 pr-6">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100" style={{ <div
className="text-sm font-medium text-gray-900 dark:text-gray-100"
style={{
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 2, WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden' overflow: 'hidden',
}}> }}
>
{preview.title || 'Untitled'} {preview.title || 'Untitled'}
</div> </div>
{preview.description && ( {preview.description && (
<div className="text-xs text-gray-600 dark:text-gray-300 mt-1" style={{ <div
className="text-xs text-gray-600 dark:text-gray-300 mt-1"
style={{
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 2, WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical', WebkitBoxOrient: 'vertical',
overflow: 'hidden' overflow: 'hidden',
}}> }}
>
{preview.description} {preview.description}
</div> </div>
)} )}

View file

@ -50,7 +50,7 @@ const Sidebar: React.FC<SidebarProps> = ({
setIsDropdownOpen(!isDropdownOpen); setIsDropdownOpen(!isDropdownOpen);
}; };
const handleNavClick = (path: string, title: string, icon: JSX.Element) => { const handleNavClick = (path: string, title: string) => {
navigate(path, { state: { title } }); navigate(path, { state: { title } });
if (window.innerWidth < 1024) { if (window.innerWidth < 1024) {
setIsSidebarOpen(false); setIsSidebarOpen(false);

View file

@ -1,8 +1,8 @@
import React from "react"; import React from 'react';
import { Squares2X2Icon, PlusCircleIcon } from "@heroicons/react/24/outline"; import { Squares2X2Icon, PlusCircleIcon } from '@heroicons/react/24/outline';
import { Location } from "react-router-dom"; import { Location } from 'react-router-dom';
import { Area } from "../../entities/Area"; import { Area } from '../../entities/Area';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
interface SidebarAreasProps { interface SidebarAreasProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void; handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
@ -20,8 +20,8 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const isActiveArea = (path: string) => { const isActiveArea = (path: string) => {
return location.pathname === path return location.pathname === path
? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white" ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: "text-gray-700 dark:text-gray-300"; : 'text-gray-700 dark:text-gray-300';
}; };
return ( return (
@ -30,12 +30,12 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
{/* "AREAS" Title with Add Button */} {/* "AREAS" Title with Add Button */}
<li <li
className={`flex justify-between items-center px-4 py-2 rounded-md uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveArea( className={`flex justify-between items-center px-4 py-2 rounded-md uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveArea(
"/areas" '/areas'
)}`} )}`}
onClick={() => onClick={() =>
handleNavClick( handleNavClick(
"/areas", '/areas',
"Areas", 'Areas',
<Squares2X2Icon className="h-5 w-5 mr-2" /> <Squares2X2Icon className="h-5 w-5 mr-2" />
) )
} }

View file

@ -33,7 +33,6 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
isDarkMode, isDarkMode,
toggleDarkMode, toggleDarkMode,
isSidebarOpen, isSidebarOpen,
setIsSidebarOpen,
openTaskModal, openTaskModal,
openProjectModal, openProjectModal,
openNoteModal, openNoteModal,
@ -51,7 +50,10 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
// Handle click outside to close dropdown // Handle click outside to close dropdown
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false); setIsDropdownOpen(false);
} }
}; };
@ -135,18 +137,51 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
}; };
const dropdownItems = [ const dropdownItems = [
{ label: 'Inbox', translationKey: 'dropdown.inbox', icon: <InboxIcon className="h-5 w-5 mr-2" />, shortcut: '⌃I' }, {
{ label: 'Task', translationKey: 'dropdown.task', icon: <CheckIcon className="h-5 w-5 mr-2" />, shortcut: '⌃T' }, label: 'Inbox',
{ label: 'Project', translationKey: 'dropdown.project', icon: <FolderIcon className="h-5 w-5 mr-2" />, shortcut: '⌃P' }, translationKey: 'dropdown.inbox',
{ label: 'Note', translationKey: 'dropdown.note', icon: <BookOpenIcon className="h-5 w-5 mr-2" />, shortcut: '⌃N' }, icon: <InboxIcon className="h-5 w-5 mr-2" />,
{ label: 'Area', translationKey: 'dropdown.area', icon: <Squares2X2Icon className="h-5 w-5 mr-2" />, shortcut: '⌃A' }, shortcut: '⌃I',
{ label: 'Tag', translationKey: 'dropdown.tag', icon: <TagIcon className="h-5 w-5 mr-2" />, shortcut: '⌃G' }, },
{
label: 'Task',
translationKey: 'dropdown.task',
icon: <CheckIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃T',
},
{
label: 'Project',
translationKey: 'dropdown.project',
icon: <FolderIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃P',
},
{
label: 'Note',
translationKey: 'dropdown.note',
icon: <BookOpenIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃N',
},
{
label: 'Area',
translationKey: 'dropdown.area',
icon: <Squares2X2Icon className="h-5 w-5 mr-2" />,
shortcut: '⌃A',
},
{
label: 'Tag',
translationKey: 'dropdown.tag',
icon: <TagIcon className="h-5 w-5 mr-2" />,
shortcut: '⌃G',
},
]; ];
return ( return (
<div className="mt-auto p-3"> <div className="mt-auto p-3">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3"> <div className="border-t border-gray-200 dark:border-gray-700 pt-3">
{isSidebarOpen && ( {isSidebarOpen && (
<div className="flex items-center justify-between" ref={dropdownRef}> <div
className="flex items-center justify-between"
ref={dropdownRef}
>
{/* Plus Icon Button - Left */} {/* Plus Icon Button - Left */}
<div className="relative"> <div className="relative">
<button <button
@ -164,21 +199,35 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
{isDropdownOpen && ( {isDropdownOpen && (
<div className="absolute bottom-full left-0 mb-2 w-52 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"> <div className="absolute bottom-full left-0 mb-2 w-52 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div className="py-1"> <div className="py-1">
{dropdownItems.map(({ label, translationKey, icon, shortcut }) => ( {dropdownItems.map(
({
label,
translationKey,
icon,
shortcut,
}) => (
<button <button
key={label} key={label}
onClick={() => handleDropdownSelect(label)} onClick={() =>
handleDropdownSelect(
label
)
}
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between transition-colors duration-150" className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between transition-colors duration-150"
> >
<div className="flex items-center"> <div className="flex items-center">
{icon} {icon}
{t(translationKey, label)} {t(
translationKey,
label
)}
</div> </div>
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded opacity-60"> <span className="text-xs text-gray-500 dark:text-gray-400 font-mono bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded opacity-60">
{shortcut} {shortcut}
</span> </span>
</button> </button>
))} )
)}
</div> </div>
</div> </div>
)} )}

View file

@ -3,9 +3,7 @@ import { Location } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
CalendarDaysIcon, CalendarDaysIcon,
ArrowRightCircleIcon,
InboxIcon, InboxIcon,
CheckCircleIcon,
ListBulletIcon, ListBulletIcon,
ClockIcon, ClockIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
@ -18,7 +16,10 @@ interface SidebarNavProps {
isDarkMode: boolean; isDarkMode: boolean;
} }
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) => { const SidebarNav: React.FC<SidebarNavProps> = ({
handleNavClick,
location,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const store = useStore(); const store = useStore();
@ -31,10 +32,28 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
}, []); }, []);
const navLinks = [ 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: '/inbox',
{ path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: <ClockIcon className="h-5 w-5" />, query: 'type=upcoming' }, title: t('sidebar.inbox', 'Inbox'),
{ path: '/tasks', title: t('sidebar.allTasks', 'All Tasks'), icon: <ListBulletIcon className="h-5 w-5" /> }, 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: <ClockIcon className="h-5 w-5" />,
query: 'type=upcoming',
},
{
path: '/tasks',
title: t('sidebar.allTasks', 'All Tasks'),
icon: <ListBulletIcon className="h-5 w-5" />,
},
]; ];
const isActive = (path: string, query?: string) => { const isActive = (path: string, query?: string) => {
@ -48,7 +67,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
// Regular case for /tasks with query params // Regular case for /tasks with query params
const isPathMatch = location.pathname === '/tasks'; const isPathMatch = location.pathname === '/tasks';
const isQueryMatch = query ? location.search.includes(query) : location.search === ''; const isQueryMatch = query
? location.search.includes(query)
: location.search === '';
return isPathMatch && isQueryMatch return isPathMatch && isQueryMatch
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white' ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300'; : 'text-gray-700 dark:text-gray-300';
@ -60,7 +81,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
<React.Fragment key={link.path}> <React.Fragment key={link.path}>
<li> <li>
<button <button
onClick={() => handleNavClick(link.path, link.title, link.icon)} onClick={() =>
handleNavClick(link.path, link.title, link.icon)
}
className={`w-full text-left px-4 py-1 flex items-center justify-between rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive( className={`w-full text-left px-4 py-1 flex items-center justify-between rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
link.path, link.path,
link.query link.query
@ -72,7 +95,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
</div> </div>
{link.path === '/inbox' && inboxItemsCount > 0 && ( {link.path === '/inbox' && inboxItemsCount > 0 && (
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white bg-blue-500 rounded-full"> <span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white bg-blue-500 rounded-full">
{inboxItemsCount > 99 ? '99+' : inboxItemsCount} {inboxItemsCount > 99
? '99+'
: inboxItemsCount}
</span> </span>
)} )}
</button> </button>

View file

@ -31,7 +31,13 @@ const SidebarNotes: React.FC<SidebarNotesProps> = ({
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveNote( className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveNote(
'/notes' '/notes'
)}`} )}`}
onClick={() => handleNavClick('/notes', 'Notes', <BookOpenIcon className="h-5 w-5 mr-2" />)} onClick={() =>
handleNavClick(
'/notes',
'Notes',
<BookOpenIcon className="h-5 w-5 mr-2" />
)
}
> >
<span className="flex items-center"> <span className="flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" /> <BookOpenIcon className="h-5 w-5 mr-2" />

View file

@ -29,7 +29,13 @@ const SidebarProjects: React.FC<SidebarProjectsProps> = ({
className={`flex justify-between items-center px-4 py-2 uppercase rounded-md text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveProject( className={`flex justify-between items-center px-4 py-2 uppercase rounded-md text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveProject(
'/projects' '/projects'
)}`} )}`}
onClick={() => handleNavClick('/projects?active=true', 'Projects', <FolderIcon className="h-5 w-5 mr-2" />)} onClick={() =>
handleNavClick(
'/projects?active=true',
'Projects',
<FolderIcon className="h-5 w-5 mr-2" />
)
}
> >
<span className="flex items-center"> <span className="flex items-center">
<FolderIcon className="h-5 w-5 mr-2" /> <FolderIcon className="h-5 w-5 mr-2" />

View file

@ -33,7 +33,13 @@ const SidebarTags: React.FC<SidebarTagsProps> = ({
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveTag( className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveTag(
'/tags' '/tags'
)}`} )}`}
onClick={() => handleNavClick('/tags', 'Tags', <TagIcon className="h-5 w-5 mr-2" />)} onClick={() =>
handleNavClick(
'/tags',
'Tags',
<TagIcon className="h-5 w-5 mr-2" />
)
}
> >
<span className="flex items-center"> <span className="flex items-center">
<TagIcon className="h-5 w-5 mr-2" /> <TagIcon className="h-5 w-5 mr-2" />

View file

@ -7,7 +7,7 @@ import {
BookOpenIcon, BookOpenIcon,
FolderIcon, FolderIcon,
PencilSquareIcon, PencilSquareIcon,
TrashIcon TrashIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import { Task } from '../../entities/Task'; import { Task } from '../../entities/Task';
import { Note } from '../../entities/Note'; import { Note } from '../../entities/Note';
@ -34,24 +34,34 @@ const TagDetails: React.FC = () => {
// State for ProjectItem components // State for ProjectItem components
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null); const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null); const [, setProjectToDelete] = useState<Project | null>(
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false); null
);
const [, setIsConfirmDialogOpen] =
useState<boolean>(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const fetchTagData = async () => { const fetchTagData = async () => {
try { try {
// First fetch tag details // First fetch tag details
const tagResponse = await fetch(`/api/tag/${encodeURIComponent(identifier!)}`); const tagResponse = await fetch(
`/api/tag/${encodeURIComponent(identifier!)}`
);
if (tagResponse.ok) { if (tagResponse.ok) {
const tagData = await tagResponse.json(); const tagData = await tagResponse.json();
setTag(tagData); setTag(tagData);
// Now fetch entities that have this tag using the tag name // Now fetch entities that have this tag using the tag name
const [tasksResponse, notesResponse, projectsResponse] = await Promise.all([ const [tasksResponse, notesResponse, projectsResponse] =
fetch(`/api/tasks?tag=${encodeURIComponent(tagData.name)}`), await Promise.all([
fetch(`/api/notes?tag=${encodeURIComponent(tagData.name)}`), fetch(
fetch(`/api/projects`) // Projects API doesn't support tag filtering yet `/api/tasks?tag=${encodeURIComponent(tagData.name)}`
),
fetch(
`/api/notes?tag=${encodeURIComponent(tagData.name)}`
),
fetch(`/api/projects`), // Projects API doesn't support tag filtering yet
]); ]);
if (tasksResponse.ok) { if (tasksResponse.ok) {
@ -67,9 +77,14 @@ const TagDetails: React.FC = () => {
if (projectsResponse.ok) { if (projectsResponse.ok) {
const projectsData = await projectsResponse.json(); const projectsData = await projectsResponse.json();
// Filter projects client-side since API doesn't support tag filtering // Filter projects client-side since API doesn't support tag filtering
const allProjects = projectsData.projects || projectsData || []; const allProjects =
const filteredProjects = allProjects.filter((project: any) => projectsData.projects || projectsData || [];
project.tags && project.tags.some((tag: any) => tag.name === tagData.name) const filteredProjects = allProjects.filter(
(project: any) =>
project.tags &&
project.tags.some(
(tag: any) => tag.name === tagData.name
)
); );
setProjects(filteredProjects); setProjects(filteredProjects);
} }
@ -77,7 +92,7 @@ const TagDetails: React.FC = () => {
const tagError = await tagResponse.json(); const tagError = await tagResponse.json();
setError(tagError.error || 'Failed to fetch tag.'); setError(tagError.error || 'Failed to fetch tag.');
} }
} catch (err) { } catch {
setError(t('tags.error')); setError(t('tags.error'));
} finally { } finally {
setLoading(false); setLoading(false);
@ -90,8 +105,8 @@ const TagDetails: React.FC = () => {
const handleTaskUpdate = async (updatedTask: Task) => { const handleTaskUpdate = async (updatedTask: Task) => {
try { try {
const response = await fetch(`/api/task/${updatedTask.id}`, { const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH", method: 'PATCH',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask), body: JSON.stringify(updatedTask),
}); });
@ -103,65 +118,73 @@ const TagDetails: React.FC = () => {
); );
} }
} catch (error) { } catch (error) {
console.error("Error updating task:", error); console.error('Error updating task:', error);
} }
}; };
const handleTaskDelete = async (taskId: number) => { const handleTaskDelete = async (taskId: number) => {
try { try {
const response = await fetch(`/api/task/${taskId}`, { const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE", method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId)); setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} }
} catch (error) { } catch (error) {
console.error("Error deleting task:", error); console.error('Error deleting task:', error);
} }
}; };
const handleToggleToday = async (taskId: number) => { const handleToggleToday = async (taskId: number) => {
try { try {
const response = await fetch(`/api/task/${taskId}/today`, { const response = await fetch(`/api/task/${taskId}/today`, {
method: "PATCH", method: 'PATCH',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
}); });
if (response.ok) { if (response.ok) {
const updatedTask = await response.json(); const updatedTask = await response.json();
setTasks((prevTasks) => setTasks((prevTasks) =>
prevTasks.map((task) => prevTasks.map((task) =>
task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count } : task task.id === taskId
? {
...task,
today: updatedTask.today,
today_move_count:
updatedTask.today_move_count,
}
: task
) )
); );
} }
} catch (error) { } catch (error) {
console.error("Error toggling today status:", error); console.error('Error toggling today status:', error);
} }
}; };
// Project handlers // Project handlers
const handleEditProject = (project: Project) => { const handleEditProject = (project: Project) => {
// For now, just log - could add modal later // For now, just log - could add modal later
console.log("Edit project:", project); console.log('Edit project:', project);
}; };
const getCompletionPercentage = (project: Project) => { const getCompletionPercentage = (project: Project) => {
return (project as any).completion_percentage || 0; return (project as any).completion_percentage || 0;
}; };
const getPriorityStyles = (priority: string) => { const getPriorityStyles = (priority: string) => {
switch (priority) { switch (priority) {
case "low": case 'low':
return { color: "bg-green-500" }; return { color: 'bg-green-500' };
case "medium": case 'medium':
return { color: "bg-yellow-500" }; return { color: 'bg-yellow-500' };
case "high": case 'high':
return { color: "bg-red-500" }; return { color: 'bg-red-500' };
default: default:
return { color: "bg-gray-500" }; return { color: 'bg-gray-500' };
} }
}; };
@ -180,7 +203,11 @@ const TagDetails: React.FC = () => {
} }
if (!tag) { if (!tag) {
return <div className="text-gray-700 dark:text-gray-300 p-4">{t('tags.notFound')}</div>; return (
<div className="text-gray-700 dark:text-gray-300 p-4">
{t('tags.notFound')}
</div>
);
} }
return ( return (
@ -200,8 +227,12 @@ const TagDetails: React.FC = () => {
<div className="flex items-center"> <div className="flex items-center">
<CheckIcon className="h-8 w-8 text-blue-500 mr-3" /> <CheckIcon className="h-8 w-8 text-blue-500 mr-3" />
<div> <div>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{tasks.length}</p> <p className="text-2xl font-semibold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-gray-400">{t('tasks.title')}</p> {tasks.length}
</p>
<p className="text-gray-600 dark:text-gray-400">
{t('tasks.title')}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -209,8 +240,12 @@ const TagDetails: React.FC = () => {
<div className="flex items-center"> <div className="flex items-center">
<BookOpenIcon className="h-8 w-8 text-green-500 mr-3" /> <BookOpenIcon className="h-8 w-8 text-green-500 mr-3" />
<div> <div>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{notes.length}</p> <p className="text-2xl font-semibold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-gray-400">{t('notes.title')}</p> {notes.length}
</p>
<p className="text-gray-600 dark:text-gray-400">
{t('notes.title')}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -218,8 +253,12 @@ const TagDetails: React.FC = () => {
<div className="flex items-center"> <div className="flex items-center">
<FolderIcon className="h-8 w-8 text-purple-500 mr-3" /> <FolderIcon className="h-8 w-8 text-purple-500 mr-3" />
<div> <div>
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{projects.length}</p> <p className="text-2xl font-semibold text-gray-900 dark:text-white">
<p className="text-gray-600 dark:text-gray-400">{t('projects.title')}</p> {projects.length}
</p>
<p className="text-gray-600 dark:text-gray-400">
{t('projects.title')}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -255,7 +294,9 @@ const TagDetails: React.FC = () => {
<li <li
key={note.id} key={note.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center" className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
onMouseEnter={() => setHoveredNoteId(note.id || null)} onMouseEnter={() =>
setHoveredNoteId(note.id || null)
}
onMouseLeave={() => setHoveredNoteId(null)} onMouseLeave={() => setHoveredNoteId(null)}
> >
<div className="flex-grow overflow-hidden pr-4"> <div className="flex-grow overflow-hidden pr-4">
@ -267,14 +308,23 @@ const TagDetails: React.FC = () => {
{note.title} {note.title}
</Link> </Link>
{/* Tags */} {/* Tags */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && ( {((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length > 0)) && (
<> <>
{(note.tags || note.Tags || []).map((noteTag) => ( {(
note.tags ||
note.Tags ||
[]
).map((noteTag) => (
<button <button
key={noteTag.id} key={noteTag.id}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
navigate(`/tag/${encodeURIComponent(noteTag.name)}`); navigate(
`/tag/${encodeURIComponent(noteTag.name)}`
);
}} }}
className="flex items-center space-x-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" className="flex items-center space-x-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
> >
@ -290,7 +340,9 @@ const TagDetails: React.FC = () => {
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={() => console.log("Edit note:", note)} onClick={() =>
console.log('Edit note:', note)
}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`} className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Edit ${note.title}`} aria-label={`Edit ${note.title}`}
title={`Edit ${note.title}`} title={`Edit ${note.title}`}
@ -298,7 +350,12 @@ const TagDetails: React.FC = () => {
<PencilSquareIcon className="h-5 w-5" /> <PencilSquareIcon className="h-5 w-5" />
</button> </button>
<button <button
onClick={() => console.log("Delete note:", note)} onClick={() =>
console.log(
'Delete note:',
note
)
}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`} className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Delete ${note.title}`} aria-label={`Delete ${note.title}`}
title={`Delete ${note.title}`} title={`Delete ${note.title}`}
@ -321,19 +378,25 @@ const TagDetails: React.FC = () => {
</h3> </h3>
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
{projects.map((project) => { {projects.map((project) => {
const { color } = getPriorityStyles(project.priority || "low"); const { color } = getPriorityStyles(
project.priority || 'low'
);
return ( return (
<ProjectItem <ProjectItem
key={project.id} key={project.id}
project={project} project={project}
viewMode="list" viewMode="list"
color={color} color={color}
getCompletionPercentage={() => getCompletionPercentage(project)} getCompletionPercentage={() =>
getCompletionPercentage(project)
}
activeDropdown={activeDropdown} activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown} setActiveDropdown={setActiveDropdown}
handleEditProject={handleEditProject} handleEditProject={handleEditProject}
setProjectToDelete={setProjectToDelete} setProjectToDelete={setProjectToDelete}
setIsConfirmDialogOpen={setIsConfirmDialogOpen} setIsConfirmDialogOpen={
setIsConfirmDialogOpen
}
/> />
); );
})} })}
@ -342,11 +405,16 @@ const TagDetails: React.FC = () => {
)} )}
{/* Empty State */} {/* Empty State */}
{tasks.length === 0 && notes.length === 0 && projects.length === 0 && ( {tasks.length === 0 &&
notes.length === 0 &&
projects.length === 0 && (
<div className="text-center py-8"> <div className="text-center py-8">
<TagIcon className="h-16 w-16 text-gray-400 mx-auto mb-4" /> <TagIcon className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400 text-lg"> <p className="text-gray-600 dark:text-gray-400 text-lg">
{t('tags.noItemsWithTag', `No items found with the tag "${tag.name}"`)} {t(
'tags.noItemsWithTag',
`No items found with the tag "${tag.name}"`
)}
</p> </p>
</div> </div>
)} )}

View file

@ -8,7 +8,11 @@ interface TagInputProps {
availableTags: Tag[]; availableTags: Tag[];
} }
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags }) => { const TagInput: React.FC<TagInputProps> = ({
initialTags,
onTagsChange,
availableTags,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(initialTags || []); const [tags, setTags] = useState<string[]>(initialTags || []);
@ -39,7 +43,8 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
return; return;
} }
const filtered = availableTags.filter(tag => const filtered = availableTags.filter(
(tag) =>
tag.name.toLowerCase().includes(inputValue.toLowerCase()) && tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
!tags.includes(tag.name) !tags.includes(tag.name)
); );
@ -77,13 +82,18 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
setHighlightedIndex(prev => (prev < filteredTags.length - 1 ? prev + 1 : prev)); setHighlightedIndex((prev) =>
prev < filteredTags.length - 1 ? prev + 1 : prev
);
} else if (event.key === 'ArrowUp') { } else if (event.key === 'ArrowUp') {
event.preventDefault(); event.preventDefault();
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : prev)); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredTags.length) { if (
highlightedIndex >= 0 &&
highlightedIndex < filteredTags.length
) {
selectTag(filteredTags[highlightedIndex].name); selectTag(filteredTags[highlightedIndex].name);
} else if (inputValue.trim()) { } else if (inputValue.trim()) {
addNewTag(inputValue.trim()); addNewTag(inputValue.trim());
@ -186,7 +196,9 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
type="button" type="button"
onClick={() => selectTag(tag.name)} onClick={() => selectTag(tag.name)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-200 dark:hover:bg-gray-700 ${
highlightedIndex === index ? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300' highlightedIndex === index
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'
}`} }`}
onMouseEnter={() => setHighlightedIndex(index)} onMouseEnter={() => setHighlightedIndex(index)}
onMouseLeave={() => setHighlightedIndex(-1)} onMouseLeave={() => setHighlightedIndex(-1)}
@ -197,7 +209,10 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
<> <>
{inputValue.length > 0 && ( {inputValue.length > 0 && (
<span className="font-semibold"> <span className="font-semibold">
{tag.name.substring(0, inputValue.length)} {tag.name.substring(
0,
inputValue.length
)}
</span> </span>
)} )}
{tag.name.substring(inputValue.length)} {tag.name.substring(inputValue.length)}
@ -215,7 +230,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700" className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
role="option" role="option"
> >
+ Create "{inputValue.trim()}" + Create &quot;{inputValue.trim()}&quot;
</button> </button>
)} )}
</div> </div>

View file

@ -83,7 +83,9 @@ const TagModal: React.FC<TagModalProps> = ({
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formData.name.trim()) { if (!formData.name.trim()) {
showErrorToast(t('errors.tagNameRequired', 'Tag name is required.')); showErrorToast(
t('errors.tagNameRequired', 'Tag name is required.')
);
return; return;
} }
@ -92,12 +94,16 @@ const TagModal: React.FC<TagModalProps> = ({
try { try {
await onSave(formData); // Wait for the save operation to complete await onSave(formData); // Wait for the save operation to complete
if (tag) { if (tag) {
showSuccessToast(t('success.tagUpdated', 'Tag updated successfully!')); showSuccessToast(
t('success.tagUpdated', 'Tag updated successfully!')
);
} else { } else {
showSuccessToast(t('success.tagCreated', 'Tag created successfully!')); showSuccessToast(
t('success.tagCreated', 'Tag created successfully!')
);
} }
handleClose(); handleClose();
} catch (err) { } catch {
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.')); showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -116,10 +122,14 @@ const TagModal: React.FC<TagModalProps> = ({
if (formData.id && onDelete) { if (formData.id && onDelete) {
try { try {
await onDelete(formData.id); await onDelete(formData.id);
showSuccessToast(t('success.tagDeleted', 'Tag deleted successfully!')); showSuccessToast(
t('success.tagDeleted', 'Tag deleted successfully!')
);
handleClose(); handleClose();
} catch (err) { } catch {
showErrorToast(t('errors.failedToDeleteTag', 'Failed to delete tag.')); showErrorToast(
t('errors.failedToDeleteTag', 'Failed to delete tag.')
);
} }
} }
}; };
@ -154,7 +164,10 @@ const TagModal: React.FC<TagModalProps> = ({
onChange={handleChange} onChange={handleChange}
required required
className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2" className="block w-full text-xl font-semibold bg-transparent text-black dark:text-white border-none focus:outline-none shadow-sm py-2"
placeholder={t('forms.tagNamePlaceholder', 'Enter tag name')} placeholder={t(
'forms.tagNamePlaceholder',
'Enter tag name'
)}
/> />
</div> </div>
</fieldset> </fieldset>
@ -165,7 +178,7 @@ const TagModal: React.FC<TagModalProps> = ({
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between"> <div className="flex-shrink-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 px-3 py-2 flex items-center justify-between">
{/* Left side: Delete and Cancel */} {/* Left side: Delete and Cancel */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{(tag && tag.id && onDelete) && ( {tag && tag.id && onDelete && (
<button <button
type="button" type="button"
onClick={handleDeleteTag} onClick={handleDeleteTag}
@ -190,7 +203,9 @@ const TagModal: React.FC<TagModalProps> = ({
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
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 text-sm ${ 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 text-sm ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : '' isSubmitting
? 'opacity-50 cursor-not-allowed'
: ''
}`} }`}
> >
{isSubmitting {isSubmitting

View file

@ -1,33 +1,41 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { TrashIcon, TagIcon, MagnifyingGlassIcon, CheckIcon, BookOpenIcon, FolderIcon, PencilSquareIcon } from '@heroicons/react/24/solid'; import {
TrashIcon,
TagIcon,
MagnifyingGlassIcon,
CheckIcon,
BookOpenIcon,
FolderIcon,
PencilSquareIcon,
} from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog'; import ConfirmDialog from './Shared/ConfirmDialog';
import TagModal from './Tag/TagModal'; import TagModal from './Tag/TagModal';
import { Tag } from '../entities/Tag'; import { Tag } from '../entities/Tag';
import { deleteTag as apiDeleteTag, createTag, updateTag } from '../utils/tagsService'; import {
deleteTag as apiDeleteTag,
createTag,
updateTag,
} from '../utils/tagsService';
import { useStore } from '../store/useStore'; import { useStore } from '../store/useStore';
const Tags: React.FC = () => { const Tags: React.FC = () => {
const { const {
tagsStore: { tagsStore: { tags, setTags, isLoading, isError },
tags,
setTags,
isLoading,
setLoading,
isError,
setError
}
} = useStore(); } = useStore();
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null); const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
const [searchQuery, setSearchQuery] = useState<string>(''); const [searchQuery, setSearchQuery] = useState<string>('');
const [hoveredTagId, setHoveredTagId] = useState<number | null>(null); const [hoveredTagId, setHoveredTagId] = useState<number | null>(null);
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false); const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null); const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [tagMetrics, setTagMetrics] = useState<Record<string, {tasks: number, notes: number, projects: number}>>({}); const [tagMetrics, setTagMetrics] = useState<
Record<string, { tasks: number; notes: number; projects: number }>
>({});
const [metricsLoaded, setMetricsLoaded] = useState<boolean>(false); const [metricsLoaded, setMetricsLoaded] = useState<boolean>(false);
const [cachedProjects, setCachedProjects] = useState<any[]>([]); const [, setCachedProjects] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
const loadMetrics = async () => { const loadMetrics = async () => {
@ -38,10 +46,11 @@ const Tags: React.FC = () => {
try { try {
// Load all data at once for better performance // Load all data at once for better performance
const [projectsResponse, tasksResponse, notesResponse] = await Promise.all([ const [projectsResponse, tasksResponse, notesResponse] =
await Promise.all([
fetch('/api/projects'), fetch('/api/projects'),
fetch('/api/tasks'), fetch('/api/tasks'),
fetch('/api/notes') fetch('/api/notes'),
]); ]);
let allProjects: any[] = []; let allProjects: any[] = [];
@ -65,31 +74,46 @@ const Tags: React.FC = () => {
} }
// Calculate metrics for all tags at once // Calculate metrics for all tags at once
const metricsMap: Record<string, {tasks: number, notes: number, projects: number}> = {}; const metricsMap: Record<
string,
{ tasks: number; notes: number; projects: number }
> = {};
tags.forEach(tag => { tags.forEach((tag) => {
const tasksCount = allTasks.filter((task: any) => const tasksCount = allTasks.filter(
task.tags && task.tags.some((taskTag: any) => taskTag.name === tag.name) (task: any) =>
task.tags &&
task.tags.some(
(taskTag: any) => taskTag.name === tag.name
)
).length; ).length;
const notesCount = allNotes.filter((note: any) => const notesCount = allNotes.filter(
note.tags && note.tags.some((noteTag: any) => noteTag.name === tag.name) (note: any) =>
note.tags &&
note.tags.some(
(noteTag: any) => noteTag.name === tag.name
)
).length; ).length;
const projectsCount = allProjects.filter((project: any) => const projectsCount = allProjects.filter(
project.tags && project.tags.some((projectTag: any) => projectTag.name === tag.name) (project: any) =>
project.tags &&
project.tags.some(
(projectTag: any) =>
projectTag.name === tag.name
)
).length; ).length;
metricsMap[tag.name] = { metricsMap[tag.name] = {
tasks: tasksCount, tasks: tasksCount,
notes: notesCount, notes: notesCount,
projects: projectsCount projects: projectsCount,
}; };
}); });
setTagMetrics(metricsMap); setTagMetrics(metricsMap);
setMetricsLoaded(true); setMetricsLoaded(true);
} catch (error) { } catch (error) {
console.error('Failed to fetch metrics:', error); console.error('Failed to fetch metrics:', error);
} }
@ -98,7 +122,6 @@ const Tags: React.FC = () => {
loadMetrics(); loadMetrics();
}, [tags]); // Only run when tags change }, [tags]); // Only run when tags change
const handleDeleteTag = async () => { const handleDeleteTag = async () => {
if (!tagToDelete) return; if (!tagToDelete) return;
try { try {
@ -117,7 +140,6 @@ const Tags: React.FC = () => {
} }
}; };
const handleEditTag = (tag: Tag) => { const handleEditTag = (tag: Tag) => {
setSelectedTag(tag); setSelectedTag(tag);
setIsTagModalOpen(true); setIsTagModalOpen(true);
@ -127,7 +149,9 @@ const Tags: React.FC = () => {
try { try {
if (tagData.id) { if (tagData.id) {
await updateTag(tagData.id, tagData); await updateTag(tagData.id, tagData);
setTags(tags.map(tag => tag.id === tagData.id ? tagData : tag)); setTags(
tags.map((tag) => (tag.id === tagData.id ? tagData : tag))
);
} else { } else {
const newTag = await createTag(tagData); const newTag = await createTag(tagData);
setTags([...tags, newTag]); setTags([...tags, newTag]);
@ -154,19 +178,24 @@ const Tags: React.FC = () => {
); );
// Group tags alphabetically by first letter // Group tags alphabetically by first letter
const groupedTags = filteredTags.reduce((groups, tag) => { const groupedTags = filteredTags.reduce(
(groups, tag) => {
const firstLetter = tag.name.charAt(0).toUpperCase(); const firstLetter = tag.name.charAt(0).toUpperCase();
if (!groups[firstLetter]) { if (!groups[firstLetter]) {
groups[firstLetter] = []; groups[firstLetter] = [];
} }
groups[firstLetter].push(tag); groups[firstLetter].push(tag);
return groups; return groups;
}, {} as Record<string, typeof tags>); },
{} as Record<string, typeof tags>
);
// Sort the groups by letter and sort tags within each group // Sort the groups by letter and sort tags within each group
const sortedGroupKeys = Object.keys(groupedTags).sort(); const sortedGroupKeys = Object.keys(groupedTags).sort();
sortedGroupKeys.forEach(letter => { sortedGroupKeys.forEach((letter) => {
groupedTags[letter].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); groupedTags[letter].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
}); });
if (isLoading) { if (isLoading) {
@ -190,7 +219,9 @@ const Tags: React.FC = () => {
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" /> <TagIcon 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">Tags</h2> <h2 className="text-2xl font-light text-gray-900 dark:text-white">
Tags
</h2>
</div> </div>
</div> </div>
@ -210,7 +241,9 @@ const Tags: React.FC = () => {
{/* Tags List */} {/* Tags List */}
{filteredTags.length === 0 ? ( {filteredTags.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">No tags found.</p> <p className="text-gray-700 dark:text-gray-300">
No tags found.
</p>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{sortedGroupKeys.map((letter) => ( {sortedGroupKeys.map((letter) => (
@ -226,15 +259,30 @@ const Tags: React.FC = () => {
{/* Tags in this group */} {/* Tags in this group */}
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{groupedTags[letter].map((tag) => { {groupedTags[letter].map((tag) => {
const metrics = tagMetrics[tag.name] || { tasks: 0, notes: 0, projects: 0 }; const metrics = tagMetrics[
const hasItems = metrics.tasks > 0 || metrics.notes > 0 || metrics.projects > 0; tag.name
] || {
tasks: 0,
notes: 0,
projects: 0,
};
const hasItems =
metrics.tasks > 0 ||
metrics.notes > 0 ||
metrics.projects > 0;
return ( return (
<li <li
key={tag.id} key={tag.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4" className="bg-white dark:bg-gray-900 shadow rounded-lg p-4"
onMouseEnter={() => setHoveredTagId(tag.id || null)} onMouseEnter={() =>
onMouseLeave={() => setHoveredTagId(null)} setHoveredTagId(
tag.id || null
)
}
onMouseLeave={() =>
setHoveredTagId(null)
}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Tag Name and Metrics - inline */} {/* Tag Name and Metrics - inline */}
@ -252,24 +300,40 @@ const Tags: React.FC = () => {
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500"></div> <div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500"></div>
</div> </div>
)} )}
{metricsLoaded && hasItems && ( {metricsLoaded &&
hasItems && (
<div className="flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-400"> <div className="flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-400">
{metrics.projects > 0 && ( {metrics.projects >
0 && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<FolderIcon className="h-4 w-4 text-purple-500" /> <FolderIcon className="h-4 w-4 text-purple-500" />
<span>{metrics.projects}</span> <span>
{
metrics.projects
}
</span>
</div> </div>
)} )}
{metrics.tasks > 0 && ( {metrics.tasks >
0 && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<CheckIcon className="h-4 w-4 text-blue-500" /> <CheckIcon className="h-4 w-4 text-blue-500" />
<span>{metrics.tasks}</span> <span>
{
metrics.tasks
}
</span>
</div> </div>
)} )}
{metrics.notes > 0 && ( {metrics.notes >
0 && (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<BookOpenIcon className="h-4 w-4 text-green-500" /> <BookOpenIcon className="h-4 w-4 text-green-500" />
<span>{metrics.notes}</span> <span>
{
metrics.notes
}
</span>
</div> </div>
)} )}
</div> </div>
@ -279,7 +343,11 @@ const Tags: React.FC = () => {
{/* Action buttons */} {/* Action buttons */}
<div className="flex space-x-2 ml-2"> <div className="flex space-x-2 ml-2">
<button <button
onClick={() => handleEditTag(tag)} onClick={() =>
handleEditTag(
tag
)
}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`} className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Edit ${tag.name}`} aria-label={`Edit ${tag.name}`}
title={`Edit ${tag.name}`} title={`Edit ${tag.name}`}
@ -287,7 +355,11 @@ const Tags: React.FC = () => {
<PencilSquareIcon className="h-4 w-4" /> <PencilSquareIcon className="h-4 w-4" />
</button> </button>
<button <button
onClick={() => openConfirmDialog(tag)} onClick={() =>
openConfirmDialog(
tag
)
}
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`} className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={`Delete ${tag.name}`} aria-label={`Delete ${tag.name}`}
title={`Delete ${tag.name}`} title={`Delete ${tag.name}`}
@ -320,7 +392,9 @@ const Tags: React.FC = () => {
setTags(tags.filter((tag) => tag.id !== tagId)); setTags(tags.filter((tag) => tag.id !== tagId));
setTagMetrics((prev) => { setTagMetrics((prev) => {
const newMetrics = { ...prev }; const newMetrics = { ...prev };
const deletedTag = tags.find(t => t.id === tagId); const deletedTag = tags.find(
(t) => t.id === tagId
);
if (deletedTag) { if (deletedTag) {
delete newMetrics[deletedTag.name]; delete newMetrics[deletedTag.name];
} }

View file

@ -11,8 +11,9 @@ interface NewTaskProps {
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => { const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const [taskName, setTaskName] = useState<string>(''); const [taskName, setTaskName] = useState<string>('');
const [showNameLengthHelper, setShowNameLengthHelper] = useState(false); const [showNameLengthHelper, setShowNameLengthHelper] = useState(false);
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true); const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
const { showSuccessToast, showErrorToast } = useToast(); useState(true);
const { showErrorToast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
// Fetch task intelligence setting when component mounts // Fetch task intelligence setting when component mounts
@ -22,7 +23,10 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const enabled = await getTaskIntelligenceEnabled(); const enabled = await getTaskIntelligenceEnabled();
setTaskIntelligenceEnabled(enabled); setTaskIntelligenceEnabled(enabled);
} catch (error) { } catch (error) {
console.error('Error fetching task intelligence setting:', error); console.error(
'Error fetching task intelligence setting:',
error
);
setTaskIntelligenceEnabled(true); // Default to enabled setTaskIntelligenceEnabled(true); // Default to enabled
} }
}; };
@ -37,11 +41,15 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
// Show helper message for task name if it's too short (only if intelligence is enabled) // Show helper message for task name if it's too short (only if intelligence is enabled)
if (taskIntelligenceEnabled) { if (taskIntelligenceEnabled) {
const trimmedValue = value.trim(); const trimmedValue = value.trim();
setShowNameLengthHelper(trimmedValue.length > 0 && trimmedValue.length < 10); setShowNameLengthHelper(
trimmedValue.length > 0 && trimmedValue.length < 10
);
} }
}; };
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = async (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === 'Enter' && taskName.trim()) { if (event.key === 'Enter' && taskName.trim()) {
const taskText = taskName.trim(); const taskText = taskName.trim();
setTaskName(''); setTaskName('');
@ -53,7 +61,9 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
} catch (error) { } catch (error) {
console.error('Error creating task:', error); console.error('Error creating task:', error);
setTaskName(taskText); setTaskName(taskText);
showErrorToast(t('errors.taskCreate', 'Failed to create task.')); showErrorToast(
t('errors.taskCreate', 'Failed to create task.')
);
} }
} }
}; };
@ -70,23 +80,42 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} 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" 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={t('tasks.addNewTask', 'Προσθήκη Νέας Εργασίας')} placeholder={t(
'tasks.addNewTask',
'Προσθήκη Νέας Εργασίας'
)}
/> />
</div> </div>
{showNameLengthHelper && taskIntelligenceEnabled && ( {showNameLengthHelper && taskIntelligenceEnabled && (
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md"> <div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className="h-4 w-4 text-blue-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20"> <svg
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /> className="h-4 w-4 text-blue-400 mt-0.5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg> </svg>
</div> </div>
<div className="ml-2"> <div className="ml-2">
<p className="text-sm text-blue-800 dark:text-blue-200"> <p className="text-sm text-blue-800 dark:text-blue-200">
<strong>{t('task.nameHelper.title', 'Make it more descriptive!')}</strong> <strong>
{t(
'task.nameHelper.title',
'Make it more descriptive!'
)}
</strong>
</p> </p>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1"> <p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
{t('task.nameHelper.suggestion', 'Try adding more details like "Call dentist to schedule cleaning appointment" instead of just "Call dentist"')} {t(
'task.nameHelper.suggestion',
'Try adding more details like "Call dentist to schedule cleaning appointment" instead of just "Call dentist"'
)}
</p> </p>
</div> </div>
</div> </div>

View file

@ -26,7 +26,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
metrics, metrics,
projects, projects,
onTaskUpdate, onTaskUpdate,
onClose onClose,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
@ -39,7 +39,6 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
return null; return null;
} }
// Helper function to check if task is not started // Helper function to check if task is not started
const isNotStarted = (task: Task) => { const isNotStarted = (task: Task) => {
return task.status === 'not_started' || task.status === 0; return task.status === 'not_started' || task.status === 0;
@ -49,15 +48,17 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
// 1. Today plan tasks (user's intentional selection for today) // 1. Today plan tasks (user's intentional selection for today)
// 2. Due today tasks (time-based urgency) // 2. Due today tasks (time-based urgency)
// 3. Suggested tasks from today page (algorithm recommendations) // 3. Suggested tasks from today page (algorithm recommendations)
const todayPlanAvailable = (metrics.today_plan_tasks || []).filter(isNotStarted); const todayPlanAvailable = (metrics.today_plan_tasks || []).filter(
isNotStarted
);
const dueTodayAvailable = metrics.tasks_due_today.filter(isNotStarted); const dueTodayAvailable = metrics.tasks_due_today.filter(isNotStarted);
const suggestedAvailable = metrics.suggested_tasks.filter(isNotStarted); const suggestedAvailable = metrics.suggested_tasks.filter(isNotStarted);
// Combine all available tasks with priority (intelligent selection) // Combine all available tasks with priority (intelligent selection)
const allAvailableTasks = [ const allAvailableTasks = [
...todayPlanAvailable.map(task => ({ task, source: 'today_plan' })), ...todayPlanAvailable.map((task) => ({ task, source: 'today_plan' })),
...dueTodayAvailable.map(task => ({ task, source: 'due_today' })), ...dueTodayAvailable.map((task) => ({ task, source: 'due_today' })),
...suggestedAvailable.map(task => ({ task, source: 'suggested' })) ...suggestedAvailable.map((task) => ({ task, source: 'suggested' })),
]; ];
if (allAvailableTasks.length === 0) { if (allAvailableTasks.length === 0) {
@ -65,7 +66,8 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
} }
// Get current task based on index, wrap around if needed // Get current task based on index, wrap around if needed
const currentTaskData = allAvailableTasks[currentTaskIndex % allAvailableTasks.length]; const currentTaskData =
allAvailableTasks[currentTaskIndex % allAvailableTasks.length];
const suggestedTask = currentTaskData.task; const suggestedTask = currentTaskData.task;
const suggestionSource = currentTaskData.source; const suggestionSource = currentTaskData.source;
@ -75,7 +77,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
return task.Project.name; return task.Project.name;
} }
if (task.project_id) { if (task.project_id) {
const project = projects.find(p => p.id === task.project_id); const project = projects.find((p) => p.id === task.project_id);
return project?.name; return project?.name;
} }
return null; return null;
@ -90,10 +92,12 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
const updatedTask = { const updatedTask = {
...suggestedTask, ...suggestedTask,
status: 'in_progress' as const, status: 'in_progress' as const,
today: true today: true,
}; };
await onTaskUpdate(updatedTask); await onTaskUpdate(updatedTask);
showSuccessToast(t('task.startedSuccessfully', 'Task started successfully!')); showSuccessToast(
t('task.startedSuccessfully', 'Task started successfully!')
);
} catch (error) { } catch (error) {
console.error('Error starting task:', error); console.error('Error starting task:', error);
} finally { } finally {
@ -102,7 +106,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
}; };
const handleGiveMeSomethingElse = () => { const handleGiveMeSomethingElse = () => {
setCurrentTaskIndex(prev => prev + 1); setCurrentTaskIndex((prev) => prev + 1);
}; };
return ( return (
@ -121,9 +125,21 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
<SparklesIcon className="h-6 w-6 text-purple-500 dark:text-purple-400 mr-3 flex-shrink-0 mt-0.5" /> <SparklesIcon className="h-6 w-6 text-purple-500 dark:text-purple-400 mr-3 flex-shrink-0 mt-0.5" />
<div className="flex-1 pr-8"> <div className="flex-1 pr-8">
<p className="text-gray-700 dark:text-gray-300 font-medium mb-2 break-words"> <p className="text-gray-700 dark:text-gray-300 font-medium mb-2 break-words">
{suggestionSource === 'today_plan' && t('nextTask.suggestionTodayPlan', 'Since there is nothing in progress, what about starting with this task from your today plan')} {suggestionSource === 'today_plan' &&
{suggestionSource === 'due_today' && t('nextTask.suggestionDueToday', 'Since there is nothing in progress, what about starting with this task due today')} t(
{suggestionSource === 'suggested' && t('nextTask.suggestionSuggested', 'Since there is nothing in progress, what about starting with this suggested task')} 'nextTask.suggestionTodayPlan',
'Since there is nothing in progress, what about starting with this task from your today plan'
)}
{suggestionSource === 'due_today' &&
t(
'nextTask.suggestionDueToday',
'Since there is nothing in progress, what about starting with this task due today'
)}
{suggestionSource === 'suggested' &&
t(
'nextTask.suggestionSuggested',
'Since there is nothing in progress, what about starting with this suggested task'
)}
</p> </p>
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-3 mb-3"> <div className="bg-gray-50 dark:bg-gray-800 rounded-md p-3 mb-3">
<p className="text-gray-900 dark:text-gray-100 font-medium break-words"> <p className="text-gray-900 dark:text-gray-100 font-medium break-words">
@ -137,7 +153,10 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
)} )}
{suggestedTask.due_date && ( {suggestedTask.due_date && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('forms.task.labels.dueDate', 'Due')}: {new Date(suggestedTask.due_date).toLocaleDateString()} {t('forms.task.labels.dueDate', 'Due')}:{' '}
{new Date(
suggestedTask.due_date
).toLocaleDateString()}
</p> </p>
)} )}
</div> </div>
@ -150,8 +169,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
<PlayIcon className="h-4 w-4 mr-2" /> <PlayIcon className="h-4 w-4 mr-2" />
{isUpdating {isUpdating
? t('nextTask.starting', 'Starting...') ? t('nextTask.starting', 'Starting...')
: t('nextTask.letsDoIt', "Yes, let's do it!") : t('nextTask.letsDoIt', "Yes, let's do it!")}
}
</button> </button>
{allAvailableTasks.length > 1 && ( {allAvailableTasks.length > 1 && (
<button <button
@ -160,7 +178,10 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
className="inline-flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-md transition-colors" className="inline-flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-md transition-colors"
> >
<ArrowPathIcon className="h-4 w-4 mr-2" /> <ArrowPathIcon className="h-4 w-4 mr-2" />
{t('nextTask.giveMeSomethingElse', 'Give me something else')} {t(
'nextTask.giveMeSomethingElse',
'Give me something else'
)}
</button> </button>
)} )}
</div> </div>

View file

@ -34,11 +34,12 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
disabled = false, disabled = false,
isChildTask = false, isChildTask = false,
parentTaskLoading = false, parentTaskLoading = false,
onEditParent, onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars
onParentRecurrenceChange, onParentRecurrenceChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [editingParentRecurrence, setEditingParentRecurrence] = useState(false); const [editingParentRecurrence, setEditingParentRecurrence] =
useState(false);
const weekdays = [ const weekdays = [
{ value: 0, label: t('weekdays.sunday', 'Sunday') }, { value: 0, label: t('weekdays.sunday', 'Sunday') },
@ -63,30 +64,52 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{ value: 'daily', label: t('recurrence.daily', 'Daily') }, { value: 'daily', label: t('recurrence.daily', 'Daily') },
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly') }, { value: 'weekly', label: t('recurrence.weekly', 'Weekly') },
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly') }, { value: 'monthly', label: t('recurrence.monthly', 'Monthly') },
{ value: 'monthly_weekday', label: t('recurrence.monthlyWeekday', 'Monthly on weekday') }, {
{ value: 'monthly_last_day', label: t('recurrence.monthlyLastDay', 'Monthly on last day') } value: 'monthly_weekday',
label: t('recurrence.monthlyWeekday', 'Monthly on weekday'),
},
{
value: 'monthly_last_day',
label: t('recurrence.monthlyLastDay', 'Monthly on last day'),
},
]; ];
const renderRecurrenceTypeSelect = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => ( const renderRecurrenceTypeSelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')} {t('forms.task.labels.recurrenceType', 'Repeat')}
</label> </label>
<RecurrenceSelectDropdown <RecurrenceSelectDropdown
value={recurrenceType} value={recurrenceType}
onChange={(value) => (customOnChange || onChange)('recurrence_type', value as RecurrenceType)} onChange={(value) =>
(customOnChange || onChange)(
'recurrence_type',
value as RecurrenceType
)
}
options={recurrenceTypeOptions} options={recurrenceTypeOptions}
disabled={isDisabled} disabled={isDisabled}
/> />
</div> </div>
); );
const renderIntervalInput = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => { const renderIntervalInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
// Determine max value based on recurrence type // Determine max value based on recurrence type
const getMaxValue = () => { const getMaxValue = () => {
if (recurrenceType === 'daily') return 30; if (recurrenceType === 'daily') return 30;
if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year) if (recurrenceType === 'weekly') return 52; // Max 52 weeks (1 year)
if (recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') return 24; // Max 24 months (2 years) if (
recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day'
)
return 24; // Max 24 months (2 years)
return 99; return 99;
}; };
@ -99,26 +122,39 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div className="w-20"> <div className="w-20">
<NumberSelectDropdown <NumberSelectDropdown
value={recurrenceInterval || 1} value={recurrenceInterval || 1}
onChange={(value) => (customOnChange || onChange)('recurrence_interval', value)} onChange={(value) =>
(customOnChange || onChange)(
'recurrence_interval',
value
)
}
min={1} min={1}
max={getMaxValue()} max={getMaxValue()}
disabled={isDisabled} disabled={isDisabled}
/> />
</div> </div>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="text-sm text-gray-600 dark:text-gray-400">
{recurrenceType === 'daily' && t('recurrence.days', 'days')} {recurrenceType === 'daily' &&
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')} t('recurrence.days', 'days')}
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')} {recurrenceType === 'weekly' &&
t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') &&
t('recurrence.months', 'months')}
</span> </span>
</div> </div>
</div> </div>
); );
}; };
const renderWeekdaySelect = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => { const renderWeekdaySelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
const weekdayOptions = [ const weekdayOptions = [
{ value: '', label: t('recurrence.anyDay', 'Any day') }, { value: '', label: t('recurrence.anyDay', 'Any day') },
...weekdays ...weekdays,
]; ];
return ( return (
@ -127,8 +163,15 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{t('forms.task.labels.weekday', 'On day')} {t('forms.task.labels.weekday', 'On day')}
</label> </label>
<RecurrenceSelectDropdown <RecurrenceSelectDropdown
value={recurrenceWeekday !== undefined ? recurrenceWeekday : ''} value={
onChange={(value) => (customOnChange || onChange)('recurrence_weekday', value !== '' ? parseInt(value as string) : null)} recurrenceWeekday !== undefined ? recurrenceWeekday : ''
}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_weekday',
value !== '' ? parseInt(value as string) : null
)
}
options={weekdayOptions} options={weekdayOptions}
disabled={isDisabled} disabled={isDisabled}
/> />
@ -136,7 +179,10 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
); );
}; };
const renderMonthDayInput = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => ( const renderMonthDayInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.monthDay', 'Day of month')} {t('forms.task.labels.monthDay', 'Day of month')}
@ -146,8 +192,16 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
min="1" min="1"
max="31" max="31"
value={recurrenceMonthDay || ''} value={recurrenceMonthDay || ''}
onChange={(e) => (customOnChange || onChange)('recurrence_month_day', e.target.value ? parseInt(e.target.value) : null)} onChange={(e) =>
placeholder={t('recurrence.monthDayPlaceholder', 'Leave empty for current day')} (customOnChange || onChange)(
'recurrence_month_day',
e.target.value ? parseInt(e.target.value) : null
)
}
placeholder={t(
'recurrence.monthDayPlaceholder',
'Leave empty for current day'
)}
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" 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"
disabled={isDisabled} disabled={isDisabled}
/> />
@ -162,7 +216,12 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
</label> </label>
<RecurrenceSelectDropdown <RecurrenceSelectDropdown
value={recurrenceWeekOfMonth || 1} value={recurrenceWeekOfMonth || 1}
onChange={(value) => onChange('recurrence_week_of_month', parseInt(value as string))} onChange={(value) =>
onChange(
'recurrence_week_of_month',
parseInt(value as string)
)
}
options={weekOfMonthOptions} options={weekOfMonthOptions}
/> />
</div> </div>
@ -172,22 +231,41 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
</label> </label>
<RecurrenceSelectDropdown <RecurrenceSelectDropdown
value={recurrenceWeekday || 1} value={recurrenceWeekday || 1}
onChange={(value) => onChange('recurrence_weekday', parseInt(value as string))} onChange={(value) =>
onChange(
'recurrence_weekday',
parseInt(value as string)
)
}
options={weekdays} options={weekdays}
/> />
</div> </div>
</div> </div>
); );
const renderEndDateInput = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => ( const renderEndDateInput = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => (
<div className="mb-4"> <div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceEndDate', 'End date (optional)')} {t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label> </label>
<DatePicker <DatePicker
value={recurrenceEndDate || ''} value={recurrenceEndDate || ''}
onChange={(value) => (customOnChange || onChange)('recurrence_end_date', value || null)} onChange={(value) =>
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')} (customOnChange || onChange)(
'recurrence_end_date',
value || null
)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={isDisabled} disabled={isDisabled}
/> />
</div> </div>
@ -198,8 +276,14 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<ToggleSwitch <ToggleSwitch
checked={completionBased} checked={completionBased}
onChange={(checked) => onChange('completion_based', checked)} onChange={(checked) => onChange('completion_based', checked)}
label={t('forms.task.labels.completionBased', 'Repeat after completion')} label={t(
description={t('forms.task.completionBasedHelp', 'If checked, the next task will be created based on completion date instead of due date')} 'forms.task.labels.completionBased',
'Repeat after completion'
)}
description={t(
'forms.task.completionBasedHelp',
'If checked, the next task will be created based on completion date instead of due date'
)}
/> />
</div> </div>
); );
@ -228,39 +312,91 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div className="text-sm text-blue-800 dark:text-blue-200"> <div className="text-sm text-blue-800 dark:text-blue-200">
<strong>Recurring Task Instance</strong> <strong>Recurring Task Instance</strong>
<p className="mt-1"> <p className="mt-1">
This task was generated from a recurring task. The recurrence settings shown below are inherited from the original task and cannot be edited here. This task was generated from a recurring task. The
recurrence settings shown below are inherited from
the original task and cannot be edited here.
</p> </p>
{onParentRecurrenceChange && ( {onParentRecurrenceChange && (
<button <button
type="button" type="button"
onClick={() => setEditingParentRecurrence(!editingParentRecurrence)} onClick={() =>
setEditingParentRecurrence(
!editingParentRecurrence
)
}
className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${ className={`mt-2 inline-flex items-center px-3 py-1 border text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${
editingParentRecurrence editingParentRecurrence
? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50' ? 'border-red-300 dark:border-red-600 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/50 hover:bg-red-100 dark:hover:bg-red-800/50'
: 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50' : 'border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 bg-white dark:bg-blue-900/50 hover:bg-blue-50 dark:hover:bg-blue-800/50'
}`} }`}
> >
{editingParentRecurrence ? 'Cancel Edit' : 'Edit Parent Recurrence'} {editingParentRecurrence
? 'Cancel Edit'
: 'Edit Parent Recurrence'}
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className={editingParentRecurrence ? '' : 'opacity-60 pointer-events-none'}> <div
className={
editingParentRecurrence
? ''
: 'opacity-60 pointer-events-none'
}
>
{editingParentRecurrence && ( {editingParentRecurrence && (
<div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md"> <div className="mb-4 p-2 bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-800 rounded-md">
<div className="text-xs text-yellow-800 dark:text-yellow-200"> <div className="text-xs text-yellow-800 dark:text-yellow-200">
You are editing the parent task's recurrence settings. Changes will affect all future instances of this recurring task. You are editing the parent task&apos;s
recurrence settings. Changes will affect all
future instances of this recurring task.
</div> </div>
</div> </div>
)} )}
{recurrenceType === 'none' ? renderRecurrenceTypeSelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence) : ( {recurrenceType === 'none' ? (
renderRecurrenceTypeSelect(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)
) : (
<> <>
{renderRecurrenceTypeSelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)} {renderRecurrenceTypeSelect(
{renderIntervalInput(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)} editingParentRecurrence
{(recurrenceType === 'weekly' || recurrenceType === 'monthly_weekday') && renderWeekdaySelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)} ? onParentRecurrenceChange
{recurrenceType === 'monthly' && renderMonthDayInput(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)} : undefined,
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()} !editingParentRecurrence
{renderEndDateInput(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence)} )}
{renderIntervalInput(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{(recurrenceType === 'weekly' ||
recurrenceType === 'monthly_weekday') &&
renderWeekdaySelect(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{recurrenceType === 'monthly' &&
renderMonthDayInput(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderEndDateInput(
editingParentRecurrence
? onParentRecurrenceChange
: undefined,
!editingParentRecurrence
)}
{renderCompletionBasedToggle()} {renderCompletionBasedToggle()}
</> </>
)} )}
@ -270,11 +406,7 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
} }
if (recurrenceType === 'none') { if (recurrenceType === 'none') {
return ( return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
<div className="pb-3">
{renderRecurrenceTypeSelect()}
</div>
);
} }
return ( return (
@ -291,13 +423,19 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
</label> </label>
<RecurrenceSelectDropdown <RecurrenceSelectDropdown
value={recurrenceType} value={recurrenceType}
onChange={(value) => onChange('recurrence_type', value as RecurrenceType)} onChange={(value) =>
onChange('recurrence_type', value as RecurrenceType)
}
options={recurrenceTypeOptions} options={recurrenceTypeOptions}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
{(recurrenceType === 'daily' || recurrenceType === 'weekly' || recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && ( {(recurrenceType === 'daily' ||
recurrenceType === 'weekly' ||
recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') && (
<div> <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')} {t('forms.task.labels.recurrenceInterval', 'Every')}
@ -306,21 +444,35 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div className="w-20"> <div className="w-20">
<NumberSelectDropdown <NumberSelectDropdown
value={recurrenceInterval || 1} value={recurrenceInterval || 1}
onChange={(value) => onChange('recurrence_interval', value)} onChange={(value) =>
onChange('recurrence_interval', value)
}
min={1} min={1}
max={ max={
recurrenceType === 'daily' ? 30 : recurrenceType === 'daily'
recurrenceType === 'weekly' ? 52 : ? 30
(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') ? 24 : : recurrenceType === 'weekly'
99 ? 52
: recurrenceType === 'monthly' ||
recurrenceType ===
'monthly_weekday' ||
recurrenceType ===
'monthly_last_day'
? 24
: 99
} }
disabled={disabled} disabled={disabled}
/> />
</div> </div>
<span className="text-sm text-gray-600 dark:text-gray-400"> <span className="text-sm text-gray-600 dark:text-gray-400">
{recurrenceType === 'daily' && t('recurrence.days', 'days')} {recurrenceType === 'daily' &&
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')} t('recurrence.days', 'days')}
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')} {recurrenceType === 'weekly' &&
t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' ||
recurrenceType === 'monthly_weekday' ||
recurrenceType === 'monthly_last_day') &&
t('recurrence.months', 'months')}
</span> </span>
</div> </div>
</div> </div>
@ -328,12 +480,20 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
<div> <div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceEndDate', 'End date (optional)')} {t(
'forms.task.labels.recurrenceEndDate',
'End date (optional)'
)}
</label> </label>
<DatePicker <DatePicker
value={recurrenceEndDate || ''} value={recurrenceEndDate || ''}
onChange={(value) => onChange('recurrence_end_date', value || null)} onChange={(value) =>
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')} onChange('recurrence_end_date', value || null)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
@ -344,7 +504,8 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{recurrenceType === 'monthly' && renderMonthDayInput()} {recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()} {recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderCompletionBasedToggle()} {renderCompletionBasedToggle()}
</div> </div>

View file

@ -9,7 +9,12 @@ interface TaskActionsProps {
onCancel: () => void; onCancel: () => void;
} }
const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onCancel }) => { const TaskActions: React.FC<TaskActionsProps> = ({
taskId,
onDelete,
onSave,
onCancel,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View file

@ -10,7 +10,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const getDueDateClass = () => { const getDueDateClass = () => {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
if (dueDate === today) return 'border-blue-700 dark:text-white'; if (dueDate === today) return 'border-blue-700 dark:text-white';
if (dueDate === tomorrow) return 'border-blue-700 dark:text-white'; if (dueDate === tomorrow) return 'border-blue-700 dark:text-white';
@ -20,12 +22,18 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
const formatDueDate = () => { const formatDueDate = () => {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; .toISOString()
.split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
if (dueDate === today) return t('dateIndicators.today', 'TODAY'); if (dueDate === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW'); if (dueDate === tomorrow)
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY'); return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
return new Date(dueDate).toLocaleDateString(undefined, { return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
@ -35,7 +43,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
}; };
return ( return (
<div className={`flex items-center text-xs py-1 px-2 rounded-md border ${getDueDateClass()} ${className}`}> <div
className={`flex items-center text-xs py-1 px-2 rounded-md border ${getDueDateClass()} ${className}`}
>
{formatDueDate()} {formatDueDate()}
</div> </div>
); );

View file

@ -10,7 +10,7 @@ interface TaskContentSectionProps {
const TaskContentSection: React.FC<TaskContentSectionProps> = ({ const TaskContentSection: React.FC<TaskContentSectionProps> = ({
taskId, taskId,
value, value,
onChange onChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -22,7 +22,10 @@ const TaskContentSection: React.FC<TaskContentSectionProps> = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
className="block w-full border-0 focus:outline-none focus:ring-0 p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 resize-none flex-1 min-h-[200px]" className="block w-full border-0 focus:outline-none focus:ring-0 p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 resize-none flex-1 min-h-[200px]"
placeholder={t('forms.noteContentPlaceholder', 'Add task description...')} placeholder={t(
'forms.noteContentPlaceholder',
'Add task description...'
)}
/> />
</div> </div>
); );

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PriorityType, StatusType } from '../../../entities/Task'; import { PriorityType, StatusType } from '../../../entities/Task';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import StatusDropdown from '../../Shared/StatusDropdown'; import StatusDropdown from '../../Shared/StatusDropdown';
import PriorityDropdown from '../../Shared/PriorityDropdown'; import PriorityDropdown from '../../Shared/PriorityDropdown';
import DatePicker from '../../Shared/DatePicker'; import DatePicker from '../../Shared/DatePicker';
@ -8,7 +9,7 @@ import DatePicker from '../../Shared/DatePicker';
interface TaskMetadataSectionProps { interface TaskMetadataSectionProps {
priority: PriorityType; priority: PriorityType;
dueDate: string; dueDate: string;
taskId: number | undefined; taskId?: number;
onStatusChange: (value: StatusType) => void; onStatusChange: (value: StatusType) => void;
onPriorityChange: (value: PriorityType) => void; onPriorityChange: (value: PriorityType) => void;
onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
@ -17,10 +18,10 @@ interface TaskMetadataSectionProps {
const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({ const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
priority, priority,
dueDate, dueDate,
taskId, taskId, // eslint-disable-line @typescript-eslint/no-unused-vars
onStatusChange, onStatusChange, // eslint-disable-line @typescript-eslint/no-unused-vars
onPriorityChange, onPriorityChange,
onDueDateChange onDueDateChange,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -43,11 +44,14 @@ const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
value={dueDate} value={dueDate}
onChange={(value) => { onChange={(value) => {
const event = { const event = {
target: { name: 'due_date', value } target: { name: 'due_date', value },
} as React.ChangeEvent<HTMLInputElement>; } as React.ChangeEvent<HTMLInputElement>;
onDueDateChange(event); onDueDateChange(event);
}} }}
placeholder={t('forms.task.dueDatePlaceholder', 'Select due date')} placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/> />
</div> </div>
</div> </div>

View file

@ -19,7 +19,7 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
filteredProjects, filteredProjects,
onProjectSelection, onProjectSelection,
onCreateProject, onCreateProject,
isCreatingProject isCreatingProject,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -27,7 +27,10 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
<div className="relative"> <div className="relative">
<input <input
type="text" type="text"
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')} placeholder={t(
'forms.task.projectSearchPlaceholder',
'Search or create a project...'
)}
value={newProjectName} value={newProjectName}
onChange={onProjectSearch} onChange={onProjectSearch}
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100" className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
@ -47,7 +50,10 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
)) ))
) : ( ) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400"> <div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{t('forms.task.noMatchingProjects', 'No matching projects')} {t(
'forms.task.noMatchingProjects',
'No matching projects'
)}
</div> </div>
)} )}
{newProjectName && ( {newProjectName && (
@ -59,7 +65,8 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
> >
{isCreatingProject {isCreatingProject
? t('forms.task.creatingProject', 'Creating...') ? t('forms.task.creatingProject', 'Creating...')
: t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`} : t('forms.task.createProject', '+ Create') +
` "${newProjectName}"`}
</button> </button>
)} )}
</div> </div>

View file

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { RecurrenceType, Task } from '../../../entities/Task'; import { Task } from '../../../entities/Task';
import RecurrenceInput from '../RecurrenceInput'; import RecurrenceInput from '../RecurrenceInput';
interface TaskRecurrenceSectionProps { interface TaskRecurrenceSectionProps {
@ -17,23 +17,53 @@ const TaskRecurrenceSection: React.FC<TaskRecurrenceSectionProps> = ({
parentTaskLoading, parentTaskLoading,
onRecurrenceChange, onRecurrenceChange,
onEditParent, onEditParent,
onParentRecurrenceChange onParentRecurrenceChange,
}) => { }) => {
return ( return (
<RecurrenceInput <RecurrenceInput
recurrenceType={parentTask ? (parentTask.recurrence_type || 'none') : (formData.recurrence_type || 'none')} recurrenceType={
recurrenceInterval={parentTask ? (parentTask.recurrence_interval || 1) : (formData.recurrence_interval || 1)} parentTask
recurrenceEndDate={parentTask ? parentTask.recurrence_end_date : formData.recurrence_end_date} ? parentTask.recurrence_type || 'none'
recurrenceWeekday={parentTask ? parentTask.recurrence_weekday : formData.recurrence_weekday} : formData.recurrence_type || 'none'
recurrenceMonthDay={parentTask ? parentTask.recurrence_month_day : formData.recurrence_month_day} }
recurrenceWeekOfMonth={parentTask ? parentTask.recurrence_week_of_month : formData.recurrence_week_of_month} recurrenceInterval={
completionBased={parentTask ? (parentTask.completion_based || false) : (formData.completion_based || false)} parentTask
? parentTask.recurrence_interval || 1
: formData.recurrence_interval || 1
}
recurrenceEndDate={
parentTask
? parentTask.recurrence_end_date
: formData.recurrence_end_date
}
recurrenceWeekday={
parentTask
? parentTask.recurrence_weekday
: formData.recurrence_weekday
}
recurrenceMonthDay={
parentTask
? parentTask.recurrence_month_day
: formData.recurrence_month_day
}
recurrenceWeekOfMonth={
parentTask
? parentTask.recurrence_week_of_month
: formData.recurrence_week_of_month
}
completionBased={
parentTask
? parentTask.completion_based || false
: formData.completion_based || false
}
onChange={onRecurrenceChange} onChange={onRecurrenceChange}
disabled={!!parentTask} disabled={!!parentTask}
isChildTask={!!parentTask} isChildTask={!!parentTask}
parentTaskLoading={parentTaskLoading} parentTaskLoading={parentTaskLoading}
onEditParent={parentTask ? onEditParent : undefined} onEditParent={parentTask ? onEditParent : undefined}
onParentRecurrenceChange={parentTask ? onParentRecurrenceChange : undefined} onParentRecurrenceChange={
parentTask ? onParentRecurrenceChange : undefined
}
/> />
); );
}; };

View file

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import TagInput from '../../Tag/TagInput'; import TagInput from '../../Tag/TagInput';
interface TaskTagsSectionProps { interface TaskTagsSectionProps {
@ -11,10 +10,8 @@ interface TaskTagsSectionProps {
const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({ const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
tags, tags,
onTagsChange, onTagsChange,
availableTags availableTags,
}) => { }) => {
const { t } = useTranslation();
return ( return (
<TagInput <TagInput
onTagsChange={onTagsChange} onTagsChange={onTagsChange}

View file

@ -21,7 +21,7 @@ const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
value, value,
onChange, onChange,
taskAnalysis, taskAnalysis,
taskIntelligenceEnabled taskIntelligenceEnabled,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -37,49 +37,82 @@ const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-none focus:outline-none focus:border-none focus:ring-0 shadow-sm py-2" className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-none focus:outline-none focus:border-none focus:ring-0 shadow-sm py-2"
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')} placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
/> />
{taskAnalysis && taskAnalysis.isVague && taskIntelligenceEnabled && ( {taskAnalysis &&
<div className={`mt-2 p-3 rounded-md border ${ taskAnalysis.isVague &&
taskIntelligenceEnabled && (
<div
className={`mt-2 p-3 rounded-md border ${
taskAnalysis.severity === 'high' taskAnalysis.severity === 'high'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700' ? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700'
: taskAnalysis.severity === 'medium' : taskAnalysis.severity === 'medium'
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700' ? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700' : 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
}`}> }`}
>
<div className="flex items-start"> <div className="flex items-start">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<svg className={`h-4 w-4 mt-0.5 ${ <svg
className={`h-4 w-4 mt-0.5 ${
taskAnalysis.severity === 'high' taskAnalysis.severity === 'high'
? 'text-red-400' ? 'text-red-400'
: taskAnalysis.severity === 'medium' : taskAnalysis.severity === 'medium'
? 'text-yellow-400' ? 'text-yellow-400'
: 'text-blue-400' : 'text-blue-400'
}`} fill="currentColor" viewBox="0 0 20 20"> }`}
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /> fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg> </svg>
</div> </div>
<div className="ml-2"> <div className="ml-2">
<p className={`text-sm ${ <p
className={`text-sm ${
taskAnalysis.severity === 'high' taskAnalysis.severity === 'high'
? 'text-red-800 dark:text-red-200' ? 'text-red-800 dark:text-red-200'
: taskAnalysis.severity === 'medium' : taskAnalysis.severity === 'medium'
? 'text-yellow-800 dark:text-yellow-200' ? 'text-yellow-800 dark:text-yellow-200'
: 'text-blue-800 dark:text-blue-200' : 'text-blue-800 dark:text-blue-200'
}`}> }`}
>
<strong> <strong>
{taskAnalysis.reason === 'short' && t('task.nameHelper.short', 'Make it more descriptive!')} {taskAnalysis.reason === 'short' &&
{taskAnalysis.reason === 'no_verb' && t('task.nameHelper.noVerb', 'Add an action verb!')} t(
{taskAnalysis.reason === 'vague_pattern' && t('task.nameHelper.vague', 'Be more specific!')} 'task.nameHelper.short',
'Make it more descriptive!'
)}
{taskAnalysis.reason === 'no_verb' &&
t(
'task.nameHelper.noVerb',
'Add an action verb!'
)}
{taskAnalysis.reason ===
'vague_pattern' &&
t(
'task.nameHelper.vague',
'Be more specific!'
)}
</strong> </strong>
</p> </p>
{taskAnalysis.suggestion && ( {taskAnalysis.suggestion && (
<p className={`text-xs mt-1 ${ <p
className={`text-xs mt-1 ${
taskAnalysis.severity === 'high' taskAnalysis.severity === 'high'
? 'text-red-700 dark:text-red-300' ? 'text-red-700 dark:text-red-300'
: taskAnalysis.severity === 'medium' : taskAnalysis.severity ===
'medium'
? 'text-yellow-700 dark:text-yellow-300' ? 'text-yellow-700 dark:text-yellow-300'
: 'text-blue-700 dark:text-blue-300' : 'text-blue-700 dark:text-blue-300'
}`}> }`}
{t(taskAnalysis.suggestion, taskAnalysis.suggestion)} >
{t(
taskAnalysis.suggestion,
taskAnalysis.suggestion
)}
</p> </p>
)} )}
</div> </div>

View file

@ -1,11 +1,15 @@
import React from "react"; import React from 'react';
import { CalendarDaysIcon, CalendarIcon, PlayIcon, ArrowPathIcon } from "@heroicons/react/24/outline"; import {
import { TagIcon, FolderIcon } from "@heroicons/react/24/solid"; CalendarDaysIcon,
import { useTranslation } from "react-i18next"; CalendarIcon,
import TaskPriorityIcon from "./TaskPriorityIcon"; PlayIcon,
import TaskTags from "./TaskTags"; ArrowPathIcon,
import { Project } from "../../entities/Project"; } from '@heroicons/react/24/outline';
import { Task, StatusType } from "../../entities/Task"; import { TagIcon, FolderIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import TaskPriorityIcon from './TaskPriorityIcon';
import { Project } from '../../entities/Project';
import { Task, StatusType } from '../../entities/Task';
interface TaskHeaderProps { interface TaskHeaderProps {
task: Task; task: Task;
@ -32,12 +36,18 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const formatDueDate = (dueDate: string) => { const formatDueDate = (dueDate: string) => {
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; .toISOString()
.split('T')[0];
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
if (dueDate === today) return t('dateIndicators.today', 'TODAY'); if (dueDate === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW'); if (dueDate === tomorrow)
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY'); return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
return new Date(dueDate).toLocaleDateString(undefined, { return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
@ -76,14 +86,24 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const handlePlayToggle = async (e: React.MouseEvent) => { const handlePlayToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening task modal e.stopPropagation(); // Prevent opening task modal
if (task.id && (task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && onTaskUpdate) { if (
task.id &&
(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) &&
onTaskUpdate
) {
try { try {
const isCurrentlyInProgress = task.status === 'in_progress' || task.status === 1; const isCurrentlyInProgress =
task.status === 'in_progress' || task.status === 1;
const updatedTask = { const updatedTask = {
...task, ...task,
status: (isCurrentlyInProgress ? 'not_started' : 'in_progress') as StatusType, status: (isCurrentlyInProgress
? 'not_started'
: 'in_progress') as StatusType,
// Automatically add to today plan when setting to in_progress // Automatically add to today plan when setting to in_progress
today: isCurrentlyInProgress ? task.today : true today: isCurrentlyInProgress ? task.today : true,
}; };
await onTaskUpdate(updatedTask); await onTaskUpdate(updatedTask);
} catch (error) { } catch (error) {
@ -97,7 +117,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{/* Full view (md and larger) */} {/* Full view (md and larger) */}
<div className="hidden md:flex flex-col md:flex-row md:items-center md:justify-between"> <div className="hidden md:flex flex-col md:flex-row md:items-center md:justify-between">
<div className="flex items-center space-x-4 mb-2 md:mb-0"> <div className="flex items-center space-x-4 mb-2 md:mb-0">
<TaskPriorityIcon priority={task.priority} status={task.status} onToggleCompletion={onToggleCompletion} /> <TaskPriorityIcon
priority={task.priority}
status={task.status}
onToggleCompletion={onToggleCompletion}
/>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center"> <div className="flex items-center">
<span className="text-md text-gray-900 dark:text-gray-100"> <span className="text-md text-gray-900 dark:text-gray-100">
@ -120,16 +144,25 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<span>{project.name}</span> <span>{project.name}</span>
</div> </div>
)} )}
{project && !hideProjectName && task.tags && task.tags.length > 0 && ( {project &&
!hideProjectName &&
task.tags &&
task.tags.length > 0 && (
<span className="mx-2"></span> <span className="mx-2"></span>
)} )}
{task.tags && task.tags.length > 0 && ( {task.tags && task.tags.length > 0 && (
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span>{task.tags.map(tag => tag.name).join(', ')}</span> <span>
{task.tags
.map((tag) => tag.name)
.join(', ')}
</span>
</div> </div>
)} )}
{((project && !hideProjectName) || (task.tags && task.tags.length > 0)) && task.due_date && ( {((project && !hideProjectName) ||
(task.tags && task.tags.length > 0)) &&
task.due_date && (
<span className="mx-2"></span> <span className="mx-2"></span>
)} )}
{task.due_date && ( {task.due_date && (
@ -138,32 +171,49 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<span>{formatDueDate(task.due_date)}</span> <span>{formatDueDate(task.due_date)}</span>
</div> </div>
)} )}
{((project && !hideProjectName) || (task.tags && task.tags.length > 0) || task.due_date) && task.recurrence_type && task.recurrence_type !== 'none' && ( {((project && !hideProjectName) ||
(task.tags && task.tags.length > 0) ||
task.due_date) &&
task.recurrence_type &&
task.recurrence_type !== 'none' && (
<span className="mx-2"></span> <span className="mx-2"></span>
)} )}
{task.recurrence_type && task.recurrence_type !== 'none' && ( {task.recurrence_type &&
task.recurrence_type !== 'none' && (
<div className="flex items-center"> <div className="flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" /> <ArrowPathIcon className="h-3 w-3 mr-1" />
<span>{formatRecurrence(task.recurrence_type)}</span> <span>
{formatRecurrence(
task.recurrence_type
)}
</span>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2"> <div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
{/* Today Plan Controls */} {/* Today Plan Controls */}
{onToggleToday && ( {onToggleToday && (
<button <button
onClick={handleTodayToggle} onClick={handleTodayToggle}
className={`items-center justify-center ${ className={`items-center justify-center ${
Number(task.today_move_count) > 1 ? 'px-2 h-6' : 'w-6 h-6' Number(task.today_move_count) > 1
? 'px-2 h-6'
: 'w-6 h-6'
} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${ } rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
task.today task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`} }`}
title={task.today ? t('tasks.removeFromToday', 'Remove from today plan') : t('tasks.addToToday', 'Add to today plan')} title={
task.today
? t(
'tasks.removeFromToday',
'Remove from today plan'
)
: t('tasks.addToToday', 'Add to today plan')
}
> >
{task.today ? ( {task.today ? (
<CalendarDaysIcon className="h-3 w-3" /> <CalendarDaysIcon className="h-3 w-3" />
@ -179,15 +229,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)} )}
{/* Play/In Progress Controls */} {/* Play/In Progress Controls */}
{(task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && ( {(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) && (
<button <button
onClick={handlePlayToggle} onClick={handlePlayToggle}
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${ className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
(task.status === 'in_progress' || task.status === 1) task.status === 'in_progress' ||
task.status === 1
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
}`} }`}
title={(task.status === 'in_progress' || task.status === 1) ? t('tasks.setNotStarted', 'Set to not started') : t('tasks.setInProgress', 'Set in progress')} title={
task.status === 'in_progress' ||
task.status === 1
? t(
'tasks.setNotStarted',
'Set to not started'
)
: t(
'tasks.setInProgress',
'Set in progress'
)
}
> >
<PlayIcon className="h-3 w-3" /> <PlayIcon className="h-3 w-3" />
</button> </button>
@ -200,7 +265,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div className="flex items-start"> <div className="flex items-start">
{/* Priority Icon - Centered vertically with entire card */} {/* Priority Icon - Centered vertically with entire card */}
<div className="flex items-center justify-center w-5 mt-4 flex-shrink-0"> <div className="flex items-center justify-center w-5 mt-4 flex-shrink-0">
<TaskPriorityIcon priority={task.priority} status={task.status} onToggleCompletion={onToggleCompletion} /> <TaskPriorityIcon
priority={task.priority}
status={task.status}
onToggleCompletion={onToggleCompletion}
/>
</div> </div>
{/* All content - Task name and metadata */} {/* All content - Task name and metadata */}
@ -229,7 +298,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{task.tags && task.tags.length > 0 && ( {task.tags && task.tags.length > 0 && (
<div className="flex items-center"> <div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" /> <TagIcon className="h-3 w-3 mr-1" />
<span>{task.tags.map(tag => tag.name).join(', ')}</span> <span>
{task.tags
.map((tag) => tag.name)
.join(', ')}
</span>
</div> </div>
)} )}
{task.due_date && ( {task.due_date && (
@ -238,10 +311,15 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<span>{formatDueDate(task.due_date)}</span> <span>{formatDueDate(task.due_date)}</span>
</div> </div>
)} )}
{task.recurrence_type && task.recurrence_type !== 'none' && ( {task.recurrence_type &&
task.recurrence_type !== 'none' && (
<div className="flex items-center"> <div className="flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" /> <ArrowPathIcon className="h-3 w-3 mr-1" />
<span>{formatRecurrence(task.recurrence_type)}</span> <span>
{formatRecurrence(
task.recurrence_type
)}
</span>
</div> </div>
)} )}
</div> </div>
@ -250,17 +328,31 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{/* Mobile badges row */} {/* Mobile badges row */}
<div className="flex items-center flex-wrap justify-start space-x-2 mt-2 ml-7"> <div className="flex items-center flex-wrap justify-start space-x-2 mt-2 ml-7">
{/* Play/In Progress Controls - Mobile */} {/* Play/In Progress Controls - Mobile */}
{(task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && ( {(task.status === 'not_started' ||
task.status === 'in_progress' ||
task.status === 0 ||
task.status === 1) && (
<button <button
onClick={handlePlayToggle} onClick={handlePlayToggle}
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${ className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
(task.status === 'in_progress' || task.status === 1) task.status === 'in_progress' ||
task.status === 1
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
}`} }`}
title={(task.status === 'in_progress' || task.status === 1) ? t('tasks.setNotStarted', 'Set to not started') : t('tasks.setInProgress', 'Set in progress')} title={
task.status === 'in_progress' ||
task.status === 1
? t(
'tasks.setNotStarted',
'Set to not started'
)
: t(
'tasks.setInProgress',
'Set in progress'
)
}
> >
<PlayIcon className="h-3 w-3" /> <PlayIcon className="h-3 w-3" />
</button> </button>
@ -275,7 +367,14 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex' ? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`} }`}
title={task.today ? t('tasks.removeFromToday', 'Remove from today plan') : t('tasks.addToToday', 'Add to today plan')} title={
task.today
? t(
'tasks.removeFromToday',
'Remove from today plan'
)
: t('tasks.addToToday', 'Add to today plan')
}
> >
{task.today ? ( {task.today ? (
<CalendarDaysIcon className="h-3 w-3" /> <CalendarDaysIcon className="h-3 w-3" />

View file

@ -37,7 +37,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
setIsModalOpen(false); setIsModalOpen(false);
}; };
const handleDelete = async (taskId: number) => { const handleDelete = async () => {
if (task.id) { if (task.id) {
await onTaskDelete(task.id); await onTaskDelete(task.id);
} }
@ -49,6 +49,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
const updatedTask = await toggleTaskCompletion(task.id); const updatedTask = await toggleTaskCompletion(task.id);
await onTaskUpdate(updatedTask); await onTaskUpdate(updatedTask);
} catch (error) { } catch (error) {
console.error('Error toggling task completion:', error);
} }
} }
}; };
@ -71,12 +72,14 @@ const TaskItem: React.FC<TaskItemProps> = ({
setProjectList((prevProjects) => [...prevProjects, newProject]); setProjectList((prevProjects) => [...prevProjects, newProject]);
return newProject; return newProject;
} catch (error) { } catch (error) {
console.error('Error creating project:', error);
throw error; throw error;
} }
}; };
// Use the project from the task's included data if available, otherwise find from projectList // Use the project from the task's included data if available, otherwise find from projectList
const project = task.Project || projectList.find((p) => p.id === task.project_id); const project =
task.Project || projectList.find((p) => p.id === task.project_id);
// Check if task is in progress to apply pulsing border animation // Check if task is in progress to apply pulsing border animation
const isInProgress = task.status === 'in_progress' || task.status === 1; const isInProgress = task.status === 'in_progress' || task.status === 1;

View file

@ -1,25 +1,33 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { PriorityType, StatusType, Task } from "../../entities/Task"; import { PriorityType, StatusType, Task } from '../../entities/Task';
import TaskActions from "./TaskActions"; import ConfirmDialog from '../Shared/ConfirmDialog';
import ConfirmDialog from "../Shared/ConfirmDialog"; import { useToast } from '../Shared/ToastContext';
import CollapsibleSection from "../Shared/CollapsibleSection"; import TimelinePanel from './TimelinePanel';
import { useToast } from "../Shared/ToastContext"; import { Project } from '../../entities/Project';
import TimelinePanel from "./TimelinePanel";
import { Project } from "../../entities/Project";
import { fetchTags } from '../../utils/tagsService'; import { fetchTags } from '../../utils/tagsService';
import { fetchTaskById } from '../../utils/tasksService'; import { fetchTaskById } from '../../utils/tasksService';
import { getTaskIntelligenceEnabled } from '../../utils/profileService'; import { getTaskIntelligenceEnabled } from '../../utils/profileService';
import { analyzeTaskName, TaskAnalysis } from '../../utils/taskIntelligenceService'; import {
import { useTranslation } from "react-i18next"; analyzeTaskName,
import { ClockIcon, TagIcon, FolderIcon, Cog6ToothIcon, ArrowPathIcon, TrashIcon } from "@heroicons/react/24/outline"; TaskAnalysis,
} from '../../utils/taskIntelligenceService';
import { useTranslation } from 'react-i18next';
import {
ClockIcon,
TagIcon,
FolderIcon,
Cog6ToothIcon,
ArrowPathIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
// Import form sections // Import form sections
import TaskTitleSection from "./TaskForm/TaskTitleSection"; import TaskTitleSection from './TaskForm/TaskTitleSection';
import TaskContentSection from "./TaskForm/TaskContentSection"; import TaskContentSection from './TaskForm/TaskContentSection';
import TaskTagsSection from "./TaskForm/TaskTagsSection"; import TaskTagsSection from './TaskForm/TaskTagsSection';
import TaskProjectSection from "./TaskForm/TaskProjectSection"; import TaskProjectSection from './TaskForm/TaskProjectSection';
import TaskMetadataSection from "./TaskForm/TaskMetadataSection"; import TaskMetadataSection from './TaskForm/TaskMetadataSection';
import TaskRecurrenceSection from "./TaskForm/TaskRecurrenceSection"; import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection';
interface TaskModalProps { interface TaskModalProps {
isOpen: boolean; isOpen: boolean;
@ -43,20 +51,27 @@ const TaskModal: React.FC<TaskModalProps> = ({
onEditParentTask, onEditParentTask,
}) => { }) => {
const [formData, setFormData] = useState<Task>(task); const [formData, setFormData] = useState<Task>(task);
const [tags, setTags] = useState<string[]>(task.tags?.map((tag) => tag.name) || []); const [tags, setTags] = useState<string[]>(
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects || []); task.tags?.map((tag) => tag.name) || []
const [newProjectName, setNewProjectName] = useState<string>(""); );
const [filteredProjects, setFilteredProjects] = useState<Project[]>(
projects || []
);
const [newProjectName, setNewProjectName] = useState<string>('');
const [isCreatingProject, setIsCreatingProject] = useState(false); const [isCreatingProject, setIsCreatingProject] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [localAvailableTags, setLocalAvailableTags] = useState<Array<{name: string}>>([]); const [localAvailableTags, setLocalAvailableTags] = useState<
Array<{ name: string }>
>([]);
const [tagsLoaded, setTagsLoaded] = useState(false); const [tagsLoaded, setTagsLoaded] = useState(false);
const [parentTask, setParentTask] = useState<Task | null>(null); const [parentTask, setParentTask] = useState<Task | null>(null);
const [parentTaskLoading, setParentTaskLoading] = useState(false); const [parentTaskLoading, setParentTaskLoading] = useState(false);
const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null); const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null);
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true); const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
useState(true);
const [isTimelineExpanded, setIsTimelineExpanded] = useState(false); const [isTimelineExpanded, setIsTimelineExpanded] = useState(false);
// Collapsible section states // Collapsible section states
@ -64,27 +79,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
tags: false, tags: false,
project: false, project: false,
metadata: false, metadata: false,
recurrence: false recurrence: false,
}); });
const { showSuccessToast, showErrorToast } = useToast(); const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation(); const { t } = useTranslation();
const toggleSection = useCallback((section: keyof typeof expandedSections) => { const toggleSection = useCallback(
setExpandedSections(prev => { (section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
const newExpanded = { const newExpanded = {
...prev, ...prev,
[section]: !prev[section] [section]: !prev[section],
}; };
// Auto-scroll to show the expanded section // Auto-scroll to show the expanded section
if (newExpanded[section]) { if (newExpanded[section]) {
setTimeout(() => { setTimeout(() => {
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto'); const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: 'smooth' behavior: 'smooth',
}); });
} }
}, 100); // Small delay to ensure DOM is updated }, 100); // Small delay to ensure DOM is updated
@ -92,7 +110,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
return newExpanded; return newExpanded;
}); });
}, []); },
[]
);
useEffect(() => { useEffect(() => {
setFormData(task); setFormData(task);
@ -107,7 +127,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
} }
// Safely find the current project, handling the case where projects might be undefined // Safely find the current project, handling the case where projects might be undefined
const currentProject = projects?.find((project) => project.id === task.project_id); const currentProject = projects?.find(
(project) => project.id === task.project_id
);
setNewProjectName(currentProject ? currentProject.name : ''); setNewProjectName(currentProject ? currentProject.name : '');
// Fetch parent task if this is a child task // Fetch parent task if this is a child task
@ -115,7 +137,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
if (task.recurring_parent_id && isOpen) { if (task.recurring_parent_id && isOpen) {
setParentTaskLoading(true); setParentTaskLoading(true);
try { try {
const parent = await fetchTaskById(task.recurring_parent_id); const parent = await fetchTaskById(
task.recurring_parent_id
);
setParentTask(parent); setParentTask(parent);
} catch (error) { } catch (error) {
console.error('Error fetching parent task:', error); console.error('Error fetching parent task:', error);
@ -139,7 +163,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
const enabled = await getTaskIntelligenceEnabled(); const enabled = await getTaskIntelligenceEnabled();
setTaskIntelligenceEnabled(enabled); setTaskIntelligenceEnabled(enabled);
} catch (error) { } catch (error) {
console.error('Error fetching task intelligence setting:', error); console.error(
'Error fetching task intelligence setting:',
error
);
setTaskIntelligenceEnabled(true); // Default to enabled setTaskIntelligenceEnabled(true); // Default to enabled
} }
} }
@ -161,7 +188,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
setParentTask({ ...parentTask, [field]: value }); setParentTask({ ...parentTask, [field]: value });
} }
// Also update the form data to reflect the change // Also update the form data to reflect the change
setFormData(prev => ({ ...prev, [field]: value, update_parent_recurrence: true })); setFormData((prev) => ({
...prev,
[field]: value,
update_parent_recurrence: true,
}));
}; };
useEffect(() => { useEffect(() => {
@ -170,11 +201,13 @@ const TaskModal: React.FC<TaskModalProps> = ({
try { try {
const fetchedTags = await fetchTags(); const fetchedTags = await fetchTags();
// Ensure fetchedTags is always an array // Ensure fetchedTags is always an array
const safeTagsArray = Array.isArray(fetchedTags) ? fetchedTags : []; const safeTagsArray = Array.isArray(fetchedTags)
? fetchedTags
: [];
setLocalAvailableTags(safeTagsArray); setLocalAvailableTags(safeTagsArray);
setTagsLoaded(true); setTagsLoaded(true);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching tags:", error); console.error('Error fetching tags:', error);
// Set empty array as fallback // Set empty array as fallback
setLocalAvailableTags([]); setLocalAvailableTags([]);
setTagsLoaded(true); // Mark as loaded even on error to prevent retry loop setTagsLoaded(true); // Mark as loaded even on error to prevent retry loop
@ -188,7 +221,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
} }
}, [isOpen, tagsLoaded]); }, [isOpen, tagsLoaded]);
const getPriorityString = (priority: PriorityType | number | undefined): PriorityType => { const getPriorityString = (
priority: PriorityType | number | undefined
): PriorityType => {
if (typeof priority === 'number') { if (typeof priority === 'number') {
const priorityNames: PriorityType[] = ['low', 'medium', 'high']; const priorityNames: PriorityType[] = ['low', 'medium', 'high'];
return priorityNames[priority] || 'medium'; return priorityNames[priority] || 'medium';
@ -196,9 +231,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
return priority || 'medium'; return priority || 'medium';
}; };
const handleChange = ( const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>
) => { ) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value })); setFormData((prev) => ({ ...prev, [name]: value }));
@ -240,7 +276,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
}; };
const handleCreateProject = async () => { const handleCreateProject = async () => {
if (newProjectName.trim() !== "") { if (newProjectName.trim() !== '') {
setIsCreatingProject(true); setIsCreatingProject(true);
try { try {
const newProject = await onCreateProject(newProjectName); const newProject = await onCreateProject(newProjectName);
@ -251,7 +287,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
showSuccessToast(t('success.projectCreated')); showSuccessToast(t('success.projectCreated'));
} catch (error) { } catch (error) {
showErrorToast(t('errors.projectCreationFailed')); showErrorToast(t('errors.projectCreationFailed'));
console.error("Error creating project:", error); console.error('Error creating project:', error);
} finally { } finally {
setIsCreatingProject(false); setIsCreatingProject(false);
} }
@ -262,7 +298,14 @@ const TaskModal: React.FC<TaskModalProps> = ({
onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) }); onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) });
const taskLink = ( const taskLink = (
<span> <span>
{t('task.updated', 'Task')} <a href={`/task/${formData.uuid}`} className="text-green-200 underline hover:text-green-100">{formData.name}</a> {t('task.updatedSuccessfully', 'updated successfully!')} {t('task.updated', 'Task')}{' '}
<a
href={`/task/${formData.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{formData.name}
</a>{' '}
{t('task.updatedSuccessfully', 'updated successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
@ -279,7 +322,14 @@ const TaskModal: React.FC<TaskModalProps> = ({
await onDelete(formData.id); await onDelete(formData.id);
const taskLink = ( const taskLink = (
<span> <span>
{t('task.deleted', 'Task')} <a href={`/task/${formData.uuid}`} className="text-green-200 underline hover:text-green-100">{formData.name}</a> {t('task.deletedSuccessfully', 'deleted successfully!')} {t('task.deleted', 'Task')}{' '}
<a
href={`/task/${formData.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{formData.name}
</a>{' '}
{t('task.deletedSuccessfully', 'deleted successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
@ -310,22 +360,26 @@ const TaskModal: React.FC<TaskModalProps> = ({
const target = event.target as Element; const target = event.target as Element;
// Ignore clicks on dropdown menus rendered via portal // Ignore clicks on dropdown menus rendered via portal
if (target && ( if (
target.closest('.recurrence-dropdown-menu') || target &&
(target.closest('.recurrence-dropdown-menu') ||
target.closest('.number-dropdown-menu') || target.closest('.number-dropdown-menu') ||
target.closest('.date-picker-menu') || target.closest('.date-picker-menu') ||
target.closest('[class*="fixed z-50"]') || target.closest('[class*="fixed z-50"]') ||
target.closest('[class*="z-50"]') target.closest('[class*="z-50"]'))
)) { ) {
return; return;
} }
if (modalRef.current && !modalRef.current.contains(event.target as Node)) { if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose(); handleClose();
} }
}; };
if (isOpen) { if (isOpen) {
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
// Disable body scroll when modal is open // Disable body scroll when modal is open
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
} else { } else {
@ -333,7 +387,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
} }
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
// Clean up: re-enable body scroll // Clean up: re-enable body scroll
document.body.style.overflow = 'unset'; document.body.style.overflow = 'unset';
}; };
@ -341,15 +395,15 @@ const TaskModal: React.FC<TaskModalProps> = ({
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === 'Escape') {
handleClose(); handleClose();
} }
}; };
if (isOpen) { if (isOpen) {
document.addEventListener("keydown", handleKeyDown); document.addEventListener('keydown', handleKeyDown);
} }
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen]); }, [isOpen]);
@ -359,23 +413,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
<> <>
<div <div
className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${ className={`fixed top-16 left-0 right-0 bottom-0 bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 overflow-hidden sm:overflow-y-auto ${
isClosing ? "opacity-0" : "opacity-100" isClosing ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
<div className="h-full flex items-start justify-center sm:px-4 sm:py-4"> <div className="h-full flex items-start justify-center sm:px-4 sm:py-4">
<div <div
ref={modalRef} ref={modalRef}
className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${ className={`bg-white dark:bg-gray-800 border-0 sm:border sm:border-gray-200 sm:dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100" isClosing ? 'scale-95' : 'scale-100'
} h-full sm:h-auto sm:my-4`} } h-full sm:h-auto sm:my-4`}
> >
<div className="flex flex-col lg:flex-row h-full sm:min-h-[600px] sm:max-h-[90vh]"> <div className="flex flex-col lg:flex-row h-full sm:min-h-[600px] sm:max-h-[90vh]">
{/* Main Form Section */} {/* Main Form Section */}
<div className={`flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 ${ <div
className={`flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800 ${
isTimelineExpanded ? 'lg:pr-2' : '' isTimelineExpanded ? 'lg:pr-2' : ''
}`}> }`}
>
<div className="flex-1 relative"> <div className="flex-1 relative">
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden" style={{ WebkitOverflowScrolling: 'touch' }}> <div
className="absolute inset-0 overflow-y-auto overflow-x-hidden"
style={{
WebkitOverflowScrolling: 'touch',
}}
>
<form className="h-full"> <form className="h-full">
<fieldset className="h-full flex flex-col"> <fieldset className="h-full flex flex-col">
{/* Task Title Section - Always Visible */} {/* Task Title Section - Always Visible */}
@ -384,13 +445,15 @@ const TaskModal: React.FC<TaskModalProps> = ({
value={formData.name} value={formData.name}
onChange={handleChange} onChange={handleChange}
taskAnalysis={taskAnalysis} taskAnalysis={taskAnalysis}
taskIntelligenceEnabled={taskIntelligenceEnabled} taskIntelligenceEnabled={
taskIntelligenceEnabled
}
/> />
{/* Content Section - Always Visible */} {/* Content Section - Always Visible */}
<TaskContentSection <TaskContentSection
taskId={task.id} taskId={task.id}
value={formData.note || ""} value={formData.note || ''}
onChange={handleChange} onChange={handleChange}
/> />
@ -398,12 +461,24 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.tags && ( {expandedSections.tags && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.labels.tags', 'Tags')} {t(
'forms.task.labels.tags',
'Tags'
)}
</h3> </h3>
<TaskTagsSection <TaskTagsSection
tags={formData.tags?.map((tag) => tag.name) || []} tags={
onTagsChange={handleTagsChange} formData.tags?.map(
availableTags={localAvailableTags} (tag) =>
tag.name
) || []
}
onTagsChange={
handleTagsChange
}
availableTags={
localAvailableTags
}
/> />
</div> </div>
)} )}
@ -411,16 +486,33 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.project && ( {expandedSections.project && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.labels.project', 'Project')} {t(
'forms.task.labels.project',
'Project'
)}
</h3> </h3>
<TaskProjectSection <TaskProjectSection
newProjectName={newProjectName} newProjectName={
onProjectSearch={handleProjectSearch} newProjectName
dropdownOpen={dropdownOpen} }
filteredProjects={filteredProjects} onProjectSearch={
onProjectSelection={handleProjectSelection} handleProjectSearch
onCreateProject={handleCreateProject} }
isCreatingProject={isCreatingProject} dropdownOpen={
dropdownOpen
}
filteredProjects={
filteredProjects
}
onProjectSelection={
handleProjectSelection
}
onCreateProject={
handleCreateProject
}
isCreatingProject={
isCreatingProject
}
/> />
</div> </div>
)} )}
@ -428,24 +520,51 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.metadata && ( {expandedSections.metadata && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 overflow-visible"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 overflow-visible">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.statusAndOptions', 'Status & Options')} {t(
'forms.task.statusAndOptions',
'Status & Options'
)}
</h3> </h3>
<TaskMetadataSection <TaskMetadataSection
priority={getPriorityString(formData.priority)} priority={getPriorityString(
dueDate={formData.due_date || ""} formData.priority
)}
dueDate={
formData.due_date ||
''
}
taskId={task.id} taskId={task.id}
onStatusChange={(value: StatusType) => { onStatusChange={(
value: StatusType
) => {
// Universal rule: when setting status to in_progress, also add to today // Universal rule: when setting status to in_progress, also add to today
const updatedData = { ...formData, status: value }; const updatedData =
if (value === 'in_progress') { {
...formData,
status: value,
};
if (
value ===
'in_progress'
) {
updatedData.today = true; updatedData.today = true;
} }
setFormData(updatedData); setFormData(
updatedData
);
}} }}
onPriorityChange={(value: PriorityType) => onPriorityChange={(
setFormData({ ...formData, priority: value }) value: PriorityType
) =>
setFormData({
...formData,
priority:
value,
})
}
onDueDateChange={
handleChange
} }
onDueDateChange={handleChange}
/> />
</div> </div>
)} )}
@ -453,15 +572,32 @@ const TaskModal: React.FC<TaskModalProps> = ({
{expandedSections.recurrence && ( {expandedSections.recurrence && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4"> <div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t('forms.task.recurrence', 'Recurrence')} {t(
'forms.task.recurrence',
'Recurrence'
)}
</h3> </h3>
<TaskRecurrenceSection <TaskRecurrenceSection
formData={formData} formData={formData}
parentTask={parentTask} parentTask={
parentTaskLoading={parentTaskLoading} parentTask
onRecurrenceChange={handleRecurrenceChange} }
onEditParent={parentTask ? handleEditParent : undefined} parentTaskLoading={
onParentRecurrenceChange={parentTask ? handleParentRecurrenceChange : undefined} parentTaskLoading
}
onRecurrenceChange={
handleRecurrenceChange
}
onEditParent={
parentTask
? handleEditParent
: undefined
}
onParentRecurrenceChange={
parentTask
? handleParentRecurrenceChange
: undefined
}
/> />
</div> </div>
)} )}
@ -476,7 +612,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
<TimelinePanel <TimelinePanel
taskId={task.id} taskId={task.id}
isExpanded={isTimelineExpanded} isExpanded={isTimelineExpanded}
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)} onToggle={() =>
setIsTimelineExpanded(
!isTimelineExpanded
)
}
/> />
</div> </div>
)} )}
@ -488,29 +628,41 @@ const TaskModal: React.FC<TaskModalProps> = ({
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{/* Tags Toggle */} {/* Tags Toggle */}
<button <button
onClick={() => toggleSection('tags')} onClick={() =>
toggleSection('tags')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.tags expandedSections.tags
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.task.labels.tags', 'Tags')} title={t(
'forms.task.labels.tags',
'Tags'
)}
> >
<TagIcon className="h-5 w-5" /> <TagIcon className="h-5 w-5" />
{formData.tags && formData.tags.length > 0 && ( {formData.tags &&
formData.tags.length >
0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span> <span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)} )}
</button> </button>
{/* Project Toggle */} {/* Project Toggle */}
<button <button
onClick={() => toggleSection('project')} onClick={() =>
toggleSection('project')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.project expandedSections.project
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.task.labels.project', 'Project')} title={t(
'forms.task.labels.project',
'Project'
)}
> >
<FolderIcon className="h-5 w-5" /> <FolderIcon className="h-5 w-5" />
{formData.project_id && ( {formData.project_id && (
@ -520,32 +672,48 @@ const TaskModal: React.FC<TaskModalProps> = ({
{/* Status & Options Toggle */} {/* Status & Options Toggle */}
<button <button
onClick={() => toggleSection('metadata')} onClick={() =>
toggleSection('metadata')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.metadata expandedSections.metadata
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.task.statusAndOptions', 'Status & Options')} title={t(
'forms.task.statusAndOptions',
'Status & Options'
)}
> >
<Cog6ToothIcon className="h-5 w-5" /> <Cog6ToothIcon className="h-5 w-5" />
{(formData.due_date || formData.priority !== 'medium' || (formData.status && formData.status !== 'not_started')) && ( {(formData.due_date ||
formData.priority !==
'medium' ||
(formData.status &&
formData.status !==
'not_started')) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span> <span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)} )}
</button> </button>
{/* Recurrence Toggle */} {/* Recurrence Toggle */}
<button <button
onClick={() => toggleSection('recurrence')} onClick={() =>
toggleSection('recurrence')
}
className={`relative p-2 rounded-full transition-colors ${ className={`relative p-2 rounded-full transition-colors ${
expandedSections.recurrence expandedSections.recurrence
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={t('forms.task.recurrence', 'Recurrence')} title={t(
'forms.task.recurrence',
'Recurrence'
)}
> >
<ArrowPathIcon className="h-5 w-5" /> <ArrowPathIcon className="h-5 w-5" />
{(formData.recurrence_type || formData.recurring_parent_id) && ( {(formData.recurrence_type ||
formData.recurring_parent_id) && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span> <span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)} )}
</button> </button>
@ -553,13 +721,25 @@ const TaskModal: React.FC<TaskModalProps> = ({
{/* Right side: Timeline Toggle Button */} {/* Right side: Timeline Toggle Button */}
<button <button
onClick={() => setIsTimelineExpanded(!isTimelineExpanded)} onClick={() =>
setIsTimelineExpanded(
!isTimelineExpanded
)
}
className={`p-2 rounded-full transition-colors ${ className={`p-2 rounded-full transition-colors ${
isTimelineExpanded isTimelineExpanded
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400' ? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={isTimelineExpanded ? t('timeline.hideActivityTimeline') : t('timeline.showActivityTimeline')} title={
isTimelineExpanded
? t(
'timeline.hideActivityTimeline'
)
: t(
'timeline.showActivityTimeline'
)
}
> >
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
</button> </button>
@ -575,7 +755,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
type="button" type="button"
onClick={handleDeleteClick} onClick={handleDeleteClick}
className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out" className="p-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none transition duration-150 ease-in-out"
title={t('common.delete', 'Delete')} title={t(
'common.delete',
'Delete'
)}
> >
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
</button> </button>
@ -604,7 +787,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
<TimelinePanel <TimelinePanel
taskId={task.id} taskId={task.id}
isExpanded={isTimelineExpanded} isExpanded={isTimelineExpanded}
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)} onToggle={() =>
setIsTimelineExpanded(!isTimelineExpanded)
}
/> />
</div> </div>
</div> </div>
@ -613,7 +798,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
{showConfirmDialog && ( {showConfirmDialog && (
<ConfirmDialog <ConfirmDialog
title={t('modals.deleteTask.title', 'Delete Task')} 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.')} message={t(
'modals.deleteTask.confirmation',
'Are you sure you want to delete this task? This action cannot be undone.'
)}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onCancel={() => setShowConfirmDialog(false)} onCancel={() => setShowConfirmDialog(false)}
/> />

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { CheckCircleIcon } from '@heroicons/react/24/solid'; import { CheckCircleIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
interface TaskPriorityIconProps { interface TaskPriorityIconProps {
priority: string | number | undefined; priority: string | number | undefined;
@ -8,8 +7,11 @@ interface TaskPriorityIconProps {
onToggleCompletion?: () => void; onToggleCompletion?: () => void;
} }
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status, onToggleCompletion }) => { const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
const { t } = useTranslation(); priority,
status,
onToggleCompletion,
}) => {
const getIconColor = () => { const getIconColor = () => {
if (status === 'done' || status === 2) return 'text-green-500'; if (status === 'done' || status === 2) return 'text-green-500';

View file

@ -7,7 +7,9 @@ interface TaskRecurrenceBadgeProps {
recurrenceType: RecurrenceType; recurrenceType: RecurrenceType;
} }
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({ recurrenceType }) => { const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({
recurrenceType,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!recurrenceType || recurrenceType === 'none') { if (!recurrenceType || recurrenceType === 'none') {

View file

@ -1,6 +1,10 @@
import React from 'react'; import React from 'react';
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid'; import {
import { useTranslation } from 'react-i18next'; MinusIcon,
CheckCircleIcon,
ArchiveBoxIcon,
ArrowPathIcon,
} from '@heroicons/react/24/solid';
import { StatusType } from '../../entities/Task'; import { StatusType } from '../../entities/Task';
interface TaskStatusBadgeProps { interface TaskStatusBadgeProps {
@ -8,41 +12,42 @@ interface TaskStatusBadgeProps {
className?: string; className?: string;
} }
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => { const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({
const { t } = useTranslation(); status,
className,
}) => {
// Convert numeric status to string // Convert numeric status to string
const getStatusString = (status: StatusType | number): StatusType => { const getStatusString = (status: StatusType | number): StatusType => {
if (typeof status === 'number') { if (typeof status === 'number') {
const statusNames: StatusType[] = ['not_started', 'in_progress', 'done', 'archived']; const statusNames: StatusType[] = [
'not_started',
'in_progress',
'done',
'archived',
];
return statusNames[status] || 'not_started'; return statusNames[status] || 'not_started';
} }
return status; return status;
}; };
const statusString = getStatusString(status); const statusString = getStatusString(status);
let statusIcon, statusLabel; let statusIcon;
switch (statusString) { switch (statusString) {
case 'not_started': case 'not_started':
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />; statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = t('status.notStarted', 'Not Started');
break; break;
case 'in_progress': case 'in_progress':
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />; statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
statusLabel = t('status.inProgress', 'In Progress');
break; break;
case 'done': case 'done':
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />; statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
statusLabel = t('status.done', 'Done');
break; break;
case 'archived': case 'archived':
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />; statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
statusLabel = t('status.archived', 'Archived');
break; break;
default: default:
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />; statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = t('status.unknown', 'Unknown');
} }
return ( return (

View file

@ -9,7 +9,11 @@ interface TaskTagsProps {
className?: string; className?: string;
} }
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }) => { const TaskTags: React.FC<TaskTagsProps> = ({
tags = [],
onTagRemove,
className,
}) => {
const navigate = useNavigate(); const navigate = useNavigate();
const handleTagClick = (tag: Tag) => { const handleTagClick = (tag: Tag) => {
@ -34,7 +38,9 @@ const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }
className="flex items-center" className="flex items-center"
> >
<TagIcon className="hidden md:block h-4 w-4 text-gray-500 dark:text-gray-300 mr-2" /> <TagIcon className="hidden md:block h-4 w-4 text-gray-500 dark:text-gray-300 mr-2" />
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span> <span className="text-xs text-gray-700 dark:text-gray-300">
{tag.name}
</span>
</button> </button>
{onTagRemove && ( {onTagRemove && (
<button <button

View file

@ -1,7 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TaskEvent } from '../../entities/TaskEvent'; import { TaskEvent } from '../../entities/TaskEvent';
import { getTaskTimeline, formatDuration, getEventTypeLabel, getStatusLabel, getPriorityLabel } from '../../utils/taskEventService'; import {
getTaskTimeline,
getEventTypeLabel,
getStatusLabel,
getPriorityLabel,
} from '../../utils/taskEventService';
import { import {
ClockIcon, ClockIcon,
CheckCircleIcon, CheckCircleIcon,
@ -11,10 +16,9 @@ import {
CalendarIcon, CalendarIcon,
FolderIcon, FolderIcon,
PlayIcon, PlayIcon,
PauseIcon,
ArchiveBoxIcon, ArchiveBoxIcon,
SparklesIcon, SparklesIcon,
AdjustmentsHorizontalIcon AdjustmentsHorizontalIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
interface TaskTimelineProps { interface TaskTimelineProps {
@ -53,24 +57,55 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
}, [taskId]); }, [taskId]);
const getEventIcon = (eventType: string, newValue?: any) => { const getEventIcon = (eventType: string, newValue?: any) => {
const iconClass = "h-3.5 w-3.5"; const iconClass = 'h-3.5 w-3.5';
switch (eventType) { switch (eventType) {
case 'created': case 'created':
return <SparklesIcon className={`${iconClass} text-blue-500`} />; return (
<SparklesIcon className={`${iconClass} text-blue-500`} />
);
case 'status_changed': case 'status_changed':
if (newValue?.status === 1) return <PlayIcon className={`${iconClass} text-yellow-500`} />; if (newValue?.status === 1)
if (newValue?.status === 2) return <CheckCircleIcon className={`${iconClass} text-green-500`} />; return (
if (newValue?.status === 3) return <ArchiveBoxIcon className={`${iconClass} text-gray-500`} />; <PlayIcon className={`${iconClass} text-yellow-500`} />
return <AdjustmentsHorizontalIcon className={`${iconClass} text-blue-500`} />; );
if (newValue?.status === 2)
return (
<CheckCircleIcon
className={`${iconClass} text-green-500`}
/>
);
if (newValue?.status === 3)
return (
<ArchiveBoxIcon
className={`${iconClass} text-gray-500`}
/>
);
return (
<AdjustmentsHorizontalIcon
className={`${iconClass} text-blue-500`}
/>
);
case 'completed': case 'completed':
return <CheckCircleIcon className={`${iconClass} text-green-500`} />; return (
<CheckCircleIcon
className={`${iconClass} text-green-500`}
/>
);
case 'priority_changed': case 'priority_changed':
return <ExclamationTriangleIcon className={`${iconClass} text-orange-500`} />; return (
<ExclamationTriangleIcon
className={`${iconClass} text-orange-500`}
/>
);
case 'due_date_changed': case 'due_date_changed':
return <CalendarIcon className={`${iconClass} text-purple-500`} />; return (
<CalendarIcon className={`${iconClass} text-purple-500`} />
);
case 'project_changed': case 'project_changed':
return <FolderIcon className={`${iconClass} text-indigo-500`} />; return (
<FolderIcon className={`${iconClass} text-indigo-500`} />
);
case 'name_changed': case 'name_changed':
case 'description_changed': case 'description_changed':
case 'note_changed': case 'note_changed':
@ -78,42 +113,49 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
case 'tags_changed': case 'tags_changed':
return <TagIcon className={`${iconClass} text-pink-500`} />; return <TagIcon className={`${iconClass} text-pink-500`} />;
case 'archived': case 'archived':
return <ArchiveBoxIcon className={`${iconClass} text-gray-500`} />; return (
<ArchiveBoxIcon className={`${iconClass} text-gray-500`} />
);
case 'today_changed': case 'today_changed':
return <CalendarIcon className={`${iconClass} text-blue-600`} />; return (
<CalendarIcon className={`${iconClass} text-blue-600`} />
);
default: default:
return <ClockIcon className={`${iconClass} text-gray-400`} />; return <ClockIcon className={`${iconClass} text-gray-400`} />;
} }
}; };
const getEventDescription = (event: TaskEvent) => { const getEventDescription = (event: TaskEvent) => {
const { event_type, old_value, new_value, field_name } = event; const { event_type, old_value, new_value } = event;
switch (event_type) { switch (event_type) {
case 'created': case 'created':
return t('timeline.events.taskCreated'); return t('timeline.events.taskCreated');
case 'status_changed': case 'status_changed':
case 'completed': case 'completed': {
const oldStatus = old_value?.status; const oldStatus = old_value?.status;
const newStatus = new_value?.status; const newStatus = new_value?.status;
if (oldStatus !== undefined && newStatus !== undefined) { if (oldStatus !== undefined && newStatus !== undefined) {
return `${t('timeline.events.status')}: ${getStatusLabel(oldStatus)}${getStatusLabel(newStatus)}`; return `${t('timeline.events.status')}: ${getStatusLabel(oldStatus)}${getStatusLabel(newStatus)}`;
} }
return t('timeline.events.statusChanged'); return t('timeline.events.statusChanged');
case 'priority_changed': }
case 'priority_changed': {
const oldPriority = old_value?.priority; const oldPriority = old_value?.priority;
const newPriority = new_value?.priority; const newPriority = new_value?.priority;
if (oldPriority !== undefined && newPriority !== undefined) { if (oldPriority !== undefined && newPriority !== undefined) {
return `${t('timeline.events.priority')}: ${getPriorityLabel(oldPriority)}${getPriorityLabel(newPriority)}`; return `${t('timeline.events.priority')}: ${getPriorityLabel(oldPriority)}${getPriorityLabel(newPriority)}`;
} }
return t('timeline.events.priorityChanged'); return t('timeline.events.priorityChanged');
case 'due_date_changed': }
case 'due_date_changed': {
const oldDate = old_value?.due_date; const oldDate = old_value?.due_date;
const newDate = new_value?.due_date; const newDate = new_value?.due_date;
if (oldDate || newDate) { if (oldDate || newDate) {
return `${t('timeline.events.dueDate')}: ${oldDate || t('timeline.events.none')}${newDate || t('timeline.events.none')}`; return `${t('timeline.events.dueDate')}: ${oldDate || t('timeline.events.none')}${newDate || t('timeline.events.none')}`;
} }
return t('timeline.events.dueDateChanged'); return t('timeline.events.dueDateChanged');
}
case 'name_changed': case 'name_changed':
return t('timeline.events.nameUpdated'); return t('timeline.events.nameUpdated');
case 'description_changed': case 'description_changed':
@ -171,7 +213,9 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
return ( return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400"> <div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<SparklesIcon className="h-6 w-6 mb-2" /> <SparklesIcon className="h-6 w-6 mb-2" />
<span className="text-sm text-center">Timeline will appear after saving</span> <span className="text-sm text-center">
Timeline will appear after saving
</span>
</div> </div>
); );
} }
@ -188,19 +232,31 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
return ( return (
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="space-y-2"> <div className="space-y-2">
{events.map((event, index) => ( {events.map((event) => (
<div key={event.id} className="relative"> <div key={event.id} className="relative">
{/* Event item */} {/* Event item */}
<div className="flex items-start space-x-3 py-1 relative z-10"> <div className="flex items-start space-x-3 py-1 relative z-10">
{/* Icon */} {/* Icon */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center border-2 ${ <div
event.event_type === 'created' ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700' : className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center border-2 ${
event.event_type === 'completed' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700' : event.event_type === 'created'
event.event_type === 'status_changed' && event.new_value?.status === 1 ? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700' : ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
event.event_type === 'priority_changed' ? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-700' : : event.event_type === 'completed'
'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700'
}`}> : event.event_type ===
{getEventIcon(event.event_type, event.new_value)} 'status_changed' &&
event.new_value?.status === 1
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
: event.event_type ===
'priority_changed'
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-700'
: 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-700'
}`}
>
{getEventIcon(
event.event_type,
event.new_value
)}
</div> </div>
{/* Content */} {/* Content */}
@ -213,16 +269,23 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
</div> </div>
{/* Additional details for certain events */} {/* Additional details for certain events */}
{event.event_type === 'tags_changed' && event.new_value && ( {event.event_type === 'tags_changed' &&
event.new_value && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{Array.isArray(event.new_value) && event.new_value.map((tag: any, tagIndex: number) => ( {Array.isArray(event.new_value) &&
event.new_value.map(
(
tag: any,
tagIndex: number
) => (
<span <span
key={tagIndex} key={tagIndex}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800" className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800"
> >
{tag.name || tag} {tag.name || tag}
</span> </span>
))} )
)}
</div> </div>
)} )}
</div> </div>

View file

@ -1,11 +1,15 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from 'react-router-dom';
import { Task } from "../../entities/Task"; import { Task } from '../../entities/Task';
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
import TaskModal from "./TaskModal"; import TaskModal from './TaskModal';
import { fetchTaskByUuid, updateTask, deleteTask } from "../../utils/tasksService"; import {
import { createProject } from "../../utils/projectsService"; fetchTaskByUuid,
import { useStore } from "../../store/useStore"; updateTask,
deleteTask,
} from '../../utils/tasksService';
import { createProject } from '../../utils/projectsService';
import { useStore } from '../../store/useStore';
const TaskView: React.FC = () => { const TaskView: React.FC = () => {
const { uuid } = useParams<{ uuid: string }>(); const { uuid } = useParams<{ uuid: string }>();
@ -18,7 +22,7 @@ const TaskView: React.FC = () => {
useEffect(() => { useEffect(() => {
const fetchTask = async () => { const fetchTask = async () => {
if (!uuid) { if (!uuid) {
setError("No task UUID provided"); setError('No task UUID provided');
setLoading(false); setLoading(false);
return; return;
} }
@ -26,8 +30,8 @@ const TaskView: React.FC = () => {
try { try {
const taskData = await fetchTaskByUuid(uuid); const taskData = await fetchTaskByUuid(uuid);
setTask(taskData); setTask(taskData);
} catch (err) { } catch {
setError("An error occurred while fetching the task"); setError('An error occurred while fetching the task');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -47,7 +51,7 @@ const TaskView: React.FC = () => {
setTask(updated); setTask(updated);
} }
} catch (error) { } catch (error) {
console.error("Error updating task:", error); console.error('Error updating task:', error);
} }
}; };
@ -56,7 +60,7 @@ const TaskView: React.FC = () => {
await deleteTask(taskId); await deleteTask(taskId);
navigate('/today'); // Navigate back to today view after deletion navigate('/today'); // Navigate back to today view after deletion
} catch (error) { } catch (error) {
console.error("Error deleting task:", error); console.error('Error deleting task:', error);
throw error; throw error;
} }
}; };
@ -65,7 +69,7 @@ const TaskView: React.FC = () => {
try { try {
return await createProject({ name }); return await createProject({ name });
} catch (error) { } catch (error) {
console.error("Error creating project:", error); console.error('Error creating project:', error);
throw error; throw error;
} }
}; };
@ -85,10 +89,10 @@ const TaskView: React.FC = () => {
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900"> <div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-center"> <div className="text-center">
<div className="text-xl font-semibold text-red-600 dark:text-red-400 mb-4"> <div className="text-xl font-semibold text-red-600 dark:text-red-400 mb-4">
{error || "Task not found"} {error || 'Task not found'}
</div> </div>
<button <button
onClick={() => navigate("/")} onClick={() => navigate('/')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
Go Home Go Home

View file

@ -1,8 +1,8 @@
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback } from 'react';
import { format } from "date-fns"; import { format } from 'date-fns';
import { el, enUS, es, ja, uk, de } from "date-fns/locale"; import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import i18n from "i18next"; import i18n from 'i18next';
import { import {
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
ArrowPathIcon, ArrowPathIcon,
@ -14,20 +14,23 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronRightIcon, ChevronRightIcon,
Cog6ToothIcon, Cog6ToothIcon,
} from "@heroicons/react/24/outline"; } from '@heroicons/react/24/outline';
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService"; import { fetchTasks, updateTask, deleteTask } from '../../utils/tasksService';
import { fetchProjects } from "../../utils/projectsService"; import { fetchProjects } from '../../utils/projectsService';
import { Task } from "../../entities/Task"; import { Task } from '../../entities/Task';
import { useStore } from "../../store/useStore"; import { useStore } from '../../store/useStore';
import TaskList from "./TaskList"; import TaskList from './TaskList';
import TodayPlan from "./TodayPlan"; import TodayPlan from './TodayPlan';
import { Metrics } from "../../entities/Metrics"; import { Metrics } from '../../entities/Metrics';
import ProductivityAssistant from "../Productivity/ProductivityAssistant"; import ProductivityAssistant from '../Productivity/ProductivityAssistant';
import NextTaskSuggestion from "./NextTaskSuggestion"; import NextTaskSuggestion from './NextTaskSuggestion';
import WeeklyCompletionChart from "./WeeklyCompletionChart"; import WeeklyCompletionChart from './WeeklyCompletionChart';
import TodaySettingsDropdown from "./TodaySettingsDropdown"; import TodaySettingsDropdown from './TodaySettingsDropdown';
import { getProductivityAssistantEnabled, getNextTaskSuggestionEnabled } from "../../utils/profileService"; import {
import { toggleTaskToday } from "../../utils/tasksService"; getProductivityAssistantEnabled,
getNextTaskSuggestionEnabled,
} from '../../utils/profileService';
import { toggleTaskToday } from '../../utils/tasksService';
const getLocale = (language: string) => { const getLocale = (language: string) => {
switch (language) { switch (language) {
@ -58,7 +61,8 @@ const TasksToday: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
const [dailyQuote, setDailyQuote] = useState<string>(''); const [dailyQuote, setDailyQuote] = useState<string>('');
const [productivityAssistantEnabled, setProductivityAssistantEnabled] = useState(true); const [productivityAssistantEnabled, setProductivityAssistantEnabled] =
useState(true);
const [isSettingsDropdownOpen, setIsSettingsDropdownOpen] = useState(false); const [isSettingsDropdownOpen, setIsSettingsDropdownOpen] = useState(false);
const [todaySettings, setTodaySettings] = useState({ const [todaySettings, setTodaySettings] = useState({
showMetrics: false, showMetrics: false,
@ -69,7 +73,8 @@ const TasksToday: React.FC = () => {
showProgressBar: true, // Always enabled showProgressBar: true, // Always enabled
showDailyQuote: true, showDailyQuote: true,
}); });
const [nextTaskSuggestionEnabled, setNextTaskSuggestionEnabled] = useState(true); const [nextTaskSuggestionEnabled, setNextTaskSuggestionEnabled] =
useState(true);
const [showNextTaskSuggestion, setShowNextTaskSuggestion] = useState(true); const [showNextTaskSuggestion, setShowNextTaskSuggestion] = useState(true);
const [isSuggestedCollapsed, setIsSuggestedCollapsed] = useState(() => { const [isSuggestedCollapsed, setIsSuggestedCollapsed] = useState(() => {
const stored = localStorage.getItem('suggestedTasksCollapsed'); const stored = localStorage.getItem('suggestedTasksCollapsed');
@ -104,12 +109,15 @@ const TasksToday: React.FC = () => {
difference: 0, difference: 0,
percentage: 0, percentage: 0,
todayCount, todayCount,
averageCount: 0 averageCount: 0,
}; };
} }
// Sum all completed tasks from the weekly data // Sum all completed tasks from the weekly data
const totalCompletedTasks = metrics.weekly_completions.reduce((sum, completion) => sum + completion.count, 0); const totalCompletedTasks = metrics.weekly_completions.reduce(
(sum, completion) => sum + completion.count,
0
);
// Average is total completed tasks divided by 7 // Average is total completed tasks divided by 7
const averageCount = totalCompletedTasks / 7; const averageCount = totalCompletedTasks / 7;
@ -117,7 +125,9 @@ const TasksToday: React.FC = () => {
// Calculate percentage change vs average // Calculate percentage change vs average
let percentage = 0; let percentage = 0;
if (averageCount > 0) { if (averageCount > 0) {
percentage = Math.round(((todayCount - averageCount) / averageCount) * 100); percentage = Math.round(
((todayCount - averageCount) / averageCount) * 100
);
} else if (todayCount > 0) { } else if (todayCount > 0) {
// If average was 0 but today has completions, it's a 100%+ increase // If average was 0 but today has completions, it's a 100%+ increase
percentage = 100; percentage = 100;
@ -129,7 +139,7 @@ const TasksToday: React.FC = () => {
difference: Math.round((todayCount - averageCount) * 10) / 10, // Round to 1 decimal difference: Math.round((todayCount - averageCount) * 10) / 10, // Round to 1 decimal
percentage: Math.abs(percentage), percentage: Math.abs(percentage),
todayCount, todayCount,
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
}; };
} else if (todayCount < averageCount) { } else if (todayCount < averageCount) {
return { return {
@ -137,7 +147,7 @@ const TasksToday: React.FC = () => {
difference: Math.round((averageCount - todayCount) * 10) / 10, // Round to 1 decimal difference: Math.round((averageCount - todayCount) * 10) / 10, // Round to 1 decimal
percentage: Math.abs(percentage), percentage: Math.abs(percentage),
todayCount, todayCount,
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
}; };
} else { } else {
return { return {
@ -145,7 +155,7 @@ const TasksToday: React.FC = () => {
difference: 0, difference: 0,
percentage: 0, percentage: 0,
todayCount, todayCount,
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
}; };
} }
}; };
@ -171,7 +181,6 @@ const TasksToday: React.FC = () => {
localStorage.setItem('completedTasksCollapsed', newState.toString()); localStorage.setItem('completedTasksCollapsed', newState.toString());
}; };
// Load data once on component mount // Load data once on component mount
useEffect(() => { useEffect(() => {
isMounted.current = true; isMounted.current = true;
@ -190,7 +199,10 @@ const TasksToday: React.FC = () => {
setProductivityAssistantEnabled(isEnabled); setProductivityAssistantEnabled(isEnabled);
} }
} catch (error) { } catch (error) {
console.error("Failed to load productivity assistant setting:", error); console.error(
'Failed to load productivity assistant setting:',
error
);
} }
try { try {
@ -200,14 +212,19 @@ const TasksToday: React.FC = () => {
setNextTaskSuggestionEnabled(isNextTaskEnabled); setNextTaskSuggestionEnabled(isNextTaskEnabled);
} }
} catch (error) { } catch (error) {
console.error("Failed to load next task suggestion setting:", error); console.error(
'Failed to load next task suggestion setting:',
error
);
} }
try { try {
// Load projects first // Load projects first
const projectsData = await fetchProjects(); const projectsData = await fetchProjects();
if (isMounted.current) { if (isMounted.current) {
const safeProjectsData = Array.isArray(projectsData) ? projectsData : []; const safeProjectsData = Array.isArray(projectsData)
? projectsData
: [];
setLocalProjects(safeProjectsData); setLocalProjects(safeProjectsData);
store.projectsStore.setProjects(safeProjectsData); store.projectsStore.setProjects(safeProjectsData);
} }
@ -221,7 +238,8 @@ const TasksToday: React.FC = () => {
try { try {
// Load tasks with metrics // Load tasks with metrics
const { tasks: fetchedTasks, metrics: fetchedMetrics } = await fetchTasks("?type=today"); const { tasks: fetchedTasks, metrics: fetchedMetrics } =
await fetchTasks('?type=today');
if (isMounted.current) { if (isMounted.current) {
setLocalTasks(fetchedTasks); setLocalTasks(fetchedTasks);
setMetrics(fetchedMetrics); setMetrics(fetchedMetrics);
@ -229,7 +247,7 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(fetchedTasks); store.tasksStore.setTasks(fetchedTasks);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch tasks:", error); console.error('Failed to fetch tasks:', error);
if (isMounted.current) { if (isMounted.current) {
setIsError(true); setIsError(true);
} }
@ -241,30 +259,46 @@ const TasksToday: React.FC = () => {
// Load daily quote from translations // Load daily quote from translations
try { try {
const response = await fetch(`/locales/${i18n.language}/quotes.json`); const response = await fetch(
`/locales/${i18n.language}/quotes.json`
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (isMounted.current && data.quotes && data.quotes.length > 0) { if (
isMounted.current &&
data.quotes &&
data.quotes.length > 0
) {
// Get a random quote from the translated quotes // Get a random quote from the translated quotes
const randomIndex = Math.floor(Math.random() * data.quotes.length); const randomIndex = Math.floor(
Math.random() * data.quotes.length
);
setDailyQuote(data.quotes[randomIndex]); setDailyQuote(data.quotes[randomIndex]);
} }
} else { } else {
// Fallback to English if language file doesn't exist // Fallback to English if language file doesn't exist
const fallbackResponse = await fetch('/locales/en/quotes.json'); const fallbackResponse = await fetch(
'/locales/en/quotes.json'
);
if (fallbackResponse.ok) { if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json(); const fallbackData = await fallbackResponse.json();
if (isMounted.current && fallbackData.quotes && fallbackData.quotes.length > 0) { if (
const randomIndex = Math.floor(Math.random() * fallbackData.quotes.length); isMounted.current &&
fallbackData.quotes &&
fallbackData.quotes.length > 0
) {
const randomIndex = Math.floor(
Math.random() * fallbackData.quotes.length
);
setDailyQuote(fallbackData.quotes[randomIndex]); setDailyQuote(fallbackData.quotes[randomIndex]);
} }
} }
} }
} catch (error) { } catch (error) {
console.error("Failed to load daily quote:", error); console.error('Failed to load daily quote:', error);
// Ultimate fallback // Ultimate fallback
if (isMounted.current) { if (isMounted.current) {
setDailyQuote("Focus on progress, not perfection."); setDailyQuote('Focus on progress, not perfection.');
} }
} }
@ -281,9 +315,14 @@ const TasksToday: React.FC = () => {
if (userData.today_settings) { if (userData.today_settings) {
if (typeof userData.today_settings === 'string') { if (typeof userData.today_settings === 'string') {
try { try {
settings = JSON.parse(userData.today_settings); settings = JSON.parse(
userData.today_settings
);
} catch (error) { } catch (error) {
console.error('Error parsing today_settings:', error); console.error(
'Error parsing today_settings:',
error
);
settings = null; settings = null;
} }
} else { } else {
@ -309,7 +348,7 @@ const TasksToday: React.FC = () => {
} }
} }
} catch (error) { } catch (error) {
console.error("Failed to load user settings:", error); console.error('Failed to load user settings:', error);
} }
}; };
@ -322,14 +361,16 @@ const TasksToday: React.FC = () => {
}, []); // Empty dependency array - only run once on mount }, []); // Empty dependency array - only run once on mount
// Memoize task handlers to prevent recreating functions on each render // Memoize task handlers to prevent recreating functions on each render
const handleTaskUpdate = useCallback(async (updatedTask: Task): Promise<void> => { const handleTaskUpdate = useCallback(
async (updatedTask: Task): Promise<void> => {
if (!updatedTask.id || !isMounted.current) return; if (!updatedTask.id || !isMounted.current) return;
setIsLoading(true); setIsLoading(true);
try { try {
await updateTask(updatedTask.id, updatedTask); await updateTask(updatedTask.id, updatedTask);
// Refetch data to ensure consistency // Refetch data to ensure consistency
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); const { tasks: updatedTasks, metrics } =
await fetchTasks('?type=today');
if (isMounted.current) { if (isMounted.current) {
setLocalTasks(updatedTasks); setLocalTasks(updatedTasks);
@ -338,7 +379,7 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(updatedTasks); store.tasksStore.setTasks(updatedTasks);
} }
} catch (error) { } catch (error) {
console.error("Error updating task:", error); console.error('Error updating task:', error);
if (isMounted.current) { if (isMounted.current) {
setIsError(true); setIsError(true);
} }
@ -347,16 +388,20 @@ const TasksToday: React.FC = () => {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [store.tasksStore]); },
[store.tasksStore]
);
const handleTaskDelete = useCallback(async (taskId: number): Promise<void> => { const handleTaskDelete = useCallback(
async (taskId: number): Promise<void> => {
if (!isMounted.current) return; if (!isMounted.current) return;
setIsLoading(true); setIsLoading(true);
try { try {
await deleteTask(taskId); await deleteTask(taskId);
// Refetch data to ensure consistency // Refetch data to ensure consistency
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); const { tasks: updatedTasks, metrics } =
await fetchTasks('?type=today');
if (isMounted.current) { if (isMounted.current) {
setLocalTasks(updatedTasks); setLocalTasks(updatedTasks);
@ -365,7 +410,7 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(updatedTasks); store.tasksStore.setTasks(updatedTasks);
} }
} catch (error) { } catch (error) {
console.error("Error deleting task:", error); console.error('Error deleting task:', error);
if (isMounted.current) { if (isMounted.current) {
setIsError(true); setIsError(true);
} }
@ -374,15 +419,19 @@ const TasksToday: React.FC = () => {
setIsLoading(false); setIsLoading(false);
} }
} }
}, [store.tasksStore]); },
[store.tasksStore]
);
const handleToggleToday = useCallback(async (taskId: number): Promise<void> => { const handleToggleToday = useCallback(
async (taskId: number): Promise<void> => {
if (!isMounted.current) return; if (!isMounted.current) return;
try { try {
await toggleTaskToday(taskId); await toggleTaskToday(taskId);
// Refetch data to ensure consistency // Refetch data to ensure consistency
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); const { tasks: updatedTasks, metrics } =
await fetchTasks('?type=today');
if (isMounted.current) { if (isMounted.current) {
setLocalTasks(updatedTasks); setLocalTasks(updatedTasks);
@ -391,14 +440,14 @@ const TasksToday: React.FC = () => {
store.tasksStore.setTasks(updatedTasks); store.tasksStore.setTasks(updatedTasks);
} }
} catch (error) { } catch (error) {
console.error("Error toggling task today status:", error); console.error('Error toggling task today status:', error);
if (isMounted.current) { if (isMounted.current) {
setIsError(true); setIsError(true);
} }
} }
}, [store.tasksStore]); },
[store.tasksStore]
);
// Calculate today's progress for the progress bar // Calculate today's progress for the progress bar
const getTodayProgress = () => { const getTodayProgress = () => {
@ -409,7 +458,10 @@ const TasksToday: React.FC = () => {
return { return {
completed: completedToday, completed: completedToday,
total: totalTodayTasks, total: totalTodayTasks,
percentage: totalTodayTasks === 0 ? 0 : Math.round((completedToday / totalTodayTasks) * 100) percentage:
totalTodayTasks === 0
? 0
: Math.round((completedToday / totalTodayTasks) * 100),
}; };
}; };
@ -424,7 +476,9 @@ const TasksToday: React.FC = () => {
if (isLoading && localTasks.length === 0) { if (isLoading && localTasks.length === 0) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
<p className="text-gray-500 dark:text-gray-400">{t('common.loading', 'Loading...')}</p> <p className="text-gray-500 dark:text-gray-400">
{t('common.loading', 'Loading...')}
</p>
</div> </div>
); );
} }
@ -433,7 +487,9 @@ const TasksToday: React.FC = () => {
if (isError && localTasks.length === 0) { if (isError && localTasks.length === 0) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
<p className="text-red-500">{t('errors.somethingWentWrong', 'Something went wrong')}</p> <p className="text-red-500">
{t('errors.somethingWentWrong', 'Something went wrong')}
</p>
</div> </div>
); );
} }
@ -451,7 +507,9 @@ const TasksToday: React.FC = () => {
{t('tasks.today')} {t('tasks.today')}
</h2> </h2>
<span className="text-gray-500"> <span className="text-gray-500">
{format(new Date(), "PPP", { locale: getLocale(i18n.language) })} {format(new Date(), 'PPP', {
locale: getLocale(i18n.language),
})}
</span> </span>
</div> </div>
@ -459,9 +517,16 @@ const TasksToday: React.FC = () => {
<div className="flex items-end space-x-2"> <div className="flex items-end space-x-2">
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsSettingsDropdownOpen(!isSettingsDropdownOpen)} onClick={() =>
setIsSettingsDropdownOpen(
!isSettingsDropdownOpen
)
}
className="flex flex-row items-center p-2 group focus:outline-none rounded-md transition-all duration-200 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800" className="flex flex-row items-center p-2 group focus:outline-none rounded-md transition-all duration-200 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
title={t('settings.todayPageSettings', 'Today Page Settings')} title={t(
'settings.todayPageSettings',
'Today Page Settings'
)}
> >
<Cog6ToothIcon className="h-5 w-5" /> <Cog6ToothIcon className="h-5 w-5" />
<span className="text-xs font-medium transition-all duration-200 max-w-0 overflow-hidden opacity-0 group-hover:max-w-xs group-hover:opacity-100 group-focus:max-w-xs group-focus:opacity-100 group-hover:ml-2 group-focus:ml-2"> <span className="text-xs font-medium transition-all duration-200 max-w-0 overflow-hidden opacity-0 group-hover:max-w-xs group-hover:opacity-100 group-focus:max-w-xs group-focus:opacity-100 group-hover:ml-2 group-focus:ml-2">
@ -471,7 +536,9 @@ const TasksToday: React.FC = () => {
<TodaySettingsDropdown <TodaySettingsDropdown
isOpen={isSettingsDropdownOpen} isOpen={isSettingsDropdownOpen}
onClose={() => setIsSettingsDropdownOpen(false)} onClose={() =>
setIsSettingsDropdownOpen(false)
}
settings={todaySettings} settings={todaySettings}
onSettingsChange={handleSettingsChange} onSettingsChange={handleSettingsChange}
/> />
@ -480,16 +547,20 @@ const TasksToday: React.FC = () => {
</div> </div>
{/* Today Progress Bar - integrated with header */} {/* Today Progress Bar - integrated with header */}
{todaySettings.showProgressBar && todayProgress.total > 0 && ( {todaySettings.showProgressBar &&
todayProgress.total > 0 && (
<div className="mb-1"> <div className="mb-1">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1"> <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1">
<div <div
className="h-1 rounded-full transition-all duration-500 ease-out bg-gradient-to-r from-blue-400 via-blue-500 to-blue-700" className="h-1 rounded-full transition-all duration-500 ease-out bg-gradient-to-r from-blue-400 via-blue-500 to-blue-700"
style={{ width: `${todayProgress.percentage}%` }} style={{
width: `${todayProgress.percentage}%`,
}}
></div> ></div>
</div> </div>
{/* Daily Quote */} {/* Daily Quote */}
{todaySettings.showDailyQuote && dailyQuote && ( {todaySettings.showDailyQuote &&
dailyQuote && (
<div className="mt-2"> <div className="mt-2">
<p className="text-s text-gray-400 dark:text-gray-500 font-light text-left"> <p className="text-s text-gray-400 dark:text-gray-500 font-light text-left">
{dailyQuote} {dailyQuote}
@ -506,12 +577,16 @@ const TasksToday: React.FC = () => {
<div className="mb-2 grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="mb-2 grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Combined Task & Project Metrics */} {/* Combined Task & Project Metrics */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-4"> <div className="bg-white dark:bg-gray-900 rounded-lg shadow p-4">
<h3 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">{t('dashboard.overview')}</h3> <h3 className="text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">
{t('dashboard.overview')}
</h3>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<ClipboardDocumentListIcon className="h-4 w-4 text-blue-500 mr-2" /> <ClipboardDocumentListIcon className="h-4 w-4 text-blue-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.backlog')}</p> <p className="text-xs text-gray-500 dark:text-gray-400">
{t('tasks.backlog')}
</p>
</div> </div>
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{metrics.total_open_tasks} {metrics.total_open_tasks}
@ -521,7 +596,9 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<ArrowPathIcon className="h-4 w-4 text-green-500 mr-2" /> <ArrowPathIcon className="h-4 w-4 text-green-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.inProgress')}</p> <p className="text-xs text-gray-500 dark:text-gray-400">
{t('tasks.inProgress')}
</p>
</div> </div>
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{metrics.tasks_in_progress_count} {metrics.tasks_in_progress_count}
@ -531,7 +608,9 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<CalendarDaysIcon className="h-4 w-4 text-red-500 mr-2" /> <CalendarDaysIcon className="h-4 w-4 text-red-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.dueToday')}</p> <p className="text-xs text-gray-500 dark:text-gray-400">
{t('tasks.dueToday')}
</p>
</div> </div>
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{metrics.tasks_due_today.length} {metrics.tasks_due_today.length}
@ -541,29 +620,60 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<CheckCircleIcon className="h-4 w-4 text-green-600 mr-2" /> <CheckCircleIcon className="h-4 w-4 text-green-600 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('tasks.completedToday', 'Completed Today')}</p> <p className="text-xs text-gray-500 dark:text-gray-400">
{t(
'tasks.completedToday',
'Completed Today'
)}
</p>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{(() => { {(() => {
const trend = getCompletionTrend(); const trend = getCompletionTrend();
const getTooltipText = () => { const getTooltipText = () => {
if (trend.direction === 'same') { if (
return t('dashboard.sameAsAverage', 'Same as average'); trend.direction === 'same'
} else if (trend.direction === 'up') { ) {
return t('dashboard.betterThanAverage', '{{percentage}}% more than average', { percentage: trend.percentage }); return t(
'dashboard.sameAsAverage',
'Same as average'
);
} else if (
trend.direction === 'up'
) {
return t(
'dashboard.betterThanAverage',
'{{percentage}}% more than average',
{
percentage:
trend.percentage,
}
);
} else { } else {
return t('dashboard.worseThanAverage', '{{percentage}}% less than average', { percentage: trend.percentage }); return t(
'dashboard.worseThanAverage',
'{{percentage}}% less than average',
{
percentage:
trend.percentage,
}
);
} }
}; };
return ( return (
<> <>
{(trend.direction === 'up' || trend.direction === 'down') && ( {(trend.direction ===
'up' ||
trend.direction ===
'down') && (
<div className="relative group"> <div className="relative group">
{trend.direction === 'up' && ( {trend.direction ===
'up' && (
<ArrowUpIcon className="h-3 w-3 text-green-500" /> <ArrowUpIcon className="h-3 w-3 text-green-500" />
)} )}
{trend.direction === 'down' && ( {trend.direction ===
'down' && (
<ArrowDownIcon className="h-3 w-3 text-red-500" /> <ArrowDownIcon className="h-3 w-3 text-red-500" />
)} )}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-700 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10"> <div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs text-white bg-gray-900 dark:bg-gray-700 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
@ -572,7 +682,11 @@ const TasksToday: React.FC = () => {
</div> </div>
)} )}
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{metrics.tasks_completed_today.length} {
metrics
.tasks_completed_today
.length
}
</p> </p>
</> </>
); );
@ -583,10 +697,16 @@ const TasksToday: React.FC = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center"> <div className="flex items-center">
<FolderIcon className="h-4 w-4 text-purple-500 mr-2" /> <FolderIcon className="h-4 w-4 text-purple-500 mr-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{t('projects.active')}</p> <p className="text-xs text-gray-500 dark:text-gray-400">
{t('projects.active')}
</p>
</div> </div>
<p className="text-sm font-semibold"> <p className="text-sm font-semibold">
{Array.isArray(localProjects) ? localProjects.filter(project => project.active).length : 0} {Array.isArray(localProjects)
? localProjects.filter(
(project) => project.active
).length
: 0}
</p> </p>
</div> </div>
</div> </div>
@ -594,14 +714,20 @@ const TasksToday: React.FC = () => {
{/* Weekly Completion Chart */} {/* Weekly Completion Chart */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<WeeklyCompletionChart data={metrics.weekly_completions} /> <WeeklyCompletionChart
data={metrics.weekly_completions}
/>
</div> </div>
</div> </div>
)} )}
{/* Productivity Assistant - Conditionally Rendered */} {/* Productivity Assistant - Conditionally Rendered */}
{todaySettings.showProductivity && productivityAssistantEnabled && ( {todaySettings.showProductivity &&
<ProductivityAssistant tasks={localTasks} projects={localProjects} /> productivityAssistantEnabled && (
<ProductivityAssistant
tasks={localTasks}
projects={localProjects}
/>
)} )}
{/* Today Plan */} {/* Today Plan */}
@ -617,13 +743,18 @@ const TasksToday: React.FC = () => {
{todaySettings.showIntelligence && ( {todaySettings.showIntelligence && (
<div className="mt-2"> <div className="mt-2">
{/* Next Task Suggestion */} {/* Next Task Suggestion */}
{nextTaskSuggestionEnabled && showNextTaskSuggestion && ( {nextTaskSuggestionEnabled &&
showNextTaskSuggestion && (
<NextTaskSuggestion <NextTaskSuggestion
metrics={{ metrics={{
tasks_due_today: metrics.tasks_due_today, tasks_due_today:
suggested_tasks: metrics.suggested_tasks, metrics.tasks_due_today,
tasks_in_progress: metrics.tasks_in_progress, suggested_tasks:
today_plan_tasks: metrics.today_plan_tasks metrics.suggested_tasks,
tasks_in_progress:
metrics.tasks_in_progress,
today_plan_tasks:
metrics.today_plan_tasks,
}} }}
projects={localProjects} projects={localProjects}
onTaskUpdate={handleTaskUpdate} onTaskUpdate={handleTaskUpdate}
@ -638,9 +769,13 @@ const TasksToday: React.FC = () => {
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700" className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
onClick={toggleSuggestedCollapsed} onClick={toggleSuggestedCollapsed}
> >
<h3 className="text-xl font-medium">{t('tasks.suggested')}</h3> <h3 className="text-xl font-medium">
{t('tasks.suggested')}
</h3>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">{metrics.suggested_tasks.length}</span> <span className="text-sm text-gray-500 mr-2">
{metrics.suggested_tasks.length}
</span>
{isSuggestedCollapsed ? ( {isSuggestedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" /> <ChevronRightIcon className="h-5 w-5 text-gray-500" />
) : ( ) : (
@ -663,9 +798,12 @@ const TasksToday: React.FC = () => {
)} )}
{/* Due Today Tasks - Conditionally Rendered */} {/* Due Today Tasks - Conditionally Rendered */}
{todaySettings.showDueToday && metrics.tasks_due_today.length > 0 && ( {todaySettings.showDueToday &&
metrics.tasks_due_today.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<h3 className="text-xl font-medium mt-6 mb-2">{t('tasks.dueToday')}</h3> <h3 className="text-xl font-medium mt-6 mb-2">
{t('tasks.dueToday')}
</h3>
<TaskList <TaskList
tasks={metrics.tasks_due_today} tasks={metrics.tasks_due_today}
onTaskUpdate={handleTaskUpdate} onTaskUpdate={handleTaskUpdate}
@ -677,15 +815,20 @@ const TasksToday: React.FC = () => {
)} )}
{/* Completed Tasks - Conditionally Rendered */} {/* Completed Tasks - Conditionally Rendered */}
{todaySettings.showCompleted && metrics.tasks_completed_today.length > 0 && ( {todaySettings.showCompleted &&
metrics.tasks_completed_today.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<div <div
className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700" className="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
onClick={toggleCompletedCollapsed} onClick={toggleCompletedCollapsed}
> >
<h3 className="text-xl font-medium">{t('tasks.completedToday')}</h3> <h3 className="text-xl font-medium">
{t('tasks.completedToday')}
</h3>
<div className="flex items-center"> <div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">{metrics.tasks_completed_today.length}</span> <span className="text-sm text-gray-500 mr-2">
{metrics.tasks_completed_today.length}
</span>
{isCompletedCollapsed ? ( {isCompletedCollapsed ? (
<ChevronRightIcon className="h-5 w-5 text-gray-500" /> <ChevronRightIcon className="h-5 w-5 text-gray-500" />
) : ( ) : (
@ -712,7 +855,6 @@ const TasksToday: React.FC = () => {
{t('tasks.noTasksAvailable')} {t('tasks.noTasksAvailable')}
</p> </p>
)} )}
</div> </div>
</div> </div>
); );

View file

@ -12,17 +12,18 @@ interface TimelinePanelProps {
const TimelinePanel: React.FC<TimelinePanelProps> = ({ const TimelinePanel: React.FC<TimelinePanelProps> = ({
taskId, taskId,
isExpanded, isExpanded,
onToggle onToggle,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={`${ <div
className={`${
isExpanded isExpanded
? 'w-full lg:w-80 opacity-100' ? 'w-full lg:w-80 opacity-100'
: 'w-0 lg:w-12 opacity-0 lg:opacity-100' : 'w-0 lg:w-12 opacity-0 lg:opacity-100'
} border-t lg:border-t-0 lg:border-l border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col transition-all duration-300 overflow-hidden`}> } border-t lg:border-t-0 lg:border-l border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 flex flex-col transition-all duration-300 overflow-hidden`}
>
{/* Collapsed state - envelope icon */} {/* Collapsed state - envelope icon */}
{!isExpanded && ( {!isExpanded && (
<div className="hidden lg:flex flex-col items-center justify-center h-full p-2"> <div className="hidden lg:flex flex-col items-center justify-center h-full p-2">

View file

@ -1,9 +1,9 @@
import React from "react"; import React from 'react';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import { CalendarDaysIcon } from "@heroicons/react/24/outline"; import { CalendarDaysIcon } from '@heroicons/react/24/outline';
import TaskList from "./TaskList"; import TaskList from './TaskList';
import { Task } from "../../entities/Task"; import { Task } from '../../entities/Task';
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
interface TodayPlanProps { interface TodayPlanProps {
todayPlanTasks: Task[] | undefined; todayPlanTasks: Task[] | undefined;
@ -31,10 +31,16 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
<div className="text-center py-8"> <div className="text-center py-8">
<CalendarDaysIcon className="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" /> <CalendarDaysIcon className="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-2"> <p className="text-gray-500 dark:text-gray-400 mb-2">
{t('tasks.noPlanToday', 'No tasks planned for today yet')} {t(
'tasks.noPlanToday',
'No tasks planned for today yet'
)}
</p> </p>
<p className="text-sm text-gray-400 dark:text-gray-500"> <p className="text-sm text-gray-400 dark:text-gray-500">
{t('tasks.addToPlanHint', 'Use the calendar icons next to suggested tasks to add them to your today plan')} {t(
'tasks.addToPlanHint',
'Use the calendar icons next to suggested tasks to add them to your today plan'
)}
</p> </p>
</div> </div>
</> </>

View file

@ -42,7 +42,10 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
onClose(); onClose();
} }
}; };
@ -59,7 +62,7 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
const handleToggle = (key: keyof typeof localSettings) => { const handleToggle = (key: keyof typeof localSettings) => {
const newSettings = { const newSettings = {
...localSettings, ...localSettings,
[key]: !localSettings[key] [key]: !localSettings[key],
}; };
setLocalSettings(newSettings); setLocalSettings(newSettings);
@ -93,13 +96,45 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
const settingsOptions: Array<{key: keyof typeof localSettings, label: string, icon: React.ElementType, disabled?: boolean}> = [ const settingsOptions: Array<{
{ key: 'showDailyQuote', label: t('settings.showDailyQuote', 'Show Daily Quote'), icon: ChatBubbleBottomCenterTextIcon }, key: keyof typeof localSettings;
{ key: 'showMetrics', label: t('settings.showMetrics', 'Show Metrics'), icon: ChartBarIcon }, label: string;
{ key: 'showProductivity', label: t('settings.showProductivity', 'Show Productivity Insights'), icon: LightBulbIcon }, icon: React.ElementType;
{ key: 'showIntelligence', label: t('settings.showIntelligence', 'Show Intelligence Suggestions'), icon: SparklesIcon }, disabled?: boolean;
{ key: 'showDueToday', label: t('settings.showDueToday', 'Show Due Today Tasks'), icon: ClockIcon }, }> = [
{ key: 'showCompleted', label: t('settings.showCompleted', 'Show Completed Tasks'), icon: TrophyIcon }, {
key: 'showDailyQuote',
label: t('settings.showDailyQuote', 'Show Daily Quote'),
icon: ChatBubbleBottomCenterTextIcon,
},
{
key: 'showMetrics',
label: t('settings.showMetrics', 'Show Metrics'),
icon: ChartBarIcon,
},
{
key: 'showProductivity',
label: t('settings.showProductivity', 'Show Productivity Insights'),
icon: LightBulbIcon,
},
{
key: 'showIntelligence',
label: t(
'settings.showIntelligence',
'Show Intelligence Suggestions'
),
icon: SparklesIcon,
},
{
key: 'showDueToday',
label: t('settings.showDueToday', 'Show Due Today Tasks'),
icon: ClockIcon,
},
{
key: 'showCompleted',
label: t('settings.showCompleted', 'Show Completed Tasks'),
icon: TrophyIcon,
},
]; ];
return ( return (
@ -118,15 +153,22 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
const isDisabled = option.disabled || isSaving; const isDisabled = option.disabled || isSaving;
return ( return (
<div key={option.key} className={`flex items-center justify-between ${isDisabled ? 'opacity-60' : ''}`}> <div
key={option.key}
className={`flex items-center justify-between ${isDisabled ? 'opacity-60' : ''}`}
>
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<IconComponent className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" /> <IconComponent className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<label className={`text-sm text-gray-700 dark:text-gray-300 ${!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed'} flex-1`}> <label
className={`text-sm text-gray-700 dark:text-gray-300 ${!isDisabled ? 'cursor-pointer' : 'cursor-not-allowed'} flex-1`}
>
{option.label} {option.label}
</label> </label>
</div> </div>
<button <button
onClick={() => !isDisabled && handleToggle(option.key)} onClick={() =>
!isDisabled && handleToggle(option.key)
}
disabled={isDisabled} disabled={isDisabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
localSettings[option.key] localSettings[option.key]
@ -136,7 +178,9 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
> >
<span <span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${ className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
localSettings[option.key] ? 'translate-x-5' : 'translate-x-1' localSettings[option.key]
? 'translate-x-5'
: 'translate-x-1'
}`} }`}
/> />
</button> </button>

View file

@ -1,5 +1,12 @@
import React from 'react'; import React from 'react';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from 'recharts'; import {
BarChart,
Bar,
XAxis,
YAxis,
ResponsiveContainer,
Tooltip,
} from 'recharts';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { WeeklyCompletion } from '../../entities/Metrics'; import { WeeklyCompletion } from '../../entities/Metrics';
@ -7,10 +14,12 @@ interface WeeklyCompletionChartProps {
data: WeeklyCompletion[]; data: WeeklyCompletion[];
} }
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) => { const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({
data,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const CustomTooltip = ({ active, payload, label }: any) => { const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const data = payload[0].payload; const data = payload[0].payload;
return ( return (
@ -19,7 +28,10 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
{data.dayName} {data.dayName}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
{data.count} {data.count === 1 ? t('tasks.taskCompleted') : t('tasks.tasksCompleted')} {data.count}{' '}
{data.count === 1
? t('tasks.taskCompleted')
: t('tasks.tasksCompleted')}
</p> </p>
</div> </div>
); );
@ -34,7 +46,10 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
</h3> </h3>
<div className="h-40"> <div className="h-40">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}> <BarChart
data={data}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
>
<XAxis <XAxis
dataKey="dayName" dataKey="dayName"
axisLine={false} axisLine={false}
@ -42,7 +57,7 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
tick={{ tick={{
fontSize: 10, fontSize: 10,
fill: 'currentColor', fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400' className: 'text-gray-600 dark:text-gray-400',
}} }}
/> />
<YAxis <YAxis
@ -51,7 +66,7 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
tick={{ tick={{
fontSize: 10, fontSize: 10,
fill: 'currentColor', fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400' className: 'text-gray-600 dark:text-gray-400',
}} }}
allowDecimals={false} allowDecimals={false}
width={25} width={25}

View file

@ -1,4 +1,4 @@
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
export const getDescription = ( export const getDescription = (
query: URLSearchParams, query: URLSearchParams,
@ -8,30 +8,36 @@ export const getDescription = (
try { try {
// Default descriptions as fallbacks in case translation function fails // Default descriptions as fallbacks in case translation function fails
const defaultDescriptions = { const defaultDescriptions = {
project: "Project tasks", project: 'Project tasks',
today: "Tasks due today or scheduled for immediate attention", today: 'Tasks due today or scheduled for immediate attention',
inbox: "Uncategorized tasks without project or due date", inbox: 'Uncategorized tasks without project or due date',
next: "Tasks that are actionable in the near future", next: 'Tasks that are actionable in the near future',
upcoming: "Tasks scheduled for the upcoming week", upcoming: 'Tasks scheduled for the upcoming week',
someday: "Tasks without urgency or specific due date", someday: 'Tasks without urgency or specific due date',
completed: "Tasks you've completed", completed: "Tasks you've completed",
allTasks: "All tasks from different projects and priorities" allTasks: 'All tasks from different projects and priorities',
}; };
// Check for project_id first // Check for project_id first
const projectId = query.get('project_id'); const projectId = query.get('project_id');
if (projectId) { if (projectId) {
try { try {
const project = projects.find((p) => p.id?.toString() === projectId); const project = projects.find(
(p) => p.id?.toString() === projectId
);
if (project) { if (project) {
return t("taskViews.project.withName", { projectName: project.name }); return t('taskViews.project.withName', {
projectName: project.name,
});
} else { } else {
return t("taskViews.project.noName"); return t('taskViews.project.noName');
} }
} catch (e) { } catch (e) {
console.error("Translation error for project description:", e); console.error('Translation error for project description:', e);
// Fallback with project name if available // Fallback with project name if available
const project = projects.find((p) => p.id?.toString() === projectId); const project = projects.find(
(p) => p.id?.toString() === projectId
);
return project return project
? `Tasks for project: ${project.name}` ? `Tasks for project: ${project.name}`
: defaultDescriptions.project; : defaultDescriptions.project;
@ -41,38 +47,41 @@ export const getDescription = (
// Then check for type and status parameters // Then check for type and status parameters
try { try {
if (query.get('type') === 'today') { if (query.get('type') === 'today') {
return t("taskViews.today"); return t('taskViews.today');
} }
if (query.get('type') === 'inbox') { if (query.get('type') === 'inbox') {
return t("taskViews.inbox"); return t('taskViews.inbox');
} }
if (query.get('type') === 'next') { if (query.get('type') === 'next') {
return t("taskViews.next"); return t('taskViews.next');
} }
if (query.get('type') === 'upcoming') { if (query.get('type') === 'upcoming') {
return t("taskViews.upcoming"); return t('taskViews.upcoming');
} }
if (query.get('type') === 'someday') { if (query.get('type') === 'someday') {
return t("taskViews.someday"); return t('taskViews.someday');
} }
if (query.get('status') === 'done') { if (query.get('status') === 'done') {
return t("taskViews.completed"); return t('taskViews.completed');
} }
return t("taskViews.allTasks"); return t('taskViews.allTasks');
} catch (e) { } catch (e) {
console.error("Translation error for task view description:", e); console.error('Translation error for task view description:', e);
// Return appropriate fallback based on type or status // Return appropriate fallback based on type or status
if (query.get('type') === 'today') return defaultDescriptions.today; if (query.get('type') === 'today') return defaultDescriptions.today;
if (query.get('type') === 'inbox') return defaultDescriptions.inbox; if (query.get('type') === 'inbox') return defaultDescriptions.inbox;
if (query.get('type') === 'next') return defaultDescriptions.next; if (query.get('type') === 'next') return defaultDescriptions.next;
if (query.get('type') === 'upcoming') return defaultDescriptions.upcoming; if (query.get('type') === 'upcoming')
if (query.get('type') === 'someday') return defaultDescriptions.someday; return defaultDescriptions.upcoming;
if (query.get('status') === 'done') return defaultDescriptions.completed; if (query.get('type') === 'someday')
return defaultDescriptions.someday;
if (query.get('status') === 'done')
return defaultDescriptions.completed;
return defaultDescriptions.allTasks; return defaultDescriptions.allTasks;
} }
} catch (error) { } catch (error) {
console.error("Error in getDescription:", error); console.error('Error in getDescription:', error);
return "Tasks overview"; return 'Tasks overview';
} }
}; };

View file

@ -1,4 +1,4 @@
import { Project } from "../../entities/Project"; import { Project } from '../../entities/Project';
import { import {
FolderIcon, FolderIcon,
CalendarIcon, CalendarIcon,
@ -25,12 +25,17 @@ export const getTitleAndIcon = (
upcoming: 'Upcoming', upcoming: 'Upcoming',
someday: 'Someday', someday: 'Someday',
completed: 'Completed', completed: 'Completed',
allTasks: 'All Tasks' allTasks: 'All Tasks',
}; };
const projectId = query.get('project_id'); const projectId = query.get('project_id');
if (projectId) { if (projectId) {
const project = projects.find((p) => p.id?.toString() === projectId); const project = projects.find(
return { title: project ? project.name : t('sidebar.projects'), icon: FolderIcon }; (p) => p.id?.toString() === projectId
);
return {
title: project ? project.name : t('sidebar.projects'),
icon: FolderIcon,
};
} }
try { try {
@ -41,32 +46,47 @@ export const getTitleAndIcon = (
return { title: t('sidebar.inbox'), icon: InboxIcon }; return { title: t('sidebar.inbox'), icon: InboxIcon };
} }
if (query.get('type') === 'next') { if (query.get('type') === 'next') {
return { title: t('sidebar.nextActions'), icon: ArrowRightIcon }; return {
title: t('sidebar.nextActions'),
icon: ArrowRightIcon,
};
} }
if (query.get('type') === 'upcoming') { if (query.get('type') === 'upcoming') {
return { title: t('sidebar.upcoming'), icon: ClockIcon }; return { title: t('sidebar.upcoming'), icon: ClockIcon };
} }
if (query.get('type') === 'someday') { if (query.get('type') === 'someday') {
return { title: t('taskViews.someday') || defaultTitles.someday, icon: MoonIcon }; return {
title: t('taskViews.someday') || defaultTitles.someday,
icon: MoonIcon,
};
} }
if (query.get('status') === 'done') { if (query.get('status') === 'done') {
return { title: t('sidebar.completed'), icon: CheckCircleIcon }; return { title: t('sidebar.completed'), icon: CheckCircleIcon };
} }
return { title: t('sidebar.allTasks'), icon: Bars4Icon }; return { title: t('sidebar.allTasks'), icon: Bars4Icon };
} catch (e) { } catch (e) {
console.error("Translation error for task view title:", e); console.error('Translation error for task view title:', e);
// Return appropriate fallback based on type or status // Return appropriate fallback based on type or status
if (query.get('type') === 'today') return { title: defaultTitles.today, icon: CalendarIcon }; if (query.get('type') === 'today')
if (query.get('type') === 'inbox') return { title: defaultTitles.inbox, icon: InboxIcon }; return { title: defaultTitles.today, icon: CalendarIcon };
if (query.get('type') === 'next') return { title: defaultTitles.next, icon: ArrowRightIcon }; if (query.get('type') === 'inbox')
if (query.get('type') === 'upcoming') return { title: defaultTitles.upcoming, icon: ClockIcon }; return { title: defaultTitles.inbox, icon: InboxIcon };
if (query.get('type') === 'someday') return { title: defaultTitles.someday, icon: MoonIcon }; if (query.get('type') === 'next')
if (query.get('status') === 'done') return { title: defaultTitles.completed, icon: CheckCircleIcon }; 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 }; return { title: defaultTitles.allTasks, icon: Bars4Icon };
} }
} catch (error) { } catch (error) {
console.error("Error in getTitleAndIcon:", error); console.error('Error in getTitleAndIcon:', error);
return { title: "Tasks", icon: Bars4Icon }; return { title: 'Tasks', icon: Bars4Icon };
} }
}; };

View file

@ -1,21 +1,21 @@
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from 'react';
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from "react-i18next"; import { useTranslation } from 'react-i18next';
import TaskList from "./Task/TaskList"; import TaskList from './Task/TaskList';
import NewTask from "./Task/NewTask"; import NewTask from './Task/NewTask';
import { Task } from "../entities/Task"; import { Task } from '../entities/Task';
import { Project } from "../entities/Project"; import { Project } from '../entities/Project';
import { getTitleAndIcon } from "./Task/getTitleAndIcon"; import { getTitleAndIcon } from './Task/getTitleAndIcon';
import { getDescription } from "./Task/getDescription"; import { getDescription } from './Task/getDescription';
import { createTask, toggleTaskToday } from "../utils/tasksService"; import { createTask, toggleTaskToday } from '../utils/tasksService';
import { useToast } from "./Shared/ToastContext"; import { useToast } from './Shared/ToastContext';
import { import {
TagIcon, TagIcon,
XMarkIcon, XMarkIcon,
ChevronDownIcon, ChevronDownIcon,
ChevronDoubleDownIcon, ChevronDoubleDownIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from "@heroicons/react/24/solid"; } from '@heroicons/react/24/solid';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@ -27,7 +27,7 @@ const getSearchPlaceholder = (language: string): string => {
es: 'Buscar tareas...', es: 'Buscar tareas...',
de: 'Aufgaben suchen...', de: 'Aufgaben suchen...',
jp: 'タスクを検索...', jp: 'タスクを検索...',
ua: 'Пошук завдань...' ua: 'Пошук завдань...',
}; };
return placeholders[language] || 'Search tasks...'; return placeholders[language] || 'Search tasks...';
@ -41,8 +41,8 @@ const Tasks: React.FC = () => {
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false); const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>("due_date:asc"); const [orderBy, setOrderBy] = useState<string>('due_date:asc');
const [taskSearchQuery, setTaskSearchQuery] = useState<string>(""); const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation(); const location = useLocation();
@ -56,22 +56,25 @@ const Tasks: React.FC = () => {
: getTitleAndIcon(query, projects, t); : getTitleAndIcon(query, projects, t);
const IconComponent = const IconComponent =
typeof icon === "string" ? React.createElement(icon) : icon; typeof icon === 'string' ? React.createElement(icon) : icon;
const tag = query.get("tag"); const tag = query.get('tag');
const status = query.get("status"); const status = query.get('status');
useEffect(() => { useEffect(() => {
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc"; const savedOrderBy = localStorage.getItem('order_by') || 'due_date:asc';
setOrderBy(savedOrderBy); setOrderBy(savedOrderBy);
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
if (!params.get("order_by")) { if (!params.get('order_by')) {
params.set("order_by", savedOrderBy); params.set('order_by', savedOrderBy);
navigate({ navigate(
{
pathname: location.pathname, pathname: location.pathname,
search: `?${params.toString()}`, search: `?${params.toString()}`,
}, { replace: true }); },
{ replace: true }
);
} }
}, [location.pathname]); }, [location.pathname]);
@ -85,10 +88,10 @@ const Tasks: React.FC = () => {
} }
}; };
if (dropdownOpen) { if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
} }
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
}; };
}, [dropdownOpen]); }, [dropdownOpen]);
@ -97,24 +100,26 @@ const Tasks: React.FC = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const tagId = query.get("tag"); const tagId = query.get('tag');
const [tasksResponse, projectsResponse] = await Promise.all([ const [tasksResponse, projectsResponse] = await Promise.all([
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`), fetch(
fetch("/api/projects"), `/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ''}`
),
fetch('/api/projects'),
]); ]);
if (tasksResponse.ok) { if (tasksResponse.ok) {
const tasksData = await tasksResponse.json(); const tasksData = await tasksResponse.json();
setTasks(tasksData.tasks || []); setTasks(tasksData.tasks || []);
} else { } else {
throw new Error("Failed to fetch tasks."); throw new Error('Failed to fetch tasks.');
} }
if (projectsResponse.ok) { if (projectsResponse.ok) {
const projectsData = await projectsResponse.json(); const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []); setProjects(projectsData?.projects || []);
} else { } else {
throw new Error("Failed to fetch projects."); throw new Error('Failed to fetch projects.');
} }
} catch (error) { } catch (error) {
setError((error as Error).message); setError((error as Error).message);
@ -128,7 +133,7 @@ const Tasks: React.FC = () => {
const handleRemoveTag = () => { const handleRemoveTag = () => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.delete("tag"); params.delete('tag');
navigate({ navigate({
pathname: location.pathname, pathname: location.pathname,
search: `?${params.toString()}`, search: `?${params.toString()}`,
@ -144,13 +149,20 @@ const Tasks: React.FC = () => {
// Show success toast with task link // Show success toast with task link
const taskLink = ( const taskLink = (
<span> <span>
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')} {t('task.created', 'Task')}{' '}
<a
href={`/task/${newTask.uuid}`}
className="text-green-200 underline hover:text-green-100"
>
{newTask.name}
</a>{' '}
{t('task.createdSuccessfully', 'created successfully!')}
</span> </span>
); );
showSuccessToast(taskLink); showSuccessToast(taskLink);
} catch (error) { } catch (error) {
console.error("Error creating task:", error); console.error('Error creating task:', error);
setError("Error creating task."); setError('Error creating task.');
throw error; // Re-throw to allow proper error handling throw error; // Re-throw to allow proper error handling
} }
}; };
@ -158,8 +170,8 @@ const Tasks: React.FC = () => {
const handleTaskUpdate = async (updatedTask: Task) => { const handleTaskUpdate = async (updatedTask: Task) => {
try { try {
const response = await fetch(`/api/task/${updatedTask.id}`, { const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH", method: 'PATCH',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask), body: JSON.stringify(updatedTask),
}); });
@ -171,31 +183,33 @@ const Tasks: React.FC = () => {
); );
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error("Failed to update task:", errorData.error); console.error('Failed to update task:', errorData.error);
setError("Failed to update task."); setError('Failed to update task.');
} }
} catch (error) { } catch (error) {
console.error("Error updating task:", error); console.error('Error updating task:', error);
setError("Error updating task."); setError('Error updating task.');
} }
}; };
const handleTaskDelete = async (taskId: number) => { const handleTaskDelete = async (taskId: number) => {
try { try {
const response = await fetch(`/api/task/${taskId}`, { const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE", method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId)); setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
console.error("Failed to delete task:", errorData.error); console.error('Failed to delete task:', errorData.error);
setError("Failed to delete task."); setError('Failed to delete task.');
} }
} catch (error) { } catch (error) {
console.error("Error deleting task:", error); console.error('Error deleting task:', error);
setError("Error deleting task."); setError('Error deleting task.');
} }
}; };
@ -204,10 +218,10 @@ const Tasks: React.FC = () => {
await toggleTaskToday(taskId); await toggleTaskToday(taskId);
// Refetch data to ensure consistency with all task relationships // Refetch data to ensure consistency with all task relationships
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const type = params.get("type") || "all"; const type = params.get('type') || 'all';
const tag = params.get("tag"); const tag = params.get('tag');
const project = params.get("project"); const project = params.get('project');
const priority = params.get("priority"); const priority = params.get('priority');
let apiPath = `/api/tasks?type=${type}&order_by=${orderBy}`; let apiPath = `/api/tasks?type=${type}&order_by=${orderBy}`;
if (tag) apiPath += `&tag=${tag}`; if (tag) apiPath += `&tag=${tag}`;
@ -215,7 +229,7 @@ const Tasks: React.FC = () => {
if (priority) apiPath += `&priority=${priority}`; if (priority) apiPath += `&priority=${priority}`;
const response = await fetch(apiPath, { const response = await fetch(apiPath, {
credentials: "include", credentials: 'include',
}); });
if (response.ok) { if (response.ok) {
@ -223,27 +237,30 @@ const Tasks: React.FC = () => {
setTasks(data.tasks || data); setTasks(data.tasks || data);
} }
} catch (error) { } catch (error) {
console.error("Error toggling task today status:", error); console.error('Error toggling task today status:', error);
setError("Error toggling task today status."); setError('Error toggling task today status.');
} }
}; };
const handleSortChange = (order: string) => { const handleSortChange = (order: string) => {
setOrderBy(order); setOrderBy(order);
localStorage.setItem("order_by", order); localStorage.setItem('order_by', order);
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set("order_by", order); params.set('order_by', order);
navigate({ navigate(
{
pathname: location.pathname, pathname: location.pathname,
search: `?${params.toString()}`, search: `?${params.toString()}`,
}, { replace: true }); },
{ replace: true }
);
setDropdownOpen(false); setDropdownOpen(false);
}; };
const description = getDescription(query, projects, t); const description = getDescription(query, projects, t);
const isNewTaskAllowed = () => { const isNewTaskAllowed = () => {
return status !== "done"; return status !== 'done';
}; };
const filteredTasks = tasks.filter((task) => const filteredTasks = tasks.filter((task) =>
@ -255,7 +272,9 @@ const Tasks: React.FC = () => {
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4"> <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"> <div className="flex items-center mb-2 sm:mb-0">
{IconComponent && <IconComponent className="h-6 w-6 mr-2" />} {IconComponent && (
<IconComponent className="h-6 w-6 mr-2" />
)}
<h2 className="text-2xl font-light">{title}</h2> <h2 className="text-2xl font-light">{title}</h2>
{tag && ( {tag && (
@ -274,7 +293,10 @@ const Tasks: React.FC = () => {
)} )}
</div> </div>
<div className="relative inline-block text-left" ref={dropdownRef}> <div
className="relative inline-block text-left"
ref={dropdownRef}
>
<button <button
type="button" type="button"
className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none" className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
@ -283,8 +305,13 @@ const Tasks: React.FC = () => {
aria-haspopup="true" aria-haspopup="true"
onClick={() => setDropdownOpen(!dropdownOpen)} onClick={() => setDropdownOpen(!dropdownOpen)}
> >
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{" "} <ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{' '}
{t(`sort.${orderBy.split(":")[0]}`, 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" /> <ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
</button> </button>
@ -295,21 +322,33 @@ const Tasks: React.FC = () => {
aria-orientation="vertical" aria-orientation="vertical"
aria-labelledby="menu-button" aria-labelledby="menu-button"
> >
<div className="py-1 max-h-60 overflow-y-auto" role="none"> <div
className="py-1 max-h-60 overflow-y-auto"
role="none"
>
{[ {[
"due_date:asc", 'due_date:asc',
"name:asc", 'name:asc',
"priority:desc", 'priority:desc',
"status:desc", 'status:desc',
"created_at:desc", 'created_at:desc',
].map((order) => ( ].map((order) => (
<button <button
key={order} key={order}
onClick={() => handleSortChange(order)} onClick={() =>
handleSortChange(order)
}
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" 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" role="menuitem"
> >
{t(`sort.${order.split(":")[0]}`, capitalize(order.split(":")[0].replace("_", " ")))} {t(
`sort.${order.split(':')[0]}`,
capitalize(
order
.split(':')[0]
.replace('_', ' ')
)
)}
</button> </button>
))} ))}
</div> </div>
@ -318,7 +357,6 @@ const Tasks: React.FC = () => {
</div> </div>
</div> </div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400"> <p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description} {description}
</p> </p>
@ -345,7 +383,10 @@ const Tasks: React.FC = () => {
{isNewTaskAllowed() && ( {isNewTaskAllowed() && (
<NewTask <NewTask
onTaskCreate={async (taskName: string) => onTaskCreate={async (taskName: string) =>
await handleTaskCreate({ name: taskName, status: "not_started" }) await handleTaskCreate({
name: taskName,
status: 'not_started',
})
} }
/> />
)} )}
@ -361,7 +402,10 @@ const Tasks: React.FC = () => {
/> />
) : ( ) : (
<p className="text-gray-500 text-center mt-4"> <p className="text-gray-500 text-center mt-4">
{t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')} {t(
'tasks.noTasksAvailable',
'Δεν υπάρχουν διαθέσιμες εργασίες.'
)}
</p> </p>
)} )}
</> </>

View file

@ -25,17 +25,19 @@ export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [modalCount, setModalCount] = useState(0); const [modalCount, setModalCount] = useState(0);
const openModal = () => { const openModal = () => {
setModalCount(prev => prev + 1); setModalCount((prev) => prev + 1);
}; };
const closeModal = () => { const closeModal = () => {
setModalCount(prev => Math.max(0, prev - 1)); setModalCount((prev) => Math.max(0, prev - 1));
}; };
const isAnyModalOpen = modalCount > 0; const isAnyModalOpen = modalCount > 0;
return ( return (
<ModalContext.Provider value={{ isAnyModalOpen, openModal, closeModal, modalCount }}> <ModalContext.Provider
value={{ isAnyModalOpen, openModal, closeModal, modalCount }}
>
{children} {children}
</ModalContext.Provider> </ModalContext.Provider>
); );

View file

@ -1,4 +1,4 @@
import { Task } from "./Task"; import { Task } from './Task';
export interface WeeklyCompletion { export interface WeeklyCompletion {
date: string; date: string;

View file

@ -1,4 +1,4 @@
import { Tag } from "./Tag"; import { Tag } from './Tag';
export interface Note { export interface Note {
id?: number; id?: number;

View file

@ -1,7 +1,7 @@
import { Area } from "./Area"; import { Area } from './Area';
import { Tag } from "./Tag"; import { Tag } from './Tag';
import { PriorityType, Task } from "./Task"; import { PriorityType, Task } from './Task';
import { Note } from "./Note"; import { Note } from './Note';
export interface Project { export interface Project {
id?: number; id?: number;

View file

@ -1,5 +1,5 @@
import { Tag } from "./Tag"; import { Tag } from './Tag';
import { Project } from "./Project"; import { Project } from './Project';
export interface Task { export interface Task {
id?: number; id?: number;
@ -29,4 +29,10 @@ export interface Task {
export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived'; export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived';
export type PriorityType = 'low' | 'medium' | 'high'; export type PriorityType = 'low' | 'medium' | 'high';
export type RecurrenceType = 'none' | 'daily' | 'weekly' | 'monthly' | 'monthly_weekday' | 'monthly_last_day'; export type RecurrenceType =
| 'none'
| 'daily'
| 'weekly'
| 'monthly'
| 'monthly_weekday'
| 'monthly_last_day';

View file

@ -2,10 +2,22 @@ export interface TaskEvent {
id: number; id: number;
task_id: number; task_id: number;
user_id: number; user_id: number;
event_type: 'created' | 'status_changed' | 'priority_changed' | 'due_date_changed' | event_type:
'project_changed' | 'name_changed' | 'description_changed' | 'note_changed' | | 'created'
'completed' | 'archived' | 'deleted' | 'restored' | 'today_changed' | | 'status_changed'
'tags_changed' | 'recurrence_changed'; | 'priority_changed'
| 'due_date_changed'
| 'project_changed'
| 'name_changed'
| 'description_changed'
| 'note_changed'
| 'completed'
| 'archived'
| 'deleted'
| 'restored'
| 'today_changed'
| 'tags_changed'
| 'recurrence_changed';
old_value?: any; old_value?: any;
new_value?: any; new_value?: any;
field_name?: string; field_name?: string;

View file

@ -24,18 +24,21 @@ const fallbackResources = {
}, },
}; };
const devResources = isDevelopment ? { const devResources = isDevelopment
? {
en: { en: {
translation: fallbackResources.en.translation, translation: fallbackResources.en.translation,
}, },
} : undefined; }
: undefined;
const i18nInstance = i18n const i18nInstance = i18n
.use(Backend) .use(Backend)
.use(LanguageDetector) .use(LanguageDetector)
.use(initReactI18next); .use(initReactI18next);
i18nInstance.init({ i18nInstance
.init({
fallbackLng: 'en', fallbackLng: 'en',
debug: isDevelopment, debug: isDevelopment,
load: 'languageOnly', load: 'languageOnly',
@ -47,7 +50,7 @@ i18nInstance.init({
lookupQuerystring: 'lng', lookupQuerystring: 'lng',
lookupCookie: 'i18next', lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng', lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie'] caches: ['localStorage', 'cookie'],
}, },
interpolation: { interpolation: {
escapeValue: false, escapeValue: false,
@ -60,41 +63,69 @@ i18nInstance.init({
requestOptions: { requestOptions: {
cache: 'default', cache: 'default',
credentials: 'same-origin', credentials: 'same-origin',
mode: 'cors' mode: 'cors',
} },
}, },
}) })
.then(() => { .then(() => {
const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`; const loadPath = isDevelopment
? `./locales/${i18n.language}/translation.json`
: `/locales/${i18n.language}/translation.json`;
fetch(loadPath) fetch(loadPath)
.then(response => { .then((response) => {
if (!response.ok) { if (!response.ok) {
if (isDevelopment) { if (isDevelopment) {
return fetch(`/locales/${i18n.language}/translation.json`); return fetch(
`/locales/${i18n.language}/translation.json`
);
} }
throw new Error(`Failed to fetch translation: ${response.status}`); throw new Error(
`Failed to fetch translation: ${response.status}`
);
} }
return response.json(); return response.json();
}) })
.then(data => { .then((data) => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true); i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
}) })
.catch(() => { .catch(() => {
if (isDevelopment) { if (isDevelopment) {
try { try {
setTimeout(() => { setTimeout(() => {
fetch(`/locales/${i18n.language}/translation.json`, { fetch(
headers: { 'Accept': 'application/json' }, `/locales/${i18n.language}/translation.json`,
mode: 'cors' {
headers: { Accept: 'application/json' },
mode: 'cors',
}
)
.then((res) => res.json())
.then((data) => {
i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
}) })
.then(res => res.json()) .catch((error) => {
.then(data => { console.error(
i18n.addResourceBundle(i18n.language, 'translation', data, true, true); 'Error loading translation:',
}) error
.catch(() => {}); );
});
}, 1000); }, 1000);
} catch (e) {} } catch (e) {
console.error('Error in retry mechanism:', e);
}
} }
}); });
}); });
@ -105,7 +136,9 @@ i18n.on('failedLoading', () => {});
i18n.on('missingKey', () => {}); i18n.on('missingKey', () => {});
const dispatchLanguageChangeEvent = (lng: string) => { const dispatchLanguageChangeEvent = (lng: string) => {
const event = new CustomEvent('app-language-changed', { detail: { language: lng } }); const event = new CustomEvent('app-language-changed', {
detail: { language: lng },
});
window.dispatchEvent(event); window.dispatchEvent(event);
}; };
@ -128,20 +161,27 @@ i18n.on('languageChanged', (lng) => {
: `/locales/${lng}/translation.json`; : `/locales/${lng}/translation.json`;
fetch(loadPath) fetch(loadPath)
.then(response => { .then((response) => {
if (!response.ok) { if (!response.ok) {
return fetch(`/locales/${lng}/translation.json`); return fetch(`/locales/${lng}/translation.json`);
} }
return response; return response;
}) })
.then(response => response.json()) .then((response) => response.json())
.then(data => { .then((data) => {
if (data) { if (data) {
i18n.addResourceBundle(lng, 'translation', data, true, true); i18n.addResourceBundle(
lng,
'translation',
data,
true,
true
);
handleTranslationsLoaded(); handleTranslationsLoaded();
} }
}) })
.catch(() => { .catch((error) => {
console.error('Error loading translations:', error);
handleTranslationsLoaded(); handleTranslationsLoaded();
}); });
} else { } else {
@ -164,7 +204,8 @@ window.checkTranslation = (key: string) => {
try { try {
const translation = i18n.t(key); const translation = i18n.t(key);
return translation; return translation;
} catch { } catch (error) {
console.error('Error checking translation:', error);
return null; return null;
} }
}; };
@ -177,12 +218,23 @@ window.forceLanguageReload = (lng?: string) => {
dispatchLanguageChangeEvent(targetLng); dispatchLanguageChangeEvent(targetLng);
if (i18n.services && i18n.services.resourceStore) { if (i18n.services && i18n.services.resourceStore) {
Object.values(i18n.services.resourceStore.data).forEach(lang => { Object.values(i18n.services.resourceStore.data).forEach(
if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) { (lang) => {
const temp = {...lang.translation as Record<string, unknown>}; if (
lang.translation &&
typeof lang.translation === 'object' &&
lang.translation !== null
) {
const temp = {
...(lang.translation as Record<
string,
unknown
>),
};
lang.translation = temp; lang.translation = temp;
} }
}); }
);
} }
if (lng) { if (lng) {
@ -191,7 +243,9 @@ window.forceLanguageReload = (lng?: string) => {
}, 50); }, 50);
} }
}) })
.catch(() => {}); .catch((error) => {
console.error('Error reloading language:', error);
});
}; };
export default i18n; export default i18n;

View file

@ -5,29 +5,31 @@ declare const module: {
}; };
}; };
import React from "react"; import React from 'react';
import { createRoot } from "react-dom/client"; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from 'react-router-dom';
import App from "./App"; import App from './App';
import { ToastProvider } from "./components/Shared/ToastContext"; import { ToastProvider } from './components/Shared/ToastContext';
import './i18n'; // Import i18n config to initialize it import './i18n'; // Import i18n config to initialize it
import './styles/markdown.css'; // Import markdown styles import './styles/markdown.css'; // Import markdown styles
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import i18n from './i18n'; // Import the i18n instance with its configuration import i18n from './i18n'; // Import the i18n instance with its configuration
const storedPreference = localStorage.getItem("isDarkMode"); const storedPreference = localStorage.getItem('isDarkMode');
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
const isDarkMode = storedPreference const isDarkMode = storedPreference
? storedPreference === "true" ? storedPreference === 'true'
: prefersDarkMode; : prefersDarkMode;
if (isDarkMode) { if (isDarkMode) {
document.documentElement.classList.add("dark"); document.documentElement.classList.add('dark');
} else { } else {
document.documentElement.classList.remove("dark"); document.documentElement.classList.remove('dark');
} }
const container = document.getElementById("root"); const container = document.getElementById('root');
// Store the root outside the if block so it can be accessed by the HMR code // Store the root outside the if block so it can be accessed by the HMR code
let root: any; let root: any;

View file

@ -1,10 +1,10 @@
import { create } from "zustand"; import { create } from 'zustand';
import { Project } from "../entities/Project"; import { Project } from '../entities/Project';
import { Area } from "../entities/Area"; import { Area } from '../entities/Area';
import { Note } from "../entities/Note"; import { Note } from '../entities/Note';
import { Task } from "../entities/Task"; import { Task } from '../entities/Task';
import { Tag } from "../entities/Tag"; import { Tag } from '../entities/Tag';
import { InboxItem } from "../entities/InboxItem"; import { InboxItem } from '../entities/InboxItem';
interface NotesStore { interface NotesStore {
notes: Note[]; notes: Note[];
@ -78,42 +78,78 @@ export const useStore = create<StoreState>((set) => ({
notes: [], notes: [],
isLoading: false, isLoading: false,
isError: false, isError: false,
setNotes: (notes) => set((state) => ({ notesStore: { ...state.notesStore, notes } })), setNotes: (notes) =>
setLoading: (isLoading) => set((state) => ({ notesStore: { ...state.notesStore, isLoading } })), set((state) => ({ notesStore: { ...state.notesStore, notes } })),
setError: (isError) => set((state) => ({ notesStore: { ...state.notesStore, isError } })), setLoading: (isLoading) =>
set((state) => ({
notesStore: { ...state.notesStore, isLoading },
})),
setError: (isError) =>
set((state) => ({ notesStore: { ...state.notesStore, isError } })),
}, },
areasStore: { areasStore: {
areas: [], areas: [],
isLoading: false, isLoading: false,
isError: false, isError: false,
setAreas: (areas) => set((state) => ({ areasStore: { ...state.areasStore, areas } })), setAreas: (areas) =>
setLoading: (isLoading) => set((state) => ({ areasStore: { ...state.areasStore, isLoading } })), set((state) => ({ areasStore: { ...state.areasStore, areas } })),
setError: (isError) => set((state) => ({ areasStore: { ...state.areasStore, isError } })), setLoading: (isLoading) =>
set((state) => ({
areasStore: { ...state.areasStore, isLoading },
})),
setError: (isError) =>
set((state) => ({ areasStore: { ...state.areasStore, isError } })),
}, },
projectsStore: { projectsStore: {
projects: [], projects: [],
isLoading: false, isLoading: false,
isError: false, isError: false,
setProjects: (projects) => set((state) => ({ projectsStore: { ...state.projectsStore, projects } })), setProjects: (projects) =>
setLoading: (isLoading) => set((state) => ({ projectsStore: { ...state.projectsStore, isLoading } })), set((state) => ({
setError: (isError) => set((state) => ({ projectsStore: { ...state.projectsStore, isError } })), projectsStore: { ...state.projectsStore, projects },
})),
setLoading: (isLoading) =>
set((state) => ({
projectsStore: { ...state.projectsStore, isLoading },
})),
setError: (isError) =>
set((state) => ({
projectsStore: { ...state.projectsStore, isError },
})),
}, },
tagsStore: { tagsStore: {
tags: [], tags: [],
isLoading: false, isLoading: false,
isError: false, isError: false,
setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })), setTags: (tags) =>
setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })), set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })), setLoading: (isLoading) =>
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
setError: (isError) =>
set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
loadTags: async () => { loadTags: async () => {
const { fetchTags } = require("../utils/tagsService"); const { fetchTags } = await import('../utils/tagsService');
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading: true, isError: false } })); set((state) => ({
tagsStore: {
...state.tagsStore,
isLoading: true,
isError: false,
},
}));
try { try {
const tags = await fetchTags(); const tags = await fetchTags();
set((state) => ({ tagsStore: { ...state.tagsStore, tags, isLoading: false } })); set((state) => ({
tagsStore: { ...state.tagsStore, tags, isLoading: false },
}));
} catch (error) { } catch (error) {
console.error("loadTags: Failed to load tags:", error); console.error('loadTags: Failed to load tags:', error);
set((state) => ({ tagsStore: { ...state.tagsStore, isError: true, isLoading: false } })); set((state) => ({
tagsStore: {
...state.tagsStore,
isError: true,
isLoading: false,
},
}));
} }
}, },
}, },
@ -121,42 +157,55 @@ export const useStore = create<StoreState>((set) => ({
tasks: [], tasks: [],
isLoading: false, isLoading: false,
isError: false, isError: false,
setTasks: (tasks) => set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })), setTasks: (tasks) =>
setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })), set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })),
setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })), setLoading: (isLoading) =>
set((state) => ({
tasksStore: { ...state.tasksStore, isLoading },
})),
setError: (isError) =>
set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
}, },
inboxStore: { inboxStore: {
inboxItems: [], inboxItems: [],
isLoading: false, isLoading: false,
isError: false, isError: false,
setInboxItems: (inboxItems) => set((state) => ({ setInboxItems: (inboxItems) =>
inboxStore: { ...state.inboxStore, inboxItems } set((state) => ({
inboxStore: { ...state.inboxStore, inboxItems },
})), })),
addInboxItem: (inboxItem) => set((state) => ({ addInboxItem: (inboxItem) =>
set((state) => ({
inboxStore: { inboxStore: {
...state.inboxStore, ...state.inboxStore,
inboxItems: [...state.inboxStore.inboxItems, inboxItem] inboxItems: [...state.inboxStore.inboxItems, inboxItem],
} },
})), })),
updateInboxItem: (inboxItem) => set((state) => ({ updateInboxItem: (inboxItem) =>
set((state) => ({
inboxStore: { inboxStore: {
...state.inboxStore, ...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.map(item => inboxItems: state.inboxStore.inboxItems.map((item) =>
item.id === inboxItem.id ? inboxItem : item item.id === inboxItem.id ? inboxItem : item
) ),
} },
})), })),
removeInboxItem: (id) => set((state) => ({ removeInboxItem: (id) =>
set((state) => ({
inboxStore: { inboxStore: {
...state.inboxStore, ...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.filter(item => item.id !== id) inboxItems: state.inboxStore.inboxItems.filter(
} (item) => item.id !== id
),
},
})), })),
setLoading: (isLoading) => set((state) => ({ setLoading: (isLoading) =>
inboxStore: { ...state.inboxStore, isLoading } set((state) => ({
inboxStore: { ...state.inboxStore, isLoading },
})), })),
setError: (isError) => set((state) => ({ setError: (isError) =>
inboxStore: { ...state.inboxStore, isError } set((state) => ({
inboxStore: { ...state.inboxStore, isError },
})), })),
}, },
})); }));

View file

@ -1,11 +1,11 @@
import { Area } from "../entities/Area"; import { Area } from '../entities/Area';
import { handleAuthResponse } from "./authUtils"; import { handleAuthResponse } from './authUtils';
export const fetchAreas = async (): Promise<Area[]> => { export const fetchAreas = async (): Promise<Area[]> => {
const response = await fetch("/api/areas?active=true", { const response = await fetch('/api/areas?active=true', {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to fetch areas.'); await handleAuthResponse(response, 'Failed to fetch areas.');
@ -18,7 +18,7 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(areaData), body: JSON.stringify(areaData),
}); });
@ -27,13 +27,16 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
return await response.json(); return await response.json();
}; };
export const updateArea = async (areaId: number, areaData: Partial<Area>): Promise<Area> => { export const updateArea = async (
areaId: number,
areaData: Partial<Area>
): Promise<Area> => {
const response = await fetch(`/api/areas/${areaId}`, { const response = await fetch(`/api/areas/${areaId}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(areaData), body: JSON.stringify(areaData),
}); });
@ -47,7 +50,7 @@ export const deleteArea = async (areaId: number): Promise<void> => {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });

View file

@ -1,8 +1,8 @@
export const getDefaultHeaders = (): Record<string, string> => { export const getDefaultHeaders = (): Record<string, string> => {
return { return {
'Accept': 'application/json', Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'Origin': window.location.origin, Origin: window.location.origin,
}; };
}; };
@ -15,7 +15,10 @@ export const getPostHeaders = (): Record<string, string> => {
let isRedirecting = false; let isRedirecting = false;
export const handleAuthResponse = async (response: Response, errorMessage: string): Promise<Response> => { export const handleAuthResponse = async (
response: Response,
errorMessage: string
): Promise<Response> => {
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
if (window.location.pathname !== '/login' && !isRedirecting) { if (window.location.pathname !== '/login' && !isRedirecting) {

View file

@ -29,7 +29,10 @@ export const getCurrentLocale = (): Locale => {
* @param formatStr - The format string (https://date-fns.org/v2.29.3/docs/format) * @param formatStr - The format string (https://date-fns.org/v2.29.3/docs/format)
* @returns The formatted date string * @returns The formatted date string
*/ */
export const formatLocalizedDate = (date: Date | number, formatStr: string): string => { export const formatLocalizedDate = (
date: Date | number,
formatStr: string
): string => {
return format(date, formatStr, { return format(date, formatStr, {
locale: getCurrentLocale(), locale: getCurrentLocale(),
}); });
@ -42,7 +45,10 @@ export const formatLocalizedDate = (date: Date | number, formatStr: string): str
* @param fallback - Fallback format to use if translation is missing * @param fallback - Fallback format to use if translation is missing
* @returns The format pattern string * @returns The format pattern string
*/ */
export const getDateFormatPattern = (formatKey: string, fallback: string): string => { export const getDateFormatPattern = (
formatKey: string,
fallback: string
): string => {
const pattern = i18n.t(`dateFormats.${formatKey}`); const pattern = i18n.t(`dateFormats.${formatKey}`);
// If the translation key doesn't exist, it will return the key itself // If the translation key doesn't exist, it will return the key itself
return pattern === `dateFormats.${formatKey}` ? fallback : pattern; return pattern === `dateFormats.${formatKey}` ? fallback : pattern;
@ -56,7 +62,10 @@ export const getDateFormatPattern = (formatKey: string, fallback: string): strin
* @returns The formatted date string * @returns The formatted date string
*/ */
export const formatLongDate = (date: Date | number): string => { export const formatLongDate = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('long', 'EEEE, MMMM d, yyyy')); return formatLocalizedDate(
date,
getDateFormatPattern('long', 'EEEE, MMMM d, yyyy')
);
}; };
/** /**
@ -67,7 +76,10 @@ export const formatLongDate = (date: Date | number): string => {
* @returns The formatted date string * @returns The formatted date string
*/ */
export const formatShortDate = (date: Date | number): string => { export const formatShortDate = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('short', 'MMM d, yyyy')); return formatLocalizedDate(
date,
getDateFormatPattern('short', 'MMM d, yyyy')
);
}; };
/** /**
@ -78,7 +90,10 @@ export const formatShortDate = (date: Date | number): string => {
* @returns The formatted date string * @returns The formatted date string
*/ */
export const formatMonthYear = (date: Date | number): string => { export const formatMonthYear = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('monthYear', 'MMMM yyyy')); return formatLocalizedDate(
date,
getDateFormatPattern('monthYear', 'MMMM yyyy')
);
}; };
/** /**
@ -89,7 +104,10 @@ export const formatMonthYear = (date: Date | number): string => {
* @returns The formatted date string * @returns The formatted date string
*/ */
export const formatDayMonth = (date: Date | number): string => { export const formatDayMonth = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('dayMonth', 'MMMM d')); return formatLocalizedDate(
date,
getDateFormatPattern('dayMonth', 'MMMM d')
);
}; };
/** /**
@ -111,7 +129,10 @@ export const formatTime = (date: Date | number): string => {
* @returns The formatted date and time string * @returns The formatted date and time string
*/ */
export const formatDateTime = (date: Date | number): string => { export const formatDateTime = (date: Date | number): string => {
return formatLocalizedDate(date, getDateFormatPattern('dateTime', 'MMM d, yyyy h:mm a')); return formatLocalizedDate(
date,
getDateFormatPattern('dateTime', 'MMM d, yyyy h:mm a')
);
}; };
/** /**
@ -120,14 +141,26 @@ export const formatDateTime = (date: Date | number): string => {
* @param task - The task to check * @param task - The task to check
* @returns True if the task is likely overdue in today plan, false otherwise * @returns True if the task is likely overdue in today plan, false otherwise
*/ */
export const isTaskOverdue = (task: { today?: boolean; created_at?: string; today_move_count?: number; status: string | number; completed_at?: string }): boolean => { export const isTaskOverdue = (task: {
today?: boolean;
created_at?: string;
today_move_count?: number;
status: string | number;
completed_at?: string;
}): boolean => {
// If task is not in today plan, it's not overdue // If task is not in today plan, it's not overdue
if (!task.today) { if (!task.today) {
return false; return false;
} }
// Only hide overdue badge if task is actually completed (done/archived), not just in progress // Only hide overdue badge if task is actually completed (done/archived), not just in progress
if (task.completed_at || task.status === 'done' || task.status === 2 || task.status === 'archived' || task.status === 3) { if (
task.completed_at ||
task.status === 'done' ||
task.status === 2 ||
task.status === 'archived' ||
task.status === 3
) {
return false; return false;
} }

View file

@ -2,13 +2,15 @@ export const fetcher = async (url: string) => {
const response = await fetch(url, { const response = await fetch(url, {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
const error = new Error(errorData.error || 'An error occurred while fetching the data.'); const error = new Error(
errorData.error || 'An error occurred while fetching the data.'
);
(error as any).info = errorData; (error as any).info = errorData;
(error as any).status = response.status; (error as any).status = response.status;
throw error; throw error;

View file

@ -1,13 +1,13 @@
import { InboxItem } from "../entities/InboxItem"; import { InboxItem } from '../entities/InboxItem';
import { useStore } from "../store/useStore"; import { useStore } from '../store/useStore';
import { handleAuthResponse } from "./authUtils"; import { handleAuthResponse } from './authUtils';
// API functions // API functions
export const fetchInboxItems = async (): Promise<InboxItem[]> => { export const fetchInboxItems = async (): Promise<InboxItem[]> => {
const response = await fetch('/api/inbox', { const response = await fetch('/api/inbox', {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
@ -22,13 +22,16 @@ export const fetchInboxItems = async (): Promise<InboxItem[]> => {
return result; return result;
}; };
export const createInboxItem = async (content: string, source?: string): Promise<InboxItem> => { export const createInboxItem = async (
content: string,
source?: string
): Promise<InboxItem> => {
const response = await fetch('/api/inbox', { const response = await fetch('/api/inbox', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(source ? { content, source } : { content }), body: JSON.stringify(source ? { content, source } : { content }),
}); });
@ -37,13 +40,16 @@ export const createInboxItem = async (content: string, source?: string): Promise
return await response.json(); return await response.json();
}; };
export const updateInboxItem = async (itemId: number, content: string): Promise<InboxItem> => { export const updateInboxItem = async (
itemId: number,
content: string
): Promise<InboxItem> => {
const response = await fetch(`/api/inbox/${itemId}`, { const response = await fetch(`/api/inbox/${itemId}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
}); });
@ -57,7 +63,7 @@ export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
@ -70,7 +76,7 @@ export const deleteInboxItem = async (itemId: number): Promise<void> => {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
@ -78,6 +84,7 @@ export const deleteInboxItem = async (itemId: number): Promise<void> => {
}; };
// Track last check time to detect new items // Track last check time to detect new items
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let lastCheckTimestamp = Date.now(); let lastCheckTimestamp = Date.now();
// Store-aware functions // Store-aware functions
@ -92,11 +99,14 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
const items = await fetchInboxItems(); const items = await fetchInboxItems();
// Check for new items since last check // Check for new items since last check
const currentItemIds = new Set(inboxStore.inboxItems.map(item => item.id)); const currentItemIds = new Set(
inboxStore.inboxItems.map((item) => item.id)
);
const currentTime = Date.now(); const currentTime = Date.now();
// New telegram items // New telegram items
const newTelegramItems = items.filter(item => const newTelegramItems = items.filter(
(item) =>
item.id && item.id &&
!currentItemIds.has(item.id) && !currentItemIds.has(item.id) &&
item.source === 'telegram' item.source === 'telegram'
@ -110,14 +120,17 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
// Get some minimal info about the items for the notification // Get some minimal info about the items for the notification
const notificationData = { const notificationData = {
count: newTelegramItems.length, count: newTelegramItems.length,
firstItemContent: newTelegramItems[0].content.substring(0, 30) + firstItemContent:
(newTelegramItems[0].content.length > 30 ? '...' : '') newTelegramItems[0].content.substring(0, 30) +
(newTelegramItems[0].content.length > 30 ? '...' : ''),
}; };
// Dispatch a custom event with the notification data // Dispatch a custom event with the notification data
window.dispatchEvent(new CustomEvent('inboxItemsUpdated', { window.dispatchEvent(
detail: notificationData new CustomEvent('inboxItemsUpdated', {
})); detail: notificationData,
})
);
} }
// Update state and timestamp // Update state and timestamp
@ -132,7 +145,10 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
} }
}; };
export const createInboxItemWithStore = async (content: string, source?: string): Promise<InboxItem> => { export const createInboxItemWithStore = async (
content: string,
source?: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore; const inboxStore = useStore.getState().inboxStore;
try { try {
@ -145,7 +161,10 @@ export const createInboxItemWithStore = async (content: string, source?: string)
} }
}; };
export const updateInboxItemWithStore = async (itemId: number, content: string): Promise<InboxItem> => { export const updateInboxItemWithStore = async (
itemId: number,
content: string
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore; const inboxStore = useStore.getState().inboxStore;
try { try {
@ -158,7 +177,9 @@ export const updateInboxItemWithStore = async (itemId: number, content: string):
} }
}; };
export const processInboxItemWithStore = async (itemId: number): Promise<InboxItem> => { export const processInboxItemWithStore = async (
itemId: number
): Promise<InboxItem> => {
const inboxStore = useStore.getState().inboxStore; const inboxStore = useStore.getState().inboxStore;
try { try {
@ -171,7 +192,9 @@ export const processInboxItemWithStore = async (itemId: number): Promise<InboxIt
} }
}; };
export const deleteInboxItemWithStore = async (itemId: number): Promise<void> => { export const deleteInboxItemWithStore = async (
itemId: number
): Promise<void> => {
const inboxStore = useStore.getState().inboxStore; const inboxStore = useStore.getState().inboxStore;
try { try {

View file

@ -1,8 +1,12 @@
import { Note } from "../entities/Note"; import { Note } from '../entities/Note';
import { handleAuthResponse, getDefaultHeaders, getPostHeaders } from "./authUtils"; import {
handleAuthResponse,
getDefaultHeaders,
getPostHeaders,
} from './authUtils';
export const fetchNotes = async (): Promise<Note[]> => { export const fetchNotes = async (): Promise<Note[]> => {
const response = await fetch("/api/notes", { const response = await fetch('/api/notes', {
credentials: 'include', credentials: 'include',
headers: getDefaultHeaders(), headers: getDefaultHeaders(),
}); });
@ -12,7 +16,6 @@ export const fetchNotes = async (): Promise<Note[]> => {
}; };
export const createNote = async (noteData: Note): Promise<Note> => { export const createNote = async (noteData: Note): Promise<Note> => {
try {
const response = await fetch('/api/note', { const response = await fetch('/api/note', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
@ -22,12 +25,12 @@ export const createNote = async (noteData: Note): Promise<Note> => {
await handleAuthResponse(response, 'Failed to create note.'); await handleAuthResponse(response, 'Failed to create note.');
return await response.json(); return await response.json();
} catch (error) {
throw error;
}
}; };
export const updateNote = async (noteId: number, noteData: Note): Promise<Note> => { export const updateNote = async (
noteId: number,
noteData: Note
): Promise<Note> => {
const response = await fetch(`/api/note/${noteId}`, { const response = await fetch(`/api/note/${noteId}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',

View file

@ -1,4 +1,4 @@
import { handleAuthResponse } from "./authUtils"; import { handleAuthResponse } from './authUtils';
interface Profile { interface Profile {
id: number; id: number;
@ -35,20 +35,22 @@ export const fetchProfile = async (): Promise<Profile> => {
const response = await fetch('/api/profile', { const response = await fetch('/api/profile', {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to fetch profile data.'); await handleAuthResponse(response, 'Failed to fetch profile data.');
return await response.json(); return await response.json();
}; };
export const updateProfile = async (profileData: Partial<Profile>): Promise<Profile> => { export const updateProfile = async (
profileData: Partial<Profile>
): Promise<Profile> => {
const response = await fetch('/api/profile', { const response = await fetch('/api/profile', {
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(profileData), body: JSON.stringify(profileData),
}); });
@ -60,7 +62,7 @@ export const fetchSchedulerStatus = async (): Promise<SchedulerStatus> => {
const response = await fetch('/api/profile/task-summary/status', { const response = await fetch('/api/profile/task-summary/status', {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to fetch scheduler status.'); await handleAuthResponse(response, 'Failed to fetch scheduler status.');
@ -73,7 +75,7 @@ export const sendTaskSummaryNow = async (): Promise<any> => {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to send task summary.'); await handleAuthResponse(response, 'Failed to send task summary.');
@ -84,20 +86,23 @@ export const fetchTelegramPollingStatus = async (): Promise<any> => {
const response = await fetch('/api/telegram/polling-status', { const response = await fetch('/api/telegram/polling-status', {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to fetch polling status.'); await handleAuthResponse(response, 'Failed to fetch polling status.');
return await response.json(); return await response.json();
}; };
export const setupTelegram = async (botToken: string, chatId: string): Promise<TelegramBotInfo> => { export const setupTelegram = async (
botToken: string,
chatId: string
): Promise<TelegramBotInfo> => {
const response = await fetch('/api/telegram/setup', { const response = await fetch('/api/telegram/setup', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
bot_token: botToken, bot_token: botToken,
@ -114,7 +119,7 @@ export const startTelegramPolling = async (): Promise<any> => {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to start telegram polling.'); await handleAuthResponse(response, 'Failed to start telegram polling.');
@ -127,20 +132,23 @@ export const stopTelegramPolling = async (): Promise<any> => {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to stop telegram polling.'); await handleAuthResponse(response, 'Failed to stop telegram polling.');
return await response.json(); return await response.json();
}; };
export const testTelegram = async (userId: number, message: string): Promise<any> => { export const testTelegram = async (
userId: number,
message: string
): Promise<any> => {
const response = await fetch(`/api/telegram/test/${userId}`, { const response = await fetch(`/api/telegram/test/${userId}`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify({ text: message }), body: JSON.stringify({ text: message }),
}); });
@ -154,24 +162,29 @@ export const toggleTaskSummary = async (): Promise<any> => {
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });
await handleAuthResponse(response, 'Failed to toggle task summary.'); await handleAuthResponse(response, 'Failed to toggle task summary.');
return await response.json(); return await response.json();
}; };
export const updateTaskSummaryFrequency = async (frequency: string): Promise<any> => { export const updateTaskSummaryFrequency = async (
frequency: string
): Promise<any> => {
const response = await fetch('/api/profile/task-summary/frequency', { const response = await fetch('/api/profile/task-summary/frequency', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify({ frequency }), body: JSON.stringify({ frequency }),
}); });
await handleAuthResponse(response, 'Failed to update task summary frequency.'); await handleAuthResponse(
response,
'Failed to update task summary frequency.'
);
return await response.json(); return await response.json();
}; };
@ -180,7 +193,9 @@ export type { Profile };
export const getTaskIntelligenceEnabled = async (): Promise<boolean> => { export const getTaskIntelligenceEnabled = async (): Promise<boolean> => {
try { try {
const profile = await fetchProfile(); const profile = await fetchProfile();
return profile.task_intelligence_enabled !== undefined ? profile.task_intelligence_enabled : true; return profile.task_intelligence_enabled !== undefined
? profile.task_intelligence_enabled
: true;
} catch (error) { } catch (error) {
console.error('Error fetching task intelligence setting:', error); console.error('Error fetching task intelligence setting:', error);
return true; // Default to enabled if we can't fetch the setting return true; // Default to enabled if we can't fetch the setting
@ -190,9 +205,14 @@ export const getTaskIntelligenceEnabled = async (): Promise<boolean> => {
export const getAutoSuggestNextActionsEnabled = async (): Promise<boolean> => { export const getAutoSuggestNextActionsEnabled = async (): Promise<boolean> => {
try { try {
const profile = await fetchProfile(); const profile = await fetchProfile();
return profile.auto_suggest_next_actions_enabled !== undefined ? profile.auto_suggest_next_actions_enabled : true; return profile.auto_suggest_next_actions_enabled !== undefined
? profile.auto_suggest_next_actions_enabled
: true;
} catch (error) { } catch (error) {
console.error('Error fetching auto-suggest next actions setting:', error); console.error(
'Error fetching auto-suggest next actions setting:',
error
);
return true; // Default to enabled if we can't fetch the setting return true; // Default to enabled if we can't fetch the setting
} }
}; };
@ -200,7 +220,9 @@ export const getAutoSuggestNextActionsEnabled = async (): Promise<boolean> => {
export const getProductivityAssistantEnabled = async (): Promise<boolean> => { export const getProductivityAssistantEnabled = async (): Promise<boolean> => {
try { try {
const profile = await fetchProfile(); const profile = await fetchProfile();
return profile.productivity_assistant_enabled !== undefined ? profile.productivity_assistant_enabled : true; return profile.productivity_assistant_enabled !== undefined
? profile.productivity_assistant_enabled
: true;
} catch (error) { } catch (error) {
console.error('Error fetching productivity assistant setting:', error); console.error('Error fetching productivity assistant setting:', error);
return true; // Default to enabled if we can't fetch the setting return true; // Default to enabled if we can't fetch the setting
@ -210,7 +232,9 @@ export const getProductivityAssistantEnabled = async (): Promise<boolean> => {
export const getNextTaskSuggestionEnabled = async (): Promise<boolean> => { export const getNextTaskSuggestionEnabled = async (): Promise<boolean> => {
try { try {
const profile = await fetchProfile(); const profile = await fetchProfile();
return profile.next_task_suggestion_enabled !== undefined ? profile.next_task_suggestion_enabled : true; return profile.next_task_suggestion_enabled !== undefined
? profile.next_task_suggestion_enabled
: true;
} catch (error) { } catch (error) {
console.error('Error fetching next task suggestion setting:', error); console.error('Error fetching next task suggestion setting:', error);
return true; // Default to enabled if we can't fetch the setting return true; // Default to enabled if we can't fetch the setting

View file

@ -1,12 +1,15 @@
import { Project } from "../entities/Project"; import { Project } from '../entities/Project';
import { handleAuthResponse } from "./authUtils"; import { handleAuthResponse } from './authUtils';
export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise<Project[]> => { export const fetchProjects = async (
activeFilter = 'all',
areaFilter = ''
): Promise<Project[]> => {
let url = `/api/projects`; let url = `/api/projects`;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (activeFilter !== "all") params.append("active", activeFilter); if (activeFilter !== 'all') params.append('active', activeFilter);
if (areaFilter) params.append("area_id", areaFilter); if (areaFilter) params.append('area_id', areaFilter);
if (params.toString()) url += `?${params.toString()}`; if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url, { const response = await fetch(url, {
@ -20,16 +23,18 @@ export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Prom
return data.projects || data; return data.projects || data;
}; };
export const fetchGroupedProjects = async (activeFilter = "all", areaFilter = ""): Promise<Record<string, Project[]>> => { export const fetchGroupedProjects = async (
activeFilter = 'all',
areaFilter = ''
): Promise<Record<string, Project[]>> => {
let url = `/api/projects`; let url = `/api/projects`;
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("grouped", "true"); params.append('grouped', 'true');
if (activeFilter !== "all") params.append("active", activeFilter); if (activeFilter !== 'all') params.append('active', activeFilter);
if (areaFilter) params.append("area_id", areaFilter); if (areaFilter) params.append('area_id', areaFilter);
if (params.toString()) url += `?${params.toString()}`; if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url, { const response = await fetch(url, {
credentials: 'include', credentials: 'include',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
@ -51,13 +56,15 @@ export const fetchProjectById = async (projectId: string): Promise<Project> => {
return await response.json(); return await response.json();
}; };
export const createProject = async (projectData: Partial<Project>): Promise<Project> => { export const createProject = async (
projectData: Partial<Project>
): Promise<Project> => {
const response = await fetch('/api/project', { const response = await fetch('/api/project', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(projectData), body: JSON.stringify(projectData),
}); });
@ -66,13 +73,16 @@ export const createProject = async (projectData: Partial<Project>): Promise<Proj
return await response.json(); return await response.json();
}; };
export const updateProject = async (projectId: number, projectData: Partial<Project>): Promise<Project> => { export const updateProject = async (
projectId: number,
projectData: Partial<Project>
): Promise<Project> => {
const response = await fetch(`/api/project/${projectId}`, { const response = await fetch(`/api/project/${projectId}`, {
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', Accept: 'application/json',
}, },
body: JSON.stringify(projectData), body: JSON.stringify(projectData),
}); });
@ -86,7 +96,7 @@ export const deleteProject = async (projectId: number): Promise<void> => {
method: 'DELETE', method: 'DELETE',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Accept': 'application/json', Accept: 'application/json',
}, },
}); });

Some files were not shown because too many files have changed in this diff Show more