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:
parent
f433dbffe3
commit
220bc92b4a
114 changed files with 30271 additions and 48239 deletions
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
|
@ -10,4 +10,14 @@ export default [
|
|||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "18"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
162
frontend/App.tsx
162
frontend/App.tsx
|
|
@ -1,35 +1,30 @@
|
|||
import React, { useEffect, useState, Suspense, lazy } from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
Outlet
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Login from "./components/Login";
|
||||
import NotFound from "./components/Shared/NotFound";
|
||||
import ProjectDetails from "./components/Project/ProjectDetails";
|
||||
import Projects from "./components/Projects";
|
||||
import AreaDetails from "./components/Area/AreaDetails";
|
||||
import Areas from "./components/Areas";
|
||||
import TagDetails from "./components/Tag/TagDetails";
|
||||
import Tags from "./components/Tags";
|
||||
import Notes from "./components/Notes";
|
||||
import NoteDetails from "./components/Note/NoteDetails";
|
||||
import Calendar from "./components/Calendar";
|
||||
import ProfileSettings from "./components/Profile/ProfileSettings";
|
||||
import About from "./components/About";
|
||||
import Layout from "./Layout";
|
||||
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";
|
||||
import React, { useEffect, useState, Suspense, lazy } from 'react';
|
||||
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Login from './components/Login';
|
||||
import NotFound from './components/Shared/NotFound';
|
||||
import ProjectDetails from './components/Project/ProjectDetails';
|
||||
import Projects from './components/Projects';
|
||||
import AreaDetails from './components/Area/AreaDetails';
|
||||
import Areas from './components/Areas';
|
||||
import TagDetails from './components/Tag/TagDetails';
|
||||
import Tags from './components/Tags';
|
||||
import Notes from './components/Notes';
|
||||
import NoteDetails from './components/Note/NoteDetails';
|
||||
import Calendar from './components/Calendar';
|
||||
import ProfileSettings from './components/Profile/ProfileSettings';
|
||||
import About from './components/About';
|
||||
import Layout from './Layout';
|
||||
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
|
||||
const Tasks = lazy(() => import("./components/Tasks"));
|
||||
const Tasks = lazy(() => import('./components/Tasks'));
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
return <LoadingScreen />;
|
||||
|
|
@ -40,10 +35,10 @@ const App: React.FC = () => {
|
|||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/current_user", {
|
||||
credentials: "include",
|
||||
const response = await fetch('/api/current_user', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -61,7 +56,7 @@ const App: React.FC = () => {
|
|||
} else {
|
||||
setCurrentUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setCurrentUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -80,59 +75,77 @@ const App: React.FC = () => {
|
|||
setCurrentUser(user);
|
||||
};
|
||||
|
||||
window.addEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
|
||||
return () => window.removeEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
|
||||
window.addEventListener(
|
||||
'userLoggedIn',
|
||||
handleUserLoggedIn as EventListener
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
'userLoggedIn',
|
||||
handleUserLoggedIn as EventListener
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (i18n.isInitialized) {
|
||||
fetch(`/locales/${i18n.language}/translation.json`)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
.then((data) => {
|
||||
i18n.addResourceBundle(
|
||||
i18n.language,
|
||||
'translation',
|
||||
data,
|
||||
true,
|
||||
true
|
||||
);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error manually fetching translation file:", error);
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Error manually fetching translation file:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [i18n.isInitialized]);
|
||||
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
const storedPreference = localStorage.getItem('isDarkMode');
|
||||
return storedPreference !== null
|
||||
? storedPreference === "true"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
? storedPreference === 'true'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newValue = !isDarkMode;
|
||||
setIsDarkMode(newValue);
|
||||
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
|
||||
localStorage.setItem('isDarkMode', JSON.stringify(newValue));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
document.documentElement.classList.toggle("dark", isDarkMode);
|
||||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||||
};
|
||||
updateTheme();
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mediaListener = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem("isDarkMode")) {
|
||||
if (!localStorage.getItem('isDarkMode')) {
|
||||
setIsDarkMode(e.matches);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", mediaListener);
|
||||
return () => mediaQuery.removeEventListener("change", mediaListener);
|
||||
mediaQuery.addEventListener('change', mediaListener);
|
||||
return () => mediaQuery.removeEventListener('change', mediaListener);
|
||||
}, [isDarkMode]);
|
||||
|
||||
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
{i18n.t('common.loading', 'Loading application... Please wait.')}
|
||||
{i18n.t(
|
||||
'common.loading',
|
||||
'Loading application... Please wait.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -158,28 +171,55 @@ const App: React.FC = () => {
|
|||
</Layout>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/today" replace />} />
|
||||
<Route
|
||||
index
|
||||
element={<Navigate to="/today" replace />}
|
||||
/>
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route path="/task/:uuid" element={<TaskView />} />
|
||||
<Route
|
||||
path="/tasks"
|
||||
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 />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/inbox" element={<InboxItems />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route
|
||||
path="/project/:id"
|
||||
element={<ProjectDetails />}
|
||||
/>
|
||||
<Route path="/areas" element={<Areas />} />
|
||||
<Route path="/area/:id" element={<AreaDetails />} />
|
||||
<Route path="/tags" element={<Tags />} />
|
||||
<Route path="/tag/:identifier" element={<TagDetails />} />
|
||||
<Route
|
||||
path="/tag/:identifier"
|
||||
element={<TagDetails />}
|
||||
/>
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<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="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
|
@ -187,8 +227,14 @@ const App: React.FC = () => {
|
|||
) : (
|
||||
<>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/login" replace />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,32 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "./components/Shared/ToastContext";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import "./styles/tailwind.css";
|
||||
import ProjectModal from "./components/Project/ProjectModal";
|
||||
import NoteModal from "./components/Note/NoteModal";
|
||||
import AreaModal from "./components/Area/AreaModal";
|
||||
import TagModal from "./components/Tag/TagModal";
|
||||
import InboxModal from "./components/Inbox/InboxModal";
|
||||
import TaskModal from "./components/Task/TaskModal";
|
||||
import { Note } from "./entities/Note";
|
||||
import { Area } from "./entities/Area";
|
||||
import { Tag } from "./entities/Tag";
|
||||
import { Project } from "./entities/Project";
|
||||
import { Task } from "./entities/Task";
|
||||
import { User } from "./entities/User";
|
||||
import { useStore } from "./store/useStore";
|
||||
import { fetchNotes, createNote, updateNote } from "./utils/notesService";
|
||||
import { fetchAreas, createArea, updateArea } from "./utils/areasService";
|
||||
import { fetchTags, createTag, updateTag } from "./utils/tagsService";
|
||||
import { fetchProjects, createProject, updateProject } from "./utils/projectsService";
|
||||
import { createTask, updateTask } from "./utils/tasksService";
|
||||
import { isAuthError } from "./utils/authUtils";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from './components/Shared/ToastContext';
|
||||
import Navbar from './components/Navbar';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import './styles/tailwind.css';
|
||||
import ProjectModal from './components/Project/ProjectModal';
|
||||
import NoteModal from './components/Note/NoteModal';
|
||||
import AreaModal from './components/Area/AreaModal';
|
||||
import TagModal from './components/Tag/TagModal';
|
||||
import InboxModal from './components/Inbox/InboxModal';
|
||||
import TaskModal from './components/Task/TaskModal';
|
||||
import { Note } from './entities/Note';
|
||||
import { Area } from './entities/Area';
|
||||
import { Tag } from './entities/Tag';
|
||||
import { Project } from './entities/Project';
|
||||
import { Task } from './entities/Task';
|
||||
import { User } from './entities/User';
|
||||
import { useStore } from './store/useStore';
|
||||
import { fetchNotes, createNote, updateNote } from './utils/notesService';
|
||||
import { fetchAreas, createArea, updateArea } from './utils/areasService';
|
||||
import { fetchTags, createTag, updateTag } from './utils/tagsService';
|
||||
import {
|
||||
fetchProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
} from './utils/projectsService';
|
||||
import { createTask, updateTask } from './utils/tasksService';
|
||||
import { isAuthError } from './utils/authUtils';
|
||||
|
||||
interface LayoutProps {
|
||||
currentUser: User;
|
||||
|
|
@ -41,18 +45,21 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(
|
||||
window.innerWidth >= 1024
|
||||
);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
|
||||
const [isTagModalOpen, setIsTagModalOpen] = useState(false);
|
||||
const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>('simplified');
|
||||
const [taskModalType, setTaskModalType] = useState<'simplified' | 'full'>(
|
||||
'simplified'
|
||||
);
|
||||
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
const [newTask, setNewTask] = useState<Task | null>(null);
|
||||
|
||||
const {
|
||||
notesStore: {
|
||||
|
|
@ -71,12 +78,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
isLoading: isAreasLoading,
|
||||
isError: isAreasError,
|
||||
},
|
||||
tasksStore: {
|
||||
setLoading: setTasksLoading,
|
||||
setError: setTasksError,
|
||||
isLoading: isTasksLoading,
|
||||
isError: isTasksError,
|
||||
},
|
||||
tasksStore: { isLoading: isTasksLoading, isError: isTasksError },
|
||||
projectsStore: {
|
||||
projects,
|
||||
setProjects,
|
||||
|
|
@ -100,14 +102,12 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
setTaskModalType(type);
|
||||
};
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsSidebarOpen(window.innerWidth >= 1024);
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const loadNotes = async () => {
|
||||
|
|
@ -116,7 +116,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const notesData = await fetchNotes();
|
||||
setNotes(notesData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching notes:", error);
|
||||
console.error('Error fetching notes:', error);
|
||||
setNotesError(true);
|
||||
} finally {
|
||||
setNotesLoading(false);
|
||||
|
|
@ -129,7 +129,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const areasData = await fetchAreas();
|
||||
setAreas(areasData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching areas:", error);
|
||||
console.error('Error fetching areas:', error);
|
||||
setAreasError(true);
|
||||
} finally {
|
||||
setAreasLoading(false);
|
||||
|
|
@ -142,7 +142,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const tagsData = await fetchTags();
|
||||
setTags(tagsData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching tags:", error);
|
||||
console.error('Error fetching tags:', error);
|
||||
setTagsError(true);
|
||||
} finally {
|
||||
setTagsLoading(false);
|
||||
|
|
@ -155,7 +155,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching projects:", error);
|
||||
console.error('Error fetching projects:', error);
|
||||
setProjectsError(true);
|
||||
} finally {
|
||||
setProjectsLoading(false);
|
||||
|
|
@ -181,7 +181,6 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
|
||||
const closeTaskModal = () => {
|
||||
setIsTaskModalOpen(false);
|
||||
setNewTask(null);
|
||||
};
|
||||
|
||||
const openProjectModal = () => {
|
||||
|
|
@ -222,7 +221,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
loadNotes();
|
||||
closeNoteModal();
|
||||
} 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)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
|
|
@ -237,7 +236,14 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
await updateTask(taskData.id, taskData);
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
|
@ -245,7 +251,14 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const createdTask = await createTask(taskData);
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
|
@ -254,7 +267,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
// This prevents unnecessary re-renders and race conditions
|
||||
closeTaskModal();
|
||||
} 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)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
|
|
@ -273,7 +286,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
});
|
||||
return newProject;
|
||||
} catch (error) {
|
||||
console.error("Error creating project:", error);
|
||||
console.error('Error creating project:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -289,7 +302,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
setProjects(projectsData);
|
||||
closeProjectModal();
|
||||
} 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)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
|
|
@ -298,7 +311,6 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSaveArea = async (areaData: Partial<Area>) => {
|
||||
try {
|
||||
if (areaData.id) {
|
||||
|
|
@ -309,7 +321,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
loadAreas();
|
||||
closeAreaModal();
|
||||
} 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)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
|
|
@ -329,7 +341,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
setTags(tagsData);
|
||||
closeTagModal();
|
||||
} 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)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
|
|
@ -338,24 +350,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
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 mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0';
|
||||
|
||||
const isLoading =
|
||||
isNotesLoading ||
|
||||
|
|
@ -372,7 +367,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
|
||||
<div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Navbar
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
|
|
@ -410,7 +405,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
|
||||
<div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Navbar
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
|
|
@ -438,14 +433,16 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
||||
>
|
||||
<div className="text-xl text-red-500">{t('errors.somethingWentWrong')}</div>
|
||||
<div className="text-xl text-red-500">
|
||||
{t('errors.somethingWentWrong')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
|
||||
<div className={`min-h-screen ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Navbar
|
||||
isDarkMode={isDarkMode}
|
||||
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-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>
|
||||
|
||||
|
||||
{isTaskModalOpen && (
|
||||
taskModalType === 'simplified' ? (
|
||||
{isTaskModalOpen &&
|
||||
(taskModalType === 'simplified' ? (
|
||||
<InboxModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={closeTaskModal}
|
||||
|
|
@ -494,16 +492,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
isOpen={isTaskModalOpen}
|
||||
onClose={closeTaskModal}
|
||||
task={{
|
||||
name: "",
|
||||
status: "not_started",
|
||||
name: '',
|
||||
status: 'not_started',
|
||||
}}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={async () => {}}
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
|
||||
{isProjectModalOpen && (
|
||||
<ProjectModal
|
||||
|
|
@ -512,7 +509,9 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
onSave={handleSaveProject}
|
||||
onDelete={async (projectId) => {
|
||||
try {
|
||||
const { deleteProject } = await import('./utils/projectsService');
|
||||
const { deleteProject } = await import(
|
||||
'./utils/projectsService'
|
||||
);
|
||||
await deleteProject(projectId);
|
||||
loadProjects();
|
||||
closeProjectModal();
|
||||
|
|
@ -531,7 +530,9 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
onSave={handleSaveNote}
|
||||
onDelete={async (noteId) => {
|
||||
try {
|
||||
const { deleteNote } = await import('./utils/notesService');
|
||||
const { deleteNote } = await import(
|
||||
'./utils/notesService'
|
||||
);
|
||||
await deleteNote(noteId);
|
||||
loadNotes();
|
||||
closeNoteModal();
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ import { HeartIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
|
|||
|
||||
const About: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [version, setVersion] = useState<string>("0.3");
|
||||
const [version, setVersion] = useState<string>('0.3');
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch version from the deployed app
|
||||
fetch('/api/version')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.version) {
|
||||
setVersion(data.version);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('Error fetching version:', error);
|
||||
// Keep default version if fetch fails
|
||||
});
|
||||
|
|
@ -26,7 +26,9 @@ const About: React.FC = () => {
|
|||
<div className="w-full max-w-5xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<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 className="max-w-2xl mx-auto">
|
||||
|
|
@ -43,7 +45,10 @@ const About: React.FC = () => {
|
|||
{/* Description */}
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
|
|
@ -56,14 +61,20 @@ const About: React.FC = () => {
|
|||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Support Links */}
|
||||
<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">
|
||||
{t('about.supportDevelopment', 'Support Development')}
|
||||
{t(
|
||||
'about.supportDevelopment',
|
||||
'Support Development'
|
||||
)}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<a
|
||||
|
|
@ -72,7 +83,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Patreon
|
||||
|
|
@ -83,7 +98,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Buy Me a Coffee
|
||||
|
|
@ -94,7 +113,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
GitHub Sponsors
|
||||
|
|
@ -105,7 +128,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
PayPal
|
||||
|
|
@ -125,7 +152,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Official Website
|
||||
|
|
@ -136,7 +167,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
Reddit
|
||||
|
|
@ -147,7 +182,11 @@ const About: React.FC = () => {
|
|||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
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"
|
||||
>
|
||||
{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" />
|
||||
</svg>
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -178,7 +224,15 @@ const About: React.FC = () => {
|
|||
{/* Footer */}
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ const AreaDetails: React.FC = () => {
|
|||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{t('areas.details')}: {area?.name}
|
||||
</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
|
||||
to={`/projects?area_id=${area?.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,13 @@ interface AreaModalProps {
|
|||
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 [formData, setFormData] = useState<Area>({
|
||||
id: area?.id || 0,
|
||||
|
|
@ -92,7 +98,11 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
|
|||
|
||||
try {
|
||||
await onSave(formData);
|
||||
showSuccessToast(formData.id ? t('success.areaUpdated') : t('success.areaCreated'));
|
||||
showSuccessToast(
|
||||
formData.id
|
||||
? t('success.areaUpdated')
|
||||
: t('success.areaCreated')
|
||||
);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
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) {
|
||||
try {
|
||||
await onDelete(formData.id);
|
||||
showSuccessToast(t('success.areaDeleted', 'Area deleted successfully!'));
|
||||
showSuccessToast(
|
||||
t('success.areaDeleted', 'Area deleted successfully!')
|
||||
);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
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 (
|
||||
<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 ${
|
||||
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
|
||||
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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-full sm:h-auto sm:my-4`}
|
||||
>
|
||||
<div className="flex flex-col h-full sm:min-h-[400px] sm:max-h-[90vh]">
|
||||
{/* Main Form Section */}
|
||||
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
|
||||
<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">
|
||||
<fieldset className="h-full flex flex-col">
|
||||
{/* Area Title Section - Always Visible */}
|
||||
|
|
@ -155,7 +172,9 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
|
|||
onChange={handleChange}
|
||||
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"
|
||||
placeholder={t('forms.areaNamePlaceholder')}
|
||||
placeholder={t(
|
||||
'forms.areaNamePlaceholder'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -167,13 +186,21 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
|
|||
value={formData.description}
|
||||
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"
|
||||
placeholder={t('forms.areaDescriptionPlaceholder')}
|
||||
style={{ minHeight: '150px' }}
|
||||
placeholder={t(
|
||||
'forms.areaDescriptionPlaceholder'
|
||||
)}
|
||||
style={{
|
||||
minHeight: '150px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</form>
|
||||
</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">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{(area && area.id && area.id !== 0 && onDelete) && (
|
||||
{area &&
|
||||
area.id &&
|
||||
area.id !== 0 &&
|
||||
onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title={t('common.delete', 'Delete')}
|
||||
title={t(
|
||||
'common.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -208,7 +241,9 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, on
|
|||
onClick={handleSubmit}
|
||||
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 ${
|
||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting
|
||||
|
|
|
|||
|
|
@ -9,16 +9,24 @@ import {
|
|||
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||
import AreaModal from './Area/AreaModal';
|
||||
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';
|
||||
|
||||
const Areas: React.FC = () => {
|
||||
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 [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 [hoveredAreaId, setHoveredAreaId] = useState<number | null>(null);
|
||||
|
||||
|
|
@ -67,11 +75,6 @@ const Areas: React.FC = () => {
|
|||
setIsAreaModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateArea = () => {
|
||||
setSelectedArea(null);
|
||||
setIsAreaModalOpen(true);
|
||||
};
|
||||
|
||||
const openConfirmDialog = (area: Area) => {
|
||||
setAreaToDelete(area);
|
||||
setIsConfirmDialogOpen(true);
|
||||
|
|
@ -115,14 +118,18 @@ const Areas: React.FC = () => {
|
|||
|
||||
{/* Areas List */}
|
||||
{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">
|
||||
{areas.map((area) => (
|
||||
<li
|
||||
key={area.id}
|
||||
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)}
|
||||
>
|
||||
{/* Area Content */}
|
||||
|
|
@ -145,20 +152,34 @@ const Areas: React.FC = () => {
|
|||
<button
|
||||
onClick={() => handleEditArea(area)}
|
||||
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 })}
|
||||
title={t('areas.editAreaTitle', { name: area.name })}
|
||||
aria-label={t(
|
||||
'areas.editAreaAriaLabel',
|
||||
{ name: area.name }
|
||||
)}
|
||||
title={t('areas.editAreaTitle', {
|
||||
name: area.name,
|
||||
})}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfirmDialog(area)}
|
||||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${
|
||||
hoveredAreaId === area.id ? 'opacity-100' : 'opacity-0'
|
||||
hoveredAreaId === area.id
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
}`}
|
||||
aria-label={t('areas.deleteAreaAriaLabel', { name: area.name })}
|
||||
title={t('areas.deleteAreaTitle', { name: area.name })}
|
||||
aria-label={t(
|
||||
'areas.deleteAreaAriaLabel',
|
||||
{ name: area.name }
|
||||
)}
|
||||
title={t('areas.deleteAreaTitle', {
|
||||
name: area.name,
|
||||
})}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -194,7 +215,9 @@ const Areas: React.FC = () => {
|
|||
{isConfirmDialogOpen && areaToDelete && (
|
||||
<ConfirmDialog
|
||||
title={t('modals.deleteArea.title')}
|
||||
message={t('modals.deleteArea.message', { name: areaToDelete.name })}
|
||||
message={t('modals.deleteArea.message', {
|
||||
name: areaToDelete.name,
|
||||
})}
|
||||
onConfirm={handleDeleteArea}
|
||||
onCancel={closeConfirmDialog}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CalendarIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
ArrowTopRightOnSquareIcon
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { format, addWeeks, addDays } from 'date-fns';
|
||||
import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
|
||||
|
|
@ -20,12 +19,18 @@ import CalendarDayView from './Calendar/CalendarDayView';
|
|||
|
||||
const getLocale = (language: string) => {
|
||||
switch (language) {
|
||||
case 'el': return el;
|
||||
case 'es': return es;
|
||||
case 'jp': return ja;
|
||||
case 'ua': return uk;
|
||||
case 'de': return de;
|
||||
default: return enUS;
|
||||
case 'el':
|
||||
return el;
|
||||
case 'es':
|
||||
return es;
|
||||
case 'jp':
|
||||
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 [currentDate, setCurrentDate] = useState(new Date());
|
||||
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 [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
|
|
@ -70,18 +77,25 @@ const Calendar: React.FC = () => {
|
|||
|
||||
// Check URL parameters for demo mode
|
||||
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' });
|
||||
setIsDemoMode(true);
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkGoogleCalendarStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/calendar/status', {
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
|
|
@ -97,7 +111,7 @@ const Calendar: React.FC = () => {
|
|||
setIsLoadingTasks(true);
|
||||
try {
|
||||
const response = await fetch('/api/tasks', {
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
|
@ -138,8 +152,7 @@ const Calendar: React.FC = () => {
|
|||
return [];
|
||||
}
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
|
||||
tasks.forEach((task) => {
|
||||
// Add tasks with due dates
|
||||
if (task.due_date) {
|
||||
const dueDate = new Date(task.due_date);
|
||||
|
|
@ -149,7 +162,7 @@ const Calendar: React.FC = () => {
|
|||
start: dueDate,
|
||||
end: new Date(dueDate.getTime() + 60 * 60 * 1000), // 1 hour duration
|
||||
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);
|
||||
}
|
||||
|
|
@ -167,7 +180,7 @@ const Calendar: React.FC = () => {
|
|||
start: createdDate,
|
||||
end: new Date(createdDate.getTime() + 30 * 60 * 1000), // 30 min duration
|
||||
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);
|
||||
}
|
||||
|
|
@ -181,7 +194,7 @@ const Calendar: React.FC = () => {
|
|||
start: new Date(), // Today
|
||||
end: new Date(Date.now() + 30 * 60 * 1000), // 30 min duration
|
||||
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);
|
||||
}
|
||||
|
|
@ -193,7 +206,7 @@ const Calendar: React.FC = () => {
|
|||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const projectsData = await response.json();
|
||||
|
|
@ -210,13 +223,16 @@ const Calendar: React.FC = () => {
|
|||
setIsConnecting(true);
|
||||
try {
|
||||
const response = await fetch('/api/calendar/auth', {
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.demo) {
|
||||
// Demo mode - simulate connection
|
||||
setGoogleStatus({ connected: true, email: 'demo@example.com' });
|
||||
setGoogleStatus({
|
||||
connected: true,
|
||||
email: 'demo@example.com',
|
||||
});
|
||||
setIsDemoMode(true);
|
||||
} else {
|
||||
// Real Google OAuth - redirect to auth URL
|
||||
|
|
@ -245,7 +261,7 @@ const Calendar: React.FC = () => {
|
|||
// Real disconnect API call
|
||||
const response = await fetch('/api/calendar/disconnect', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
setGoogleStatus({ connected: false });
|
||||
|
|
@ -259,7 +275,7 @@ const Calendar: React.FC = () => {
|
|||
};
|
||||
|
||||
const navigate = (direction: 'prev' | 'next') => {
|
||||
setCurrentDate(prev => {
|
||||
setCurrentDate((prev) => {
|
||||
if (view === 'month') {
|
||||
const newDate = new Date(prev);
|
||||
if (direction === 'prev') {
|
||||
|
|
@ -269,9 +285,14 @@ const Calendar: React.FC = () => {
|
|||
}
|
||||
return newDate;
|
||||
} else if (view === 'week') {
|
||||
return direction === 'prev' ? addWeeks(prev, -1) : addWeeks(prev, 1);
|
||||
} else { // day
|
||||
return direction === 'prev' ? addDays(prev, -1) : addDays(prev, 1);
|
||||
return direction === 'prev'
|
||||
? addWeeks(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) => {
|
||||
|
||||
// Handle task events
|
||||
if (event.type === 'task') {
|
||||
// Extract task ID from event ID
|
||||
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) {
|
||||
// Convert task to proper Task entity format for TaskModal
|
||||
|
|
@ -305,7 +325,7 @@ const Calendar: React.FC = () => {
|
|||
due_date: task.due_date,
|
||||
created_at: task.created_at,
|
||||
completed_at: task.completed_at,
|
||||
project_id: task.project_id
|
||||
project_id: task.project_id,
|
||||
};
|
||||
|
||||
setSelectedTask(taskEntity);
|
||||
|
|
@ -325,7 +345,9 @@ const Calendar: React.FC = () => {
|
|||
|
||||
const handleTaskSave = (updatedTask: Task) => {
|
||||
// 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
|
||||
loadTasks();
|
||||
// Close modal
|
||||
|
|
@ -337,7 +359,7 @@ const Calendar: React.FC = () => {
|
|||
try {
|
||||
await deleteTask(taskId);
|
||||
// Remove task from allTasks
|
||||
setAllTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
setAllTasks((prev) => prev.filter((t) => t.id !== taskId));
|
||||
// Refresh calendar
|
||||
loadTasks();
|
||||
// Close modal
|
||||
|
|
@ -354,12 +376,12 @@ const Calendar: React.FC = () => {
|
|||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name, description: '' })
|
||||
body: JSON.stringify({ name, description: '' }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newProject = await response.json();
|
||||
setProjects(prev => [...prev, newProject]);
|
||||
setProjects((prev) => [...prev, newProject]);
|
||||
return newProject;
|
||||
} else {
|
||||
throw new Error('Failed to create project');
|
||||
|
|
@ -391,7 +413,11 @@ const Calendar: React.FC = () => {
|
|||
{['month', 'week', 'day'].map((viewType) => (
|
||||
<button
|
||||
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 ${
|
||||
view === viewType
|
||||
? 'bg-blue-500 text-white'
|
||||
|
|
@ -434,7 +460,6 @@ const Calendar: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Calendar view */}
|
||||
{view === 'month' && (
|
||||
<CalendarMonthView
|
||||
|
|
@ -474,18 +499,20 @@ const Calendar: React.FC = () => {
|
|||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{isDemoMode
|
||||
? 'Demo mode: Google Calendar integration simulated for testing purposes.'
|
||||
: t('calendar.googleDescription')
|
||||
}
|
||||
: t('calendar.googleDescription')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{t('calendar.googleStatus')}:
|
||||
{googleStatus.connected ? (
|
||||
<span className="text-green-500 ml-1">
|
||||
{t('calendar.connected')}
|
||||
{googleStatus.email && ` (${googleStatus.email})`}
|
||||
{googleStatus.email &&
|
||||
` (${googleStatus.email})`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500 ml-1">{t('calendar.notConnected')}</span>
|
||||
<span className="text-red-500 ml-1">
|
||||
{t('calendar.notConnected')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -502,7 +529,9 @@ const Calendar: React.FC = () => {
|
|||
disabled={isConnecting}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -549,7 +578,12 @@ interface TaskEventModalProps {
|
|||
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 locale = getLocale(i18n.language);
|
||||
|
||||
|
|
@ -587,12 +621,16 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
|
|||
{t('calendar.status')}
|
||||
</label>
|
||||
<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
|
||||
? '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'
|
||||
}`}>
|
||||
{task.completed_at ? `✅ ${t('calendar.completed')}` : `⏳ ${t('calendar.pending')}`}
|
||||
}`}
|
||||
>
|
||||
{task.completed_at
|
||||
? `✅ ${t('calendar.completed')}`
|
||||
: `⏳ ${t('calendar.pending')}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -604,7 +642,9 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
|
|||
{t('calendar.dueDate')}
|
||||
</label>
|
||||
<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>
|
||||
</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">
|
||||
{t('calendar.priority')}
|
||||
</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'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
: task.priority === 'medium'
|
||||
? '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'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
{t(`calendar.${task.priority}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -660,7 +702,9 @@ const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose,
|
|||
{t('calendar.created')}
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { format, addHours, isToday } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
|
|
@ -22,14 +21,12 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
|||
currentDate,
|
||||
events,
|
||||
onEventClick,
|
||||
onTimeSlotClick
|
||||
onTimeSlotClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
const getEventsForTimeSlot = (hour: number) => {
|
||||
return events.filter(event => {
|
||||
return events.filter((event) => {
|
||||
const eventDay = format(event.start, 'yyyy-MM-dd');
|
||||
const currentDay = format(currentDate, 'yyyy-MM-dd');
|
||||
const eventHour = event.start.getHours();
|
||||
|
|
@ -67,14 +64,22 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
|||
{/* Header */}
|
||||
<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-lg font-medium ${
|
||||
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-lg font-medium ${
|
||||
isToday(currentDate)
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{format(currentDate, 'EEEE')}
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-2xl font-bold ${
|
||||
isToday(currentDate)
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{format(currentDate, 'd')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
|
|
@ -85,27 +90,41 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
|||
|
||||
{/* 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="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">
|
||||
{events
|
||||
.filter(event => {
|
||||
.filter((event) => {
|
||||
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)
|
||||
const duration = event.end.getTime() - event.start.getTime();
|
||||
return eventDay === currentDay && duration >= 24 * 60 * 60 * 1000;
|
||||
const duration =
|
||||
event.end.getTime() - event.start.getTime();
|
||||
return (
|
||||
eventDay === currentDay &&
|
||||
duration >= 24 * 60 * 60 * 1000
|
||||
);
|
||||
})
|
||||
.map(event => (
|
||||
.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={(e) => handleEventClick(event, e)}
|
||||
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}`}
|
||||
>
|
||||
{event.type === 'task' && '📋 '}{event.title}
|
||||
{event.type === 'task' && '📋 '}
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -113,15 +132,24 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
|||
|
||||
{/* Time slots */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{hours.map(hour => {
|
||||
{hours.map((hour) => {
|
||||
const timeSlotEvents = getEventsForTimeSlot(hour);
|
||||
|
||||
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">
|
||||
{/* 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">
|
||||
{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>
|
||||
|
||||
{/* Event area */}
|
||||
|
|
@ -129,23 +157,36 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
|||
onClick={() => handleTimeSlotClick(hour)}
|
||||
className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative"
|
||||
>
|
||||
{timeSlotEvents.map(event => (
|
||||
{timeSlotEvents.map((event) => (
|
||||
<div
|
||||
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 ${
|
||||
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',
|
||||
top: calculateEventPosition(event),
|
||||
height: calculateEventHeight(event)
|
||||
backgroundColor:
|
||||
event.color || '#3b82f6',
|
||||
top: calculateEventPosition(
|
||||
event
|
||||
),
|
||||
height: calculateEventHeight(
|
||||
event
|
||||
),
|
||||
}}
|
||||
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">
|
||||
{format(event.start, 'HH:mm')} - {format(event.end, 'HH:mm')}
|
||||
{format(event.start, 'HH:mm')} -{' '}
|
||||
{format(event.end, 'HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
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';
|
||||
|
||||
interface CalendarEvent {
|
||||
|
|
@ -22,7 +31,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
currentDate,
|
||||
events,
|
||||
onDateClick,
|
||||
onEventClick
|
||||
onEventClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -33,7 +42,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
|
||||
const days = eachDayOfInterval({
|
||||
start: calendarStart,
|
||||
end: calendarEnd
|
||||
end: calendarEnd,
|
||||
});
|
||||
|
||||
const weekDays = [
|
||||
|
|
@ -43,7 +52,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
t('weekdays.thursday', 'Thu'),
|
||||
t('weekdays.friday', 'Fri'),
|
||||
t('weekdays.saturday', 'Sat'),
|
||||
t('weekdays.sunday', 'Sun')
|
||||
t('weekdays.sunday', 'Sun'),
|
||||
];
|
||||
|
||||
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">
|
||||
{/* 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">
|
||||
{weekDays.map(day => (
|
||||
<div key={day} className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{weekDays.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -72,9 +84,11 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map(day => {
|
||||
const dayEvents = events.filter(event =>
|
||||
format(event.start, 'yyyy-MM-dd') === format(day, 'yyyy-MM-dd')
|
||||
{days.map((day) => {
|
||||
const dayEvents = events.filter(
|
||||
(event) =>
|
||||
format(event.start, 'yyyy-MM-dd') ===
|
||||
format(day, 'yyyy-MM-dd')
|
||||
);
|
||||
|
||||
const isCurrentMonth = isSameMonth(day, currentDate);
|
||||
|
|
@ -90,11 +104,13 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
: 'bg-white dark:bg-gray-900'
|
||||
} ${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
|
||||
? 'text-gray-400 dark:text-gray-600'
|
||||
: '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 && (
|
||||
<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')}
|
||||
|
|
@ -105,17 +121,25 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
|||
|
||||
{/* Events */}
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 3).map(event => (
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<div
|
||||
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 ${
|
||||
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}`}
|
||||
>
|
||||
{event.type === 'task' && '📋 '}{event.title}
|
||||
{event.type === 'task' && '📋 '}
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import React from 'react';
|
||||
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isToday, addHours } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
format,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isToday,
|
||||
addHours,
|
||||
} from 'date-fns';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
|
|
@ -22,12 +28,9 @@ interface CalendarWeekViewProps {
|
|||
const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
|
||||
currentDate,
|
||||
events,
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
onTimeSlotClick
|
||||
onTimeSlotClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
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 getEventsForTimeSlot = (day: Date, hour: number) => {
|
||||
return events.filter(event => {
|
||||
return events.filter((event) => {
|
||||
const eventDay = format(event.start, 'yyyy-MM-dd');
|
||||
const slotDay = format(day, 'yyyy-MM-dd');
|
||||
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">
|
||||
Time
|
||||
</div>
|
||||
{weekDays.map(day => (
|
||||
<div key={day.toString()} className={`p-3 text-center border-l border-gray-200 dark:border-gray-700 ${
|
||||
{weekDays.map((day) => (
|
||||
<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' : ''
|
||||
}`}>
|
||||
<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')}
|
||||
</div>
|
||||
<div className={`text-lg ${
|
||||
isToday(day) ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-lg ${
|
||||
isToday(day)
|
||||
? 'text-blue-600 dark:text-blue-400 font-bold'
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
{format(day, 'd')}
|
||||
|
|
@ -90,36 +104,57 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
|
|||
|
||||
{/* Time slots */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{hours.map(hour => (
|
||||
<div key={hour} className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800">
|
||||
{hours.map((hour) => (
|
||||
<div
|
||||
key={hour}
|
||||
className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
{/* 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">
|
||||
{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>
|
||||
|
||||
{/* Day columns */}
|
||||
{weekDays.map(day => {
|
||||
const timeSlotEvents = getEventsForTimeSlot(day, hour);
|
||||
{weekDays.map((day) => {
|
||||
const timeSlotEvents = getEventsForTimeSlot(
|
||||
day,
|
||||
hour
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
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 ${
|
||||
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
|
||||
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 ${
|
||||
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')}`}
|
||||
>
|
||||
{event.type === 'task' && '📋 '}{event.title}
|
||||
{event.type === 'task' && '📋 '}
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import React, { useState } from 'react';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
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 { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useStore } from '../../store/useStore';
|
||||
|
||||
|
|
@ -21,16 +27,17 @@ interface InboxItemDetailProps {
|
|||
|
||||
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
||||
item,
|
||||
onProcess,
|
||||
onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onDelete,
|
||||
onUpdate,
|
||||
openTaskModal,
|
||||
openProjectModal,
|
||||
openNoteModal
|
||||
openNoteModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { tagsStore: { tags } } = useStore();
|
||||
const {
|
||||
tagsStore: { tags },
|
||||
} = useStore();
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
|
@ -39,16 +46,18 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
const parseHashtags = (text: string): string[] => {
|
||||
const hashtagRegex = /#([a-zA-Z0-9_]+)/g;
|
||||
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 handleConvertToTask = () => {
|
||||
// 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
|
||||
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase());
|
||||
const existingTag = tags.find(
|
||||
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
|
||||
);
|
||||
return existingTag || { name: hashtagName };
|
||||
});
|
||||
|
||||
|
|
@ -56,7 +65,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
name: item.content,
|
||||
status: 'not_started',
|
||||
priority: 'medium',
|
||||
tags: taskTags
|
||||
tags: taskTags,
|
||||
};
|
||||
|
||||
if (item.id !== undefined) {
|
||||
|
|
@ -68,9 +77,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
|
||||
const handleConvertToProject = () => {
|
||||
// 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
|
||||
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase());
|
||||
const existingTag = tags.find(
|
||||
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
|
||||
);
|
||||
return existingTag || { name: hashtagName };
|
||||
});
|
||||
|
||||
|
|
@ -78,7 +89,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
name: item.content,
|
||||
description: '',
|
||||
active: true,
|
||||
tags: projectTags
|
||||
tags: projectTags,
|
||||
};
|
||||
|
||||
if (item.id !== undefined) {
|
||||
|
|
@ -89,25 +100,32 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
};
|
||||
|
||||
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 isBookmark = false;
|
||||
|
||||
try {
|
||||
const { isUrl, extractUrlTitle } = await import("../../utils/urlService");
|
||||
const { isUrl, extractUrlTitle } = await import(
|
||||
'../../utils/urlService'
|
||||
);
|
||||
|
||||
if (isUrl(item.content.trim())) {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Add a timeout to prevent infinite loading
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), 10000) // 10 second timeout
|
||||
const timeoutPromise = new Promise(
|
||||
(_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout')),
|
||||
10000
|
||||
) // 10 second timeout
|
||||
);
|
||||
|
||||
const result = await Promise.race([
|
||||
const result = (await Promise.race([
|
||||
extractUrlTitle(item.content.trim()),
|
||||
timeoutPromise
|
||||
]) as any;
|
||||
timeoutPromise,
|
||||
])) as any;
|
||||
|
||||
if (result && result.title) {
|
||||
title = result.title;
|
||||
|
|
@ -115,7 +133,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
isBookmark = true;
|
||||
}
|
||||
} 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
|
||||
// Still mark as bookmark if it's a URL
|
||||
isBookmark = true;
|
||||
|
|
@ -124,25 +142,27 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking URL or extracting title:", error);
|
||||
console.error('Error checking URL or extracting title:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// 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
|
||||
const existingTag = tags.find(tag => tag.name.toLowerCase() === hashtagName.toLowerCase());
|
||||
const existingTag = tags.find(
|
||||
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
|
||||
);
|
||||
return existingTag || { name: hashtagName };
|
||||
});
|
||||
|
||||
// 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 newNote: Note = {
|
||||
title: title,
|
||||
content: content,
|
||||
tags: tagObjects
|
||||
tags: tagObjects,
|
||||
};
|
||||
|
||||
if (item.id !== undefined) {
|
||||
|
|
@ -152,7 +172,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
|
@ -241,7 +260,10 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title={t('inbox.deleteConfirmTitle', 'Delete Item')}
|
||||
message={t('inbox.deleteConfirmMessage', 'Are you sure you want to delete this inbox item? This action cannot be undone.')}
|
||||
message={t(
|
||||
'inbox.deleteConfirmMessage',
|
||||
'Are you sure you want to delete this inbox item? This action cannot be undone.'
|
||||
)}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
loadInboxItemsToStore,
|
||||
processInboxItemWithStore,
|
||||
deleteInboxItemWithStore,
|
||||
updateInboxItemWithStore
|
||||
updateInboxItemWithStore,
|
||||
} from '../../utils/inboxService';
|
||||
import InboxItemDetail from './InboxItemDetail';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
|
@ -29,7 +29,7 @@ const InboxItems: React.FC = () => {
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
// Access store data
|
||||
const { inboxItems, isLoading } = useStore(state => state.inboxStore);
|
||||
const { inboxItems, isLoading } = useStore((state) => state.inboxStore);
|
||||
|
||||
// Modal states
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
|
@ -43,7 +43,9 @@ const InboxItems: React.FC = () => {
|
|||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
|
||||
// Track the current inbox item ID being converted (for task/project/note conversion)
|
||||
const [currentConversionItemId, setCurrentConversionItemId] = useState<number | null>(null);
|
||||
const [currentConversionItemId, setCurrentConversionItemId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Track the current inbox item being edited
|
||||
const [itemToEdit, setItemToEdit] = useState<number | null>(null);
|
||||
|
|
@ -69,19 +71,33 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
|
||||
// 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
|
||||
if (event.detail.count > 0) {
|
||||
// Show notification for the first new item
|
||||
showSuccessToast(t('inbox.newTelegramItem', 'New item from Telegram: {{content}}', {
|
||||
content: event.detail.firstItemContent
|
||||
}));
|
||||
showSuccessToast(
|
||||
t(
|
||||
'inbox.newTelegramItem',
|
||||
'New item from Telegram: {{content}}',
|
||||
{
|
||||
content: event.detail.firstItemContent,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// If multiple new items, show a summary notification as well
|
||||
if (event.detail.count > 1) {
|
||||
showSuccessToast(t('inbox.multipleNewItems', '{{count}} more new items added', {
|
||||
count: event.detail.count - 1
|
||||
}));
|
||||
showSuccessToast(
|
||||
t(
|
||||
'inbox.multipleNewItems',
|
||||
'{{count}} more new items added',
|
||||
{
|
||||
count: event.detail.count - 1,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -94,12 +110,18 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
// Add event listeners
|
||||
window.addEventListener('forceInboxReload', handleForceReload);
|
||||
window.addEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
|
||||
window.addEventListener(
|
||||
'inboxItemsUpdated',
|
||||
handleInboxItemsUpdated as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval);
|
||||
window.removeEventListener('forceInboxReload', handleForceReload);
|
||||
window.removeEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
|
||||
window.removeEventListener(
|
||||
'inboxItemsUpdated',
|
||||
handleInboxItemsUpdated as EventListener
|
||||
);
|
||||
};
|
||||
}, [refreshInboxItems]);
|
||||
|
||||
|
|
@ -165,7 +187,10 @@ const InboxItems: React.FC = () => {
|
|||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenProjectModal = (project: Project | null, inboxItemId?: number) => {
|
||||
const handleOpenProjectModal = (
|
||||
project: Project | null,
|
||||
inboxItemId?: number
|
||||
) => {
|
||||
setProjectToEdit(project);
|
||||
|
||||
if (inboxItemId) {
|
||||
|
|
@ -175,7 +200,10 @@ const InboxItems: React.FC = () => {
|
|||
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
|
||||
try {
|
||||
const projectData = await fetchProjects();
|
||||
|
|
@ -191,7 +219,7 @@ const InboxItems: React.FC = () => {
|
|||
if (note && note.content && isUrl(note.content.trim())) {
|
||||
if (!note.tags) {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
|
@ -210,7 +238,14 @@ const InboxItems: React.FC = () => {
|
|||
const createdTask = await createTask(task);
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
|
@ -246,7 +281,6 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSaveNote = async (note: Note) => {
|
||||
try {
|
||||
// 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
|
||||
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
|
||||
note.tags = [...note.tags, { name: 'bookmark' }];
|
||||
}
|
||||
|
||||
// Create the note with proper tags
|
||||
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
|
||||
if (currentConversionItemId !== null) {
|
||||
|
|
@ -315,7 +354,10 @@ const InboxItems: React.FC = () => {
|
|||
</div>
|
||||
|
||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('taskViews.inbox', 'Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don\'t have a due date will appear here. This is your \'brain dump\' area where you can quickly note down tasks and organize them later.')}
|
||||
{t(
|
||||
'taskViews.inbox',
|
||||
"Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don't have a due date will appear here. This is your 'brain dump' area where you can quickly note down tasks and organize them later."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -344,7 +386,13 @@ const InboxItems: React.FC = () => {
|
|||
setIsTaskModalOpen(false);
|
||||
setTaskToEdit(null);
|
||||
}}
|
||||
task={taskToEdit || { name: '', status: 'not_started', priority: 'medium' }}
|
||||
task={
|
||||
taskToEdit || {
|
||||
name: '',
|
||||
status: 'not_started',
|
||||
priority: 'medium',
|
||||
}
|
||||
}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={async () => {}} // No need to delete since it's a new task
|
||||
projects={Array.isArray(projects) ? projects : []}
|
||||
|
|
@ -358,7 +406,8 @@ const InboxItems: React.FC = () => {
|
|||
})()}
|
||||
|
||||
{/* Project Modal - Only render when needed to prevent infinite loops */}
|
||||
{isProjectModalOpen && (() => {
|
||||
{isProjectModalOpen &&
|
||||
(() => {
|
||||
try {
|
||||
return (
|
||||
<ProjectModal
|
||||
|
|
@ -409,7 +458,10 @@ const InboxItems: React.FC = () => {
|
|||
setItemToEdit(null);
|
||||
}}
|
||||
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}
|
||||
onEdit={handleSaveEditedItem}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { Tag } from "../../entities/Tag";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createInboxItemWithStore } from "../../utils/inboxService";
|
||||
import { isAuthError } from "../../utils/authUtils";
|
||||
import { createTag } from "../../utils/tagsService";
|
||||
import { XMarkIcon, TagIcon } from "@heroicons/react/24/outline";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { Link } from "react-router-dom";
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createInboxItemWithStore } from '../../utils/inboxService';
|
||||
import { isAuthError } from '../../utils/authUtils';
|
||||
import { createTag } from '../../utils/tagsService';
|
||||
import { XMarkIcon, TagIcon } from '@heroicons/react/24/outline';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { Link } from 'react-router-dom';
|
||||
// import UrlPreview from "../Shared/UrlPreview";
|
||||
// import { UrlTitleResult } from "../../utils/urlService";
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialText = "",
|
||||
initialText = '',
|
||||
editMode = false,
|
||||
onEdit,
|
||||
}) => {
|
||||
|
|
@ -37,12 +37,17 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
|
||||
const { tagsStore: { tags, setTags } } = useStore();
|
||||
const {
|
||||
tagsStore: { tags, setTags },
|
||||
} = useStore();
|
||||
const [showTagSuggestions, setShowTagSuggestions] = useState(false);
|
||||
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [currentHashtagQuery, setCurrentHashtagQuery] = useState('');
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ left: 0, top: 0 });
|
||||
const [, setCurrentHashtagQuery] = useState('');
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
|
||||
|
||||
// Dispatch global modal events to hide floating + button
|
||||
|
|
@ -51,7 +56,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
const parseHashtags = (text: string): string[] => {
|
||||
const hashtagRegex = /#([a-zA-Z0-9_]+)/g;
|
||||
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
|
||||
|
|
@ -62,31 +67,36 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
};
|
||||
|
||||
// Helper function to render text with clickable hashtags
|
||||
const renderTextWithHashtags = (text: string) => {
|
||||
const parts = text.split(/(#[a-zA-Z0-9_]+)/g);
|
||||
return parts.map((part, index) => {
|
||||
if (part.startsWith('#')) {
|
||||
const tagName = part.substring(1);
|
||||
const tag = tags.find(t => t.name.toLowerCase() === tagName.toLowerCase());
|
||||
if (tag) {
|
||||
return (
|
||||
<Link
|
||||
key={index}
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <span key={index}>{part}</span>;
|
||||
});
|
||||
};
|
||||
// const renderTextWithHashtags = (text: string) => {
|
||||
// const parts = text.split(/(#[a-zA-Z0-9_]+)/g);
|
||||
// return parts.map((part, index) => {
|
||||
// if (part.startsWith('#')) {
|
||||
// const tagName = part.substring(1);
|
||||
// const tag = tags.find(
|
||||
// (t) => t.name.toLowerCase() === tagName.toLowerCase()
|
||||
// );
|
||||
// if (tag) {
|
||||
// return (
|
||||
// <Link
|
||||
// key={index}
|
||||
// to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
// className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
// onClick={(e) => e.stopPropagation()}
|
||||
// >
|
||||
// {part}
|
||||
// </Link>
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
// return <span key={index}>{part}</span>;
|
||||
// });
|
||||
// };
|
||||
|
||||
// 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
|
||||
const temp = document.createElement('span');
|
||||
temp.style.visibility = 'hidden';
|
||||
|
|
@ -122,7 +132,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
|
||||
return {
|
||||
left: hashtagOffset,
|
||||
top: input.offsetHeight
|
||||
top: input.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -162,12 +172,19 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
|
||||
if (newText.charAt(newCursorPosition - 1) === '#' || hashtagQuery) {
|
||||
// Filter tags based on current query
|
||||
const filtered = tags.filter(tag =>
|
||||
tag.name.toLowerCase().startsWith(hashtagQuery.toLowerCase())
|
||||
).slice(0, 5); // Limit to 5 suggestions
|
||||
const filtered = tags
|
||||
.filter((tag) =>
|
||||
tag.name
|
||||
.toLowerCase()
|
||||
.startsWith(hashtagQuery.toLowerCase())
|
||||
)
|
||||
.slice(0, 5); // Limit to 5 suggestions
|
||||
|
||||
// Calculate dropdown position
|
||||
const position = calculateDropdownPosition(e.target, newCursorPosition);
|
||||
const position = calculateDropdownPosition(
|
||||
e.target,
|
||||
newCursorPosition
|
||||
);
|
||||
setDropdownPosition(position);
|
||||
|
||||
setFilteredTags(filtered);
|
||||
|
|
@ -185,7 +202,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
const hashtagMatch = beforeCursor.match(/#([a-zA-Z0-9_]*)$/);
|
||||
|
||||
if (hashtagMatch) {
|
||||
const newText = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) + afterCursor;
|
||||
const newText =
|
||||
beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`) +
|
||||
afterCursor;
|
||||
setInputText(newText);
|
||||
setShowTagSuggestions(false);
|
||||
setFilteredTags([]);
|
||||
|
|
@ -194,8 +213,14 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
setTimeout(() => {
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
const newCursorPos = beforeCursor.replace(/#([a-zA-Z0-9_]*)$/, `#${tagName}`).length;
|
||||
nameInputRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||
const newCursorPos = beforeCursor.replace(
|
||||
/#([a-zA-Z0-9_]*)$/,
|
||||
`#${tagName}`
|
||||
).length;
|
||||
nameInputRef.current.setSelectionRange(
|
||||
newCursorPos,
|
||||
newCursorPos
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
|
@ -204,9 +229,9 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
// Create missing tags automatically
|
||||
const createMissingTags = async (text: string): Promise<void> => {
|
||||
const hashtagsInText = parseHashtags(text);
|
||||
const existingTagNames = tags.map(tag => tag.name.toLowerCase());
|
||||
const missingTags = hashtagsInText.filter(tagName =>
|
||||
!existingTagNames.includes(tagName.toLowerCase())
|
||||
const existingTagNames = tags.map((tag) => tag.name.toLowerCase());
|
||||
const missingTags = hashtagsInText.filter(
|
||||
(tagName) => !existingTagNames.includes(tagName.toLowerCase())
|
||||
);
|
||||
|
||||
for (const tagName of missingTags) {
|
||||
|
|
@ -243,7 +268,7 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
if (saveMode === 'task') {
|
||||
const newTask: Task = {
|
||||
name: inputText.trim(),
|
||||
status: "not_started",
|
||||
status: 'not_started',
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -276,19 +301,36 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
if (editMode) {
|
||||
showErrorToast(t('inbox.updateError'));
|
||||
} else {
|
||||
showErrorToast(saveMode === 'task' ? t('task.createError') : t('inbox.addError'));
|
||||
showErrorToast(
|
||||
saveMode === 'task'
|
||||
? t('task.createError')
|
||||
: t('inbox.addError')
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
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(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
if (!editMode) {
|
||||
setInputText("");
|
||||
setInputText('');
|
||||
setSaveMode('inbox');
|
||||
}
|
||||
setIsClosing(false);
|
||||
|
|
@ -297,7 +339,10 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
setShowTagSuggestions(false);
|
||||
setFilteredTags([]);
|
||||
|
|
@ -307,16 +352,16 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, showTagSuggestions, handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
if (event.key === 'Escape') {
|
||||
if (showTagSuggestions) {
|
||||
setShowTagSuggestions(false);
|
||||
setFilteredTags([]);
|
||||
|
|
@ -326,10 +371,10 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, showTagSuggestions, handleClose]);
|
||||
|
||||
|
|
@ -338,13 +383,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
return (
|
||||
<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 ${
|
||||
isClosing ? "opacity-0" : "opacity-100"
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} flex flex-col`}
|
||||
>
|
||||
{/* Close button - only visible on mobile */}
|
||||
|
|
@ -367,29 +412,44 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
value={inputText}
|
||||
onChange={handleChange}
|
||||
onSelect={(e) => {
|
||||
const pos = e.currentTarget.selectionStart || 0;
|
||||
const pos =
|
||||
e.currentTarget.selectionStart || 0;
|
||||
setCursorPosition(pos);
|
||||
// Update dropdown position if showing suggestions
|
||||
if (showTagSuggestions) {
|
||||
const position = calculateDropdownPosition(e.currentTarget, pos);
|
||||
const position =
|
||||
calculateDropdownPosition(
|
||||
e.currentTarget,
|
||||
pos
|
||||
);
|
||||
setDropdownPosition(position);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
const pos = e.currentTarget.selectionStart || 0;
|
||||
const pos =
|
||||
e.currentTarget.selectionStart || 0;
|
||||
setCursorPosition(pos);
|
||||
// Update dropdown position if showing suggestions
|
||||
if (showTagSuggestions) {
|
||||
const position = calculateDropdownPosition(e.currentTarget, pos);
|
||||
const position =
|
||||
calculateDropdownPosition(
|
||||
e.currentTarget,
|
||||
pos
|
||||
);
|
||||
setDropdownPosition(position);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const pos = e.currentTarget.selectionStart || 0;
|
||||
const pos =
|
||||
e.currentTarget.selectionStart || 0;
|
||||
setCursorPosition(pos);
|
||||
// Update dropdown position if showing suggestions
|
||||
if (showTagSuggestions) {
|
||||
const position = calculateDropdownPosition(e.currentTarget, pos);
|
||||
const position =
|
||||
calculateDropdownPosition(
|
||||
e.currentTarget,
|
||||
pos
|
||||
);
|
||||
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"
|
||||
placeholder={t('inbox.captureThought')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isSaving && !showTagSuggestions) {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
!isSaving &&
|
||||
!showTagSuggestions
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
|
|
@ -405,54 +470,85 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
/>
|
||||
|
||||
{/* 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">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>
|
||||
{parseHashtags(inputText).map((tagName, index) => {
|
||||
const tag = tags.find(t => t.name.toLowerCase() === tagName.toLowerCase());
|
||||
const isLast = index === parseHashtags(inputText).length - 1;
|
||||
{parseHashtags(inputText).map(
|
||||
(tagName, index) => {
|
||||
const tag = tags.find(
|
||||
(t) =>
|
||||
t.name.toLowerCase() ===
|
||||
tagName.toLowerCase()
|
||||
);
|
||||
const isLast =
|
||||
index ===
|
||||
parseHashtags(
|
||||
inputText
|
||||
).length -
|
||||
1;
|
||||
|
||||
if (tag) {
|
||||
return (
|
||||
<span key={index}>
|
||||
<span
|
||||
key={index}
|
||||
>
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(
|
||||
e
|
||||
) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
>
|
||||
{tagName}
|
||||
{
|
||||
tagName
|
||||
}
|
||||
</Link>
|
||||
{!isLast && ', '}
|
||||
{!isLast &&
|
||||
', '}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span key={index} className="text-orange-500 dark:text-orange-400">
|
||||
{tagName}{!isLast && ', '}
|
||||
<span
|
||||
key={index}
|
||||
className="text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
{tagName}
|
||||
{!isLast &&
|
||||
', '}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag Suggestions Dropdown */}
|
||||
{showTagSuggestions && filteredTags.length > 0 && (
|
||||
{showTagSuggestions &&
|
||||
filteredTags.length > 0 && (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50"
|
||||
style={{
|
||||
left: `${dropdownPosition.left}px`,
|
||||
top: `${dropdownPosition.top + 4}px`,
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px'
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{filteredTags.map((tag, index) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
#{tag.name}
|
||||
|
|
@ -467,11 +563,13 @@ const InboxModal: React.FC<InboxModalProps> = ({
|
|||
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 ${
|
||||
inputText.trim() && !isSaving
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-blue-400 cursor-not-allowed"
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-blue-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('common.save')}
|
||||
{isSaving
|
||||
? t('common.saving')
|
||||
: t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{/* URL Preview disabled */}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const Login: React.FC = () => {
|
|||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
|
@ -30,7 +30,9 @@ const Login: React.FC = () => {
|
|||
await i18n.changeLanguage(data.user.language);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn', { detail: data.user }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('userLoggedIn', { detail: data.user })
|
||||
);
|
||||
|
||||
navigate('/today');
|
||||
} else {
|
||||
|
|
@ -44,14 +46,10 @@ const Login: React.FC = () => {
|
|||
|
||||
return (
|
||||
<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">
|
||||
tududi
|
||||
</h1>
|
||||
<h1 className="text-5xl font-bold text-gray-300 mb-6">tududi</h1>
|
||||
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
|
||||
{error && (
|
||||
<div className="mb-4 text-center text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
<div className="mb-4 text-center text-red-500">{error}</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { UserIcon, Bars3Icon, BoltIcon, InboxIcon } from "@heroicons/react/24/solid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PomodoroTimer from "./Shared/PomodoroTimer";
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
UserIcon,
|
||||
Bars3Icon,
|
||||
BoltIcon,
|
||||
InboxIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PomodoroTimer from './Shared/PomodoroTimer';
|
||||
|
||||
interface NavbarProps {
|
||||
isDarkMode: boolean;
|
||||
|
|
@ -18,8 +23,6 @@ interface NavbarProps {
|
|||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
currentUser,
|
||||
setCurrentUser,
|
||||
isSidebarOpen,
|
||||
|
|
@ -41,9 +44,9 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -52,11 +55,15 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile', {
|
||||
credentials: 'include'
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
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) {
|
||||
console.error('Error fetching profile:', error);
|
||||
|
|
@ -71,10 +78,16 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
setPomodoroEnabled(event.detail.enabled);
|
||||
};
|
||||
|
||||
window.addEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
|
||||
window.addEventListener(
|
||||
'pomodoroSettingChanged',
|
||||
handlePomodoroSettingChange as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
|
||||
window.removeEventListener(
|
||||
'pomodoroSettingChanged',
|
||||
handlePomodoroSettingChange as EventListener
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -107,7 +120,11 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<button
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
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" />
|
||||
</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"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
{t('navigation.profileSettings', 'Profile Settings')}
|
||||
{t(
|
||||
'navigation.profileSettings',
|
||||
'Profile Settings'
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
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 NoteModal from './NoteModal';
|
||||
import MarkdownRenderer from '../Shared/MarkdownRenderer';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService';
|
||||
import {
|
||||
fetchNotes,
|
||||
deleteNote as apiDeleteNote,
|
||||
updateNote as apiUpdateNote,
|
||||
} from '../../utils/notesService';
|
||||
|
||||
const NoteDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [note, setNote] = useState<Note | null>(null);
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
|
@ -52,10 +62,13 @@ const NoteDetails: React.FC = () => {
|
|||
const handleSaveNote = async (updatedNote: Note) => {
|
||||
try {
|
||||
if (updatedNote.id !== undefined) {
|
||||
const savedNote = await apiUpdateNote(updatedNote.id, updatedNote);
|
||||
const savedNote = await apiUpdateNote(
|
||||
updatedNote.id,
|
||||
updatedNote
|
||||
);
|
||||
setNote(savedNote);
|
||||
} else {
|
||||
console.error("Error: Note ID is undefined.");
|
||||
console.error('Error: Note ID is undefined.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving note:', err);
|
||||
|
|
@ -86,7 +99,9 @@ const NoteDetails: React.FC = () => {
|
|||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">
|
||||
{isError ? 'Error loading note details.' : 'Note not found.'}
|
||||
{isError
|
||||
? 'Error loading note details.'
|
||||
: 'Note not found.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -124,26 +139,38 @@ const NoteDetails: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
{/* 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="flex items-start">
|
||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" />
|
||||
<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">
|
||||
{(note.tags || note.Tags || []).map((tag) => (
|
||||
{(note.tags || note.Tags || []).map(
|
||||
(tag) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<TagIcon className="h-3 w-3" />
|
||||
<span>{tag.name}</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,7 +178,14 @@ const NoteDetails: React.FC = () => {
|
|||
)}
|
||||
{/* 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">
|
||||
Project
|
||||
</h3>
|
||||
|
|
|
|||
|
|
@ -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 { Project } from '../../entities/Project';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
|
@ -7,7 +13,13 @@ import MarkdownRenderer from '../Shared/MarkdownRenderer';
|
|||
import { Tag } from '../../entities/Tag';
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
|
|
@ -19,7 +31,15 @@ interface NoteModalProps {
|
|||
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 [formData, setFormData] = useState<Note>({
|
||||
id: note?.id,
|
||||
|
|
@ -27,7 +47,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
content: note?.content || '',
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -37,7 +59,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
|
||||
// Project-related state
|
||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>([]);
|
||||
const [newProjectName, setNewProjectName] = useState<string>("");
|
||||
const [newProjectName, setNewProjectName] = useState<string>('');
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
|
|
@ -88,7 +110,10 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
setError(null);
|
||||
|
||||
// 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 : '');
|
||||
}
|
||||
}, [isOpen, note, memoizedProjects]);
|
||||
|
|
@ -152,21 +177,24 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
}));
|
||||
}, []);
|
||||
|
||||
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
|
||||
setExpandedSections(prev => {
|
||||
const toggleSection = useCallback(
|
||||
(section: keyof typeof expandedSections) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newExpanded = {
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
[section]: !prev[section],
|
||||
};
|
||||
|
||||
// Auto-scroll to show the expanded section
|
||||
if (newExpanded[section]) {
|
||||
setTimeout(() => {
|
||||
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto');
|
||||
const scrollContainer = document.querySelector(
|
||||
'.absolute.inset-0.overflow-y-auto'
|
||||
);
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 100); // Small delay to ensure DOM is updated
|
||||
|
|
@ -174,7 +202,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
|
||||
return newExpanded;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleProjectSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
|
@ -194,24 +224,24 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
};
|
||||
|
||||
const handleProjectSelection = (project: Project) => {
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
project: { id: project.id!, name: project.name },
|
||||
project_id: project.id
|
||||
project_id: project.id,
|
||||
}));
|
||||
setNewProjectName(project.name);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (newProjectName.trim() !== "" && onCreateProject) {
|
||||
if (newProjectName.trim() !== '' && onCreateProject) {
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const newProject = await onCreateProject(newProjectName.trim());
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
project: { id: newProject.id!, name: newProject.name },
|
||||
project_id: newProject.id
|
||||
project_id: newProject.id,
|
||||
}));
|
||||
setFilteredProjects([...filteredProjects, newProject]);
|
||||
setNewProjectName(newProject.name);
|
||||
|
|
@ -219,7 +249,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
showSuccessToast(t('success.projectCreated'));
|
||||
} catch (error) {
|
||||
showErrorToast(t('errors.projectCreationFailed'));
|
||||
console.error("Error creating project:", error);
|
||||
console.error('Error creating project:', error);
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
|
|
@ -237,13 +267,17 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
|
||||
try {
|
||||
// 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
|
||||
const finalFormData = { ...formData, tags: noteTags };
|
||||
|
||||
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();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
|
|
@ -272,21 +306,26 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
<>
|
||||
<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 ${
|
||||
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
|
||||
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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-full sm:h-auto sm:my-4`}
|
||||
>
|
||||
<div className="flex flex-col h-full sm:min-h-[600px] sm:max-h-[90vh]">
|
||||
{/* Main Form Section */}
|
||||
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
|
||||
<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">
|
||||
<fieldset className="h-full flex flex-col">
|
||||
{/* Note Title Section - Always Visible */}
|
||||
|
|
@ -299,7 +338,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
onChange={handleChange}
|
||||
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"
|
||||
placeholder={t('forms.noteTitlePlaceholder')}
|
||||
placeholder={t(
|
||||
'forms.noteTitlePlaceholder'
|
||||
)}
|
||||
/>
|
||||
</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="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
<div className="flex space-x-1">
|
||||
<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 ${
|
||||
activeTab === 'edit'
|
||||
activeTab ===
|
||||
'edit'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
<span>Edit</span>
|
||||
<span>
|
||||
Edit
|
||||
</span>
|
||||
</button>
|
||||
<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 ${
|
||||
activeTab === 'preview'
|
||||
activeTab ===
|
||||
'preview'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<EyeIcon className="h-3 w-3" />
|
||||
<span>Preview</span>
|
||||
<span>
|
||||
Preview
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -341,19 +402,39 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
<textarea
|
||||
id="noteContent"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
value={
|
||||
formData.content
|
||||
}
|
||||
onChange={
|
||||
handleChange
|
||||
}
|
||||
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"
|
||||
placeholder="Write your content using Markdown formatting... Examples: # Heading **Bold text** *Italic text* - List item ```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 ? (
|
||||
<MarkdownRenderer content={formData.content} />
|
||||
<MarkdownRenderer
|
||||
content={
|
||||
formData.content
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -367,9 +448,13 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
{t('forms.tags')}
|
||||
</h3>
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
onTagsChange={
|
||||
handleTagsChange
|
||||
}
|
||||
initialTags={tags}
|
||||
availableTags={availableTags}
|
||||
availableTags={
|
||||
availableTags
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -377,45 +462,84 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
{expandedSections.project && (
|
||||
<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">
|
||||
{t('forms.task.labels.project', 'Project')}
|
||||
{t(
|
||||
'forms.task.labels.project',
|
||||
'Project'
|
||||
)}
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
|
||||
value={newProjectName}
|
||||
onChange={handleProjectSearch}
|
||||
placeholder={t(
|
||||
'forms.task.projectSearchPlaceholder',
|
||||
'Search or create a project...'
|
||||
)}
|
||||
value={
|
||||
newProjectName
|
||||
}
|
||||
onChange={
|
||||
handleProjectSearch
|
||||
}
|
||||
className="block w-full border border-gray-300 dark:border-gray-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">
|
||||
{filteredProjects.length > 0 && (
|
||||
filteredProjects.map((project) => (
|
||||
{filteredProjects.length >
|
||||
0 &&
|
||||
filteredProjects.map(
|
||||
(
|
||||
project
|
||||
) => (
|
||||
<button
|
||||
key={project.id}
|
||||
key={
|
||||
project.id
|
||||
}
|
||||
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"
|
||||
>
|
||||
{project.name}
|
||||
{
|
||||
project.name
|
||||
}
|
||||
</button>
|
||||
))
|
||||
)
|
||||
)}
|
||||
{filteredProjects.length === 0 && (
|
||||
{filteredProjects.length ===
|
||||
0 && (
|
||||
<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>
|
||||
)}
|
||||
{newProjectName.trim() && onCreateProject && (
|
||||
{newProjectName.trim() &&
|
||||
onCreateProject && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateProject}
|
||||
disabled={isCreatingProject}
|
||||
onClick={
|
||||
handleCreateProject
|
||||
}
|
||||
disabled={
|
||||
isCreatingProject
|
||||
}
|
||||
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
{isCreatingProject
|
||||
? t('forms.task.creatingProject', 'Creating...')
|
||||
: t('forms.task.createProject', '+ Create') + ` "${newProjectName.trim()}"`}
|
||||
? t(
|
||||
'forms.task.creatingProject',
|
||||
'Creating...'
|
||||
)
|
||||
: t(
|
||||
'forms.task.createProject',
|
||||
'+ Create'
|
||||
) +
|
||||
` "${newProjectName.trim()}"`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -424,7 +548,11 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -438,7 +566,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
{/* Tags Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('tags')}
|
||||
onClick={() =>
|
||||
toggleSection('tags')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.tags
|
||||
? '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')}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -455,13 +587,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
{/* Project Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('project')}
|
||||
onClick={() =>
|
||||
toggleSection('project')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.project
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.task.labels.project', 'Project')}
|
||||
title={t(
|
||||
'forms.task.labels.project',
|
||||
'Project'
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="h-5 w-5" />
|
||||
{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">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{(note && note.id && onDelete) && (
|
||||
{note && note.id && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title={t('common.delete', 'Delete')}
|
||||
title={t(
|
||||
'common.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -501,7 +641,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave, on
|
|||
onClick={handleSubmit}
|
||||
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 ${
|
||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookOpenIcon,
|
||||
|
|
@ -25,7 +25,6 @@ const Notes: React.FC = () => {
|
|||
console.log('Notes component rendering...');
|
||||
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
|
|
@ -41,8 +40,13 @@ const Notes: React.FC = () => {
|
|||
// Memoize projects to ensure stable reference
|
||||
const memoizedProjects = useMemo(() => projects || [], [projects]);
|
||||
|
||||
console.log('Notes component render - projects:', { projectsLength: projects?.length, projects: projects?.map(p => p.name) });
|
||||
console.log('Memoized projects:', { memoizedLength: memoizedProjects?.length });
|
||||
console.log('Notes component render - projects:', {
|
||||
projectsLength: projects?.length,
|
||||
projects: projects?.map((p) => p.name),
|
||||
});
|
||||
console.log('Memoized projects:', {
|
||||
memoizedLength: memoizedProjects?.length,
|
||||
});
|
||||
|
||||
const [isError, setIsError] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
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...');
|
||||
try {
|
||||
// Fetch all projects (active and inactive)
|
||||
const fetchedProjects = await fetchProjects("all", "");
|
||||
const fetchedProjects = await fetchProjects('all', '');
|
||||
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);
|
||||
console.log('setProjects called');
|
||||
} catch (error) {
|
||||
|
|
@ -88,7 +99,9 @@ const Notes: React.FC = () => {
|
|||
if (!noteToDelete) return;
|
||||
try {
|
||||
await apiDeleteNote(noteToDelete.id!);
|
||||
setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id));
|
||||
setNotes((prev) =>
|
||||
prev.filter((note) => note.id !== noteToDelete.id)
|
||||
);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setNoteToDelete(null);
|
||||
} catch (err) {
|
||||
|
|
@ -101,7 +114,7 @@ const Notes: React.FC = () => {
|
|||
projectsLength: projects?.length,
|
||||
memoizedLength: memoizedProjects?.length,
|
||||
projectsExist: !!projects,
|
||||
memoizedExist: !!memoizedProjects
|
||||
memoizedExist: !!memoizedProjects,
|
||||
});
|
||||
setSelectedNote(note);
|
||||
setIsNoteModalOpen(true);
|
||||
|
|
@ -129,7 +142,10 @@ const Notes: React.FC = () => {
|
|||
|
||||
const handleCreateProject = async (name: string) => {
|
||||
try {
|
||||
const newProject = await createProject({ name, priority: 'medium' });
|
||||
const newProject = await createProject({
|
||||
name,
|
||||
priority: 'medium',
|
||||
});
|
||||
return newProject;
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
|
|
@ -143,7 +159,6 @@ const Notes: React.FC = () => {
|
|||
note.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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 */}
|
||||
{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">
|
||||
{filteredNotes.map((note) => (
|
||||
<li
|
||||
key={note.id}
|
||||
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)}
|
||||
>
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
|
|
@ -210,21 +229,57 @@ const Notes: React.FC = () => {
|
|||
{note.title}
|
||||
</Link>
|
||||
{/* 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">
|
||||
{(note.project || note.Project) && (
|
||||
{(note.project ||
|
||||
note.Project) && (
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<span>{(note.project || note.Project)?.name}</span>
|
||||
<span>
|
||||
{
|
||||
(
|
||||
note.project ||
|
||||
note.Project
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(note.project || note.Project) && ((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
|
||||
<span className="mx-2">•</span>
|
||||
{(note.project ||
|
||||
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">
|
||||
<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>
|
||||
|
|
@ -235,8 +290,13 @@ const Notes: React.FC = () => {
|
|||
<button
|
||||
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'}`}
|
||||
aria-label={t('notes.editNoteAriaLabel', { noteTitle: note.title })}
|
||||
title={t('notes.editNoteTitle', { noteTitle: note.title })}
|
||||
aria-label={t(
|
||||
'notes.editNoteAriaLabel',
|
||||
{ noteTitle: note.title }
|
||||
)}
|
||||
title={t('notes.editNoteTitle', {
|
||||
noteTitle: note.title,
|
||||
})}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -246,8 +306,13 @@ const Notes: React.FC = () => {
|
|||
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'}`}
|
||||
aria-label={t('notes.deleteNoteAriaLabel', { noteTitle: note.title })}
|
||||
title={t('notes.deleteNoteTitle', { noteTitle: note.title })}
|
||||
aria-label={t(
|
||||
'notes.deleteNoteAriaLabel',
|
||||
{ noteTitle: note.title }
|
||||
)}
|
||||
title={t('notes.deleteNoteTitle', {
|
||||
noteTitle: note.title,
|
||||
})}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -262,14 +327,18 @@ const Notes: React.FC = () => {
|
|||
<NoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
onClose={() => {
|
||||
console.log('Closing modal, projects at close:', { projectsLength: projects?.length });
|
||||
console.log('Closing modal, projects at close:', {
|
||||
projectsLength: projects?.length,
|
||||
});
|
||||
setIsNoteModalOpen(false);
|
||||
}}
|
||||
onSave={handleSaveNote}
|
||||
onDelete={async (noteId) => {
|
||||
try {
|
||||
await apiDeleteNote(noteId);
|
||||
setNotes((prev) => prev.filter((note) => note.id !== noteId));
|
||||
setNotes((prev) =>
|
||||
prev.filter((note) => note.id !== noteId)
|
||||
);
|
||||
setIsNoteModalOpen(false);
|
||||
setSelectedNote(null);
|
||||
} catch (err) {
|
||||
|
|
@ -277,20 +346,35 @@ const Notes: React.FC = () => {
|
|||
}
|
||||
}}
|
||||
note={selectedNote}
|
||||
projects={projects?.length > 0 ? projects : [
|
||||
{ id: 1, name: 'Test Project 1', active: true, priority: 'medium' },
|
||||
{ id: 2, name: 'tududi', active: true, priority: 'high' }
|
||||
] as any}
|
||||
projects={
|
||||
projects?.length > 0
|
||||
? projects
|
||||
: ([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project 1',
|
||||
active: true,
|
||||
priority: 'medium',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'tududi',
|
||||
active: true,
|
||||
priority: 'high',
|
||||
},
|
||||
] as any)
|
||||
}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && noteToDelete && (
|
||||
<ConfirmDialog
|
||||
title={t('modals.deleteNote.title')}
|
||||
message={t('modals.deleteNote.message', { noteTitle: noteToDelete.title })}
|
||||
message={t('modals.deleteNote.message', {
|
||||
noteTitle: noteToDelete.title,
|
||||
})}
|
||||
onConfirm={handleDeleteNote}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,18 +7,28 @@ import {
|
|||
ClockIcon,
|
||||
FolderIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
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 { useToast } from '../Shared/ToastContext';
|
||||
import { getVagueTasks } from '../../utils/taskIntelligenceService';
|
||||
|
||||
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;
|
||||
description: string;
|
||||
items: (Task | Project)[];
|
||||
|
|
@ -31,14 +41,19 @@ interface ProductivityAssistantProps {
|
|||
projects: Project[];
|
||||
}
|
||||
|
||||
const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, projects }) => {
|
||||
const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
|
||||
tasks,
|
||||
projects,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [insights, setInsights] = useState<ProductivityInsight[]>([]);
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<number>>(new Set());
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// Modal states
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
|
@ -46,7 +61,18 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
const [allProjects, setAllProjects] = useState<Project[]>(projects);
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,30 +80,48 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
const newInsights: ProductivityInsight[] = [];
|
||||
|
||||
// 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)
|
||||
const stalledProjects = projects.filter(project =>
|
||||
project.active && !activeTasks.some(task => task.project_id === project.id)
|
||||
const stalledProjects = projects.filter(
|
||||
(project) =>
|
||||
project.active &&
|
||||
!activeTasks.some((task) => task.project_id === project.id)
|
||||
);
|
||||
|
||||
if (stalledProjects.length > 0) {
|
||||
newInsights.push({
|
||||
type: 'stalled_projects',
|
||||
title: t('productivity.stalledProjects', 'Stalled Projects'),
|
||||
description: t('productivity.stalledProjectsDesc', 'These projects have no tasks or actions'),
|
||||
title: t(
|
||||
'productivity.stalledProjects',
|
||||
'Stalled Projects'
|
||||
),
|
||||
description: t(
|
||||
'productivity.stalledProjectsDesc',
|
||||
'These projects have no tasks or actions'
|
||||
),
|
||||
items: stalledProjects,
|
||||
icon: FolderIcon,
|
||||
color: 'text-red-500'
|
||||
color: 'text-red-500',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Projects with completed tasks but no next action
|
||||
const projectsNeedingNextAction = projects.filter(project => {
|
||||
const projectTasks = tasks.filter(task => task.project_id === project.id);
|
||||
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')
|
||||
const projectsNeedingNextAction = projects.filter((project) => {
|
||||
const projectTasks = tasks.filter(
|
||||
(task) => task.project_id === project.id
|
||||
);
|
||||
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;
|
||||
});
|
||||
|
|
@ -85,29 +129,43 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
if (projectsNeedingNextAction.length > 0) {
|
||||
newInsights.push({
|
||||
type: 'completed_no_next',
|
||||
title: t('productivity.needsNextAction', 'Projects Need Next Action'),
|
||||
description: t('productivity.needsNextActionDesc', 'These projects have completed tasks but no next action'),
|
||||
title: t(
|
||||
'productivity.needsNextAction',
|
||||
'Projects Need Next Action'
|
||||
),
|
||||
description: t(
|
||||
'productivity.needsNextActionDesc',
|
||||
'These projects have completed tasks but no next action'
|
||||
),
|
||||
items: projectsNeedingNextAction,
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-yellow-500'
|
||||
color: 'text-yellow-500',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Tasks that are actually projects
|
||||
const tasksAreProjects = activeTasks.filter(task => {
|
||||
const tasksAreProjects = activeTasks.filter((task) => {
|
||||
const taskName = task.name.toLowerCase();
|
||||
return PROJECT_VERBS.some(verb => taskName.includes(verb)) &&
|
||||
taskName.length > 30; // Longer tasks are more likely to be projects
|
||||
return (
|
||||
PROJECT_VERBS.some((verb) => taskName.includes(verb)) &&
|
||||
taskName.length > 30
|
||||
); // Longer tasks are more likely to be projects
|
||||
});
|
||||
|
||||
if (tasksAreProjects.length > 0) {
|
||||
newInsights.push({
|
||||
type: 'tasks_are_projects',
|
||||
title: t('productivity.tasksAreProjects', 'Tasks That Look Like Projects'),
|
||||
description: t('productivity.tasksAreProjectsDesc', 'These tasks might need to be broken down'),
|
||||
title: t(
|
||||
'productivity.tasksAreProjects',
|
||||
'Tasks That Look Like Projects'
|
||||
),
|
||||
description: t(
|
||||
'productivity.tasksAreProjectsDesc',
|
||||
'These tasks might need to be broken down'
|
||||
),
|
||||
items: tasksAreProjects,
|
||||
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) {
|
||||
newInsights.push({
|
||||
type: 'vague_tasks',
|
||||
title: t('productivity.vagueTasks', 'Tasks Without Clear Action'),
|
||||
description: t('productivity.vagueTasksDesc', 'These tasks need clearer action verbs'),
|
||||
title: t(
|
||||
'productivity.vagueTasks',
|
||||
'Tasks Without Clear Action'
|
||||
),
|
||||
description: t(
|
||||
'productivity.vagueTasksDesc',
|
||||
'These tasks need clearer action verbs'
|
||||
),
|
||||
items: vagueTasks,
|
||||
icon: ExclamationTriangleIcon,
|
||||
color: 'text-orange-500'
|
||||
color: 'text-orange-500',
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Overdue or stale tasks
|
||||
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
|
||||
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;
|
||||
});
|
||||
|
|
@ -140,28 +208,39 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
newInsights.push({
|
||||
type: 'overdue_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,
|
||||
icon: ClockIcon,
|
||||
color: 'text-gray-500'
|
||||
color: 'text-gray-500',
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Stuck projects (not updated in a month)
|
||||
const stuckProjects = projects.filter(project => {
|
||||
const stuckProjects = projects.filter((project) => {
|
||||
if (!project.active) return false;
|
||||
|
||||
// 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"
|
||||
|
||||
// Find the most recent task date for this project
|
||||
const mostRecentTaskDate = projectTasks.reduce((latest, task) => {
|
||||
const taskDate = task.created_at ? new Date(task.created_at) : null;
|
||||
const mostRecentTaskDate = projectTasks.reduce(
|
||||
(latest, task) => {
|
||||
const taskDate = task.created_at
|
||||
? new Date(task.created_at)
|
||||
: null;
|
||||
if (!taskDate) return latest;
|
||||
return !latest || taskDate > latest ? taskDate : latest;
|
||||
}, null as Date | null);
|
||||
},
|
||||
null as Date | null
|
||||
);
|
||||
|
||||
return mostRecentTaskDate && mostRecentTaskDate < thresholdDate;
|
||||
});
|
||||
|
|
@ -170,10 +249,13 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
newInsights.push({
|
||||
type: '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,
|
||||
icon: FolderIcon,
|
||||
color: 'text-purple-500'
|
||||
color: 'text-purple-500',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +265,10 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
generateInsights();
|
||||
}, [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 newExpanded = new Set(expandedInsights);
|
||||
|
|
@ -207,7 +292,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
setIsTaskModalOpen(true);
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -223,7 +310,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
await updateTask(updatedTask.id, updatedTask);
|
||||
setIsTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
showSuccessToast(t('task.updateSuccess', 'Task updated successfully'));
|
||||
showSuccessToast(
|
||||
t('task.updateSuccess', 'Task updated successfully')
|
||||
);
|
||||
// Optionally refresh the parent component data
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -238,7 +327,9 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
await deleteTask(selectedTask.id);
|
||||
setIsTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
showSuccessToast(t('task.deleteSuccess', 'Task deleted successfully'));
|
||||
showSuccessToast(
|
||||
t('task.deleteSuccess', 'Task deleted successfully')
|
||||
);
|
||||
// Optionally refresh the parent component data
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -250,7 +341,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const project = await createProject({ name, active: true });
|
||||
setAllProjects(prev => [...prev, project]);
|
||||
setAllProjects((prev) => [...prev, project]);
|
||||
return project;
|
||||
} catch (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" />
|
||||
<div className="flex-1 text-left">
|
||||
<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 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>
|
||||
</div>
|
||||
{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="space-y-4">
|
||||
{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">
|
||||
<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">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{insight.title} ({insight.items.length})
|
||||
{insight.title} (
|
||||
{insight.items.length})
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
<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 (
|
||||
<div key={itemIndex} className="text-sm">
|
||||
<div
|
||||
key={itemIndex}
|
||||
className="text-sm"
|
||||
>
|
||||
<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"
|
||||
disabled={loading}
|
||||
>
|
||||
|
|
@ -332,13 +446,16 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
})}
|
||||
{insight.items.length > 3 && (
|
||||
<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"
|
||||
>
|
||||
{expandedInsights.has(index)
|
||||
? '... show less'
|
||||
: `... and ${insight.items.length - 3} more items`
|
||||
}
|
||||
: `... and ${insight.items.length - 3} more items`}
|
||||
</button>
|
||||
)}
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
||||
interface AutoSuggestNextActionBoxProps {
|
||||
onAddAction: (actionDescription: string) => void;
|
||||
|
|
@ -11,9 +11,9 @@ interface AutoSuggestNextActionBoxProps {
|
|||
const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
|
||||
onAddAction,
|
||||
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 inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -32,12 +32,12 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
|
|||
if (actionDescription.trim()) {
|
||||
onAddAction(actionDescription.trim());
|
||||
showSuccessToast(t('success.nextActionAdded'));
|
||||
setActionDescription("");
|
||||
setActionDescription('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
|
|
@ -63,7 +63,10 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</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"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -88,7 +101,10 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
|
|||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
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 ${
|
||||
isFocused
|
||||
? '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">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
|
|
@ -9,23 +9,34 @@ import {
|
|||
Squares2X2Icon,
|
||||
BookOpenIcon,
|
||||
TagIcon,
|
||||
ListBulletIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
import TaskList from "../Task/TaskList";
|
||||
import ProjectModal from "../Project/ProjectModal";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import NewTask from "../Task/NewTask";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { PriorityType, Task } from "../../entities/Task";
|
||||
import { Note } from "../../entities/Note";
|
||||
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService";
|
||||
import { 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";
|
||||
ListBulletIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import TaskList from '../Task/TaskList';
|
||||
import ProjectModal from '../Project/ProjectModal';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import NewTask from '../Task/NewTask';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { PriorityType, Task } from '../../entities/Task';
|
||||
import { Note } from '../../entities/Note';
|
||||
import {
|
||||
fetchProjectById,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from '../../utils/projectsService';
|
||||
import {
|
||||
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 };
|
||||
|
||||
|
|
@ -48,7 +59,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
|
|
@ -59,7 +70,7 @@ const ProjectDetails: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const loadProjectData = async () => {
|
||||
if (!id) {
|
||||
console.error("Project ID is missing.");
|
||||
console.error('Project ID is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -69,13 +80,15 @@ const ProjectDetails: React.FC = () => {
|
|||
const projectData = await fetchProjectById(id);
|
||||
setProject(projectData);
|
||||
// Handle both 'tasks' and 'Tasks' property names
|
||||
const projectTasks = projectData.tasks || projectData.Tasks || [];
|
||||
const projectTasks =
|
||||
projectData.tasks || projectData.Tasks || [];
|
||||
setTasks(projectTasks);
|
||||
// Handle project notes
|
||||
const projectNotes = projectData.notes || projectData.Notes || [];
|
||||
const projectNotes =
|
||||
projectData.notes || projectData.Notes || [];
|
||||
setNotes(projectNotes);
|
||||
} catch (error) {
|
||||
console.error("Error fetching project data:", error);
|
||||
console.error('Error fetching project data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -88,7 +101,8 @@ const ProjectDetails: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const checkAutoSuggest = async () => {
|
||||
if (project && tasks.length === 0 && !loading) {
|
||||
const autoSuggestEnabled = await getAutoSuggestNextActionsEnabled();
|
||||
const autoSuggestEnabled =
|
||||
await getAutoSuggestNextActionsEnabled();
|
||||
if (autoSuggestEnabled) {
|
||||
setShowAutoSuggestForm(true);
|
||||
}
|
||||
|
|
@ -100,14 +114,14 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
const handleTaskCreate = async (taskName: string) => {
|
||||
if (!project) {
|
||||
console.error("Cannot create task: Project is missing");
|
||||
throw new Error("Cannot create task: Project is missing");
|
||||
console.error('Cannot create task: Project is missing');
|
||||
throw new Error('Cannot create task: Project is missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const newTask = await createTask({
|
||||
name: taskName,
|
||||
status: "not_started",
|
||||
status: 'not_started',
|
||||
project_id: project.id,
|
||||
});
|
||||
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||
|
|
@ -115,12 +129,19 @@ const ProjectDetails: React.FC = () => {
|
|||
// Show success toast with task link
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
} catch (err: any) {
|
||||
console.error("Error creating task:", err);
|
||||
console.error('Error creating task:', err);
|
||||
// Check if it's an authentication error
|
||||
if (isAuthError(err)) {
|
||||
return;
|
||||
|
|
@ -131,22 +152,22 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
if (!updatedTask.id) {
|
||||
console.error("Cannot update task: Task ID is missing");
|
||||
console.error('Cannot update task: Task ID is missing');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Use direct fetch call like Tasks.tsx to ensure proper tag saving
|
||||
const response = await fetch(`/api/task/${updatedTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(updatedTask),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error("Failed to update task:", errorData.error);
|
||||
throw new Error("Failed to update task");
|
||||
console.error('Failed to update task:', errorData.error);
|
||||
throw new Error('Failed to update task');
|
||||
}
|
||||
|
||||
const savedTask = await response.json();
|
||||
|
|
@ -156,20 +177,22 @@ const ProjectDetails: React.FC = () => {
|
|||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error updating task:", err);
|
||||
console.error('Error updating task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number | undefined) => {
|
||||
if (!taskId) {
|
||||
console.error("Cannot delete task: Task ID is missing");
|
||||
console.error('Cannot delete task: Task ID is missing');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.filter((task) => task.id !== taskId)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Error deleting task:", err);
|
||||
console.error('Error deleting task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -177,13 +200,19 @@ const ProjectDetails: React.FC = () => {
|
|||
try {
|
||||
const updatedTask = await toggleTaskToday(taskId);
|
||||
// Update the task in the local state immediately to avoid UI flashing
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count } : task
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
today: updatedTask.today,
|
||||
today_move_count: updatedTask.today_move_count,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
} 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
|
||||
if (id) {
|
||||
try {
|
||||
|
|
@ -191,7 +220,10 @@ const ProjectDetails: React.FC = () => {
|
|||
setProject(updatedProject);
|
||||
setTasks(updatedProject.tasks || []);
|
||||
} 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) => {
|
||||
if (!updatedProject.id) {
|
||||
console.error("Cannot save project: Project ID is missing");
|
||||
console.error('Cannot save project: Project ID is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const savedProject = await updateProject(updatedProject.id, updatedProject);
|
||||
const savedProject = await updateProject(
|
||||
updatedProject.id,
|
||||
updatedProject
|
||||
);
|
||||
setProject(savedProject);
|
||||
setIsModalOpen(false);
|
||||
} 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 {
|
||||
const newTask = await createTask({
|
||||
name: actionDescription,
|
||||
status: "not_started",
|
||||
status: 'not_started',
|
||||
project_id: projectId,
|
||||
priority: "medium"
|
||||
priority: 'medium',
|
||||
});
|
||||
|
||||
// Update the tasks list to include the new task
|
||||
setTasks(prevTasks => [...prevTasks, newTask]);
|
||||
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||
setShowAutoSuggestForm(false);
|
||||
|
||||
// Show success toast with task link
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
} catch (error) {
|
||||
console.error("Error creating next action:", error);
|
||||
console.error('Error creating next action:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleSkipNextAction = () => {
|
||||
setShowAutoSuggestForm(false);
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project?.id) {
|
||||
console.error("Cannot delete project: Project ID is missing");
|
||||
console.error('Cannot delete project: Project ID is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
navigate("/projects");
|
||||
navigate('/projects');
|
||||
} 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) => {
|
||||
return typeof task.status === 'number' ? task.status !== 2 : task.status !== 'done';
|
||||
const activeTasks =
|
||||
tasks?.filter((task) => {
|
||||
return typeof task.status === 'number'
|
||||
? task.status !== 2
|
||||
: task.status !== 'done';
|
||||
}) || []; //TODO: Also add archived
|
||||
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 date = new Date(dateString);
|
||||
|
|
@ -303,7 +354,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const formatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
};
|
||||
|
||||
return date.toLocaleDateString(currentLang, formatOptions);
|
||||
|
|
@ -327,12 +378,13 @@ const ProjectDetails: React.FC = () => {
|
|||
</h1>
|
||||
</div>
|
||||
{/* 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={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${
|
||||
getPriorityStyle(project.priority)
|
||||
}`}
|
||||
className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${getPriorityStyle(
|
||||
project.priority
|
||||
)}`}
|
||||
title={`Priority: ${priorityLabel(project.priority)}`}
|
||||
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
||||
></div>
|
||||
|
|
@ -357,14 +409,19 @@ const ProjectDetails: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* 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="grid gap-3">
|
||||
{project.description && (
|
||||
<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" />
|
||||
<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">
|
||||
{project.description}
|
||||
</p>
|
||||
|
|
@ -375,7 +432,9 @@ const ProjectDetails: React.FC = () => {
|
|||
{project.area && (
|
||||
<div className="flex items-center">
|
||||
<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">
|
||||
{project.area.name}
|
||||
</span>
|
||||
|
|
@ -385,9 +444,13 @@ const ProjectDetails: React.FC = () => {
|
|||
{project.due_date_at && (
|
||||
<div className="flex items-center">
|
||||
<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">
|
||||
{formatProjectDueDate(project.due_date_at)}
|
||||
{formatProjectDueDate(
|
||||
project.due_date_at
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -395,17 +458,30 @@ const ProjectDetails: React.FC = () => {
|
|||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex items-start">
|
||||
<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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<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">
|
||||
{project.tags.map((tag, index) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{tag.name}
|
||||
|
|
@ -428,11 +504,12 @@ const ProjectDetails: React.FC = () => {
|
|||
{project.name}
|
||||
</h2>
|
||||
{/* Show priority indicator only when no image */}
|
||||
{project.priority !== undefined && project.priority !== null && (
|
||||
{project.priority !== undefined &&
|
||||
project.priority !== null && (
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
|
||||
getPriorityStyle(project.priority)
|
||||
}`}
|
||||
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${getPriorityStyle(
|
||||
project.priority
|
||||
)}`}
|
||||
title={`Priority: ${priorityLabel(project.priority)}`}
|
||||
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
||||
></div>
|
||||
|
|
@ -459,24 +536,38 @@ const ProjectDetails: React.FC = () => {
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
{completedTasks.length > 0 && (
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => setShowCompleted(e.target.checked)}
|
||||
onChange={(e) =>
|
||||
setShowCompleted(e.target.checked)
|
||||
}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-10 h-5 rounded-full transition-colors ${
|
||||
showCompleted ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}>
|
||||
<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
|
||||
className={`w-10 h-5 rounded-full transition-colors ${
|
||||
showCompleted
|
||||
? 'bg-blue-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</label>
|
||||
|
|
@ -502,14 +593,19 @@ const ProjectDetails: React.FC = () => {
|
|||
<AutoSuggestNextActionBox
|
||||
onAddAction={(actionDescription) => {
|
||||
if (project?.id) {
|
||||
handleCreateNextAction(project.id, actionDescription);
|
||||
handleCreateNextAction(
|
||||
project.id,
|
||||
actionDescription
|
||||
);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
|
||||
|
|
@ -533,20 +629,34 @@ const ProjectDetails: React.FC = () => {
|
|||
to={`/note/${note.id}`}
|
||||
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>
|
||||
{note.content && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||
{note.content.length > 150
|
||||
? note.content.substring(0, 150) + '...'
|
||||
: note.content
|
||||
}
|
||||
? note.content.substring(
|
||||
0,
|
||||
150
|
||||
) + '...'
|
||||
: note.content}
|
||||
</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">
|
||||
<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>
|
||||
|
|
@ -555,7 +665,9 @@ const ProjectDetails: React.FC = () => {
|
|||
))}
|
||||
</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>
|
||||
|
||||
|
|
@ -575,7 +687,6 @@ const ProjectDetails: React.FC = () => {
|
|||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -583,7 +694,8 @@ const ProjectDetails: React.FC = () => {
|
|||
|
||||
const priorityLabel = (priority: PriorityType | number) => {
|
||||
// Handle both string and numeric priorities
|
||||
const normalizedPriority = typeof priority === 'number'
|
||||
const normalizedPriority =
|
||||
typeof priority === 'number'
|
||||
? (['low', 'medium', 'high'][priority] as PriorityType)
|
||||
: priority;
|
||||
|
||||
|
|
@ -601,7 +713,8 @@ const priorityLabel = (priority: PriorityType | number) => {
|
|||
|
||||
const getPriorityStyle = (priority: PriorityType | number) => {
|
||||
// Handle both string and numeric priorities
|
||||
const normalizedPriority = typeof priority === 'number'
|
||||
const normalizedPriority =
|
||||
typeof priority === 'number'
|
||||
? (['low', 'medium', 'high'][priority] as PriorityType)
|
||||
: priority;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProjectItemProps {
|
||||
project: Project;
|
||||
viewMode: "cards" | "list";
|
||||
viewMode: 'cards' | 'list';
|
||||
color: string;
|
||||
getCompletionPercentage: () => number;
|
||||
activeDropdown: number | null;
|
||||
|
|
@ -19,12 +19,12 @@ interface ProjectItemProps {
|
|||
const getProjectInitials = (name: string) => {
|
||||
const words = name
|
||||
.trim()
|
||||
.split(" ")
|
||||
.split(' ')
|
||||
.filter((word) => word.length > 0);
|
||||
if (words.length === 1) {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
return words.map((word) => word[0].toUpperCase()).join("");
|
||||
return words.map((word) => word[0].toUpperCase()).join('');
|
||||
};
|
||||
|
||||
const ProjectItem: React.FC<ProjectItemProps> = ({
|
||||
|
|
@ -42,19 +42,19 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
return (
|
||||
<div
|
||||
className={`${
|
||||
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-row items-center p-4"
|
||||
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-row items-center p-4'
|
||||
}`}
|
||||
style={{
|
||||
minHeight: viewMode === "cards" ? "250px" : "auto",
|
||||
maxHeight: viewMode === "cards" ? "250px" : "auto",
|
||||
minHeight: viewMode === 'cards' ? '250px' : 'auto',
|
||||
maxHeight: viewMode === 'cards' ? '250px' : 'auto',
|
||||
}}
|
||||
>
|
||||
{viewMode === "cards" && (
|
||||
{viewMode === 'cards' && (
|
||||
<div
|
||||
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 ? (
|
||||
<img
|
||||
|
|
@ -65,7 +65,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
) : (
|
||||
<span
|
||||
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)}
|
||||
</span>
|
||||
|
|
@ -76,7 +76,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "list" && project.image_url && (
|
||||
{viewMode === 'list' && project.image_url && (
|
||||
<div className="w-16 h-16 mr-4 flex-shrink-0">
|
||||
<img
|
||||
src={project.image_url}
|
||||
|
|
@ -88,19 +88,21 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
|
||||
<div
|
||||
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">
|
||||
{viewMode === "list" && !project.image_url && (
|
||||
<div className={`w-3 h-3 rounded-full ${color} mr-3 flex-shrink-0`}></div>
|
||||
{viewMode === 'list' && !project.image_url && (
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${color} mr-3 flex-shrink-0`}
|
||||
></div>
|
||||
)}
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
className={`${
|
||||
viewMode === "cards"
|
||||
? "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"
|
||||
viewMode === 'cards'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{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"
|
||||
onClick={() =>
|
||||
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" />
|
||||
</button>
|
||||
|
|
@ -125,7 +129,7 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
onClick={() => handleEditProject(project)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
{t("projectItem.edit")}
|
||||
{t('projectItem.edit')}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{t("projectItem.delete")}
|
||||
{t('projectItem.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === "cards" && (
|
||||
{viewMode === 'cards' && (
|
||||
<div className="absolute bottom-4 left-0 right-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
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
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Area } from "../../entities/Area";
|
||||
import { Project } from "../../entities/Project";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import TagInput from "../Tag/TagInput";
|
||||
import PriorityDropdown from "../Shared/PriorityDropdown";
|
||||
import { PriorityType } from "../../entities/Task";
|
||||
import Switch from "../Shared/Switch";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TagIcon, FolderIcon, Cog6ToothIcon, TrashIcon, CameraIcon, CalendarIcon, ExclamationTriangleIcon, PowerIcon } from '@heroicons/react/24/outline';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { Project } from '../../entities/Project';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import TagInput from '../Tag/TagInput';
|
||||
import PriorityDropdown from '../Shared/PriorityDropdown';
|
||||
import { PriorityType } from '../../entities/Task';
|
||||
import Switch from '../Shared/Switch';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
TagIcon,
|
||||
FolderIcon,
|
||||
TrashIcon,
|
||||
CameraIcon,
|
||||
CalendarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PowerIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -30,14 +38,14 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}) => {
|
||||
const [formData, setFormData] = useState<Project>(
|
||||
project || {
|
||||
name: "",
|
||||
description: "",
|
||||
name: '',
|
||||
description: '',
|
||||
area_id: null,
|
||||
active: true,
|
||||
tags: [],
|
||||
priority: "low",
|
||||
due_date_at: "",
|
||||
image_url: "",
|
||||
priority: 'low',
|
||||
due_date_at: '',
|
||||
image_url: '',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -45,7 +53,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
project?.tags?.map((tag) => tag.name) || []
|
||||
);
|
||||
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 { tagsStore } = useStore();
|
||||
|
|
@ -67,7 +77,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
active: false,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { showSuccessToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -75,24 +85,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
setFormData({
|
||||
...project,
|
||||
tags: project.tags || [],
|
||||
due_date_at: project.due_date_at || "",
|
||||
image_url: project.image_url || "",
|
||||
due_date_at: project.due_date_at || '',
|
||||
image_url: project.image_url || '',
|
||||
});
|
||||
setTags(project.tags?.map((tag) => tag.name) || []);
|
||||
setImagePreview(project.image_url || "");
|
||||
setImagePreview(project.image_url || '');
|
||||
} else {
|
||||
setFormData({
|
||||
name: "",
|
||||
description: "",
|
||||
name: '',
|
||||
description: '',
|
||||
area_id: null,
|
||||
active: true,
|
||||
tags: [],
|
||||
priority: "low",
|
||||
due_date_at: "",
|
||||
image_url: "",
|
||||
priority: 'low',
|
||||
due_date_at: '',
|
||||
image_url: '',
|
||||
});
|
||||
setTags([]);
|
||||
setImagePreview("");
|
||||
setImagePreview('');
|
||||
}
|
||||
setImageFile(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)
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -117,24 +133,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
|
|
@ -151,7 +167,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
setError(null);
|
||||
}
|
||||
|
||||
if (type === "checkbox") {
|
||||
if (type === 'checkbox') {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const checked = target.checked;
|
||||
setFormData((prev) => ({
|
||||
|
|
@ -219,20 +235,22 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
const handleRemoveImage = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview("");
|
||||
setImagePreview('');
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
image_url: "",
|
||||
image_url: '',
|
||||
}));
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate required fields
|
||||
if (!formData.name.trim()) {
|
||||
setError(t('errors.projectNameRequired', 'Project name is required'));
|
||||
setError(
|
||||
t('errors.projectNameRequired', 'Project name is required')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +268,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
const projectData = {
|
||||
...formData,
|
||||
image_url: imageUrl,
|
||||
tags: tags.map((name) => ({ name }))
|
||||
tags: tags.map((name) => ({ name })),
|
||||
};
|
||||
|
||||
// Save the project
|
||||
|
|
@ -258,8 +276,8 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
showSuccessToast(
|
||||
project
|
||||
? "Project updated successfully!"
|
||||
: "Project created successfully!"
|
||||
? 'Project updated successfully!'
|
||||
: 'Project created successfully!'
|
||||
);
|
||||
|
||||
handleClose();
|
||||
|
|
@ -297,21 +315,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
}));
|
||||
};
|
||||
|
||||
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
|
||||
setExpandedSections(prev => {
|
||||
const toggleSection = useCallback(
|
||||
(section: keyof typeof expandedSections) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newExpanded = {
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
[section]: !prev[section],
|
||||
};
|
||||
|
||||
// Auto-scroll to show the expanded section
|
||||
if (newExpanded[section]) {
|
||||
setTimeout(() => {
|
||||
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto');
|
||||
const scrollContainer = document.querySelector(
|
||||
'.absolute.inset-0.overflow-y-auto'
|
||||
);
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 100); // Small delay to ensure DOM is updated
|
||||
|
|
@ -319,8 +340,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
return newExpanded;
|
||||
});
|
||||
}, []);
|
||||
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
|
|
@ -328,20 +350,23 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
<>
|
||||
<div
|
||||
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
|
||||
isClosing ? "opacity-0" : "opacity-100"
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} h-full sm:h-auto sm:my-4`}
|
||||
>
|
||||
<div className="flex flex-col h-full sm:min-h-[500px] sm:max-h-[80vh]">
|
||||
{/* Main Form Section */}
|
||||
<div className="flex-1 flex flex-col transition-all duration-300 bg-white dark:bg-gray-800">
|
||||
<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">
|
||||
<fieldset className="h-full flex flex-col">
|
||||
{/* Project Title Section - Always Visible */}
|
||||
|
|
@ -354,7 +379,10 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onChange={handleChange}
|
||||
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`}
|
||||
placeholder={t('project.name', 'Enter project name')}
|
||||
placeholder={t(
|
||||
'project.name',
|
||||
'Enter project name'
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<div className="mt-2 text-red-500 text-sm font-medium">
|
||||
|
|
@ -368,11 +396,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
<textarea
|
||||
id="projectDescription"
|
||||
name="description"
|
||||
value={formData.description || ""}
|
||||
value={
|
||||
formData.description ||
|
||||
''
|
||||
}
|
||||
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"
|
||||
placeholder={t('forms.areaDescriptionPlaceholder', 'Enter project description (optional)')}
|
||||
style={{ minHeight: '200px' }}
|
||||
placeholder={t(
|
||||
'forms.areaDescriptionPlaceholder',
|
||||
'Enter project description (optional)'
|
||||
)}
|
||||
style={{
|
||||
minHeight: '200px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -381,18 +417,28 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{expandedSections.active && (
|
||||
<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">
|
||||
{t('projects.active', 'Status')}
|
||||
{t(
|
||||
'projects.active',
|
||||
'Status'
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
isChecked={formData.active}
|
||||
onToggle={handleToggleActive}
|
||||
isChecked={
|
||||
formData.active
|
||||
}
|
||||
onToggle={
|
||||
handleToggleActive
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="active"
|
||||
className="ml-2 block text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{t('projects.active', 'Active')}
|
||||
{t(
|
||||
'projects.active',
|
||||
'Active'
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -401,12 +447,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{expandedSections.tags && (
|
||||
<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">
|
||||
{t('forms.tags', 'Tags')}
|
||||
{t(
|
||||
'forms.tags',
|
||||
'Tags'
|
||||
)}
|
||||
</h3>
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
onTagsChange={
|
||||
handleTagsChange
|
||||
}
|
||||
initialTags={tags}
|
||||
availableTags={availableTags}
|
||||
availableTags={
|
||||
availableTags
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -414,18 +467,32 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{expandedSections.area && (
|
||||
<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">
|
||||
{t('common.area', 'Area')}
|
||||
{t(
|
||||
'common.area',
|
||||
'Area'
|
||||
)}
|
||||
</h3>
|
||||
<select
|
||||
id="projectArea"
|
||||
name="area_id"
|
||||
value={formData.area_id || ""}
|
||||
value={
|
||||
formData.area_id ||
|
||||
''
|
||||
}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('common.none', 'No Area')}</option>
|
||||
<option value="">
|
||||
{t(
|
||||
'common.none',
|
||||
'No Area'
|
||||
)}
|
||||
</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
<option
|
||||
key={area.id}
|
||||
value={area.id}
|
||||
>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -436,20 +503,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{expandedSections.image && (
|
||||
<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">
|
||||
{t('project.projectImage', 'Project Image')}
|
||||
{t(
|
||||
'project.projectImage',
|
||||
'Project Image'
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{imagePreview ? (
|
||||
<div className="mb-3">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={imagePreview}
|
||||
src={
|
||||
imagePreview
|
||||
}
|
||||
alt="Project preview"
|
||||
className="w-32 h-20 object-cover rounded-md border border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
×
|
||||
|
|
@ -462,21 +536,41 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageSelect}
|
||||
onChange={
|
||||
handleImageSelect
|
||||
}
|
||||
className="hidden"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<svg 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
|
||||
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>
|
||||
{t('project.browseImage', 'Browse Image')}
|
||||
{t(
|
||||
'project.browseImage',
|
||||
'Browse Image'
|
||||
)}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -484,12 +578,23 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{expandedSections.priority && (
|
||||
<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">
|
||||
{t('forms.priority', 'Priority')}
|
||||
{t(
|
||||
'forms.priority',
|
||||
'Priority'
|
||||
)}
|
||||
</h3>
|
||||
<PriorityDropdown
|
||||
value={formData.priority || "medium"}
|
||||
onChange={(value: PriorityType) =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
value={
|
||||
formData.priority ||
|
||||
'medium'
|
||||
}
|
||||
onChange={(
|
||||
value: PriorityType
|
||||
) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
priority: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -498,12 +603,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{expandedSections.dueDate && (
|
||||
<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">
|
||||
{t('forms.dueDate', 'Due Date')}
|
||||
{t(
|
||||
'forms.dueDate',
|
||||
'Due Date'
|
||||
)}
|
||||
</h3>
|
||||
<input
|
||||
type="date"
|
||||
name="due_date_at"
|
||||
value={formData.due_date_at || ""}
|
||||
value={
|
||||
formData.due_date_at ||
|
||||
''
|
||||
}
|
||||
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"
|
||||
/>
|
||||
|
|
@ -522,13 +633,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{/* Active Status Toggle - First */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('active')}
|
||||
onClick={() =>
|
||||
toggleSection('active')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.active
|
||||
? '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'
|
||||
}`}
|
||||
title={t('projects.active', 'Status')}
|
||||
title={t(
|
||||
'projects.active',
|
||||
'Status'
|
||||
)}
|
||||
>
|
||||
<PowerIcon className="h-5 w-5" />
|
||||
{!formData.active && (
|
||||
|
|
@ -539,7 +655,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{/* Tags Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('tags')}
|
||||
onClick={() =>
|
||||
toggleSection('tags')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.tags
|
||||
? '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')}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -556,7 +675,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{/* Area Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('area')}
|
||||
onClick={() =>
|
||||
toggleSection('area')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.area
|
||||
? '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 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('image')}
|
||||
onClick={() =>
|
||||
toggleSection('image')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.image
|
||||
? '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'
|
||||
}`}
|
||||
title={t('project.projectImage', 'Project Image')}
|
||||
title={t(
|
||||
'project.projectImage',
|
||||
'Project Image'
|
||||
)}
|
||||
>
|
||||
<CameraIcon className="h-5 w-5" />
|
||||
{formData.image_url && (
|
||||
|
|
@ -590,13 +716,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{/* Priority Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('priority')}
|
||||
onClick={() =>
|
||||
toggleSection('priority')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.priority
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.priority', 'Priority')}
|
||||
title={t(
|
||||
'forms.priority',
|
||||
'Priority'
|
||||
)}
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-5 w-5" />
|
||||
{formData.priority !== 'medium' && (
|
||||
|
|
@ -607,13 +738,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
{/* Due Date Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection('dueDate')}
|
||||
onClick={() =>
|
||||
toggleSection('dueDate')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.dueDate
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.dueDate', 'Due Date')}
|
||||
title={t(
|
||||
'forms.dueDate',
|
||||
'Due Date'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="h-5 w-5" />
|
||||
{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">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{(project && project.id && onDelete) && (
|
||||
{project && project.id && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteClick}
|
||||
|
|
@ -653,10 +789,22 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onClick={handleSubmit}
|
||||
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 ${
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -672,7 +820,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,56 +1,73 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
FolderIcon,
|
||||
Squares2X2Icon,
|
||||
Bars3Icon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import ConfirmDialog from "./Shared/ConfirmDialog";
|
||||
import ProjectModal from "./Project/ProjectModal";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { fetchGroupedProjects, createProject, updateProject, deleteProject } from "../utils/projectsService";
|
||||
import { fetchAreas } from "../utils/areasService";
|
||||
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";
|
||||
} from '@heroicons/react/24/solid';
|
||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||
import ProjectModal from './Project/ProjectModal';
|
||||
import { useStore } from '../store/useStore';
|
||||
import {
|
||||
fetchGroupedProjects,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from '../utils/projectsService';
|
||||
import { fetchAreas } from '../utils/areasService';
|
||||
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) => {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return { color: "bg-green-500" };
|
||||
case "medium":
|
||||
return { color: "bg-yellow-500" };
|
||||
case "high":
|
||||
return { color: "bg-red-500" };
|
||||
case 'low':
|
||||
return { color: 'bg-green-500' };
|
||||
case 'medium':
|
||||
return { color: 'bg-yellow-500' };
|
||||
case 'high':
|
||||
return { color: 'bg-red-500' };
|
||||
default:
|
||||
return { color: "bg-gray-500" };
|
||||
return { color: 'bg-gray-500' };
|
||||
}
|
||||
};
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore);
|
||||
const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore);
|
||||
const {
|
||||
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 [groupedProjects, setGroupedProjects] = useState<Record<string, Project[]>>({});
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
|
||||
const [groupedProjects, setGroupedProjects] = useState<
|
||||
Record<string, Project[]>
|
||||
>({});
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] =
|
||||
useState<boolean>(false);
|
||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(
|
||||
null
|
||||
);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [viewMode, setViewMode] = useState<"cards" | "list">("cards");
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeFilter = searchParams.get("active") || "all";
|
||||
|
||||
// Dispatch global modal events
|
||||
const areaFilter = searchParams.get("area_id") || "";
|
||||
const activeFilter = searchParams.get('active') || 'all';
|
||||
const areaFilter = searchParams.get('area_id') || '';
|
||||
|
||||
useEffect(() => {
|
||||
const loadAreas = async () => {
|
||||
|
|
@ -58,7 +75,7 @@ useEffect(() => {
|
|||
const areasData = await fetchAreas();
|
||||
setAreas(areasData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch areas:", error);
|
||||
console.error('Failed to fetch areas:', error);
|
||||
setAreasError(true);
|
||||
}
|
||||
};
|
||||
|
|
@ -69,10 +86,13 @@ useEffect(() => {
|
|||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
|
||||
const groupedProjectsData = await fetchGroupedProjects(
|
||||
activeFilter,
|
||||
areaFilter
|
||||
);
|
||||
setGroupedProjects(groupedProjectsData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
console.error('Failed to fetch projects:', error);
|
||||
setProjectsError(true);
|
||||
}
|
||||
};
|
||||
|
|
@ -88,10 +108,13 @@ useEffect(() => {
|
|||
} else {
|
||||
await createProject(project);
|
||||
}
|
||||
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
|
||||
const groupedProjectsData = await fetchGroupedProjects(
|
||||
activeFilter,
|
||||
areaFilter
|
||||
);
|
||||
setGroupedProjects(groupedProjectsData);
|
||||
} catch (error) {
|
||||
console.error("Error saving project:", error);
|
||||
console.error('Error saving project:', error);
|
||||
setProjectsError(true);
|
||||
} finally {
|
||||
setProjectsLoading(false);
|
||||
|
|
@ -99,7 +122,6 @@ useEffect(() => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleEditProject = (project: Project) => {
|
||||
setProjectToEdit(project);
|
||||
setIsProjectModalOpen(true);
|
||||
|
|
@ -112,13 +134,16 @@ useEffect(() => {
|
|||
if (projectToDelete.id !== undefined) {
|
||||
setProjectsLoading(true);
|
||||
await deleteProject(projectToDelete.id);
|
||||
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
|
||||
const groupedProjectsData = await fetchGroupedProjects(
|
||||
activeFilter,
|
||||
areaFilter
|
||||
);
|
||||
setGroupedProjects(groupedProjectsData);
|
||||
} else {
|
||||
console.error("Cannot delete project: ID is undefined.");
|
||||
console.error('Cannot delete project: ID is undefined.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting project:", error);
|
||||
console.error('Error deleting project:', error);
|
||||
setProjectsError(true);
|
||||
} finally {
|
||||
setProjectsLoading(false);
|
||||
|
|
@ -132,39 +157,47 @@ useEffect(() => {
|
|||
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 params = new URLSearchParams(searchParams);
|
||||
|
||||
if (newActiveFilter === "all") {
|
||||
params.delete("active");
|
||||
if (newActiveFilter === 'all') {
|
||||
params.delete('active');
|
||||
} else {
|
||||
params.set("active", newActiveFilter);
|
||||
params.set('active', newActiveFilter);
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const handleAreaFilterChange = (
|
||||
e: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
const newAreaFilter = e.target.value;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (newAreaFilter === "") {
|
||||
params.delete("area_id");
|
||||
if (newAreaFilter === '') {
|
||||
params.delete('area_id');
|
||||
} else {
|
||||
params.set("area_id", newAreaFilter);
|
||||
params.set('area_id', newAreaFilter);
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
// Apply search filter to the grouped projects from backend
|
||||
const searchFilteredGroupedProjects = Object.keys(groupedProjects).reduce<Record<string, Project[]>>(
|
||||
(acc, areaName) => {
|
||||
const searchFilteredGroupedProjects = Object.keys(groupedProjects).reduce<
|
||||
Record<string, Project[]>
|
||||
>((acc, areaName) => {
|
||||
const projectsInArea = groupedProjects[areaName];
|
||||
|
||||
// Defensive check: ensure projectsInArea is an array
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +208,7 @@ useEffect(() => {
|
|||
acc[areaName] = filteredProjects;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}, {});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -192,7 +223,9 @@ useEffect(() => {
|
|||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">{t('projects.error')}</div>
|
||||
<div className="text-red-500 text-lg">
|
||||
{t('projects.error')}
|
||||
</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 items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setViewMode("cards")}
|
||||
onClick={() => setViewMode('cards')}
|
||||
className={`p-2 rounded-md focus:outline-none ${
|
||||
viewMode === "cards"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
viewMode === 'cards'
|
||||
? 'bg-blue-500 text-white'
|
||||
: '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" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-md focus:outline-none ${
|
||||
viewMode === "list"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-500 text-white'
|
||||
: '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" />
|
||||
</button>
|
||||
|
|
@ -249,9 +282,15 @@ useEffect(() => {
|
|||
onChange={handleActiveFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="true">{t('projects.filters.active')}</option>
|
||||
<option value="false">{t('projects.filters.inactive')}</option>
|
||||
<option value="all">{t('projects.filters.all')}</option>
|
||||
<option value="true">
|
||||
{t('projects.filters.active')}
|
||||
</option>
|
||||
<option value="false">
|
||||
{t('projects.filters.inactive')}
|
||||
</option>
|
||||
<option value="all">
|
||||
{t('projects.filters.all')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -268,9 +307,14 @@ useEffect(() => {
|
|||
onChange={handleAreaFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">{t('projects.filters.allAreas')}</option>
|
||||
<option value="">
|
||||
{t('projects.filters.allAreas')}
|
||||
</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id?.toString()}>
|
||||
<option
|
||||
key={area.id}
|
||||
value={area.id?.toString()}
|
||||
>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
|
|
@ -296,9 +340,9 @@ useEffect(() => {
|
|||
{/* Projects Grid/List */}
|
||||
<div
|
||||
className={`${
|
||||
viewMode === "cards"
|
||||
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "flex flex-col space-y-1"
|
||||
viewMode === 'cards'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'
|
||||
: 'flex flex-col space-y-1'
|
||||
}`}
|
||||
>
|
||||
{Object.keys(searchFilteredGroupedProjects).length === 0 ? (
|
||||
|
|
@ -306,34 +350,54 @@ useEffect(() => {
|
|||
{t('projects.noProjectsFound')}
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(searchFilteredGroupedProjects).map((areaName) => (
|
||||
Object.keys(searchFilteredGroupedProjects).map(
|
||||
(areaName) => (
|
||||
<React.Fragment key={areaName}>
|
||||
<h3 className={`${
|
||||
viewMode === "cards"
|
||||
? "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"
|
||||
}`}>
|
||||
<h3
|
||||
className={`${
|
||||
viewMode === 'cards'
|
||||
? '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}
|
||||
</h3>
|
||||
{searchFilteredGroupedProjects[areaName].map((project) => {
|
||||
const { color } = getPriorityStyles(project.priority || "low");
|
||||
{searchFilteredGroupedProjects[
|
||||
areaName
|
||||
].map((project) => {
|
||||
const { color } = getPriorityStyles(
|
||||
project.priority || 'low'
|
||||
);
|
||||
return (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
viewMode={viewMode}
|
||||
color={color}
|
||||
getCompletionPercentage={() => getCompletionPercentage(project)}
|
||||
getCompletionPercentage={() =>
|
||||
getCompletionPercentage(
|
||||
project
|
||||
)
|
||||
}
|
||||
activeDropdown={activeDropdown}
|
||||
setActiveDropdown={setActiveDropdown}
|
||||
handleEditProject={handleEditProject}
|
||||
setProjectToDelete={setProjectToDelete}
|
||||
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
|
||||
setActiveDropdown={
|
||||
setActiveDropdown
|
||||
}
|
||||
handleEditProject={
|
||||
handleEditProject
|
||||
}
|
||||
setProjectToDelete={
|
||||
setProjectToDelete
|
||||
}
|
||||
setIsConfirmDialogOpen={
|
||||
setIsConfirmDialogOpen
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -349,7 +413,11 @@ useEffect(() => {
|
|||
onDelete={async (projectId) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setProjects(projects.filter((p: Project) => p.id !== projectId));
|
||||
setProjects(
|
||||
projects.filter(
|
||||
(p: Project) => p.id !== projectId
|
||||
)
|
||||
);
|
||||
setIsProjectModalOpen(false);
|
||||
setProjectToEdit(null);
|
||||
} catch (error) {
|
||||
|
|
@ -364,7 +432,9 @@ useEffect(() => {
|
|||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title={t('modals.deleteProject.title')}
|
||||
message={t('modals.deleteProject.message', { projectName: projectToDelete?.name })}
|
||||
message={t('modals.deleteProject.message', {
|
||||
projectName: projectToDelete?.name,
|
||||
})}
|
||||
onConfirm={handleDeleteProject}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
|
|
@ -14,10 +14,12 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
className = ""
|
||||
className = '',
|
||||
}) => {
|
||||
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
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
|
|
@ -33,12 +35,14 @@ const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
|||
)}
|
||||
</button>
|
||||
|
||||
<div className={`transition-all duration-300 ease-in-out ${
|
||||
isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden'
|
||||
}`}>
|
||||
<div className="px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
isExpanded
|
||||
? 'max-h-[500px] opacity-100'
|
||||
: 'max-h-0 opacity-0 overflow-hidden'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 pb-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,23 @@ interface ConfirmDialogProps {
|
|||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => {
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{title}</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-8">{message}</p>
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-8">
|
||||
{message}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { MoonIcon, SunIcon } from "@heroicons/react/24/solid";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const DarkModeToggle: React.FC = () => {
|
||||
const [darkMode, setDarkMode] = useState<boolean>(() => {
|
||||
return localStorage.getItem("darkMode") === "true";
|
||||
return localStorage.getItem('darkMode') === 'true';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("dark-mode", darkMode);
|
||||
localStorage.setItem("darkMode", darkMode.toString());
|
||||
document.body.classList.toggle('dark-mode', darkMode);
|
||||
localStorage.setItem('darkMode', darkMode.toString());
|
||||
}, [darkMode]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
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 {
|
||||
value: string;
|
||||
|
|
@ -8,7 +12,6 @@ interface DatePickerProps {
|
|||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = ({
|
||||
|
|
@ -17,17 +20,31 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
placeholder = 'Select date',
|
||||
disabled = false,
|
||||
className = '',
|
||||
label
|
||||
}) => {
|
||||
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 dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const months = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
|
@ -50,7 +67,7 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -81,7 +98,10 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
openUpward = true;
|
||||
top = Math.max(padding, rect.top - menuHeight - 8);
|
||||
} 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,
|
||||
left,
|
||||
width: Math.max(rect.width, 280), // Minimum width for calendar
|
||||
openUpward
|
||||
openUpward,
|
||||
});
|
||||
|
||||
// Set current month based on selected date or today
|
||||
if (value) {
|
||||
const selectedDate = parseDate(value);
|
||||
if (selectedDate && !isNaN(selectedDate.getTime())) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
setCurrentMonth(
|
||||
new Date(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
1
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setCurrentMonth(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
||||
setCurrentMonth(
|
||||
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||
menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -135,7 +167,7 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
};
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
setCurrentMonth(prev => {
|
||||
setCurrentMonth((prev) => {
|
||||
const newMonth = new Date(prev);
|
||||
if (direction === 'prev') {
|
||||
newMonth.setMonth(newMonth.getMonth() - 1);
|
||||
|
|
@ -177,7 +209,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
const isSelected = (date: Date) => {
|
||||
if (!value) return false;
|
||||
const selectedDate = parseDate(value);
|
||||
return selectedDate && date.toDateString() === selectedDate.toDateString();
|
||||
return (
|
||||
selectedDate && date.toDateString() === selectedDate.toDateString()
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -193,11 +227,16 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
}, [isOpen]);
|
||||
|
||||
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
|
||||
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 ${
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -206,7 +245,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
}}
|
||||
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)}
|
||||
</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
|
|
@ -223,7 +264,8 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
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"
|
||||
|
|
@ -244,7 +286,8 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
<ChevronLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{months[currentMonth.getMonth()]} {currentMonth.getFullYear()}
|
||||
{months[currentMonth.getMonth()]}{' '}
|
||||
{currentMonth.getFullYear()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -259,8 +302,11 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
<div className="p-3">
|
||||
{/* Day Headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{days.map(day => (
|
||||
<div key={day} className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1">
|
||||
{days.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -273,7 +319,9 @@ const DatePicker: React.FC<DatePickerProps> = ({
|
|||
{date && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDateSelect(date)}
|
||||
onClick={() =>
|
||||
handleDateSelect(date)
|
||||
}
|
||||
className={`w-full h-full text-xs rounded transition-colors ${
|
||||
isSelected(date)
|
||||
? 'bg-blue-600 text-white'
|
||||
|
|
|
|||
|
|
@ -7,4 +7,3 @@ const LoadingScreen: React.FC = () => (
|
|||
);
|
||||
|
||||
export default LoadingScreen;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,11 +9,29 @@ interface MarkdownRendererProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
className = '',
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// Configure highlight.js
|
||||
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
|
||||
|
|
@ -31,64 +49,183 @@ const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className
|
|||
<div className={`markdown-content ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
|
||||
rehypePlugins={[
|
||||
[rehypeHighlight, { detect: true, ignoreMissing: true }],
|
||||
]}
|
||||
components={{
|
||||
// Customize heading styles
|
||||
h1: ({...props}) => <h1 className="text-3xl font-bold mb-4 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} />,
|
||||
h1: ({ ...props }) => (
|
||||
<h1
|
||||
className="text-3xl font-bold mb-4 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
|
||||
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
|
||||
ul: ({...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} />,
|
||||
ul: ({ ...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} />,
|
||||
|
||||
// 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
|
||||
code: ({ className, children, ...props }) => {
|
||||
// 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) {
|
||||
// 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 {
|
||||
// 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
|
||||
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) {
|
||||
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
|
||||
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
|
||||
table: ({...props}) => <table className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600" {...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} />,
|
||||
table: ({ ...props }) => (
|
||||
<table
|
||||
className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600"
|
||||
{...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
|
||||
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
|
||||
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
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const NotFound: React.FC = () => {
|
|||
return (
|
||||
<div>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,10 +19,15 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
|
|||
max = 99,
|
||||
placeholder = 'Select number',
|
||||
disabled = false,
|
||||
className = ''
|
||||
className = '',
|
||||
}) => {
|
||||
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 menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -41,21 +46,26 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
|
|||
const spaceAbove = rect.top;
|
||||
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({
|
||||
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
openUpward
|
||||
openUpward,
|
||||
});
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||
menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -82,14 +92,19 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
|
|||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedOption = options.find(option => option.value === value);
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
|
||||
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
|
||||
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 ${
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -101,10 +116,13 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
|
|||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</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>
|
||||
|
||||
{isOpen && createPortal(
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
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"
|
||||
|
|
@ -132,7 +150,9 @@ const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
|
|||
>
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && (
|
||||
<span className="text-blue-600 dark:text-blue-400">✓</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
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';
|
||||
|
||||
interface PomodoroTimerProps {
|
||||
|
|
@ -37,8 +42,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
|||
|
||||
// If timer was running, calculate how much time has passed
|
||||
if (state.isRunning && state.startTime) {
|
||||
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
|
||||
const newTimeLeft = Math.max(0, state.timeLeft - elapsed);
|
||||
const elapsed = Math.floor(
|
||||
(Date.now() - state.startTime) / 1000
|
||||
);
|
||||
const newTimeLeft = Math.max(
|
||||
0,
|
||||
state.timeLeft - elapsed
|
||||
);
|
||||
setTimeLeft(newTimeLeft);
|
||||
if (newTimeLeft > 0) {
|
||||
setIsRunning(true);
|
||||
|
|
@ -59,7 +69,9 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
|||
isActive,
|
||||
timeLeft,
|
||||
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));
|
||||
}, [isActive, timeLeft, isRunning]);
|
||||
|
|
@ -67,7 +79,7 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
|||
useEffect(() => {
|
||||
if (isRunning && timeLeft > 0) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
setIsRunning(false);
|
||||
setShowCompletionMessage(true);
|
||||
|
|
@ -157,7 +169,10 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
|||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<div className={`flex items-center ${className}`} onClick={handleTomatoClick}>
|
||||
<div
|
||||
className={`flex items-center ${className}`}
|
||||
onClick={handleTomatoClick}
|
||||
>
|
||||
<TomatoIcon />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -173,7 +188,9 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
|||
<button
|
||||
onClick={handlePlayPause}
|
||||
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 ? (
|
||||
<PauseIcon className="h-3 w-3" />
|
||||
|
|
@ -203,9 +220,13 @@ const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
|||
{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="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>
|
||||
<p className="text-xs mb-3">{t('pomodoro.completeMessage')}</p>
|
||||
<p className="text-xs mb-3">
|
||||
{t('pomodoro.completeMessage')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCompletionMessage(false);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -9,16 +14,42 @@ interface PriorityDropdownProps {
|
|||
onChange: (value: PriorityType) => void;
|
||||
}
|
||||
|
||||
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => {
|
||||
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: t('priority.low', 'Low'), icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'medium', label: t('priority.medium', 'Medium'), icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'high', label: t('priority.high', 'High'), icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
|
||||
{
|
||||
value: 'low',
|
||||
label: t('priority.low', 'Low'),
|
||||
icon: (
|
||||
<ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: t('priority.medium', 'Medium'),
|
||||
icon: (
|
||||
<ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: t('priority.high', 'High'),
|
||||
icon: (
|
||||
<FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [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 menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -29,13 +60,14 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
|
|||
const spaceAbove = rect.top;
|
||||
const menuHeight = 120;
|
||||
|
||||
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
|
||||
const openUpward =
|
||||
spaceAbove > spaceBelow && spaceBelow < menuHeight;
|
||||
|
||||
setPosition({
|
||||
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
openUpward
|
||||
openUpward,
|
||||
});
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
|
|
@ -69,10 +101,13 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
|
|||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedPriority = priorities.find(p => p.value === value);
|
||||
const selectedPriority = priorities.find((p) => p.value === value);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative inline-block text-left w-full">
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="relative inline-block text-left w-full"
|
||||
>
|
||||
<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"
|
||||
|
|
@ -80,12 +115,17 @@ const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange })
|
|||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{selectedPriority ? selectedPriority.icon : ''}
|
||||
<span>{selectedPriority ? selectedPriority.label : t('forms.priority', 'Select Priority')}</span>
|
||||
<span>
|
||||
{selectedPriority
|
||||
? selectedPriority.label
|
||||
: t('forms.priority', 'Select Priority')}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
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) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{priority.icon} <span>{priority.label}</span>
|
||||
{priority.icon}{' '}
|
||||
<span>{priority.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -9,20 +14,64 @@ interface RecurrenceDropdownProps {
|
|||
onChange: (value: RecurrenceType) => void;
|
||||
}
|
||||
|
||||
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange }) => {
|
||||
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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: '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" /> }
|
||||
{
|
||||
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: '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 [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 menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -33,20 +82,24 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
|
|||
const spaceAbove = rect.top;
|
||||
const menuHeight = 240;
|
||||
|
||||
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
|
||||
const openUpward =
|
||||
spaceAbove > spaceBelow && spaceBelow < menuHeight;
|
||||
|
||||
setPosition({
|
||||
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
openUpward
|
||||
openUpward,
|
||||
});
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -68,10 +121,13 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
|
|||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedRecurrence = recurrenceOptions.find(r => r.value === value);
|
||||
const selectedRecurrence = recurrenceOptions.find((r) => r.value === value);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative inline-block text-left w-full">
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="relative inline-block text-left w-full"
|
||||
>
|
||||
<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"
|
||||
|
|
@ -79,12 +135,20 @@ const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange
|
|||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{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>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{isOpen && createPortal(
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
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) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{recurrence.icon} <span>{recurrence.label}</span>
|
||||
{recurrence.icon}{' '}
|
||||
<span>{recurrence.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,15 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
|
|||
options,
|
||||
placeholder = 'Select option',
|
||||
disabled = false,
|
||||
className = ''
|
||||
className = '',
|
||||
}) => {
|
||||
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 menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -38,21 +43,26 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
|
|||
const spaceAbove = rect.top;
|
||||
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({
|
||||
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
openUpward
|
||||
openUpward,
|
||||
});
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||
menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -79,14 +89,19 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
|
|||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedOption = options.find(option => option.value === value);
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
|
||||
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
|
||||
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 ${
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -98,10 +113,13 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
|
|||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</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>
|
||||
|
||||
{isOpen && createPortal(
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
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"
|
||||
|
|
@ -129,7 +147,9 @@ const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
|
|||
>
|
||||
<span>{option.label}</span>
|
||||
{option.value === value && (
|
||||
<span className="text-blue-600 dark:text-blue-400">✓</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -12,10 +18,34 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
|||
const { t } = useTranslation();
|
||||
|
||||
const statuses = [
|
||||
{ value: 'not_started', label: t('status.notStarted', 'Not Started'), icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'in_progress', label: t('status.inProgress', 'In Progress'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'done', label: t('status.done', 'Done'), icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'archived', label: t('status.archived', 'Archived'), icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{
|
||||
value: 'not_started',
|
||||
label: t('status.notStarted', 'Not Started'),
|
||||
icon: (
|
||||
<MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'in_progress',
|
||||
label: t('status.inProgress', 'In Progress'),
|
||||
icon: (
|
||||
<ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'done',
|
||||
label: t('status.done', 'Done'),
|
||||
icon: (
|
||||
<CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'archived',
|
||||
label: t('status.archived', 'Archived'),
|
||||
icon: (
|
||||
<ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
|
||||
),
|
||||
},
|
||||
];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -25,7 +55,10 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
|||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -47,10 +80,13 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
|||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedStatus = statuses.find(s => s.value === value);
|
||||
const selectedStatus = statuses.find((s) => s.value === value);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative inline-block text-left w-full">
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="relative inline-block text-left w-full"
|
||||
>
|
||||
<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"
|
||||
|
|
@ -58,7 +94,11 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
|||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{selectedStatus ? selectedStatus.icon : ''}
|
||||
<span>{selectedStatus ? selectedStatus.label : 'Select Status'}</span>
|
||||
<span>
|
||||
{selectedStatus
|
||||
? selectedStatus.label
|
||||
: 'Select Status'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
|
@ -68,7 +108,9 @@ const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
|||
{statuses.map((status) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ interface SwitchProps {
|
|||
|
||||
const Switch: React.FC<SwitchProps> = ({ isChecked, onToggle }) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -7,15 +7,22 @@ interface ToastContextProps {
|
|||
|
||||
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toastMessage, setToastMessage] = useState<string | React.ReactNode | null>(null);
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [toastMessage, setToastMessage] = useState<
|
||||
string | React.ReactNode | null
|
||||
>(null);
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||
|
||||
const showSuccessToast = useCallback((message: string | React.ReactNode) => {
|
||||
const showSuccessToast = useCallback(
|
||||
(message: string | React.ReactNode) => {
|
||||
setToastMessage(message);
|
||||
setToastType('success');
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const showErrorToast = useCallback((message: string | React.ReactNode) => {
|
||||
setToastMessage(message);
|
||||
|
|
@ -26,7 +33,13 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||
return (
|
||||
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
|
||||
{children}
|
||||
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -39,7 +52,11 @@ export const useToast = () => {
|
|||
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 (
|
||||
<div
|
||||
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-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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
className = ''
|
||||
className = '',
|
||||
}) => {
|
||||
const handleToggle = () => {
|
||||
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
|
||||
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' : ''}
|
||||
${checked
|
||||
${
|
||||
checked
|
||||
? 'bg-blue-600 dark:bg-blue-500'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}
|
||||
|
|
@ -64,11 +65,13 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
|||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
disabled
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
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 {
|
||||
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="flex items-center space-x-2">
|
||||
<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>
|
||||
);
|
||||
|
|
@ -94,21 +96,27 @@ const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
|
|||
)}
|
||||
</div>
|
||||
<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',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{preview.title || 'Untitled'}
|
||||
</div>
|
||||
{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',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{preview.description}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
setIsDropdownOpen(!isDropdownOpen);
|
||||
};
|
||||
|
||||
const handleNavClick = (path: string, title: string, icon: JSX.Element) => {
|
||||
const handleNavClick = (path: string, title: string) => {
|
||||
navigate(path, { state: { title } });
|
||||
if (window.innerWidth < 1024) {
|
||||
setIsSidebarOpen(false);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react";
|
||||
import { Squares2X2Icon, PlusCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Location } from "react-router-dom";
|
||||
import { Area } from "../../entities/Area";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from 'react';
|
||||
import { Squares2X2Icon, PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SidebarAreasProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -20,8 +20,8 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
|
|||
const { t } = useTranslation();
|
||||
const isActiveArea = (path: string) => {
|
||||
return location.pathname === path
|
||||
? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
: "text-gray-700 dark:text-gray-300";
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -30,12 +30,12 @@ const SidebarAreas: React.FC<SidebarAreasProps> = ({
|
|||
{/* "AREAS" Title with Add Button */}
|
||||
<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(
|
||||
"/areas"
|
||||
'/areas'
|
||||
)}`}
|
||||
onClick={() =>
|
||||
handleNavClick(
|
||||
"/areas",
|
||||
"Areas",
|
||||
'/areas',
|
||||
'Areas',
|
||||
<Squares2X2Icon className="h-5 w-5 mr-2" />
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
openTaskModal,
|
||||
openProjectModal,
|
||||
openNoteModal,
|
||||
|
|
@ -51,7 +50,10 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
@ -135,18 +137,51 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
};
|
||||
|
||||
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: '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' },
|
||||
{
|
||||
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: '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 (
|
||||
<div className="mt-auto p-3">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
{isSidebarOpen && (
|
||||
<div className="flex items-center justify-between" ref={dropdownRef}>
|
||||
<div
|
||||
className="flex items-center justify-between"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{/* Plus Icon Button - Left */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
|
@ -164,21 +199,35 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
{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="py-1">
|
||||
{dropdownItems.map(({ label, translationKey, icon, shortcut }) => (
|
||||
{dropdownItems.map(
|
||||
({
|
||||
label,
|
||||
translationKey,
|
||||
icon,
|
||||
shortcut,
|
||||
}) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{icon}
|
||||
{t(translationKey, label)}
|
||||
{t(
|
||||
translationKey,
|
||||
label
|
||||
)}
|
||||
</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">
|
||||
{shortcut}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { Location } from 'react-router-dom';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
ArrowRightCircleIcon,
|
||||
InboxIcon,
|
||||
CheckCircleIcon,
|
||||
ListBulletIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
|
@ -18,7 +16,10 @@ interface SidebarNavProps {
|
|||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) => {
|
||||
const SidebarNav: React.FC<SidebarNavProps> = ({
|
||||
handleNavClick,
|
||||
location,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const store = useStore();
|
||||
|
||||
|
|
@ -31,10 +32,28 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
|
|||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/inbox', title: t('sidebar.inbox', 'Inbox'), icon: <InboxIcon className="h-5 w-5" /> },
|
||||
{ path: '/today', title: t('sidebar.today', 'Today'), icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: <ClockIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks', title: t('sidebar.allTasks', 'All Tasks'), icon: <ListBulletIcon className="h-5 w-5" /> },
|
||||
{
|
||||
path: '/inbox',
|
||||
title: t('sidebar.inbox', 'Inbox'),
|
||||
icon: <InboxIcon className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
path: '/today',
|
||||
title: t('sidebar.today', 'Today'),
|
||||
icon: <CalendarDaysIcon className="h-5 w-5" />,
|
||||
query: 'type=today',
|
||||
},
|
||||
{
|
||||
path: '/tasks?type=upcoming',
|
||||
title: t('sidebar.upcoming', 'Upcoming'),
|
||||
icon: <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) => {
|
||||
|
|
@ -48,7 +67,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
|
|||
|
||||
// Regular case for /tasks with query params
|
||||
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
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
|
|
@ -60,7 +81,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
|
|||
<React.Fragment key={link.path}>
|
||||
<li>
|
||||
<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(
|
||||
link.path,
|
||||
link.query
|
||||
|
|
@ -72,7 +95,9 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
|
|||
</div>
|
||||
{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">
|
||||
{inboxItemsCount > 99 ? '99+' : inboxItemsCount}
|
||||
{inboxItemsCount > 99
|
||||
? '99+'
|
||||
: inboxItemsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'/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">
|
||||
<BookOpenIcon className="h-5 w-5 mr-2" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'/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">
|
||||
<FolderIcon className="h-5 w-5 mr-2" />
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'/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">
|
||||
<TagIcon className="h-5 w-5 mr-2" />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
BookOpenIcon,
|
||||
FolderIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
|
@ -34,24 +34,34 @@ const TagDetails: React.FC = () => {
|
|||
// State for ProjectItem components
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [, setProjectToDelete] = useState<Project | null>(
|
||||
null
|
||||
);
|
||||
const [, setIsConfirmDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTagData = async () => {
|
||||
try {
|
||||
// First fetch tag details
|
||||
const tagResponse = await fetch(`/api/tag/${encodeURIComponent(identifier!)}`);
|
||||
const tagResponse = await fetch(
|
||||
`/api/tag/${encodeURIComponent(identifier!)}`
|
||||
);
|
||||
if (tagResponse.ok) {
|
||||
const tagData = await tagResponse.json();
|
||||
setTag(tagData);
|
||||
|
||||
// Now fetch entities that have this tag using the tag name
|
||||
const [tasksResponse, notesResponse, projectsResponse] = await Promise.all([
|
||||
fetch(`/api/tasks?tag=${encodeURIComponent(tagData.name)}`),
|
||||
fetch(`/api/notes?tag=${encodeURIComponent(tagData.name)}`),
|
||||
fetch(`/api/projects`) // Projects API doesn't support tag filtering yet
|
||||
const [tasksResponse, notesResponse, projectsResponse] =
|
||||
await Promise.all([
|
||||
fetch(
|
||||
`/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) {
|
||||
|
|
@ -67,9 +77,14 @@ const TagDetails: React.FC = () => {
|
|||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
// Filter projects client-side since API doesn't support tag filtering
|
||||
const allProjects = projectsData.projects || projectsData || [];
|
||||
const filteredProjects = allProjects.filter((project: any) =>
|
||||
project.tags && project.tags.some((tag: any) => tag.name === tagData.name)
|
||||
const allProjects =
|
||||
projectsData.projects || projectsData || [];
|
||||
const filteredProjects = allProjects.filter(
|
||||
(project: any) =>
|
||||
project.tags &&
|
||||
project.tags.some(
|
||||
(tag: any) => tag.name === tagData.name
|
||||
)
|
||||
);
|
||||
setProjects(filteredProjects);
|
||||
}
|
||||
|
|
@ -77,7 +92,7 @@ const TagDetails: React.FC = () => {
|
|||
const tagError = await tagResponse.json();
|
||||
setError(tagError.error || 'Failed to fetch tag.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(t('tags.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -90,8 +105,8 @@ const TagDetails: React.FC = () => {
|
|||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${updatedTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedTask),
|
||||
});
|
||||
|
||||
|
|
@ -103,65 +118,73 @@ const TagDetails: React.FC = () => {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
console.error('Error updating task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${taskId}`, {
|
||||
method: "DELETE",
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.filter((task) => task.id !== taskId)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
console.error('Error deleting task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleToday = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${taskId}/today`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedTask = await response.json();
|
||||
setTasks((prevTasks) =>
|
||||
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) {
|
||||
console.error("Error toggling today status:", error);
|
||||
console.error('Error toggling today status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Project handlers
|
||||
const handleEditProject = (project: Project) => {
|
||||
// For now, just log - could add modal later
|
||||
console.log("Edit project:", project);
|
||||
console.log('Edit project:', project);
|
||||
};
|
||||
|
||||
|
||||
const getCompletionPercentage = (project: Project) => {
|
||||
return (project as any).completion_percentage || 0;
|
||||
};
|
||||
|
||||
const getPriorityStyles = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return { color: "bg-green-500" };
|
||||
case "medium":
|
||||
return { color: "bg-yellow-500" };
|
||||
case "high":
|
||||
return { color: "bg-red-500" };
|
||||
case 'low':
|
||||
return { color: 'bg-green-500' };
|
||||
case 'medium':
|
||||
return { color: 'bg-yellow-500' };
|
||||
case 'high':
|
||||
return { color: 'bg-red-500' };
|
||||
default:
|
||||
return { color: "bg-gray-500" };
|
||||
return { color: 'bg-gray-500' };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -180,7 +203,11 @@ const TagDetails: React.FC = () => {
|
|||
}
|
||||
|
||||
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 (
|
||||
|
|
@ -200,8 +227,12 @@ const TagDetails: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<CheckIcon className="h-8 w-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{tasks.length}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('tasks.title')}</p>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{tasks.length}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('tasks.title')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -209,8 +240,12 @@ const TagDetails: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<BookOpenIcon className="h-8 w-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{notes.length}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('notes.title')}</p>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{notes.length}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('notes.title')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -218,8 +253,12 @@ const TagDetails: React.FC = () => {
|
|||
<div className="flex items-center">
|
||||
<FolderIcon className="h-8 w-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{projects.length}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('projects.title')}</p>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{projects.length}
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('projects.title')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -255,7 +294,9 @@ const TagDetails: React.FC = () => {
|
|||
<li
|
||||
key={note.id}
|
||||
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)}
|
||||
>
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
|
|
@ -267,14 +308,23 @@ const TagDetails: React.FC = () => {
|
|||
{note.title}
|
||||
</Link>
|
||||
{/* 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
|
||||
key={noteTag.id}
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
|
|
@ -290,7 +340,9 @@ const TagDetails: React.FC = () => {
|
|||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<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'}`}
|
||||
aria-label={`Edit ${note.title}`}
|
||||
title={`Edit ${note.title}`}
|
||||
|
|
@ -298,7 +350,12 @@ const TagDetails: React.FC = () => {
|
|||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</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'}`}
|
||||
aria-label={`Delete ${note.title}`}
|
||||
title={`Delete ${note.title}`}
|
||||
|
|
@ -321,19 +378,25 @@ const TagDetails: React.FC = () => {
|
|||
</h3>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{projects.map((project) => {
|
||||
const { color } = getPriorityStyles(project.priority || "low");
|
||||
const { color } = getPriorityStyles(
|
||||
project.priority || 'low'
|
||||
);
|
||||
return (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
viewMode="list"
|
||||
color={color}
|
||||
getCompletionPercentage={() => getCompletionPercentage(project)}
|
||||
getCompletionPercentage={() =>
|
||||
getCompletionPercentage(project)
|
||||
}
|
||||
activeDropdown={activeDropdown}
|
||||
setActiveDropdown={setActiveDropdown}
|
||||
handleEditProject={handleEditProject}
|
||||
setProjectToDelete={setProjectToDelete}
|
||||
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
|
||||
setIsConfirmDialogOpen={
|
||||
setIsConfirmDialogOpen
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -342,11 +405,16 @@ const TagDetails: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* 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">
|
||||
<TagIcon className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ interface TagInputProps {
|
|||
availableTags: Tag[];
|
||||
}
|
||||
|
||||
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags }) => {
|
||||
const TagInput: React.FC<TagInputProps> = ({
|
||||
initialTags,
|
||||
onTagsChange,
|
||||
availableTags,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [tags, setTags] = useState<string[]>(initialTags || []);
|
||||
|
|
@ -39,7 +43,8 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
return;
|
||||
}
|
||||
|
||||
const filtered = availableTags.filter(tag =>
|
||||
const filtered = availableTags.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||
!tags.includes(tag.name)
|
||||
);
|
||||
|
|
@ -77,13 +82,18 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
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') {
|
||||
event.preventDefault();
|
||||
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < filteredTags.length) {
|
||||
if (
|
||||
highlightedIndex >= 0 &&
|
||||
highlightedIndex < filteredTags.length
|
||||
) {
|
||||
selectTag(filteredTags[highlightedIndex].name);
|
||||
} else if (inputValue.trim()) {
|
||||
addNewTag(inputValue.trim());
|
||||
|
|
@ -186,7 +196,9 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
type="button"
|
||||
onClick={() => selectTag(tag.name)}
|
||||
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)}
|
||||
onMouseLeave={() => setHighlightedIndex(-1)}
|
||||
|
|
@ -197,7 +209,10 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
<>
|
||||
{inputValue.length > 0 && (
|
||||
<span className="font-semibold">
|
||||
{tag.name.substring(0, inputValue.length)}
|
||||
{tag.name.substring(
|
||||
0,
|
||||
inputValue.length
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{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"
|
||||
role="option"
|
||||
>
|
||||
+ Create "{inputValue.trim()}"
|
||||
+ Create "{inputValue.trim()}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
showErrorToast(t('errors.tagNameRequired', 'Tag name is required.'));
|
||||
showErrorToast(
|
||||
t('errors.tagNameRequired', 'Tag name is required.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -92,12 +94,16 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
try {
|
||||
await onSave(formData); // Wait for the save operation to complete
|
||||
if (tag) {
|
||||
showSuccessToast(t('success.tagUpdated', 'Tag updated successfully!'));
|
||||
showSuccessToast(
|
||||
t('success.tagUpdated', 'Tag updated successfully!')
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t('success.tagCreated', 'Tag created successfully!'));
|
||||
showSuccessToast(
|
||||
t('success.tagCreated', 'Tag created successfully!')
|
||||
);
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -116,10 +122,14 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
if (formData.id && onDelete) {
|
||||
try {
|
||||
await onDelete(formData.id);
|
||||
showSuccessToast(t('success.tagDeleted', 'Tag deleted successfully!'));
|
||||
showSuccessToast(
|
||||
t('success.tagDeleted', 'Tag deleted successfully!')
|
||||
);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
showErrorToast(t('errors.failedToDeleteTag', 'Failed to delete tag.'));
|
||||
} catch {
|
||||
showErrorToast(
|
||||
t('errors.failedToDeleteTag', 'Failed to delete tag.')
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -154,7 +164,10 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
onChange={handleChange}
|
||||
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"
|
||||
placeholder={t('forms.tagNamePlaceholder', 'Enter tag name')}
|
||||
placeholder={t(
|
||||
'forms.tagNamePlaceholder',
|
||||
'Enter tag name'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
{/* Left side: Delete and Cancel */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{(tag && tag.id && onDelete) && (
|
||||
{tag && tag.id && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteTag}
|
||||
|
|
@ -190,7 +203,9 @@ const TagModal: React.FC<TagModalProps> = ({
|
|||
onClick={handleSubmit}
|
||||
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 ${
|
||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
isSubmitting
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting
|
||||
|
|
|
|||
|
|
@ -1,33 +1,41 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
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 TagModal from './Tag/TagModal';
|
||||
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';
|
||||
|
||||
const Tags: React.FC = () => {
|
||||
const {
|
||||
tagsStore: {
|
||||
tags,
|
||||
setTags,
|
||||
isLoading,
|
||||
setLoading,
|
||||
isError,
|
||||
setError
|
||||
}
|
||||
tagsStore: { tags, setTags, isLoading, isError },
|
||||
} = useStore();
|
||||
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [hoveredTagId, setHoveredTagId] = useState<number | null>(null);
|
||||
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
|
||||
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 [cachedProjects, setCachedProjects] = useState<any[]>([]);
|
||||
const [, setCachedProjects] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMetrics = async () => {
|
||||
|
|
@ -38,10 +46,11 @@ const Tags: React.FC = () => {
|
|||
|
||||
try {
|
||||
// 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/tasks'),
|
||||
fetch('/api/notes')
|
||||
fetch('/api/notes'),
|
||||
]);
|
||||
|
||||
let allProjects: any[] = [];
|
||||
|
|
@ -65,31 +74,46 @@ const Tags: React.FC = () => {
|
|||
}
|
||||
|
||||
// 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 => {
|
||||
const tasksCount = allTasks.filter((task: any) =>
|
||||
task.tags && task.tags.some((taskTag: any) => taskTag.name === tag.name)
|
||||
tags.forEach((tag) => {
|
||||
const tasksCount = allTasks.filter(
|
||||
(task: any) =>
|
||||
task.tags &&
|
||||
task.tags.some(
|
||||
(taskTag: any) => taskTag.name === tag.name
|
||||
)
|
||||
).length;
|
||||
|
||||
const notesCount = allNotes.filter((note: any) =>
|
||||
note.tags && note.tags.some((noteTag: any) => noteTag.name === tag.name)
|
||||
const notesCount = allNotes.filter(
|
||||
(note: any) =>
|
||||
note.tags &&
|
||||
note.tags.some(
|
||||
(noteTag: any) => noteTag.name === tag.name
|
||||
)
|
||||
).length;
|
||||
|
||||
const projectsCount = allProjects.filter((project: any) =>
|
||||
project.tags && project.tags.some((projectTag: any) => projectTag.name === tag.name)
|
||||
const projectsCount = allProjects.filter(
|
||||
(project: any) =>
|
||||
project.tags &&
|
||||
project.tags.some(
|
||||
(projectTag: any) =>
|
||||
projectTag.name === tag.name
|
||||
)
|
||||
).length;
|
||||
|
||||
metricsMap[tag.name] = {
|
||||
tasks: tasksCount,
|
||||
notes: notesCount,
|
||||
projects: projectsCount
|
||||
projects: projectsCount,
|
||||
};
|
||||
});
|
||||
|
||||
setTagMetrics(metricsMap);
|
||||
setMetricsLoaded(true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch metrics:', error);
|
||||
}
|
||||
|
|
@ -98,7 +122,6 @@ const Tags: React.FC = () => {
|
|||
loadMetrics();
|
||||
}, [tags]); // Only run when tags change
|
||||
|
||||
|
||||
const handleDeleteTag = async () => {
|
||||
if (!tagToDelete) return;
|
||||
try {
|
||||
|
|
@ -117,7 +140,6 @@ const Tags: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setSelectedTag(tag);
|
||||
setIsTagModalOpen(true);
|
||||
|
|
@ -127,7 +149,9 @@ const Tags: React.FC = () => {
|
|||
try {
|
||||
if (tagData.id) {
|
||||
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 {
|
||||
const newTag = await createTag(tagData);
|
||||
setTags([...tags, newTag]);
|
||||
|
|
@ -154,19 +178,24 @@ const Tags: React.FC = () => {
|
|||
);
|
||||
|
||||
// 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();
|
||||
if (!groups[firstLetter]) {
|
||||
groups[firstLetter] = [];
|
||||
}
|
||||
groups[firstLetter].push(tag);
|
||||
return groups;
|
||||
}, {} as Record<string, typeof tags>);
|
||||
},
|
||||
{} as Record<string, typeof tags>
|
||||
);
|
||||
|
||||
// Sort the groups by letter and sort tags within each group
|
||||
const sortedGroupKeys = Object.keys(groupedTags).sort();
|
||||
sortedGroupKeys.forEach(letter => {
|
||||
groupedTags[letter].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
sortedGroupKeys.forEach((letter) => {
|
||||
groupedTags[letter].sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -190,7 +219,9 @@ const Tags: React.FC = () => {
|
|||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
|
||||
|
|
@ -210,7 +241,9 @@ const Tags: React.FC = () => {
|
|||
|
||||
{/* Tags List */}
|
||||
{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">
|
||||
{sortedGroupKeys.map((letter) => (
|
||||
|
|
@ -226,15 +259,30 @@ const Tags: React.FC = () => {
|
|||
{/* Tags in this group */}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{groupedTags[letter].map((tag) => {
|
||||
const metrics = tagMetrics[tag.name] || { tasks: 0, notes: 0, projects: 0 };
|
||||
const hasItems = metrics.tasks > 0 || metrics.notes > 0 || metrics.projects > 0;
|
||||
const metrics = tagMetrics[
|
||||
tag.name
|
||||
] || {
|
||||
tasks: 0,
|
||||
notes: 0,
|
||||
projects: 0,
|
||||
};
|
||||
const hasItems =
|
||||
metrics.tasks > 0 ||
|
||||
metrics.notes > 0 ||
|
||||
metrics.projects > 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={tag.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4"
|
||||
onMouseEnter={() => setHoveredTagId(tag.id || null)}
|
||||
onMouseLeave={() => setHoveredTagId(null)}
|
||||
onMouseEnter={() =>
|
||||
setHoveredTagId(
|
||||
tag.id || null
|
||||
)
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
setHoveredTagId(null)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* 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>
|
||||
)}
|
||||
{metricsLoaded && hasItems && (
|
||||
{metricsLoaded &&
|
||||
hasItems && (
|
||||
<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">
|
||||
<FolderIcon className="h-4 w-4 text-purple-500" />
|
||||
<span>{metrics.projects}</span>
|
||||
<span>
|
||||
{
|
||||
metrics.projects
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{metrics.tasks > 0 && (
|
||||
{metrics.tasks >
|
||||
0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<CheckIcon className="h-4 w-4 text-blue-500" />
|
||||
<span>{metrics.tasks}</span>
|
||||
<span>
|
||||
{
|
||||
metrics.tasks
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{metrics.notes > 0 && (
|
||||
{metrics.notes >
|
||||
0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<BookOpenIcon className="h-4 w-4 text-green-500" />
|
||||
<span>{metrics.notes}</span>
|
||||
<span>
|
||||
{
|
||||
metrics.notes
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -279,7 +343,11 @@ const Tags: React.FC = () => {
|
|||
{/* Action buttons */}
|
||||
<div className="flex space-x-2 ml-2">
|
||||
<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'}`}
|
||||
aria-label={`Edit ${tag.name}`}
|
||||
title={`Edit ${tag.name}`}
|
||||
|
|
@ -287,7 +355,11 @@ const Tags: React.FC = () => {
|
|||
<PencilSquareIcon className="h-4 w-4" />
|
||||
</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'}`}
|
||||
aria-label={`Delete ${tag.name}`}
|
||||
title={`Delete ${tag.name}`}
|
||||
|
|
@ -320,7 +392,9 @@ const Tags: React.FC = () => {
|
|||
setTags(tags.filter((tag) => tag.id !== tagId));
|
||||
setTagMetrics((prev) => {
|
||||
const newMetrics = { ...prev };
|
||||
const deletedTag = tags.find(t => t.id === tagId);
|
||||
const deletedTag = tags.find(
|
||||
(t) => t.id === tagId
|
||||
);
|
||||
if (deletedTag) {
|
||||
delete newMetrics[deletedTag.name];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ interface NewTaskProps {
|
|||
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
||||
const [taskName, setTaskName] = useState<string>('');
|
||||
const [showNameLengthHelper, setShowNameLengthHelper] = useState(false);
|
||||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
|
||||
useState(true);
|
||||
const { showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Fetch task intelligence setting when component mounts
|
||||
|
|
@ -22,7 +23,10 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
const enabled = await getTaskIntelligenceEnabled();
|
||||
setTaskIntelligenceEnabled(enabled);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task intelligence setting:', error);
|
||||
console.error(
|
||||
'Error fetching task intelligence setting:',
|
||||
error
|
||||
);
|
||||
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)
|
||||
if (taskIntelligenceEnabled) {
|
||||
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()) {
|
||||
const taskText = taskName.trim();
|
||||
setTaskName('');
|
||||
|
|
@ -53,7 +61,9 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
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}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-medium text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none"
|
||||
placeholder={t('tasks.addNewTask', 'Προσθήκη Νέας Εργασίας')}
|
||||
placeholder={t(
|
||||
'tasks.addNewTask',
|
||||
'Προσθήκη Νέας Εργασίας'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{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="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg 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
|
||||
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>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
metrics,
|
||||
projects,
|
||||
onTaskUpdate,
|
||||
onClose
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
|
|
@ -39,7 +39,6 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Helper function to check if task is not started
|
||||
const isNotStarted = (task: Task) => {
|
||||
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)
|
||||
// 2. Due today tasks (time-based urgency)
|
||||
// 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 suggestedAvailable = metrics.suggested_tasks.filter(isNotStarted);
|
||||
|
||||
// Combine all available tasks with priority (intelligent selection)
|
||||
const allAvailableTasks = [
|
||||
...todayPlanAvailable.map(task => ({ task, source: 'today_plan' })),
|
||||
...dueTodayAvailable.map(task => ({ task, source: 'due_today' })),
|
||||
...suggestedAvailable.map(task => ({ task, source: 'suggested' }))
|
||||
...todayPlanAvailable.map((task) => ({ task, source: 'today_plan' })),
|
||||
...dueTodayAvailable.map((task) => ({ task, source: 'due_today' })),
|
||||
...suggestedAvailable.map((task) => ({ task, source: 'suggested' })),
|
||||
];
|
||||
|
||||
if (allAvailableTasks.length === 0) {
|
||||
|
|
@ -65,7 +66,8 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
}
|
||||
|
||||
// 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 suggestionSource = currentTaskData.source;
|
||||
|
||||
|
|
@ -75,7 +77,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
return task.Project.name;
|
||||
}
|
||||
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 null;
|
||||
|
|
@ -90,10 +92,12 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
const updatedTask = {
|
||||
...suggestedTask,
|
||||
status: 'in_progress' as const,
|
||||
today: true
|
||||
today: true,
|
||||
};
|
||||
await onTaskUpdate(updatedTask);
|
||||
showSuccessToast(t('task.startedSuccessfully', 'Task started successfully!'));
|
||||
showSuccessToast(
|
||||
t('task.startedSuccessfully', 'Task started successfully!')
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error starting task:', error);
|
||||
} finally {
|
||||
|
|
@ -102,7 +106,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
};
|
||||
|
||||
const handleGiveMeSomethingElse = () => {
|
||||
setCurrentTaskIndex(prev => prev + 1);
|
||||
setCurrentTaskIndex((prev) => prev + 1);
|
||||
};
|
||||
|
||||
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" />
|
||||
<div className="flex-1 pr-8">
|
||||
<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 === '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')}
|
||||
{suggestionSource === 'today_plan' &&
|
||||
t(
|
||||
'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>
|
||||
<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">
|
||||
|
|
@ -137,7 +153,10 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
)}
|
||||
{suggestedTask.due_date && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -150,8 +169,7 @@ const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
|||
<PlayIcon className="h-4 w-4 mr-2" />
|
||||
{isUpdating
|
||||
? t('nextTask.starting', 'Starting...')
|
||||
: t('nextTask.letsDoIt', "Yes, let's do it!")
|
||||
}
|
||||
: t('nextTask.letsDoIt', "Yes, let's do it!")}
|
||||
</button>
|
||||
{allAvailableTasks.length > 1 && (
|
||||
<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"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4 mr-2" />
|
||||
{t('nextTask.giveMeSomethingElse', 'Give me something else')}
|
||||
{t(
|
||||
'nextTask.giveMeSomethingElse',
|
||||
'Give me something else'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,11 +34,12 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
disabled = false,
|
||||
isChildTask = false,
|
||||
parentTaskLoading = false,
|
||||
onEditParent,
|
||||
onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onParentRecurrenceChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [editingParentRecurrence, setEditingParentRecurrence] = useState(false);
|
||||
const [editingParentRecurrence, setEditingParentRecurrence] =
|
||||
useState(false);
|
||||
|
||||
const weekdays = [
|
||||
{ value: 0, label: t('weekdays.sunday', 'Sunday') },
|
||||
|
|
@ -63,30 +64,52 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
{ value: 'daily', label: t('recurrence.daily', 'Daily') },
|
||||
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly') },
|
||||
{ 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">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceType', 'Repeat')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceType}
|
||||
onChange={(value) => (customOnChange || onChange)('recurrence_type', value as RecurrenceType)}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_type',
|
||||
value as RecurrenceType
|
||||
)
|
||||
}
|
||||
options={recurrenceTypeOptions}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</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
|
||||
const getMaxValue = () => {
|
||||
if (recurrenceType === 'daily') return 30;
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -99,26 +122,39 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
<div className="w-20">
|
||||
<NumberSelectDropdown
|
||||
value={recurrenceInterval || 1}
|
||||
onChange={(value) => (customOnChange || onChange)('recurrence_interval', value)}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_interval',
|
||||
value
|
||||
)
|
||||
}
|
||||
min={1}
|
||||
max={getMaxValue()}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{recurrenceType === 'daily' && t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')}
|
||||
{recurrenceType === 'daily' &&
|
||||
t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' &&
|
||||
t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') &&
|
||||
t('recurrence.months', 'months')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderWeekdaySelect = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => {
|
||||
const renderWeekdaySelect = (
|
||||
customOnChange?: (field: string, value: any) => void,
|
||||
isDisabled?: boolean
|
||||
) => {
|
||||
const weekdayOptions = [
|
||||
{ value: '', label: t('recurrence.anyDay', 'Any day') },
|
||||
...weekdays
|
||||
...weekdays,
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -127,8 +163,15 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
{t('forms.task.labels.weekday', 'On day')}
|
||||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceWeekday !== undefined ? recurrenceWeekday : ''}
|
||||
onChange={(value) => (customOnChange || onChange)('recurrence_weekday', value !== '' ? parseInt(value as string) : null)}
|
||||
value={
|
||||
recurrenceWeekday !== undefined ? recurrenceWeekday : ''
|
||||
}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_weekday',
|
||||
value !== '' ? parseInt(value as string) : null
|
||||
)
|
||||
}
|
||||
options={weekdayOptions}
|
||||
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">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.monthDay', 'Day of month')}
|
||||
|
|
@ -146,8 +192,16 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
min="1"
|
||||
max="31"
|
||||
value={recurrenceMonthDay || ''}
|
||||
onChange={(e) => (customOnChange || onChange)('recurrence_month_day', e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder={t('recurrence.monthDayPlaceholder', 'Leave empty for current day')}
|
||||
onChange={(e) =>
|
||||
(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"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
|
|
@ -162,7 +216,12 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -172,22 +231,41 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceWeekday || 1}
|
||||
onChange={(value) => onChange('recurrence_weekday', parseInt(value as string))}
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
'recurrence_weekday',
|
||||
parseInt(value as string)
|
||||
)
|
||||
}
|
||||
options={weekdays}
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
<DatePicker
|
||||
value={recurrenceEndDate || ''}
|
||||
onChange={(value) => (customOnChange || onChange)('recurrence_end_date', value || null)}
|
||||
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')}
|
||||
onChange={(value) =>
|
||||
(customOnChange || onChange)(
|
||||
'recurrence_end_date',
|
||||
value || null
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
'forms.task.endDatePlaceholder',
|
||||
'Select end date'
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -198,8 +276,14 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
<ToggleSwitch
|
||||
checked={completionBased}
|
||||
onChange={(checked) => onChange('completion_based', checked)}
|
||||
label={t('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')}
|
||||
label={t(
|
||||
'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>
|
||||
);
|
||||
|
|
@ -228,39 +312,91 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>Recurring Task Instance</strong>
|
||||
<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>
|
||||
{onParentRecurrenceChange && (
|
||||
<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 ${
|
||||
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-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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={editingParentRecurrence ? '' : 'opacity-60 pointer-events-none'}>
|
||||
<div
|
||||
className={
|
||||
editingParentRecurrence
|
||||
? ''
|
||||
: 'opacity-60 pointer-events-none'
|
||||
}
|
||||
>
|
||||
{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="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's
|
||||
recurrence settings. Changes will affect all
|
||||
future instances of this recurring task.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{recurrenceType === 'none' ? renderRecurrenceTypeSelect(editingParentRecurrence ? onParentRecurrenceChange : undefined, !editingParentRecurrence) : (
|
||||
{recurrenceType === 'none' ? (
|
||||
renderRecurrenceTypeSelect(
|
||||
editingParentRecurrence
|
||||
? onParentRecurrenceChange
|
||||
: undefined,
|
||||
!editingParentRecurrence
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderRecurrenceTypeSelect(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)}
|
||||
{renderRecurrenceTypeSelect(
|
||||
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()}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -270,11 +406,7 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
}
|
||||
|
||||
if (recurrenceType === 'none') {
|
||||
return (
|
||||
<div className="pb-3">
|
||||
{renderRecurrenceTypeSelect()}
|
||||
</div>
|
||||
);
|
||||
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -291,13 +423,19 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
</label>
|
||||
<RecurrenceSelectDropdown
|
||||
value={recurrenceType}
|
||||
onChange={(value) => onChange('recurrence_type', value as RecurrenceType)}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_type', value as RecurrenceType)
|
||||
}
|
||||
options={recurrenceTypeOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.recurrenceInterval', 'Every')}
|
||||
|
|
@ -306,21 +444,35 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
<div className="w-20">
|
||||
<NumberSelectDropdown
|
||||
value={recurrenceInterval || 1}
|
||||
onChange={(value) => onChange('recurrence_interval', value)}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_interval', value)
|
||||
}
|
||||
min={1}
|
||||
max={
|
||||
recurrenceType === 'daily' ? 30 :
|
||||
recurrenceType === 'weekly' ? 52 :
|
||||
(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') ? 24 :
|
||||
99
|
||||
recurrenceType === 'daily'
|
||||
? 30
|
||||
: recurrenceType === 'weekly'
|
||||
? 52
|
||||
: recurrenceType === 'monthly' ||
|
||||
recurrenceType ===
|
||||
'monthly_weekday' ||
|
||||
recurrenceType ===
|
||||
'monthly_last_day'
|
||||
? 24
|
||||
: 99
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{recurrenceType === 'daily' && t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')}
|
||||
{recurrenceType === 'daily' &&
|
||||
t('recurrence.days', 'days')}
|
||||
{recurrenceType === 'weekly' &&
|
||||
t('recurrence.weeks', 'weeks')}
|
||||
{(recurrenceType === 'monthly' ||
|
||||
recurrenceType === 'monthly_weekday' ||
|
||||
recurrenceType === 'monthly_last_day') &&
|
||||
t('recurrence.months', 'months')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -328,12 +480,20 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
|
||||
<div>
|
||||
<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>
|
||||
<DatePicker
|
||||
value={recurrenceEndDate || ''}
|
||||
onChange={(value) => onChange('recurrence_end_date', value || null)}
|
||||
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')}
|
||||
onChange={(value) =>
|
||||
onChange('recurrence_end_date', value || null)
|
||||
}
|
||||
placeholder={t(
|
||||
'forms.task.endDatePlaceholder',
|
||||
'Select end date'
|
||||
)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -344,7 +504,8 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
|
||||
{recurrenceType === 'monthly' && renderMonthDayInput()}
|
||||
|
||||
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()}
|
||||
{recurrenceType === 'monthly_weekday' &&
|
||||
renderMonthlyWeekdayInputs()}
|
||||
|
||||
{renderCompletionBasedToggle()}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ interface TaskActionsProps {
|
|||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onCancel }) => {
|
||||
const TaskActions: React.FC<TaskActionsProps> = ({
|
||||
taskId,
|
||||
onDelete,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
|
|||
const { t } = useTranslation();
|
||||
const getDueDateClass = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
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 === tomorrow) return 'border-blue-700 dark:text-white';
|
||||
|
|
@ -20,12 +22,18 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
|
|||
|
||||
const formatDueDate = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
|
||||
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW');
|
||||
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY');
|
||||
if (dueDate === tomorrow)
|
||||
return t('dateIndicators.tomorrow', 'TOMORROW');
|
||||
if (dueDate === yesterday)
|
||||
return t('dateIndicators.yesterday', 'YESTERDAY');
|
||||
|
||||
return new Date(dueDate).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
|
|
@ -35,7 +43,9 @@ const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
|
|||
};
|
||||
|
||||
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()}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface TaskContentSectionProps {
|
|||
const TaskContentSection: React.FC<TaskContentSectionProps> = ({
|
||||
taskId,
|
||||
value,
|
||||
onChange
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -22,7 +22,10 @@ const TaskContentSection: React.FC<TaskContentSectionProps> = ({
|
|||
value={value}
|
||||
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]"
|
||||
placeholder={t('forms.noteContentPlaceholder', 'Add task description...')}
|
||||
placeholder={t(
|
||||
'forms.noteContentPlaceholder',
|
||||
'Add task description...'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PriorityType, StatusType } from '../../../entities/Task';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import StatusDropdown from '../../Shared/StatusDropdown';
|
||||
import PriorityDropdown from '../../Shared/PriorityDropdown';
|
||||
import DatePicker from '../../Shared/DatePicker';
|
||||
|
|
@ -8,7 +9,7 @@ import DatePicker from '../../Shared/DatePicker';
|
|||
interface TaskMetadataSectionProps {
|
||||
priority: PriorityType;
|
||||
dueDate: string;
|
||||
taskId: number | undefined;
|
||||
taskId?: number;
|
||||
onStatusChange: (value: StatusType) => void;
|
||||
onPriorityChange: (value: PriorityType) => void;
|
||||
onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
|
|
@ -17,10 +18,10 @@ interface TaskMetadataSectionProps {
|
|||
const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
|
||||
priority,
|
||||
dueDate,
|
||||
taskId,
|
||||
onStatusChange,
|
||||
taskId, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onStatusChange, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onPriorityChange,
|
||||
onDueDateChange
|
||||
onDueDateChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -43,11 +44,14 @@ const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
|
|||
value={dueDate}
|
||||
onChange={(value) => {
|
||||
const event = {
|
||||
target: { name: 'due_date', value }
|
||||
target: { name: 'due_date', value },
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onDueDateChange(event);
|
||||
}}
|
||||
placeholder={t('forms.task.dueDatePlaceholder', 'Select due date')}
|
||||
placeholder={t(
|
||||
'forms.task.dueDatePlaceholder',
|
||||
'Select due date'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
|||
filteredProjects,
|
||||
onProjectSelection,
|
||||
onCreateProject,
|
||||
isCreatingProject
|
||||
isCreatingProject,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -27,7 +27,10 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
|||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
|
||||
placeholder={t(
|
||||
'forms.task.projectSearchPlaceholder',
|
||||
'Search or create a project...'
|
||||
)}
|
||||
value={newProjectName}
|
||||
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"
|
||||
|
|
@ -47,7 +50,10 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
|||
))
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{newProjectName && (
|
||||
|
|
@ -59,7 +65,8 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
|||
>
|
||||
{isCreatingProject
|
||||
? t('forms.task.creatingProject', 'Creating...')
|
||||
: t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`}
|
||||
: t('forms.task.createProject', '+ Create') +
|
||||
` "${newProjectName}"`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { RecurrenceType, Task } from '../../../entities/Task';
|
||||
import { Task } from '../../../entities/Task';
|
||||
import RecurrenceInput from '../RecurrenceInput';
|
||||
|
||||
interface TaskRecurrenceSectionProps {
|
||||
|
|
@ -17,23 +17,53 @@ const TaskRecurrenceSection: React.FC<TaskRecurrenceSectionProps> = ({
|
|||
parentTaskLoading,
|
||||
onRecurrenceChange,
|
||||
onEditParent,
|
||||
onParentRecurrenceChange
|
||||
onParentRecurrenceChange,
|
||||
}) => {
|
||||
return (
|
||||
<RecurrenceInput
|
||||
recurrenceType={parentTask ? (parentTask.recurrence_type || 'none') : (formData.recurrence_type || 'none')}
|
||||
recurrenceInterval={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)}
|
||||
recurrenceType={
|
||||
parentTask
|
||||
? parentTask.recurrence_type || 'none'
|
||||
: formData.recurrence_type || 'none'
|
||||
}
|
||||
recurrenceInterval={
|
||||
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}
|
||||
disabled={!!parentTask}
|
||||
isChildTask={!!parentTask}
|
||||
parentTaskLoading={parentTaskLoading}
|
||||
onEditParent={parentTask ? onEditParent : undefined}
|
||||
onParentRecurrenceChange={parentTask ? onParentRecurrenceChange : undefined}
|
||||
onParentRecurrenceChange={
|
||||
parentTask ? onParentRecurrenceChange : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TagInput from '../../Tag/TagInput';
|
||||
|
||||
interface TaskTagsSectionProps {
|
||||
|
|
@ -11,10 +10,8 @@ interface TaskTagsSectionProps {
|
|||
const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
availableTags
|
||||
availableTags,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
onTagsChange={onTagsChange}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
|
|||
value,
|
||||
onChange,
|
||||
taskAnalysis,
|
||||
taskIntelligenceEnabled
|
||||
taskIntelligenceEnabled,
|
||||
}) => {
|
||||
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"
|
||||
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
|
||||
/>
|
||||
{taskAnalysis && taskAnalysis.isVague && taskIntelligenceEnabled && (
|
||||
<div className={`mt-2 p-3 rounded-md border ${
|
||||
{taskAnalysis &&
|
||||
taskAnalysis.isVague &&
|
||||
taskIntelligenceEnabled && (
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-md border ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? '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'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<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'
|
||||
? 'text-red-400'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-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>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className={`text-sm ${
|
||||
<p
|
||||
className={`text-sm ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-800 dark:text-yellow-200'
|
||||
: 'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<strong>
|
||||
{taskAnalysis.reason === 'short' && t('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!')}
|
||||
{taskAnalysis.reason === 'short' &&
|
||||
t(
|
||||
'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>
|
||||
</p>
|
||||
{taskAnalysis.suggestion && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
: taskAnalysis.severity ===
|
||||
'medium'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: 'text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{t(taskAnalysis.suggestion, taskAnalysis.suggestion)}
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
taskAnalysis.suggestion,
|
||||
taskAnalysis.suggestion
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React from "react";
|
||||
import { CalendarDaysIcon, CalendarIcon, PlayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { TagIcon, FolderIcon } from "@heroicons/react/24/solid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TaskPriorityIcon from "./TaskPriorityIcon";
|
||||
import TaskTags from "./TaskTags";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { Task, StatusType } from "../../entities/Task";
|
||||
import React from 'react';
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CalendarIcon,
|
||||
PlayIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
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 {
|
||||
task: Task;
|
||||
|
|
@ -32,12 +36,18 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
|
||||
const formatDueDate = (dueDate: string) => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
|
||||
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW');
|
||||
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY');
|
||||
if (dueDate === tomorrow)
|
||||
return t('dateIndicators.tomorrow', 'TOMORROW');
|
||||
if (dueDate === yesterday)
|
||||
return t('dateIndicators.yesterday', 'YESTERDAY');
|
||||
|
||||
return new Date(dueDate).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
|
|
@ -76,14 +86,24 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
|
||||
const handlePlayToggle = async (e: React.MouseEvent) => {
|
||||
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 {
|
||||
const isCurrentlyInProgress = task.status === 'in_progress' || task.status === 1;
|
||||
const isCurrentlyInProgress =
|
||||
task.status === 'in_progress' || task.status === 1;
|
||||
const updatedTask = {
|
||||
...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
|
||||
today: isCurrentlyInProgress ? task.today : true
|
||||
today: isCurrentlyInProgress ? task.today : true,
|
||||
};
|
||||
await onTaskUpdate(updatedTask);
|
||||
} catch (error) {
|
||||
|
|
@ -97,7 +117,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{/* Full view (md and larger) */}
|
||||
<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">
|
||||
<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 items-center">
|
||||
<span className="text-md text-gray-900 dark:text-gray-100">
|
||||
|
|
@ -120,16 +144,25 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<span>{project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{project && !hideProjectName && task.tags && task.tags.length > 0 && (
|
||||
{project &&
|
||||
!hideProjectName &&
|
||||
task.tags &&
|
||||
task.tags.length > 0 && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
)}
|
||||
{((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>
|
||||
)}
|
||||
{task.due_date && (
|
||||
|
|
@ -138,32 +171,49 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<span>{formatDueDate(task.due_date)}</span>
|
||||
</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>
|
||||
)}
|
||||
{task.recurrence_type && task.recurrence_type !== 'none' && (
|
||||
{task.recurrence_type &&
|
||||
task.recurrence_type !== 'none' && (
|
||||
<div className="flex items-center">
|
||||
<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 className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
|
||||
|
||||
{/* Today Plan Controls */}
|
||||
{onToggleToday && (
|
||||
<button
|
||||
onClick={handleTodayToggle}
|
||||
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 ${
|
||||
task.today
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
|
||||
}`}
|
||||
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 ? (
|
||||
<CalendarDaysIcon className="h-3 w-3" />
|
||||
|
|
@ -179,15 +229,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
)}
|
||||
|
||||
{/* 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
|
||||
onClick={handlePlayToggle}
|
||||
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-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" />
|
||||
</button>
|
||||
|
|
@ -200,7 +265,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<div className="flex items-start">
|
||||
{/* Priority Icon - Centered vertically with entire card */}
|
||||
<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>
|
||||
|
||||
{/* All content - Task name and metadata */}
|
||||
|
|
@ -229,7 +298,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
)}
|
||||
{task.due_date && (
|
||||
|
|
@ -238,10 +311,15 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<span>{formatDueDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.recurrence_type && task.recurrence_type !== 'none' && (
|
||||
{task.recurrence_type &&
|
||||
task.recurrence_type !== 'none' && (
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-3 w-3 mr-1" />
|
||||
<span>{formatRecurrence(task.recurrence_type)}</span>
|
||||
<span>
|
||||
{formatRecurrence(
|
||||
task.recurrence_type
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -250,17 +328,31 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
|
||||
{/* Mobile badges row */}
|
||||
<div className="flex items-center flex-wrap justify-start space-x-2 mt-2 ml-7">
|
||||
|
||||
{/* 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
|
||||
onClick={handlePlayToggle}
|
||||
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-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" />
|
||||
</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-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 ? (
|
||||
<CalendarDaysIcon className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (taskId: number) => {
|
||||
const handleDelete = async () => {
|
||||
if (task.id) {
|
||||
await onTaskDelete(task.id);
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
const updatedTask = await toggleTaskCompletion(task.id);
|
||||
await onTaskUpdate(updatedTask);
|
||||
} catch (error) {
|
||||
console.error('Error toggling task completion:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -71,12 +72,14 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
setProjectList((prevProjects) => [...prevProjects, newProject]);
|
||||
return newProject;
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
const isInProgress = task.status === 'in_progress' || task.status === 1;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,33 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { PriorityType, StatusType, Task } from "../../entities/Task";
|
||||
import TaskActions from "./TaskActions";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import CollapsibleSection from "../Shared/CollapsibleSection";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import { Project } from "../../entities/Project";
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { PriorityType, StatusType, Task } from '../../entities/Task';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import TimelinePanel from './TimelinePanel';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
import { fetchTaskById } from '../../utils/tasksService';
|
||||
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
|
||||
import { analyzeTaskName, TaskAnalysis } from '../../utils/taskIntelligenceService';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClockIcon, TagIcon, FolderIcon, Cog6ToothIcon, ArrowPathIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
analyzeTaskName,
|
||||
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 TaskTitleSection from "./TaskForm/TaskTitleSection";
|
||||
import TaskContentSection from "./TaskForm/TaskContentSection";
|
||||
import TaskTagsSection from "./TaskForm/TaskTagsSection";
|
||||
import TaskProjectSection from "./TaskForm/TaskProjectSection";
|
||||
import TaskMetadataSection from "./TaskForm/TaskMetadataSection";
|
||||
import TaskRecurrenceSection from "./TaskForm/TaskRecurrenceSection";
|
||||
import TaskTitleSection from './TaskForm/TaskTitleSection';
|
||||
import TaskContentSection from './TaskForm/TaskContentSection';
|
||||
import TaskTagsSection from './TaskForm/TaskTagsSection';
|
||||
import TaskProjectSection from './TaskForm/TaskProjectSection';
|
||||
import TaskMetadataSection from './TaskForm/TaskMetadataSection';
|
||||
import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection';
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -43,20 +51,27 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
onEditParentTask,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Task>(task);
|
||||
const [tags, setTags] = useState<string[]>(task.tags?.map((tag) => tag.name) || []);
|
||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects || []);
|
||||
const [newProjectName, setNewProjectName] = useState<string>("");
|
||||
const [tags, setTags] = useState<string[]>(
|
||||
task.tags?.map((tag) => tag.name) || []
|
||||
);
|
||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>(
|
||||
projects || []
|
||||
);
|
||||
const [newProjectName, setNewProjectName] = useState<string>('');
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = 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 [parentTask, setParentTask] = useState<Task | null>(null);
|
||||
const [parentTaskLoading, setParentTaskLoading] = useState(false);
|
||||
const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null);
|
||||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true);
|
||||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
|
||||
useState(true);
|
||||
const [isTimelineExpanded, setIsTimelineExpanded] = useState(false);
|
||||
|
||||
// Collapsible section states
|
||||
|
|
@ -64,27 +79,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
tags: false,
|
||||
project: false,
|
||||
metadata: false,
|
||||
recurrence: false
|
||||
recurrence: false,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
|
||||
setExpandedSections(prev => {
|
||||
const toggleSection = useCallback(
|
||||
(section: keyof typeof expandedSections) => {
|
||||
setExpandedSections((prev) => {
|
||||
const newExpanded = {
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
[section]: !prev[section],
|
||||
};
|
||||
|
||||
// Auto-scroll to show the expanded section
|
||||
if (newExpanded[section]) {
|
||||
setTimeout(() => {
|
||||
const scrollContainer = document.querySelector('.absolute.inset-0.overflow-y-auto');
|
||||
const scrollContainer = document.querySelector(
|
||||
'.absolute.inset-0.overflow-y-auto'
|
||||
);
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, 100); // Small delay to ensure DOM is updated
|
||||
|
|
@ -92,7 +110,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
return newExpanded;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(task);
|
||||
|
|
@ -107,7 +127,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
}
|
||||
|
||||
// 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 : '');
|
||||
|
||||
// Fetch parent task if this is a child task
|
||||
|
|
@ -115,7 +137,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
if (task.recurring_parent_id && isOpen) {
|
||||
setParentTaskLoading(true);
|
||||
try {
|
||||
const parent = await fetchTaskById(task.recurring_parent_id);
|
||||
const parent = await fetchTaskById(
|
||||
task.recurring_parent_id
|
||||
);
|
||||
setParentTask(parent);
|
||||
} catch (error) {
|
||||
console.error('Error fetching parent task:', error);
|
||||
|
|
@ -139,7 +163,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const enabled = await getTaskIntelligenceEnabled();
|
||||
setTaskIntelligenceEnabled(enabled);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task intelligence setting:', error);
|
||||
console.error(
|
||||
'Error fetching task intelligence setting:',
|
||||
error
|
||||
);
|
||||
setTaskIntelligenceEnabled(true); // Default to enabled
|
||||
}
|
||||
}
|
||||
|
|
@ -161,7 +188,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
setParentTask({ ...parentTask, [field]: value });
|
||||
}
|
||||
// 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(() => {
|
||||
|
|
@ -170,11 +201,13 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
try {
|
||||
const fetchedTags = await fetchTags();
|
||||
// Ensure fetchedTags is always an array
|
||||
const safeTagsArray = Array.isArray(fetchedTags) ? fetchedTags : [];
|
||||
const safeTagsArray = Array.isArray(fetchedTags)
|
||||
? fetchedTags
|
||||
: [];
|
||||
setLocalAvailableTags(safeTagsArray);
|
||||
setTagsLoaded(true);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching tags:", error);
|
||||
console.error('Error fetching tags:', error);
|
||||
// Set empty array as fallback
|
||||
setLocalAvailableTags([]);
|
||||
setTagsLoaded(true); // Mark as loaded even on error to prevent retry loop
|
||||
|
|
@ -188,7 +221,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
}
|
||||
}, [isOpen, tagsLoaded]);
|
||||
|
||||
const getPriorityString = (priority: PriorityType | number | undefined): PriorityType => {
|
||||
const getPriorityString = (
|
||||
priority: PriorityType | number | undefined
|
||||
): PriorityType => {
|
||||
if (typeof priority === 'number') {
|
||||
const priorityNames: PriorityType[] = ['low', 'medium', 'high'];
|
||||
return priorityNames[priority] || 'medium';
|
||||
|
|
@ -196,9 +231,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
return priority || 'medium';
|
||||
};
|
||||
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
|
@ -240,7 +276,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (newProjectName.trim() !== "") {
|
||||
if (newProjectName.trim() !== '') {
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const newProject = await onCreateProject(newProjectName);
|
||||
|
|
@ -251,7 +287,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
showSuccessToast(t('success.projectCreated'));
|
||||
} catch (error) {
|
||||
showErrorToast(t('errors.projectCreationFailed'));
|
||||
console.error("Error creating project:", error);
|
||||
console.error('Error creating project:', error);
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
|
|
@ -262,7 +298,14 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) });
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
|
@ -279,7 +322,14 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
await onDelete(formData.id);
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
|
@ -310,22 +360,26 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const target = event.target as Element;
|
||||
|
||||
// Ignore clicks on dropdown menus rendered via portal
|
||||
if (target && (
|
||||
target.closest('.recurrence-dropdown-menu') ||
|
||||
if (
|
||||
target &&
|
||||
(target.closest('.recurrence-dropdown-menu') ||
|
||||
target.closest('.number-dropdown-menu') ||
|
||||
target.closest('.date-picker-menu') ||
|
||||
target.closest('[class*="fixed z-50"]') ||
|
||||
target.closest('[class*="z-50"]')
|
||||
)) {
|
||||
target.closest('[class*="z-50"]'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(event.target as Node)
|
||||
) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// Disable body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
|
|
@ -333,7 +387,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
// Clean up: re-enable body scroll
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
|
|
@ -341,15 +395,15 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
|
|
@ -359,23 +413,30 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<>
|
||||
<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 ${
|
||||
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
|
||||
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 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
} 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]">
|
||||
{/* 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' : ''
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<fieldset className="h-full flex flex-col">
|
||||
{/* Task Title Section - Always Visible */}
|
||||
|
|
@ -384,13 +445,15 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
taskAnalysis={taskAnalysis}
|
||||
taskIntelligenceEnabled={taskIntelligenceEnabled}
|
||||
taskIntelligenceEnabled={
|
||||
taskIntelligenceEnabled
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Content Section - Always Visible */}
|
||||
<TaskContentSection
|
||||
taskId={task.id}
|
||||
value={formData.note || ""}
|
||||
value={formData.note || ''}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
|
|
@ -398,12 +461,24 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
{expandedSections.tags && (
|
||||
<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">
|
||||
{t('forms.task.labels.tags', 'Tags')}
|
||||
{t(
|
||||
'forms.task.labels.tags',
|
||||
'Tags'
|
||||
)}
|
||||
</h3>
|
||||
<TaskTagsSection
|
||||
tags={formData.tags?.map((tag) => tag.name) || []}
|
||||
onTagsChange={handleTagsChange}
|
||||
availableTags={localAvailableTags}
|
||||
tags={
|
||||
formData.tags?.map(
|
||||
(tag) =>
|
||||
tag.name
|
||||
) || []
|
||||
}
|
||||
onTagsChange={
|
||||
handleTagsChange
|
||||
}
|
||||
availableTags={
|
||||
localAvailableTags
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -411,16 +486,33 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
{expandedSections.project && (
|
||||
<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">
|
||||
{t('forms.task.labels.project', 'Project')}
|
||||
{t(
|
||||
'forms.task.labels.project',
|
||||
'Project'
|
||||
)}
|
||||
</h3>
|
||||
<TaskProjectSection
|
||||
newProjectName={newProjectName}
|
||||
onProjectSearch={handleProjectSearch}
|
||||
dropdownOpen={dropdownOpen}
|
||||
filteredProjects={filteredProjects}
|
||||
onProjectSelection={handleProjectSelection}
|
||||
onCreateProject={handleCreateProject}
|
||||
isCreatingProject={isCreatingProject}
|
||||
newProjectName={
|
||||
newProjectName
|
||||
}
|
||||
onProjectSearch={
|
||||
handleProjectSearch
|
||||
}
|
||||
dropdownOpen={
|
||||
dropdownOpen
|
||||
}
|
||||
filteredProjects={
|
||||
filteredProjects
|
||||
}
|
||||
onProjectSelection={
|
||||
handleProjectSelection
|
||||
}
|
||||
onCreateProject={
|
||||
handleCreateProject
|
||||
}
|
||||
isCreatingProject={
|
||||
isCreatingProject
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -428,24 +520,51 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
{expandedSections.metadata && (
|
||||
<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">
|
||||
{t('forms.task.statusAndOptions', 'Status & Options')}
|
||||
{t(
|
||||
'forms.task.statusAndOptions',
|
||||
'Status & Options'
|
||||
)}
|
||||
</h3>
|
||||
<TaskMetadataSection
|
||||
priority={getPriorityString(formData.priority)}
|
||||
dueDate={formData.due_date || ""}
|
||||
priority={getPriorityString(
|
||||
formData.priority
|
||||
)}
|
||||
dueDate={
|
||||
formData.due_date ||
|
||||
''
|
||||
}
|
||||
taskId={task.id}
|
||||
onStatusChange={(value: StatusType) => {
|
||||
onStatusChange={(
|
||||
value: StatusType
|
||||
) => {
|
||||
// Universal rule: when setting status to in_progress, also add to today
|
||||
const updatedData = { ...formData, status: value };
|
||||
if (value === 'in_progress') {
|
||||
const updatedData =
|
||||
{
|
||||
...formData,
|
||||
status: value,
|
||||
};
|
||||
if (
|
||||
value ===
|
||||
'in_progress'
|
||||
) {
|
||||
updatedData.today = true;
|
||||
}
|
||||
setFormData(updatedData);
|
||||
setFormData(
|
||||
updatedData
|
||||
);
|
||||
}}
|
||||
onPriorityChange={(value: PriorityType) =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
onPriorityChange={(
|
||||
value: PriorityType
|
||||
) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
priority:
|
||||
value,
|
||||
})
|
||||
}
|
||||
onDueDateChange={
|
||||
handleChange
|
||||
}
|
||||
onDueDateChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -453,15 +572,32 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
{expandedSections.recurrence && (
|
||||
<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">
|
||||
{t('forms.task.recurrence', 'Recurrence')}
|
||||
{t(
|
||||
'forms.task.recurrence',
|
||||
'Recurrence'
|
||||
)}
|
||||
</h3>
|
||||
<TaskRecurrenceSection
|
||||
formData={formData}
|
||||
parentTask={parentTask}
|
||||
parentTaskLoading={parentTaskLoading}
|
||||
onRecurrenceChange={handleRecurrenceChange}
|
||||
onEditParent={parentTask ? handleEditParent : undefined}
|
||||
onParentRecurrenceChange={parentTask ? handleParentRecurrenceChange : undefined}
|
||||
parentTask={
|
||||
parentTask
|
||||
}
|
||||
parentTaskLoading={
|
||||
parentTaskLoading
|
||||
}
|
||||
onRecurrenceChange={
|
||||
handleRecurrenceChange
|
||||
}
|
||||
onEditParent={
|
||||
parentTask
|
||||
? handleEditParent
|
||||
: undefined
|
||||
}
|
||||
onParentRecurrenceChange={
|
||||
parentTask
|
||||
? handleParentRecurrenceChange
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -476,7 +612,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<TimelinePanel
|
||||
taskId={task.id}
|
||||
isExpanded={isTimelineExpanded}
|
||||
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)}
|
||||
onToggle={() =>
|
||||
setIsTimelineExpanded(
|
||||
!isTimelineExpanded
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -488,29 +628,41 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<div className="flex items-center space-x-1">
|
||||
{/* Tags Toggle */}
|
||||
<button
|
||||
onClick={() => toggleSection('tags')}
|
||||
onClick={() =>
|
||||
toggleSection('tags')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.tags
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.task.labels.tags', 'Tags')}
|
||||
title={t(
|
||||
'forms.task.labels.tags',
|
||||
'Tags'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Project Toggle */}
|
||||
<button
|
||||
onClick={() => toggleSection('project')}
|
||||
onClick={() =>
|
||||
toggleSection('project')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.project
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.task.labels.project', 'Project')}
|
||||
title={t(
|
||||
'forms.task.labels.project',
|
||||
'Project'
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="h-5 w-5" />
|
||||
{formData.project_id && (
|
||||
|
|
@ -520,32 +672,48 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
{/* Status & Options Toggle */}
|
||||
<button
|
||||
onClick={() => toggleSection('metadata')}
|
||||
onClick={() =>
|
||||
toggleSection('metadata')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.metadata
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.task.statusAndOptions', 'Status & Options')}
|
||||
title={t(
|
||||
'forms.task.statusAndOptions',
|
||||
'Status & Options'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Recurrence Toggle */}
|
||||
<button
|
||||
onClick={() => toggleSection('recurrence')}
|
||||
onClick={() =>
|
||||
toggleSection('recurrence')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.recurrence
|
||||
? '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'
|
||||
}`}
|
||||
title={t('forms.task.recurrence', 'Recurrence')}
|
||||
title={t(
|
||||
'forms.task.recurrence',
|
||||
'Recurrence'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -553,13 +721,25 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
{/* Right side: Timeline Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsTimelineExpanded(!isTimelineExpanded)}
|
||||
onClick={() =>
|
||||
setIsTimelineExpanded(
|
||||
!isTimelineExpanded
|
||||
)
|
||||
}
|
||||
className={`p-2 rounded-full transition-colors ${
|
||||
isTimelineExpanded
|
||||
? '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'
|
||||
}`}
|
||||
title={isTimelineExpanded ? t('timeline.hideActivityTimeline') : t('timeline.showActivityTimeline')}
|
||||
title={
|
||||
isTimelineExpanded
|
||||
? t(
|
||||
'timeline.hideActivityTimeline'
|
||||
)
|
||||
: t(
|
||||
'timeline.showActivityTimeline'
|
||||
)
|
||||
}
|
||||
>
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
|
@ -575,7 +755,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
type="button"
|
||||
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"
|
||||
title={t('common.delete', 'Delete')}
|
||||
title={t(
|
||||
'common.delete',
|
||||
'Delete'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -604,7 +787,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<TimelinePanel
|
||||
taskId={task.id}
|
||||
isExpanded={isTimelineExpanded}
|
||||
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)}
|
||||
onToggle={() =>
|
||||
setIsTimelineExpanded(!isTimelineExpanded)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -613,7 +798,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
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}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskPriorityIconProps {
|
||||
priority: string | number | undefined;
|
||||
|
|
@ -8,8 +7,11 @@ interface TaskPriorityIconProps {
|
|||
onToggleCompletion?: () => void;
|
||||
}
|
||||
|
||||
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status, onToggleCompletion }) => {
|
||||
const { t } = useTranslation();
|
||||
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
|
||||
priority,
|
||||
status,
|
||||
onToggleCompletion,
|
||||
}) => {
|
||||
const getIconColor = () => {
|
||||
if (status === 'done' || status === 2) return 'text-green-500';
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ interface TaskRecurrenceBadgeProps {
|
|||
recurrenceType: RecurrenceType;
|
||||
}
|
||||
|
||||
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({ recurrenceType }) => {
|
||||
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({
|
||||
recurrenceType,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!recurrenceType || recurrenceType === 'none') {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React from 'react';
|
||||
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
MinusIcon,
|
||||
CheckCircleIcon,
|
||||
ArchiveBoxIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { StatusType } from '../../entities/Task';
|
||||
|
||||
interface TaskStatusBadgeProps {
|
||||
|
|
@ -8,41 +12,42 @@ interface TaskStatusBadgeProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({
|
||||
status,
|
||||
className,
|
||||
}) => {
|
||||
// Convert numeric status to string
|
||||
const getStatusString = (status: StatusType | number): StatusType => {
|
||||
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 status;
|
||||
};
|
||||
|
||||
const statusString = getStatusString(status);
|
||||
let statusIcon, statusLabel;
|
||||
let statusIcon;
|
||||
|
||||
switch (statusString) {
|
||||
case 'not_started':
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = t('status.notStarted', 'Not Started');
|
||||
break;
|
||||
case 'in_progress':
|
||||
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
|
||||
statusLabel = t('status.inProgress', 'In Progress');
|
||||
break;
|
||||
case 'done':
|
||||
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
|
||||
statusLabel = t('status.done', 'Done');
|
||||
break;
|
||||
case 'archived':
|
||||
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = t('status.archived', 'Archived');
|
||||
break;
|
||||
default:
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = t('status.unknown', 'Unknown');
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ interface TaskTagsProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }) => {
|
||||
const TaskTags: React.FC<TaskTagsProps> = ({
|
||||
tags = [],
|
||||
onTagRemove,
|
||||
className,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleTagClick = (tag: Tag) => {
|
||||
|
|
@ -34,7 +38,9 @@ const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }
|
|||
className="flex items-center"
|
||||
>
|
||||
<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>
|
||||
{onTagRemove && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TaskEvent } from '../../entities/TaskEvent';
|
||||
import { getTaskTimeline, formatDuration, getEventTypeLabel, getStatusLabel, getPriorityLabel } from '../../utils/taskEventService';
|
||||
import {
|
||||
getTaskTimeline,
|
||||
getEventTypeLabel,
|
||||
getStatusLabel,
|
||||
getPriorityLabel,
|
||||
} from '../../utils/taskEventService';
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
|
|
@ -11,10 +16,9 @@ import {
|
|||
CalendarIcon,
|
||||
FolderIcon,
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
ArchiveBoxIcon,
|
||||
SparklesIcon,
|
||||
AdjustmentsHorizontalIcon
|
||||
AdjustmentsHorizontalIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface TaskTimelineProps {
|
||||
|
|
@ -53,24 +57,55 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
|
|||
}, [taskId]);
|
||||
|
||||
const getEventIcon = (eventType: string, newValue?: any) => {
|
||||
const iconClass = "h-3.5 w-3.5";
|
||||
const iconClass = 'h-3.5 w-3.5';
|
||||
|
||||
switch (eventType) {
|
||||
case 'created':
|
||||
return <SparklesIcon className={`${iconClass} text-blue-500`} />;
|
||||
return (
|
||||
<SparklesIcon className={`${iconClass} text-blue-500`} />
|
||||
);
|
||||
case 'status_changed':
|
||||
if (newValue?.status === 1) return <PlayIcon className={`${iconClass} text-yellow-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`} />;
|
||||
if (newValue?.status === 1)
|
||||
return (
|
||||
<PlayIcon className={`${iconClass} text-yellow-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':
|
||||
return <CheckCircleIcon className={`${iconClass} text-green-500`} />;
|
||||
return (
|
||||
<CheckCircleIcon
|
||||
className={`${iconClass} text-green-500`}
|
||||
/>
|
||||
);
|
||||
case 'priority_changed':
|
||||
return <ExclamationTriangleIcon className={`${iconClass} text-orange-500`} />;
|
||||
return (
|
||||
<ExclamationTriangleIcon
|
||||
className={`${iconClass} text-orange-500`}
|
||||
/>
|
||||
);
|
||||
case 'due_date_changed':
|
||||
return <CalendarIcon className={`${iconClass} text-purple-500`} />;
|
||||
return (
|
||||
<CalendarIcon className={`${iconClass} text-purple-500`} />
|
||||
);
|
||||
case 'project_changed':
|
||||
return <FolderIcon className={`${iconClass} text-indigo-500`} />;
|
||||
return (
|
||||
<FolderIcon className={`${iconClass} text-indigo-500`} />
|
||||
);
|
||||
case 'name_changed':
|
||||
case 'description_changed':
|
||||
case 'note_changed':
|
||||
|
|
@ -78,42 +113,49 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
|
|||
case 'tags_changed':
|
||||
return <TagIcon className={`${iconClass} text-pink-500`} />;
|
||||
case 'archived':
|
||||
return <ArchiveBoxIcon className={`${iconClass} text-gray-500`} />;
|
||||
return (
|
||||
<ArchiveBoxIcon className={`${iconClass} text-gray-500`} />
|
||||
);
|
||||
case 'today_changed':
|
||||
return <CalendarIcon className={`${iconClass} text-blue-600`} />;
|
||||
return (
|
||||
<CalendarIcon className={`${iconClass} text-blue-600`} />
|
||||
);
|
||||
default:
|
||||
return <ClockIcon className={`${iconClass} text-gray-400`} />;
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
case 'created':
|
||||
return t('timeline.events.taskCreated');
|
||||
case 'status_changed':
|
||||
case 'completed':
|
||||
case 'completed': {
|
||||
const oldStatus = old_value?.status;
|
||||
const newStatus = new_value?.status;
|
||||
if (oldStatus !== undefined && newStatus !== undefined) {
|
||||
return `${t('timeline.events.status')}: ${getStatusLabel(oldStatus)} → ${getStatusLabel(newStatus)}`;
|
||||
}
|
||||
return t('timeline.events.statusChanged');
|
||||
case 'priority_changed':
|
||||
}
|
||||
case 'priority_changed': {
|
||||
const oldPriority = old_value?.priority;
|
||||
const newPriority = new_value?.priority;
|
||||
if (oldPriority !== undefined && newPriority !== undefined) {
|
||||
return `${t('timeline.events.priority')}: ${getPriorityLabel(oldPriority)} → ${getPriorityLabel(newPriority)}`;
|
||||
}
|
||||
return t('timeline.events.priorityChanged');
|
||||
case 'due_date_changed':
|
||||
}
|
||||
case 'due_date_changed': {
|
||||
const oldDate = old_value?.due_date;
|
||||
const newDate = new_value?.due_date;
|
||||
if (oldDate || newDate) {
|
||||
return `${t('timeline.events.dueDate')}: ${oldDate || t('timeline.events.none')} → ${newDate || t('timeline.events.none')}`;
|
||||
}
|
||||
return t('timeline.events.dueDateChanged');
|
||||
}
|
||||
case 'name_changed':
|
||||
return t('timeline.events.nameUpdated');
|
||||
case 'description_changed':
|
||||
|
|
@ -171,7 +213,9 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
|
|||
return (
|
||||
<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" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -188,19 +232,31 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
|
|||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{events.map((event, index) => (
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="relative">
|
||||
{/* Event item */}
|
||||
<div className="flex items-start space-x-3 py-1 relative z-10">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center border-2 ${
|
||||
event.event_type === 'created' ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700' :
|
||||
event.event_type === 'completed' ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700' :
|
||||
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' :
|
||||
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
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center border-2 ${
|
||||
event.event_type === 'created'
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
: event.event_type === 'completed'
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700'
|
||||
: 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'
|
||||
: 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>
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -213,16 +269,23 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
{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
|
||||
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"
|
||||
>
|
||||
{tag.name || tag}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { Project } from "../../entities/Project";
|
||||
import TaskModal from "./TaskModal";
|
||||
import { fetchTaskByUuid, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { createProject } from "../../utils/projectsService";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import TaskModal from './TaskModal';
|
||||
import {
|
||||
fetchTaskByUuid,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
} from '../../utils/tasksService';
|
||||
import { createProject } from '../../utils/projectsService';
|
||||
import { useStore } from '../../store/useStore';
|
||||
|
||||
const TaskView: React.FC = () => {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
|
|
@ -18,7 +22,7 @@ const TaskView: React.FC = () => {
|
|||
useEffect(() => {
|
||||
const fetchTask = async () => {
|
||||
if (!uuid) {
|
||||
setError("No task UUID provided");
|
||||
setError('No task UUID provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -26,8 +30,8 @@ const TaskView: React.FC = () => {
|
|||
try {
|
||||
const taskData = await fetchTaskByUuid(uuid);
|
||||
setTask(taskData);
|
||||
} catch (err) {
|
||||
setError("An error occurred while fetching the task");
|
||||
} catch {
|
||||
setError('An error occurred while fetching the task');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -47,7 +51,7 @@ const TaskView: React.FC = () => {
|
|||
setTask(updated);
|
||||
}
|
||||
} 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);
|
||||
navigate('/today'); // Navigate back to today view after deletion
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
console.error('Error deleting task:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -65,7 +69,7 @@ const TaskView: React.FC = () => {
|
|||
try {
|
||||
return await createProject({ name });
|
||||
} catch (error) {
|
||||
console.error("Error creating project:", error);
|
||||
console.error('Error creating project:', 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="text-center">
|
||||
<div className="text-xl font-semibold text-red-600 dark:text-red-400 mb-4">
|
||||
{error || "Task not found"}
|
||||
{error || 'Task not found'}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
onClick={() => navigate('/')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Go Home
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { el, enUS, es, ja, uk, de } from "date-fns/locale";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import {
|
||||
ClipboardDocumentListIcon,
|
||||
ArrowPathIcon,
|
||||
|
|
@ -14,20 +14,23 @@ import {
|
|||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Cog6ToothIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { fetchProjects } from "../../utils/projectsService";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import TaskList from "./TaskList";
|
||||
import TodayPlan from "./TodayPlan";
|
||||
import { Metrics } from "../../entities/Metrics";
|
||||
import ProductivityAssistant from "../Productivity/ProductivityAssistant";
|
||||
import NextTaskSuggestion from "./NextTaskSuggestion";
|
||||
import WeeklyCompletionChart from "./WeeklyCompletionChart";
|
||||
import TodaySettingsDropdown from "./TodaySettingsDropdown";
|
||||
import { getProductivityAssistantEnabled, getNextTaskSuggestionEnabled } from "../../utils/profileService";
|
||||
import { toggleTaskToday } from "../../utils/tasksService";
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { fetchTasks, updateTask, deleteTask } from '../../utils/tasksService';
|
||||
import { fetchProjects } from '../../utils/projectsService';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import TaskList from './TaskList';
|
||||
import TodayPlan from './TodayPlan';
|
||||
import { Metrics } from '../../entities/Metrics';
|
||||
import ProductivityAssistant from '../Productivity/ProductivityAssistant';
|
||||
import NextTaskSuggestion from './NextTaskSuggestion';
|
||||
import WeeklyCompletionChart from './WeeklyCompletionChart';
|
||||
import TodaySettingsDropdown from './TodaySettingsDropdown';
|
||||
import {
|
||||
getProductivityAssistantEnabled,
|
||||
getNextTaskSuggestionEnabled,
|
||||
} from '../../utils/profileService';
|
||||
import { toggleTaskToday } from '../../utils/tasksService';
|
||||
|
||||
const getLocale = (language: string) => {
|
||||
switch (language) {
|
||||
|
|
@ -58,7 +61,8 @@ const TasksToday: React.FC = () => {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [dailyQuote, setDailyQuote] = useState<string>('');
|
||||
const [productivityAssistantEnabled, setProductivityAssistantEnabled] = useState(true);
|
||||
const [productivityAssistantEnabled, setProductivityAssistantEnabled] =
|
||||
useState(true);
|
||||
const [isSettingsDropdownOpen, setIsSettingsDropdownOpen] = useState(false);
|
||||
const [todaySettings, setTodaySettings] = useState({
|
||||
showMetrics: false,
|
||||
|
|
@ -69,7 +73,8 @@ const TasksToday: React.FC = () => {
|
|||
showProgressBar: true, // Always enabled
|
||||
showDailyQuote: true,
|
||||
});
|
||||
const [nextTaskSuggestionEnabled, setNextTaskSuggestionEnabled] = useState(true);
|
||||
const [nextTaskSuggestionEnabled, setNextTaskSuggestionEnabled] =
|
||||
useState(true);
|
||||
const [showNextTaskSuggestion, setShowNextTaskSuggestion] = useState(true);
|
||||
const [isSuggestedCollapsed, setIsSuggestedCollapsed] = useState(() => {
|
||||
const stored = localStorage.getItem('suggestedTasksCollapsed');
|
||||
|
|
@ -104,12 +109,15 @@ const TasksToday: React.FC = () => {
|
|||
difference: 0,
|
||||
percentage: 0,
|
||||
todayCount,
|
||||
averageCount: 0
|
||||
averageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
const averageCount = totalCompletedTasks / 7;
|
||||
|
|
@ -117,7 +125,9 @@ const TasksToday: React.FC = () => {
|
|||
// Calculate percentage change vs average
|
||||
let percentage = 0;
|
||||
if (averageCount > 0) {
|
||||
percentage = Math.round(((todayCount - averageCount) / averageCount) * 100);
|
||||
percentage = Math.round(
|
||||
((todayCount - averageCount) / averageCount) * 100
|
||||
);
|
||||
} else if (todayCount > 0) {
|
||||
// If average was 0 but today has completions, it's a 100%+ increase
|
||||
percentage = 100;
|
||||
|
|
@ -129,7 +139,7 @@ const TasksToday: React.FC = () => {
|
|||
difference: Math.round((todayCount - averageCount) * 10) / 10, // Round to 1 decimal
|
||||
percentage: Math.abs(percentage),
|
||||
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) {
|
||||
return {
|
||||
|
|
@ -137,7 +147,7 @@ const TasksToday: React.FC = () => {
|
|||
difference: Math.round((averageCount - todayCount) * 10) / 10, // Round to 1 decimal
|
||||
percentage: Math.abs(percentage),
|
||||
todayCount,
|
||||
averageCount: Math.round(averageCount * 10) / 10 // Round to 1 decimal
|
||||
averageCount: Math.round(averageCount * 10) / 10, // Round to 1 decimal
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
|
|
@ -145,7 +155,7 @@ const TasksToday: React.FC = () => {
|
|||
difference: 0,
|
||||
percentage: 0,
|
||||
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());
|
||||
};
|
||||
|
||||
|
||||
// Load data once on component mount
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
|
@ -190,7 +199,10 @@ const TasksToday: React.FC = () => {
|
|||
setProductivityAssistantEnabled(isEnabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load productivity assistant setting:", error);
|
||||
console.error(
|
||||
'Failed to load productivity assistant setting:',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -200,14 +212,19 @@ const TasksToday: React.FC = () => {
|
|||
setNextTaskSuggestionEnabled(isNextTaskEnabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load next task suggestion setting:", error);
|
||||
console.error(
|
||||
'Failed to load next task suggestion setting:',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load projects first
|
||||
const projectsData = await fetchProjects();
|
||||
if (isMounted.current) {
|
||||
const safeProjectsData = Array.isArray(projectsData) ? projectsData : [];
|
||||
const safeProjectsData = Array.isArray(projectsData)
|
||||
? projectsData
|
||||
: [];
|
||||
setLocalProjects(safeProjectsData);
|
||||
store.projectsStore.setProjects(safeProjectsData);
|
||||
}
|
||||
|
|
@ -221,7 +238,8 @@ const TasksToday: React.FC = () => {
|
|||
|
||||
try {
|
||||
// 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) {
|
||||
setLocalTasks(fetchedTasks);
|
||||
setMetrics(fetchedMetrics);
|
||||
|
|
@ -229,7 +247,7 @@ const TasksToday: React.FC = () => {
|
|||
store.tasksStore.setTasks(fetchedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch tasks:", error);
|
||||
console.error('Failed to fetch tasks:', error);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
|
|
@ -241,30 +259,46 @@ const TasksToday: React.FC = () => {
|
|||
|
||||
// Load daily quote from translations
|
||||
try {
|
||||
const response = await fetch(`/locales/${i18n.language}/quotes.json`);
|
||||
const response = await fetch(
|
||||
`/locales/${i18n.language}/quotes.json`
|
||||
);
|
||||
if (response.ok) {
|
||||
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
|
||||
const randomIndex = Math.floor(Math.random() * data.quotes.length);
|
||||
const randomIndex = Math.floor(
|
||||
Math.random() * data.quotes.length
|
||||
);
|
||||
setDailyQuote(data.quotes[randomIndex]);
|
||||
}
|
||||
} else {
|
||||
// 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) {
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
if (isMounted.current && fallbackData.quotes && fallbackData.quotes.length > 0) {
|
||||
const randomIndex = Math.floor(Math.random() * fallbackData.quotes.length);
|
||||
if (
|
||||
isMounted.current &&
|
||||
fallbackData.quotes &&
|
||||
fallbackData.quotes.length > 0
|
||||
) {
|
||||
const randomIndex = Math.floor(
|
||||
Math.random() * fallbackData.quotes.length
|
||||
);
|
||||
setDailyQuote(fallbackData.quotes[randomIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load daily quote:", error);
|
||||
console.error('Failed to load daily quote:', error);
|
||||
// Ultimate fallback
|
||||
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 (typeof userData.today_settings === 'string') {
|
||||
try {
|
||||
settings = JSON.parse(userData.today_settings);
|
||||
settings = JSON.parse(
|
||||
userData.today_settings
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error parsing today_settings:', error);
|
||||
console.error(
|
||||
'Error parsing today_settings:',
|
||||
error
|
||||
);
|
||||
settings = null;
|
||||
}
|
||||
} else {
|
||||
|
|
@ -309,7 +348,7 @@ const TasksToday: React.FC = () => {
|
|||
}
|
||||
}
|
||||
} 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
|
||||
|
||||
// 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;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await updateTask(updatedTask.id, updatedTask);
|
||||
// Refetch data to ensure consistency
|
||||
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||
const { tasks: updatedTasks, metrics } =
|
||||
await fetchTasks('?type=today');
|
||||
|
||||
if (isMounted.current) {
|
||||
setLocalTasks(updatedTasks);
|
||||
|
|
@ -338,7 +379,7 @@ const TasksToday: React.FC = () => {
|
|||
store.tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
console.error('Error updating task:', error);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
|
|
@ -347,16 +388,20 @@ const TasksToday: React.FC = () => {
|
|||
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;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
// Refetch data to ensure consistency
|
||||
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||
const { tasks: updatedTasks, metrics } =
|
||||
await fetchTasks('?type=today');
|
||||
|
||||
if (isMounted.current) {
|
||||
setLocalTasks(updatedTasks);
|
||||
|
|
@ -365,7 +410,7 @@ const TasksToday: React.FC = () => {
|
|||
store.tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
console.error('Error deleting task:', error);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
|
|
@ -374,15 +419,19 @@ const TasksToday: React.FC = () => {
|
|||
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;
|
||||
|
||||
try {
|
||||
await toggleTaskToday(taskId);
|
||||
// Refetch data to ensure consistency
|
||||
const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today");
|
||||
const { tasks: updatedTasks, metrics } =
|
||||
await fetchTasks('?type=today');
|
||||
|
||||
if (isMounted.current) {
|
||||
setLocalTasks(updatedTasks);
|
||||
|
|
@ -391,14 +440,14 @@ const TasksToday: React.FC = () => {
|
|||
store.tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling task today status:", error);
|
||||
console.error('Error toggling task today status:', error);
|
||||
if (isMounted.current) {
|
||||
setIsError(true);
|
||||
}
|
||||
}
|
||||
}, [store.tasksStore]);
|
||||
|
||||
|
||||
},
|
||||
[store.tasksStore]
|
||||
);
|
||||
|
||||
// Calculate today's progress for the progress bar
|
||||
const getTodayProgress = () => {
|
||||
|
|
@ -409,7 +458,10 @@ const TasksToday: React.FC = () => {
|
|||
return {
|
||||
completed: completedToday,
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -433,7 +487,9 @@ const TasksToday: React.FC = () => {
|
|||
if (isError && localTasks.length === 0) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<p className="text-red-500">{t('errors.somethingWentWrong', 'Something went wrong')}</p>
|
||||
<p className="text-red-500">
|
||||
{t('errors.somethingWentWrong', 'Something went wrong')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -451,7 +507,9 @@ const TasksToday: React.FC = () => {
|
|||
{t('tasks.today')}
|
||||
</h2>
|
||||
<span className="text-gray-500">
|
||||
{format(new Date(), "PPP", { locale: getLocale(i18n.language) })}
|
||||
{format(new Date(), 'PPP', {
|
||||
locale: getLocale(i18n.language),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -459,9 +517,16 @@ const TasksToday: React.FC = () => {
|
|||
<div className="flex items-end space-x-2">
|
||||
<div className="relative">
|
||||
<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"
|
||||
title={t('settings.todayPageSettings', 'Today Page Settings')}
|
||||
title={t(
|
||||
'settings.todayPageSettings',
|
||||
'Today Page Settings'
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
|
|
@ -471,7 +536,9 @@ const TasksToday: React.FC = () => {
|
|||
|
||||
<TodaySettingsDropdown
|
||||
isOpen={isSettingsDropdownOpen}
|
||||
onClose={() => setIsSettingsDropdownOpen(false)}
|
||||
onClose={() =>
|
||||
setIsSettingsDropdownOpen(false)
|
||||
}
|
||||
settings={todaySettings}
|
||||
onSettingsChange={handleSettingsChange}
|
||||
/>
|
||||
|
|
@ -480,16 +547,20 @@ const TasksToday: React.FC = () => {
|
|||
</div>
|
||||
|
||||
{/* Today Progress Bar - integrated with header */}
|
||||
{todaySettings.showProgressBar && todayProgress.total > 0 && (
|
||||
{todaySettings.showProgressBar &&
|
||||
todayProgress.total > 0 && (
|
||||
<div className="mb-1">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1">
|
||||
<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"
|
||||
style={{ width: `${todayProgress.percentage}%` }}
|
||||
style={{
|
||||
width: `${todayProgress.percentage}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
{/* Daily Quote */}
|
||||
{todaySettings.showDailyQuote && dailyQuote && (
|
||||
{todaySettings.showDailyQuote &&
|
||||
dailyQuote && (
|
||||
<div className="mt-2">
|
||||
<p className="text-s text-gray-400 dark:text-gray-500 font-light text-left">
|
||||
{dailyQuote}
|
||||
|
|
@ -506,12 +577,16 @@ const TasksToday: React.FC = () => {
|
|||
<div className="mb-2 grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Combined Task & Project Metrics */}
|
||||
<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="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
<p className="text-sm font-semibold">
|
||||
{metrics.total_open_tasks}
|
||||
|
|
@ -521,7 +596,9 @@ const TasksToday: React.FC = () => {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
<p className="text-sm font-semibold">
|
||||
{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">
|
||||
<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>
|
||||
<p className="text-sm font-semibold">
|
||||
{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">
|
||||
<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 className="flex items-center space-x-1">
|
||||
{(() => {
|
||||
const trend = getCompletionTrend();
|
||||
const getTooltipText = () => {
|
||||
if (trend.direction === 'same') {
|
||||
return t('dashboard.sameAsAverage', 'Same as average');
|
||||
} else if (trend.direction === 'up') {
|
||||
return t('dashboard.betterThanAverage', '{{percentage}}% more than average', { percentage: trend.percentage });
|
||||
if (
|
||||
trend.direction === 'same'
|
||||
) {
|
||||
return t(
|
||||
'dashboard.sameAsAverage',
|
||||
'Same as average'
|
||||
);
|
||||
} else if (
|
||||
trend.direction === 'up'
|
||||
) {
|
||||
return t(
|
||||
'dashboard.betterThanAverage',
|
||||
'{{percentage}}% more than average',
|
||||
{
|
||||
percentage:
|
||||
trend.percentage,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return t('dashboard.worseThanAverage', '{{percentage}}% less than average', { percentage: trend.percentage });
|
||||
return t(
|
||||
'dashboard.worseThanAverage',
|
||||
'{{percentage}}% less than average',
|
||||
{
|
||||
percentage:
|
||||
trend.percentage,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(trend.direction === 'up' || trend.direction === 'down') && (
|
||||
{(trend.direction ===
|
||||
'up' ||
|
||||
trend.direction ===
|
||||
'down') && (
|
||||
<div className="relative group">
|
||||
{trend.direction === 'up' && (
|
||||
{trend.direction ===
|
||||
'up' && (
|
||||
<ArrowUpIcon className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
{trend.direction === 'down' && (
|
||||
{trend.direction ===
|
||||
'down' && (
|
||||
<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">
|
||||
|
|
@ -572,7 +682,11 @@ const TasksToday: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
<p className="text-sm font-semibold">
|
||||
{metrics.tasks_completed_today.length}
|
||||
{
|
||||
metrics
|
||||
.tasks_completed_today
|
||||
.length
|
||||
}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
|
@ -583,10 +697,16 @@ const TasksToday: React.FC = () => {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -594,14 +714,20 @@ const TasksToday: React.FC = () => {
|
|||
|
||||
{/* Weekly Completion Chart */}
|
||||
<div className="lg:col-span-2">
|
||||
<WeeklyCompletionChart data={metrics.weekly_completions} />
|
||||
<WeeklyCompletionChart
|
||||
data={metrics.weekly_completions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Productivity Assistant - Conditionally Rendered */}
|
||||
{todaySettings.showProductivity && productivityAssistantEnabled && (
|
||||
<ProductivityAssistant tasks={localTasks} projects={localProjects} />
|
||||
{todaySettings.showProductivity &&
|
||||
productivityAssistantEnabled && (
|
||||
<ProductivityAssistant
|
||||
tasks={localTasks}
|
||||
projects={localProjects}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Today Plan */}
|
||||
|
|
@ -617,13 +743,18 @@ const TasksToday: React.FC = () => {
|
|||
{todaySettings.showIntelligence && (
|
||||
<div className="mt-2">
|
||||
{/* Next Task Suggestion */}
|
||||
{nextTaskSuggestionEnabled && showNextTaskSuggestion && (
|
||||
{nextTaskSuggestionEnabled &&
|
||||
showNextTaskSuggestion && (
|
||||
<NextTaskSuggestion
|
||||
metrics={{
|
||||
tasks_due_today: metrics.tasks_due_today,
|
||||
suggested_tasks: metrics.suggested_tasks,
|
||||
tasks_in_progress: metrics.tasks_in_progress,
|
||||
today_plan_tasks: metrics.today_plan_tasks
|
||||
tasks_due_today:
|
||||
metrics.tasks_due_today,
|
||||
suggested_tasks:
|
||||
metrics.suggested_tasks,
|
||||
tasks_in_progress:
|
||||
metrics.tasks_in_progress,
|
||||
today_plan_tasks:
|
||||
metrics.today_plan_tasks,
|
||||
}}
|
||||
projects={localProjects}
|
||||
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"
|
||||
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">
|
||||
<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 ? (
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
|
|
@ -663,9 +798,12 @@ const TasksToday: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* 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">
|
||||
<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
|
||||
tasks={metrics.tasks_due_today}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
|
|
@ -677,15 +815,20 @@ const TasksToday: React.FC = () => {
|
|||
)}
|
||||
|
||||
{/* 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="flex items-center justify-between cursor-pointer mt-6 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
|
||||
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">
|
||||
<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 ? (
|
||||
<ChevronRightIcon className="h-5 w-5 text-gray-500" />
|
||||
) : (
|
||||
|
|
@ -712,7 +855,6 @@ const TasksToday: React.FC = () => {
|
|||
{t('tasks.noTasksAvailable')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,17 +12,18 @@ interface TimelinePanelProps {
|
|||
const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
taskId,
|
||||
isExpanded,
|
||||
onToggle
|
||||
onToggle,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={`${
|
||||
<div
|
||||
className={`${
|
||||
isExpanded
|
||||
? 'w-full lg:w-80 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 */}
|
||||
{!isExpanded && (
|
||||
<div className="hidden lg:flex flex-col items-center justify-center h-full p-2">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
import TaskList from "./TaskList";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { Project } from "../../entities/Project";
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarDaysIcon } from '@heroicons/react/24/outline';
|
||||
import TaskList from './TaskList';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
interface TodayPlanProps {
|
||||
todayPlanTasks: Task[] | undefined;
|
||||
|
|
@ -31,10 +31,16 @@ const TodayPlan: React.FC<TodayPlanProps> = ({
|
|||
<div className="text-center py-8">
|
||||
<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">
|
||||
{t('tasks.noPlanToday', 'No tasks planned for today yet')}
|
||||
{t(
|
||||
'tasks.noPlanToday',
|
||||
'No tasks planned for today yet'
|
||||
)}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
|
|||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
|
@ -59,7 +62,7 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
|
|||
const handleToggle = (key: keyof typeof localSettings) => {
|
||||
const newSettings = {
|
||||
...localSettings,
|
||||
[key]: !localSettings[key]
|
||||
[key]: !localSettings[key],
|
||||
};
|
||||
setLocalSettings(newSettings);
|
||||
|
||||
|
|
@ -93,13 +96,45 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const settingsOptions: Array<{key: keyof typeof localSettings, label: string, icon: React.ElementType, disabled?: boolean}> = [
|
||||
{ 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 },
|
||||
const settingsOptions: Array<{
|
||||
key: keyof typeof localSettings;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
disabled?: boolean;
|
||||
}> = [
|
||||
{
|
||||
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 (
|
||||
|
|
@ -118,15 +153,22 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
|
|||
const isDisabled = option.disabled || isSaving;
|
||||
|
||||
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">
|
||||
<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}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => !isDisabled && handleToggle(option.key)}
|
||||
onClick={() =>
|
||||
!isDisabled && handleToggle(option.key)
|
||||
}
|
||||
disabled={isDisabled}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
localSettings[option.key]
|
||||
|
|
@ -136,7 +178,9 @@ const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
|
|||
>
|
||||
<span
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
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 { WeeklyCompletion } from '../../entities/Metrics';
|
||||
|
||||
|
|
@ -7,10 +14,12 @@ interface WeeklyCompletionChartProps {
|
|||
data: WeeklyCompletion[];
|
||||
}
|
||||
|
||||
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) => {
|
||||
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
|
|
@ -19,7 +28,10 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
|
|||
{data.dayName}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -34,7 +46,10 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
|
|||
</h3>
|
||||
<div className="h-40">
|
||||
<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
|
||||
dataKey="dayName"
|
||||
axisLine={false}
|
||||
|
|
@ -42,7 +57,7 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
|
|||
tick={{
|
||||
fontSize: 10,
|
||||
fill: 'currentColor',
|
||||
className: 'text-gray-600 dark:text-gray-400'
|
||||
className: 'text-gray-600 dark:text-gray-400',
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
|
|
@ -51,7 +66,7 @@ const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) =
|
|||
tick={{
|
||||
fontSize: 10,
|
||||
fill: 'currentColor',
|
||||
className: 'text-gray-600 dark:text-gray-400'
|
||||
className: 'text-gray-600 dark:text-gray-400',
|
||||
}}
|
||||
allowDecimals={false}
|
||||
width={25}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Project } from "../../entities/Project";
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
export const getDescription = (
|
||||
query: URLSearchParams,
|
||||
|
|
@ -8,30 +8,36 @@ export const getDescription = (
|
|||
try {
|
||||
// Default descriptions as fallbacks in case translation function fails
|
||||
const defaultDescriptions = {
|
||||
project: "Project tasks",
|
||||
today: "Tasks due today or scheduled for immediate attention",
|
||||
inbox: "Uncategorized tasks without project or due date",
|
||||
next: "Tasks that are actionable in the near future",
|
||||
upcoming: "Tasks scheduled for the upcoming week",
|
||||
someday: "Tasks without urgency or specific due date",
|
||||
project: 'Project tasks',
|
||||
today: 'Tasks due today or scheduled for immediate attention',
|
||||
inbox: 'Uncategorized tasks without project or due date',
|
||||
next: 'Tasks that are actionable in the near future',
|
||||
upcoming: 'Tasks scheduled for the upcoming week',
|
||||
someday: 'Tasks without urgency or specific due date',
|
||||
completed: "Tasks you've completed",
|
||||
allTasks: "All tasks from different projects and priorities"
|
||||
allTasks: 'All tasks from different projects and priorities',
|
||||
};
|
||||
|
||||
// Check for project_id first
|
||||
const projectId = query.get('project_id');
|
||||
if (projectId) {
|
||||
try {
|
||||
const project = projects.find((p) => p.id?.toString() === projectId);
|
||||
const project = projects.find(
|
||||
(p) => p.id?.toString() === projectId
|
||||
);
|
||||
if (project) {
|
||||
return t("taskViews.project.withName", { projectName: project.name });
|
||||
return t('taskViews.project.withName', {
|
||||
projectName: project.name,
|
||||
});
|
||||
} else {
|
||||
return t("taskViews.project.noName");
|
||||
return t('taskViews.project.noName');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Translation error for project description:", e);
|
||||
console.error('Translation error for project description:', e);
|
||||
// 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
|
||||
? `Tasks for project: ${project.name}`
|
||||
: defaultDescriptions.project;
|
||||
|
|
@ -41,38 +47,41 @@ export const getDescription = (
|
|||
// Then check for type and status parameters
|
||||
try {
|
||||
if (query.get('type') === 'today') {
|
||||
return t("taskViews.today");
|
||||
return t('taskViews.today');
|
||||
}
|
||||
if (query.get('type') === 'inbox') {
|
||||
return t("taskViews.inbox");
|
||||
return t('taskViews.inbox');
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return t("taskViews.next");
|
||||
return t('taskViews.next');
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return t("taskViews.upcoming");
|
||||
return t('taskViews.upcoming');
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return t("taskViews.someday");
|
||||
return t('taskViews.someday');
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return t("taskViews.completed");
|
||||
return t('taskViews.completed');
|
||||
}
|
||||
return t("taskViews.allTasks");
|
||||
return t('taskViews.allTasks');
|
||||
} 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
|
||||
if (query.get('type') === 'today') return defaultDescriptions.today;
|
||||
if (query.get('type') === 'inbox') return defaultDescriptions.inbox;
|
||||
if (query.get('type') === 'next') return defaultDescriptions.next;
|
||||
if (query.get('type') === 'upcoming') return defaultDescriptions.upcoming;
|
||||
if (query.get('type') === 'someday') return defaultDescriptions.someday;
|
||||
if (query.get('status') === 'done') return defaultDescriptions.completed;
|
||||
if (query.get('type') === 'upcoming')
|
||||
return defaultDescriptions.upcoming;
|
||||
if (query.get('type') === 'someday')
|
||||
return defaultDescriptions.someday;
|
||||
if (query.get('status') === 'done')
|
||||
return defaultDescriptions.completed;
|
||||
return defaultDescriptions.allTasks;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in getDescription:", error);
|
||||
return "Tasks overview";
|
||||
console.error('Error in getDescription:', error);
|
||||
return 'Tasks overview';
|
||||
}
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Project } from "../../entities/Project";
|
||||
import { Project } from '../../entities/Project';
|
||||
import {
|
||||
FolderIcon,
|
||||
CalendarIcon,
|
||||
|
|
@ -25,12 +25,17 @@ export const getTitleAndIcon = (
|
|||
upcoming: 'Upcoming',
|
||||
someday: 'Someday',
|
||||
completed: 'Completed',
|
||||
allTasks: 'All Tasks'
|
||||
allTasks: 'All Tasks',
|
||||
};
|
||||
const projectId = query.get('project_id');
|
||||
if (projectId) {
|
||||
const project = projects.find((p) => p.id?.toString() === projectId);
|
||||
return { title: project ? project.name : t('sidebar.projects'), icon: FolderIcon };
|
||||
const project = projects.find(
|
||||
(p) => p.id?.toString() === projectId
|
||||
);
|
||||
return {
|
||||
title: project ? project.name : t('sidebar.projects'),
|
||||
icon: FolderIcon,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -41,32 +46,47 @@ export const getTitleAndIcon = (
|
|||
return { title: t('sidebar.inbox'), icon: InboxIcon };
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return { title: t('sidebar.nextActions'), icon: ArrowRightIcon };
|
||||
return {
|
||||
title: t('sidebar.nextActions'),
|
||||
icon: ArrowRightIcon,
|
||||
};
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return { title: t('sidebar.upcoming'), icon: ClockIcon };
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return { title: t('taskViews.someday') || defaultTitles.someday, icon: MoonIcon };
|
||||
return {
|
||||
title: t('taskViews.someday') || defaultTitles.someday,
|
||||
icon: MoonIcon,
|
||||
};
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return { title: t('sidebar.completed'), icon: CheckCircleIcon };
|
||||
}
|
||||
return { title: t('sidebar.allTasks'), icon: Bars4Icon };
|
||||
} catch (e) {
|
||||
console.error("Translation error for task view title:", e);
|
||||
console.error('Translation error for task view title:', e);
|
||||
|
||||
// Return appropriate fallback based on type or status
|
||||
if (query.get('type') === 'today') return { title: defaultTitles.today, icon: CalendarIcon };
|
||||
if (query.get('type') === 'inbox') return { title: defaultTitles.inbox, icon: InboxIcon };
|
||||
if (query.get('type') === 'next') return { title: defaultTitles.next, icon: ArrowRightIcon };
|
||||
if (query.get('type') === 'upcoming') return { title: defaultTitles.upcoming, icon: ClockIcon };
|
||||
if (query.get('type') === 'someday') return { title: defaultTitles.someday, icon: MoonIcon };
|
||||
if (query.get('status') === 'done') return { title: defaultTitles.completed, icon: CheckCircleIcon };
|
||||
if (query.get('type') === 'today')
|
||||
return { title: defaultTitles.today, icon: CalendarIcon };
|
||||
if (query.get('type') === 'inbox')
|
||||
return { title: defaultTitles.inbox, icon: InboxIcon };
|
||||
if (query.get('type') === 'next')
|
||||
return { title: defaultTitles.next, icon: ArrowRightIcon };
|
||||
if (query.get('type') === 'upcoming')
|
||||
return { title: defaultTitles.upcoming, icon: ClockIcon };
|
||||
if (query.get('type') === 'someday')
|
||||
return { title: defaultTitles.someday, icon: MoonIcon };
|
||||
if (query.get('status') === 'done')
|
||||
return {
|
||||
title: defaultTitles.completed,
|
||||
icon: CheckCircleIcon,
|
||||
};
|
||||
return { title: defaultTitles.allTasks, icon: Bars4Icon };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in getTitleAndIcon:", error);
|
||||
return { title: "Tasks", icon: Bars4Icon };
|
||||
console.error('Error in getTitleAndIcon:', error);
|
||||
return { title: 'Tasks', icon: Bars4Icon };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TaskList from "./Task/TaskList";
|
||||
import NewTask from "./Task/NewTask";
|
||||
import { Task } from "../entities/Task";
|
||||
import { Project } from "../entities/Project";
|
||||
import { getTitleAndIcon } from "./Task/getTitleAndIcon";
|
||||
import { getDescription } from "./Task/getDescription";
|
||||
import { createTask, toggleTaskToday } from "../utils/tasksService";
|
||||
import { useToast } from "./Shared/ToastContext";
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TaskList from './Task/TaskList';
|
||||
import NewTask from './Task/NewTask';
|
||||
import { Task } from '../entities/Task';
|
||||
import { Project } from '../entities/Project';
|
||||
import { getTitleAndIcon } from './Task/getTitleAndIcon';
|
||||
import { getDescription } from './Task/getDescription';
|
||||
import { createTask, toggleTaskToday } from '../utils/tasksService';
|
||||
import { useToast } from './Shared/ToastContext';
|
||||
import {
|
||||
TagIcon,
|
||||
XMarkIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronDoubleDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
|
|
@ -27,7 +27,7 @@ const getSearchPlaceholder = (language: string): string => {
|
|||
es: 'Buscar tareas...',
|
||||
de: 'Aufgaben suchen...',
|
||||
jp: 'タスクを検索...',
|
||||
ua: 'Пошук завдань...'
|
||||
ua: 'Пошук завдань...',
|
||||
};
|
||||
|
||||
return placeholders[language] || 'Search tasks...';
|
||||
|
|
@ -41,8 +41,8 @@ const Tasks: React.FC = () => {
|
|||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
|
||||
const [orderBy, setOrderBy] = useState<string>("due_date:asc");
|
||||
const [taskSearchQuery, setTaskSearchQuery] = useState<string>("");
|
||||
const [orderBy, setOrderBy] = useState<string>('due_date:asc');
|
||||
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
|
|
@ -56,22 +56,25 @@ const Tasks: React.FC = () => {
|
|||
: getTitleAndIcon(query, projects, t);
|
||||
|
||||
const IconComponent =
|
||||
typeof icon === "string" ? React.createElement(icon) : icon;
|
||||
typeof icon === 'string' ? React.createElement(icon) : icon;
|
||||
|
||||
const tag = query.get("tag");
|
||||
const status = query.get("status");
|
||||
const tag = query.get('tag');
|
||||
const status = query.get('status');
|
||||
|
||||
useEffect(() => {
|
||||
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc";
|
||||
const savedOrderBy = localStorage.getItem('order_by') || 'due_date:asc';
|
||||
setOrderBy(savedOrderBy);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (!params.get("order_by")) {
|
||||
params.set("order_by", savedOrderBy);
|
||||
navigate({
|
||||
if (!params.get('order_by')) {
|
||||
params.set('order_by', savedOrderBy);
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
}, { replace: true });
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
|
|
@ -85,10 +88,10 @@ const Tasks: React.FC = () => {
|
|||
}
|
||||
};
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
|
|
@ -97,24 +100,26 @@ const Tasks: React.FC = () => {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tagId = query.get("tag");
|
||||
const tagId = query.get('tag');
|
||||
const [tasksResponse, projectsResponse] = await Promise.all([
|
||||
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`),
|
||||
fetch("/api/projects"),
|
||||
fetch(
|
||||
`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ''}`
|
||||
),
|
||||
fetch('/api/projects'),
|
||||
]);
|
||||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
setTasks(tasksData.tasks || []);
|
||||
} else {
|
||||
throw new Error("Failed to fetch tasks.");
|
||||
throw new Error('Failed to fetch tasks.');
|
||||
}
|
||||
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
setProjects(projectsData?.projects || []);
|
||||
} else {
|
||||
throw new Error("Failed to fetch projects.");
|
||||
throw new Error('Failed to fetch projects.');
|
||||
}
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
|
|
@ -128,7 +133,7 @@ const Tasks: React.FC = () => {
|
|||
|
||||
const handleRemoveTag = () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("tag");
|
||||
params.delete('tag');
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
|
|
@ -144,13 +149,20 @@ const Tasks: React.FC = () => {
|
|||
// Show success toast with task link
|
||||
const taskLink = (
|
||||
<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>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
} catch (error) {
|
||||
console.error("Error creating task:", error);
|
||||
setError("Error creating task.");
|
||||
console.error('Error creating task:', error);
|
||||
setError('Error creating task.');
|
||||
throw error; // Re-throw to allow proper error handling
|
||||
}
|
||||
};
|
||||
|
|
@ -158,8 +170,8 @@ const Tasks: React.FC = () => {
|
|||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${updatedTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedTask),
|
||||
});
|
||||
|
||||
|
|
@ -171,31 +183,33 @@ const Tasks: React.FC = () => {
|
|||
);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error("Failed to update task:", errorData.error);
|
||||
setError("Failed to update task.");
|
||||
console.error('Failed to update task:', errorData.error);
|
||||
setError('Failed to update task.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
setError("Error updating task.");
|
||||
console.error('Error updating task:', error);
|
||||
setError('Error updating task.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${taskId}`, {
|
||||
method: "DELETE",
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.filter((task) => task.id !== taskId)
|
||||
);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error("Failed to delete task:", errorData.error);
|
||||
setError("Failed to delete task.");
|
||||
console.error('Failed to delete task:', errorData.error);
|
||||
setError('Failed to delete task.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
setError("Error deleting task.");
|
||||
console.error('Error deleting task:', error);
|
||||
setError('Error deleting task.');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -204,10 +218,10 @@ const Tasks: React.FC = () => {
|
|||
await toggleTaskToday(taskId);
|
||||
// Refetch data to ensure consistency with all task relationships
|
||||
const params = new URLSearchParams(location.search);
|
||||
const type = params.get("type") || "all";
|
||||
const tag = params.get("tag");
|
||||
const project = params.get("project");
|
||||
const priority = params.get("priority");
|
||||
const type = params.get('type') || 'all';
|
||||
const tag = params.get('tag');
|
||||
const project = params.get('project');
|
||||
const priority = params.get('priority');
|
||||
|
||||
let apiPath = `/api/tasks?type=${type}&order_by=${orderBy}`;
|
||||
if (tag) apiPath += `&tag=${tag}`;
|
||||
|
|
@ -215,7 +229,7 @@ const Tasks: React.FC = () => {
|
|||
if (priority) apiPath += `&priority=${priority}`;
|
||||
|
||||
const response = await fetch(apiPath, {
|
||||
credentials: "include",
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
|
@ -223,27 +237,30 @@ const Tasks: React.FC = () => {
|
|||
setTasks(data.tasks || data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error toggling task today status:", error);
|
||||
setError("Error toggling task today status.");
|
||||
console.error('Error toggling task today status:', error);
|
||||
setError('Error toggling task today status.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (order: string) => {
|
||||
setOrderBy(order);
|
||||
localStorage.setItem("order_by", order);
|
||||
localStorage.setItem('order_by', order);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("order_by", order);
|
||||
navigate({
|
||||
params.set('order_by', order);
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
}, { replace: true });
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const description = getDescription(query, projects, t);
|
||||
|
||||
const isNewTaskAllowed = () => {
|
||||
return status !== "done";
|
||||
return status !== 'done';
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
|
|
@ -255,7 +272,9 @@ const Tasks: React.FC = () => {
|
|||
<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 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>
|
||||
|
||||
{tag && (
|
||||
|
|
@ -274,7 +293,10 @@ const Tasks: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||
<div
|
||||
className="relative inline-block text-left"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<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"
|
||||
|
|
@ -283,8 +305,13 @@ const Tasks: React.FC = () => {
|
|||
aria-haspopup="true"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{" "}
|
||||
{t(`sort.${orderBy.split(":")[0]}`, capitalize(orderBy.split(":")[0].replace("_", " ")))}
|
||||
<ChevronDoubleDownIcon className="h-5 w-5 text-gray-500 mr-2" />{' '}
|
||||
{t(
|
||||
`sort.${orderBy.split(':')[0]}`,
|
||||
capitalize(
|
||||
orderBy.split(':')[0].replace('_', ' ')
|
||||
)
|
||||
)}
|
||||
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
|
|
@ -295,21 +322,33 @@ const Tasks: React.FC = () => {
|
|||
aria-orientation="vertical"
|
||||
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",
|
||||
"name:asc",
|
||||
"priority:desc",
|
||||
"status:desc",
|
||||
"created_at:desc",
|
||||
'due_date:asc',
|
||||
'name:asc',
|
||||
'priority:desc',
|
||||
'status:desc',
|
||||
'created_at:desc',
|
||||
].map((order) => (
|
||||
<button
|
||||
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"
|
||||
role="menuitem"
|
||||
>
|
||||
{t(`sort.${order.split(":")[0]}`, capitalize(order.split(":")[0].replace("_", " ")))}
|
||||
{t(
|
||||
`sort.${order.split(':')[0]}`,
|
||||
capitalize(
|
||||
order
|
||||
.split(':')[0]
|
||||
.replace('_', ' ')
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -318,7 +357,6 @@ const Tasks: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
|
|
@ -345,7 +383,10 @@ const Tasks: React.FC = () => {
|
|||
{isNewTaskAllowed() && (
|
||||
<NewTask
|
||||
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">
|
||||
{t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')}
|
||||
{t(
|
||||
'tasks.noTasksAvailable',
|
||||
'Δεν υπάρχουν διαθέσιμες εργασίες.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -25,17 +25,19 @@ export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
|
|||
const [modalCount, setModalCount] = useState(0);
|
||||
|
||||
const openModal = () => {
|
||||
setModalCount(prev => prev + 1);
|
||||
setModalCount((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalCount(prev => Math.max(0, prev - 1));
|
||||
setModalCount((prev) => Math.max(0, prev - 1));
|
||||
};
|
||||
|
||||
const isAnyModalOpen = modalCount > 0;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ isAnyModalOpen, openModal, closeModal, modalCount }}>
|
||||
<ModalContext.Provider
|
||||
value={{ isAnyModalOpen, openModal, closeModal, modalCount }}
|
||||
>
|
||||
{children}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Task } from "./Task";
|
||||
import { Task } from './Task';
|
||||
|
||||
export interface WeeklyCompletion {
|
||||
date: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Tag } from "./Tag";
|
||||
import { Tag } from './Tag';
|
||||
|
||||
export interface Note {
|
||||
id?: number;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Area } from "./Area";
|
||||
import { Tag } from "./Tag";
|
||||
import { PriorityType, Task } from "./Task";
|
||||
import { Note } from "./Note";
|
||||
import { Area } from './Area';
|
||||
import { Tag } from './Tag';
|
||||
import { PriorityType, Task } from './Task';
|
||||
import { Note } from './Note';
|
||||
|
||||
export interface Project {
|
||||
id?: number;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Tag } from "./Tag";
|
||||
import { Project } from "./Project";
|
||||
import { Tag } from './Tag';
|
||||
import { Project } from './Project';
|
||||
|
||||
export interface Task {
|
||||
id?: number;
|
||||
|
|
@ -29,4 +29,10 @@ export interface Task {
|
|||
|
||||
export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived';
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -2,10 +2,22 @@ export interface TaskEvent {
|
|||
id: number;
|
||||
task_id: number;
|
||||
user_id: number;
|
||||
event_type: 'created' | 'status_changed' | 'priority_changed' | 'due_date_changed' |
|
||||
'project_changed' | 'name_changed' | 'description_changed' | 'note_changed' |
|
||||
'completed' | 'archived' | 'deleted' | 'restored' | 'today_changed' |
|
||||
'tags_changed' | 'recurrence_changed';
|
||||
event_type:
|
||||
| 'created'
|
||||
| 'status_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;
|
||||
new_value?: any;
|
||||
field_name?: string;
|
||||
|
|
|
|||
120
frontend/i18n.ts
120
frontend/i18n.ts
|
|
@ -24,18 +24,21 @@ const fallbackResources = {
|
|||
},
|
||||
};
|
||||
|
||||
const devResources = isDevelopment ? {
|
||||
const devResources = isDevelopment
|
||||
? {
|
||||
en: {
|
||||
translation: fallbackResources.en.translation,
|
||||
},
|
||||
} : undefined;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const i18nInstance = i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next);
|
||||
|
||||
i18nInstance.init({
|
||||
i18nInstance
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: isDevelopment,
|
||||
load: 'languageOnly',
|
||||
|
|
@ -47,7 +50,7 @@ i18nInstance.init({
|
|||
lookupQuerystring: 'lng',
|
||||
lookupCookie: 'i18next',
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage', 'cookie']
|
||||
caches: ['localStorage', 'cookie'],
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
|
|
@ -60,41 +63,69 @@ i18nInstance.init({
|
|||
requestOptions: {
|
||||
cache: 'default',
|
||||
credentials: 'same-origin',
|
||||
mode: 'cors'
|
||||
}
|
||||
mode: 'cors',
|
||||
},
|
||||
},
|
||||
})
|
||||
.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)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
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();
|
||||
})
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
.then((data) => {
|
||||
i18n.addResourceBundle(
|
||||
i18n.language,
|
||||
'translation',
|
||||
data,
|
||||
true,
|
||||
true
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isDevelopment) {
|
||||
try {
|
||||
setTimeout(() => {
|
||||
fetch(`/locales/${i18n.language}/translation.json`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
mode: 'cors'
|
||||
fetch(
|
||||
`/locales/${i18n.language}/translation.json`,
|
||||
{
|
||||
headers: { Accept: 'application/json' },
|
||||
mode: 'cors',
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
i18n.addResourceBundle(
|
||||
i18n.language,
|
||||
'translation',
|
||||
data,
|
||||
true,
|
||||
true
|
||||
);
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Error loading translation:',
|
||||
error
|
||||
);
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
console.error('Error in retry mechanism:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -105,7 +136,9 @@ i18n.on('failedLoading', () => {});
|
|||
i18n.on('missingKey', () => {});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
|
|
@ -128,20 +161,27 @@ i18n.on('languageChanged', (lng) => {
|
|||
: `/locales/${lng}/translation.json`;
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
return fetch(`/locales/${lng}/translation.json`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
i18n.addResourceBundle(lng, 'translation', data, true, true);
|
||||
i18n.addResourceBundle(
|
||||
lng,
|
||||
'translation',
|
||||
data,
|
||||
true,
|
||||
true
|
||||
);
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
console.error('Error loading translations:', error);
|
||||
handleTranslationsLoaded();
|
||||
});
|
||||
} else {
|
||||
|
|
@ -164,7 +204,8 @@ window.checkTranslation = (key: string) => {
|
|||
try {
|
||||
const translation = i18n.t(key);
|
||||
return translation;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error('Error checking translation:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -177,12 +218,23 @@ window.forceLanguageReload = (lng?: string) => {
|
|||
dispatchLanguageChangeEvent(targetLng);
|
||||
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
Object.values(i18n.services.resourceStore.data).forEach(lang => {
|
||||
if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) {
|
||||
const temp = {...lang.translation as Record<string, unknown>};
|
||||
Object.values(i18n.services.resourceStore.data).forEach(
|
||||
(lang) => {
|
||||
if (
|
||||
lang.translation &&
|
||||
typeof lang.translation === 'object' &&
|
||||
lang.translation !== null
|
||||
) {
|
||||
const temp = {
|
||||
...(lang.translation as Record<
|
||||
string,
|
||||
unknown
|
||||
>),
|
||||
};
|
||||
lang.translation = temp;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (lng) {
|
||||
|
|
@ -191,7 +243,9 @@ window.forceLanguageReload = (lng?: string) => {
|
|||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
console.error('Error reloading language:', error);
|
||||
});
|
||||
};
|
||||
|
||||
export default i18n;
|
||||
|
|
|
|||
|
|
@ -5,29 +5,31 @@ declare const module: {
|
|||
};
|
||||
};
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./components/Shared/ToastContext";
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { ToastProvider } from './components/Shared/ToastContext';
|
||||
import './i18n'; // Import i18n config to initialize it
|
||||
import './styles/markdown.css'; // Import markdown styles
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from './i18n'; // Import the i18n instance with its configuration
|
||||
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const storedPreference = localStorage.getItem('isDarkMode');
|
||||
const prefersDarkMode = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
const isDarkMode = storedPreference
|
||||
? storedPreference === "true"
|
||||
? storedPreference === 'true'
|
||||
: prefersDarkMode;
|
||||
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.documentElement.classList.add('dark');
|
||||
} 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
|
||||
let root: any;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { create } from "zustand";
|
||||
import { Project } from "../entities/Project";
|
||||
import { Area } from "../entities/Area";
|
||||
import { Note } from "../entities/Note";
|
||||
import { Task } from "../entities/Task";
|
||||
import { Tag } from "../entities/Tag";
|
||||
import { InboxItem } from "../entities/InboxItem";
|
||||
import { create } from 'zustand';
|
||||
import { Project } from '../entities/Project';
|
||||
import { Area } from '../entities/Area';
|
||||
import { Note } from '../entities/Note';
|
||||
import { Task } from '../entities/Task';
|
||||
import { Tag } from '../entities/Tag';
|
||||
import { InboxItem } from '../entities/InboxItem';
|
||||
|
||||
interface NotesStore {
|
||||
notes: Note[];
|
||||
|
|
@ -78,42 +78,78 @@ export const useStore = create<StoreState>((set) => ({
|
|||
notes: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setNotes: (notes) => set((state) => ({ notesStore: { ...state.notesStore, notes } })),
|
||||
setLoading: (isLoading) => set((state) => ({ notesStore: { ...state.notesStore, isLoading } })),
|
||||
setError: (isError) => set((state) => ({ notesStore: { ...state.notesStore, isError } })),
|
||||
setNotes: (notes) =>
|
||||
set((state) => ({ notesStore: { ...state.notesStore, notes } })),
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({
|
||||
notesStore: { ...state.notesStore, isLoading },
|
||||
})),
|
||||
setError: (isError) =>
|
||||
set((state) => ({ notesStore: { ...state.notesStore, isError } })),
|
||||
},
|
||||
areasStore: {
|
||||
areas: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setAreas: (areas) => set((state) => ({ areasStore: { ...state.areasStore, areas } })),
|
||||
setLoading: (isLoading) => set((state) => ({ areasStore: { ...state.areasStore, isLoading } })),
|
||||
setError: (isError) => set((state) => ({ areasStore: { ...state.areasStore, isError } })),
|
||||
setAreas: (areas) =>
|
||||
set((state) => ({ areasStore: { ...state.areasStore, areas } })),
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({
|
||||
areasStore: { ...state.areasStore, isLoading },
|
||||
})),
|
||||
setError: (isError) =>
|
||||
set((state) => ({ areasStore: { ...state.areasStore, isError } })),
|
||||
},
|
||||
projectsStore: {
|
||||
projects: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setProjects: (projects) => set((state) => ({ projectsStore: { ...state.projectsStore, projects } })),
|
||||
setLoading: (isLoading) => set((state) => ({ projectsStore: { ...state.projectsStore, isLoading } })),
|
||||
setError: (isError) => set((state) => ({ projectsStore: { ...state.projectsStore, isError } })),
|
||||
setProjects: (projects) =>
|
||||
set((state) => ({
|
||||
projectsStore: { ...state.projectsStore, projects },
|
||||
})),
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({
|
||||
projectsStore: { ...state.projectsStore, isLoading },
|
||||
})),
|
||||
setError: (isError) =>
|
||||
set((state) => ({
|
||||
projectsStore: { ...state.projectsStore, isError },
|
||||
})),
|
||||
},
|
||||
tagsStore: {
|
||||
tags: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
|
||||
setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
|
||||
setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
|
||||
setTags: (tags) =>
|
||||
set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
|
||||
setError: (isError) =>
|
||||
set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
|
||||
loadTags: async () => {
|
||||
const { fetchTags } = require("../utils/tagsService");
|
||||
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading: true, isError: false } }));
|
||||
const { fetchTags } = await import('../utils/tagsService');
|
||||
set((state) => ({
|
||||
tagsStore: {
|
||||
...state.tagsStore,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const tags = await fetchTags();
|
||||
set((state) => ({ tagsStore: { ...state.tagsStore, tags, isLoading: false } }));
|
||||
set((state) => ({
|
||||
tagsStore: { ...state.tagsStore, tags, isLoading: false },
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("loadTags: Failed to load tags:", error);
|
||||
set((state) => ({ tagsStore: { ...state.tagsStore, isError: true, isLoading: false } }));
|
||||
console.error('loadTags: Failed to load tags:', error);
|
||||
set((state) => ({
|
||||
tagsStore: {
|
||||
...state.tagsStore,
|
||||
isError: true,
|
||||
isLoading: false,
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -121,42 +157,55 @@ export const useStore = create<StoreState>((set) => ({
|
|||
tasks: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setTasks: (tasks) => set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })),
|
||||
setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })),
|
||||
setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
|
||||
setTasks: (tasks) =>
|
||||
set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })),
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({
|
||||
tasksStore: { ...state.tasksStore, isLoading },
|
||||
})),
|
||||
setError: (isError) =>
|
||||
set((state) => ({ tasksStore: { ...state.tasksStore, isError } })),
|
||||
},
|
||||
inboxStore: {
|
||||
inboxItems: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
setInboxItems: (inboxItems) => set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, inboxItems }
|
||||
setInboxItems: (inboxItems) =>
|
||||
set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, inboxItems },
|
||||
})),
|
||||
addInboxItem: (inboxItem) => set((state) => ({
|
||||
addInboxItem: (inboxItem) =>
|
||||
set((state) => ({
|
||||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: [...state.inboxStore.inboxItems, inboxItem]
|
||||
}
|
||||
inboxItems: [...state.inboxStore.inboxItems, inboxItem],
|
||||
},
|
||||
})),
|
||||
updateInboxItem: (inboxItem) => set((state) => ({
|
||||
updateInboxItem: (inboxItem) =>
|
||||
set((state) => ({
|
||||
inboxStore: {
|
||||
...state.inboxStore,
|
||||
inboxItems: state.inboxStore.inboxItems.map(item =>
|
||||
inboxItems: state.inboxStore.inboxItems.map((item) =>
|
||||
item.id === inboxItem.id ? inboxItem : item
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
})),
|
||||
removeInboxItem: (id) => set((state) => ({
|
||||
removeInboxItem: (id) =>
|
||||
set((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) => ({
|
||||
inboxStore: { ...state.inboxStore, isLoading }
|
||||
setLoading: (isLoading) =>
|
||||
set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, isLoading },
|
||||
})),
|
||||
setError: (isError) => set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, isError }
|
||||
setError: (isError) =>
|
||||
set((state) => ({
|
||||
inboxStore: { ...state.inboxStore, isError },
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { Area } from "../entities/Area";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
import { Area } from '../entities/Area';
|
||||
import { handleAuthResponse } from './authUtils';
|
||||
|
||||
export const fetchAreas = async (): Promise<Area[]> => {
|
||||
const response = await fetch("/api/areas?active=true", {
|
||||
const response = await fetch('/api/areas?active=true', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch areas.');
|
||||
|
|
@ -18,7 +18,7 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
|
|
@ -27,13 +27,16 @@ export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
|
|||
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}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
|
|
@ -47,7 +50,7 @@ export const deleteArea = async (areaId: number): Promise<void> => {
|
|||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export const getDefaultHeaders = (): Record<string, string> => {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'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;
|
||||
|
||||
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.status === 401) {
|
||||
if (window.location.pathname !== '/login' && !isRedirecting) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ export const getCurrentLocale = (): Locale => {
|
|||
* @param formatStr - The format string (https://date-fns.org/v2.29.3/docs/format)
|
||||
* @returns The formatted date string
|
||||
*/
|
||||
export const formatLocalizedDate = (date: Date | number, formatStr: string): string => {
|
||||
export const formatLocalizedDate = (
|
||||
date: Date | number,
|
||||
formatStr: string
|
||||
): string => {
|
||||
return format(date, formatStr, {
|
||||
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
|
||||
* @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}`);
|
||||
// If the translation key doesn't exist, it will return the key itself
|
||||
return pattern === `dateFormats.${formatKey}` ? fallback : pattern;
|
||||
|
|
@ -56,7 +62,10 @@ export const getDateFormatPattern = (formatKey: string, fallback: string): strin
|
|||
* @returns The formatted date 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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
* @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.today) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@ export const fetcher = async (url: string) => {
|
|||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
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).status = response.status;
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { InboxItem } from "../entities/InboxItem";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
import { InboxItem } from '../entities/InboxItem';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { handleAuthResponse } from './authUtils';
|
||||
|
||||
// API functions
|
||||
export const fetchInboxItems = async (): Promise<InboxItem[]> => {
|
||||
const response = await fetch('/api/inbox', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -22,13 +22,16 @@ export const fetchInboxItems = async (): Promise<InboxItem[]> => {
|
|||
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', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(source ? { content, source } : { content }),
|
||||
});
|
||||
|
|
@ -37,13 +40,16 @@ export const createInboxItem = async (content: string, source?: string): Promise
|
|||
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}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
|
@ -57,7 +63,7 @@ export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
|
|||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -70,7 +76,7 @@ export const deleteInboxItem = async (itemId: number): Promise<void> => {
|
|||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let lastCheckTimestamp = Date.now();
|
||||
|
||||
// Store-aware functions
|
||||
|
|
@ -92,11 +99,14 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
|
|||
const items = await fetchInboxItems();
|
||||
|
||||
// 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();
|
||||
|
||||
// New telegram items
|
||||
const newTelegramItems = items.filter(item =>
|
||||
const newTelegramItems = items.filter(
|
||||
(item) =>
|
||||
item.id &&
|
||||
!currentItemIds.has(item.id) &&
|
||||
item.source === 'telegram'
|
||||
|
|
@ -110,14 +120,17 @@ export const loadInboxItemsToStore = async (): Promise<void> => {
|
|||
// Get some minimal info about the items for the notification
|
||||
const notificationData = {
|
||||
count: newTelegramItems.length,
|
||||
firstItemContent: newTelegramItems[0].content.substring(0, 30) +
|
||||
(newTelegramItems[0].content.length > 30 ? '...' : '')
|
||||
firstItemContent:
|
||||
newTelegramItems[0].content.substring(0, 30) +
|
||||
(newTelegramItems[0].content.length > 30 ? '...' : ''),
|
||||
};
|
||||
|
||||
// Dispatch a custom event with the notification data
|
||||
window.dispatchEvent(new CustomEvent('inboxItemsUpdated', {
|
||||
detail: notificationData
|
||||
}));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('inboxItemsUpdated', {
|
||||
detail: notificationData,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { Note } from "../entities/Note";
|
||||
import { handleAuthResponse, getDefaultHeaders, getPostHeaders } from "./authUtils";
|
||||
import { Note } from '../entities/Note';
|
||||
import {
|
||||
handleAuthResponse,
|
||||
getDefaultHeaders,
|
||||
getPostHeaders,
|
||||
} from './authUtils';
|
||||
|
||||
export const fetchNotes = async (): Promise<Note[]> => {
|
||||
const response = await fetch("/api/notes", {
|
||||
const response = await fetch('/api/notes', {
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
|
|
@ -12,7 +16,6 @@ export const fetchNotes = async (): Promise<Note[]> => {
|
|||
};
|
||||
|
||||
export const createNote = async (noteData: Note): Promise<Note> => {
|
||||
try {
|
||||
const response = await fetch('/api/note', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
|
|
@ -22,12 +25,12 @@ export const createNote = async (noteData: Note): Promise<Note> => {
|
|||
|
||||
await handleAuthResponse(response, 'Failed to create note.');
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { handleAuthResponse } from "./authUtils";
|
||||
import { handleAuthResponse } from './authUtils';
|
||||
|
||||
interface Profile {
|
||||
id: number;
|
||||
|
|
@ -35,20 +35,22 @@ export const fetchProfile = async (): Promise<Profile> => {
|
|||
const response = await fetch('/api/profile', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch profile data.');
|
||||
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', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(profileData),
|
||||
});
|
||||
|
|
@ -60,7 +62,7 @@ export const fetchSchedulerStatus = async (): Promise<SchedulerStatus> => {
|
|||
const response = await fetch('/api/profile/task-summary/status', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch scheduler status.');
|
||||
|
|
@ -73,7 +75,7 @@ export const sendTaskSummaryNow = async (): Promise<any> => {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
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', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch polling status.');
|
||||
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', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bot_token: botToken,
|
||||
|
|
@ -114,7 +119,7 @@ export const startTelegramPolling = async (): Promise<any> => {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to start telegram polling.');
|
||||
|
|
@ -127,20 +132,23 @@ export const stopTelegramPolling = async (): Promise<any> => {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to stop telegram polling.');
|
||||
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}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: message }),
|
||||
});
|
||||
|
|
@ -154,24 +162,29 @@ export const toggleTaskSummary = async (): Promise<any> => {
|
|||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to toggle task summary.');
|
||||
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', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
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();
|
||||
};
|
||||
|
||||
|
|
@ -180,7 +193,9 @@ export type { Profile };
|
|||
export const getTaskIntelligenceEnabled = async (): Promise<boolean> => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Error fetching task intelligence setting:', error);
|
||||
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> => {
|
||||
try {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
};
|
||||
|
|
@ -200,7 +220,9 @@ export const getAutoSuggestNextActionsEnabled = async (): Promise<boolean> => {
|
|||
export const getProductivityAssistantEnabled = async (): Promise<boolean> => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Error fetching productivity assistant setting:', error);
|
||||
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> => {
|
||||
try {
|
||||
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) {
|
||||
console.error('Error fetching next task suggestion setting:', error);
|
||||
return true; // Default to enabled if we can't fetch the setting
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { Project } from "../entities/Project";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
import { Project } from '../entities/Project';
|
||||
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`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (activeFilter !== "all") params.append("active", activeFilter);
|
||||
if (areaFilter) params.append("area_id", areaFilter);
|
||||
if (activeFilter !== 'all') params.append('active', activeFilter);
|
||||
if (areaFilter) params.append('area_id', areaFilter);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
|
@ -20,16 +23,18 @@ export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Prom
|
|||
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`;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append("grouped", "true");
|
||||
if (activeFilter !== "all") params.append("active", activeFilter);
|
||||
if (areaFilter) params.append("area_id", areaFilter);
|
||||
params.append('grouped', 'true');
|
||||
if (activeFilter !== 'all') params.append('active', activeFilter);
|
||||
if (areaFilter) params.append('area_id', areaFilter);
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
|
|
@ -51,13 +56,15 @@ export const fetchProjectById = async (projectId: string): Promise<Project> => {
|
|||
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', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectData),
|
||||
});
|
||||
|
|
@ -66,13 +73,16 @@ export const createProject = async (projectData: Partial<Project>): Promise<Proj
|
|||
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}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectData),
|
||||
});
|
||||
|
|
@ -86,7 +96,7 @@ export const deleteProject = async (projectId: number): Promise<void> => {
|
|||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue