Lint frontend (#131)

* Add lint-fix npm target

* Sync eslint+plugins with backend

* Add prettier

* Ignore no-explicit-any lint rule for now

* Silence eslint react warning

* Format frontend via prettier

* Lint frontend.

---------

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

6
.prettierrc.json Normal file
View file

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

View file

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

View file

@ -1,199 +1,245 @@
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();
if (!i18n.isInitialized) {
return <LoadingScreen />;
}
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const { i18n } = useTranslation();
const fetchCurrentUser = async () => {
try {
const response = await fetch("/api/current_user", {
credentials: "include",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
if (response.status === 401) {
setCurrentUser(null);
return;
}
throw new Error(`Failed to fetch user: ${response.status}`);
}
const data = await response.json();
if (data.user) {
setCurrentUser(data.user);
} else {
setCurrentUser(null);
}
} catch (err) {
setCurrentUser(null);
} finally {
setLoading(false);
if (!i18n.isInitialized) {
return <LoadingScreen />;
}
};
useEffect(() => {
// Fetch user on mount
fetchCurrentUser();
}, []);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Listen for login events to update user state
useEffect(() => {
const handleUserLoggedIn = (event: CustomEvent) => {
const user = event.detail;
setCurrentUser(user);
};
window.addEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
return () => window.removeEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
}, []);
const fetchCurrentUser = async () => {
try {
const response = await fetch('/api/current_user', {
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
useEffect(() => {
if (i18n.isInitialized) {
fetch(`/locales/${i18n.language}/translation.json`)
.then(response => {
return response.json();
})
.then(data => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
})
.catch(error => {
console.error("Error manually fetching translation file:", error);
});
}
}, [i18n.isInitialized]);
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const storedPreference = localStorage.getItem("isDarkMode");
return storedPreference !== null
? storedPreference === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
});
const toggleDarkMode = () => {
const newValue = !isDarkMode;
setIsDarkMode(newValue);
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
};
useEffect(() => {
const updateTheme = () => {
document.documentElement.classList.toggle("dark", isDarkMode);
};
updateTheme();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const mediaListener = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("isDarkMode")) {
setIsDarkMode(e.matches);
}
};
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.')}
</div>
</div>
);
if (loading) {
return <LoadingComponent />;
}
return (
<Suspense fallback={<LoadingComponent />}>
<Routes>
{currentUser ? (
<>
<Route
element={
<Layout
currentUser={currentUser}
setCurrentUser={setCurrentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
>
<Outlet />
</Layout>
}
>
<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>}>
<Tasks />
</Suspense>
if (!response.ok) {
if (response.status === 401) {
setCurrentUser(null);
return;
}
/>
<Route path="/inbox" element={<InboxItems />} />
<Route path="/projects" element={<Projects />} />
<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="/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="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Route>
</>
) : (
<>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</>
)}
</Routes>
</Suspense>
);
throw new Error(`Failed to fetch user: ${response.status}`);
}
const data = await response.json();
if (data.user) {
setCurrentUser(data.user);
} else {
setCurrentUser(null);
}
} catch {
setCurrentUser(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
// Fetch user on mount
fetchCurrentUser();
}, []);
// Listen for login events to update user state
useEffect(() => {
const handleUserLoggedIn = (event: CustomEvent) => {
const user = event.detail;
setCurrentUser(user);
};
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) => {
return response.json();
})
.then((data) => {
i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
})
.catch((error) => {
console.error(
'Error manually fetching translation file:',
error
);
});
}
}, [i18n.isInitialized]);
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const storedPreference = localStorage.getItem('isDarkMode');
return storedPreference !== null
? storedPreference === 'true'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const toggleDarkMode = () => {
const newValue = !isDarkMode;
setIsDarkMode(newValue);
localStorage.setItem('isDarkMode', JSON.stringify(newValue));
};
useEffect(() => {
const updateTheme = () => {
document.documentElement.classList.toggle('dark', isDarkMode);
};
updateTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaListener = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('isDarkMode')) {
setIsDarkMode(e.matches);
}
};
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.'
)}
</div>
</div>
);
if (loading) {
return <LoadingComponent />;
}
return (
<Suspense fallback={<LoadingComponent />}>
<Routes>
{currentUser ? (
<>
<Route
element={
<Layout
currentUser={currentUser}
setCurrentUser={setCurrentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
>
<Outlet />
</Layout>
}
>
<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>
}
>
<Tasks />
</Suspense>
}
/>
<Route path="/inbox" element={<InboxItems />} />
<Route path="/projects" element={<Projects />} />
<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="/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="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Route>
</>
) : (
<>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={<Navigate to="/login" replace />}
/>
<Route
path="*"
element={<Navigate to="/login" replace />}
/>
</>
)}
</Routes>
</Suspense>
);
};
export default App;

File diff suppressed because it is too large Load diff

View file

@ -3,188 +3,242 @@ import { useTranslation } from 'react-i18next';
import { HeartIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
const About: React.FC = () => {
const { t } = useTranslation();
const [version, setVersion] = useState<string>("0.3");
const { t } = useTranslation();
const [version, setVersion] = useState<string>('0.3');
useEffect(() => {
// Fetch version from the deployed app
fetch('/api/version')
.then(response => response.json())
.then(data => {
if (data.version) {
setVersion(data.version);
}
})
.catch(error => {
console.error('Error fetching version:', error);
// Keep default version if fetch fails
});
}, []);
useEffect(() => {
// Fetch version from the deployed app
fetch('/api/version')
.then((response) => response.json())
.then((data) => {
if (data.version) {
setVersion(data.version);
}
})
.catch((error) => {
console.error('Error fetching version:', error);
// Keep default version if fetch fails
});
}, []);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
<div className="flex items-center mb-4">
<InformationCircleIcon className="h-6 w-6 mr-2" />
<h2 className="text-2xl font-light">{t('about.title', 'About')}</h2>
</div>
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
<div className="flex items-center mb-4">
<InformationCircleIcon className="h-6 w-6 mr-2" />
<h2 className="text-2xl font-light">
{t('about.title', 'About')}
</h2>
</div>
<div className="max-w-2xl mx-auto">
{/* Logo and Version */}
<div className="text-center mb-8">
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
tududi
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{t('about.version', 'Version')} {version}
</p>
</div>
<div className="max-w-2xl mx-auto">
{/* Logo and Version */}
<div className="text-center mb-8">
<h2 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
tududi
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">
{t('about.version', 'Version')} {version}
</p>
</div>
{/* 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.')}
</p>
</div>
{/* 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.'
)}
</p>
</div>
{/* Appreciation */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<HeartIcon className="h-6 w-6 text-red-500 mr-2" />
<span className="text-lg font-medium text-gray-900 dark:text-white">
{t('about.madeWithLove', 'Made with love')}
</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.')}
</p>
</div>
{/* Appreciation */}
<div className="text-center mb-8">
<div className="flex items-center justify-center mb-4">
<HeartIcon className="h-6 w-6 text-red-500 mr-2" />
<span className="text-lg font-medium text-gray-900 dark:text-white">
{t('about.madeWithLove', 'Made with love')}
</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.'
)}
</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')}
</h3>
<div className="grid grid-cols-2 gap-4">
<a
href="https://www.patreon.com/ChrisVeleris"
target="_blank"
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">
<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
</a>
<a
href="https://coff.ee/chrisveleris"
target="_blank"
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">
<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
</a>
<a
href="https://github.com/sponsors/chrisvel"
target="_blank"
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">
<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
</a>
<a
href="https://www.paypal.com/donate/?hosted_button_id=QEQCKLXPB6XAE"
target="_blank"
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">
<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
</a>
</div>
</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'
)}
</h3>
<div className="grid grid-cols-2 gap-4">
<a
href="https://www.patreon.com/ChrisVeleris"
target="_blank"
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"
>
<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
</a>
<a
href="https://coff.ee/chrisveleris"
target="_blank"
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"
>
<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
</a>
<a
href="https://github.com/sponsors/chrisvel"
target="_blank"
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"
>
<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
</a>
<a
href="https://www.paypal.com/donate/?hosted_button_id=QEQCKLXPB6XAE"
target="_blank"
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"
>
<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
</a>
</div>
</div>
{/* Community 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.community', 'Community')}
</h3>
<div className="grid grid-cols-2 gap-4">
<a
href="https://tududi.com"
target="_blank"
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">
<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
</a>
<a
href="https://reddit.com/r/tududi"
target="_blank"
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">
<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
</a>
<a
href="https://discord.gg/fkbeJ9CmcH"
target="_blank"
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">
<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
</a>
</div>
</div>
{/* Community 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.community', 'Community')}
</h3>
<div className="grid grid-cols-2 gap-4">
<a
href="https://tududi.com"
target="_blank"
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"
>
<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
</a>
<a
href="https://reddit.com/r/tududi"
target="_blank"
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"
>
<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
</a>
<a
href="https://discord.gg/fkbeJ9CmcH"
target="_blank"
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"
>
<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
</a>
</div>
</div>
{/* Links */}
<div className="text-center">
<div className="space-y-2">
<a
href="https://github.com/chrisvel/tududi"
target="_blank"
rel="noopener noreferrer"
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">
<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')}
</span>
</div>
</div>
{/* Links */}
<div className="text-center">
<div className="space-y-2">
<a
href="https://github.com/chrisvel/tududi"
target="_blank"
rel="noopener noreferrer"
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"
>
<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'
)}
</span>
</div>
</div>
{/* 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>
</p>
{/* 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>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
);
};
export default About;
export default About;

View file

@ -1,63 +1,65 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useStore } from '../../store/useStore';
import { useStore } from '../../store/useStore';
import { Area } from '../../entities/Area';
import { useTranslation } from 'react-i18next';
const AreaDetails: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const { areas } = useStore((state) => state.areasStore);
const [area, setArea] = useState<Area | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const { areas } = useStore((state) => state.areasStore);
const [area, setArea] = useState<Area | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
if (!areas.length) setIsLoading(true);
const foundArea = areas.find((a: Area) => a.id === Number(id));
setArea(foundArea || null);
if (!foundArea) {
setIsError(true);
useEffect(() => {
if (!areas.length) setIsLoading(true);
const foundArea = areas.find((a: Area) => a.id === Number(id));
setArea(foundArea || null);
if (!foundArea) {
setIsError(true);
}
setIsLoading(false);
}, [id, areas]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('areas.loading')}
</div>
</div>
);
}
if (isError || !area) {
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 ? t('areas.error') : t('areas.notFound')}
</div>
</div>
);
}
setIsLoading(false);
}, [id, areas]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('areas.loading')}
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-4 sm:p-6 lg:p-8">
<div className="max-w-5xl mx-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('areas.details')}: {area?.name}
</h2>
<p className="text-md text-gray-700 dark:text-gray-300">
{area?.description}
</p>
<Link
to={`/projects?area_id=${area?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"
>
{t('areas.viewProjects', { name: area?.name })}
</Link>
</div>
</div>
</div>
);
}
if (isError || !area) {
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 ? t('areas.error') : t('areas.notFound')}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-4 sm:p-6 lg:p-8">
<div className="max-w-5xl mx-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
{t('areas.details')}: {area?.name}
</h2>
<p className="text-md text-gray-700 dark:text-gray-300">{area?.description}</p>
<Link
to={`/projects?area_id=${area?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"
>
{t('areas.viewProjects', { name: area?.name })}
</Link>
</div>
</div>
);
};
export default AreaDetails;
export default AreaDetails;

View file

@ -5,225 +5,260 @@ import { useTranslation } from 'react-i18next';
import { TrashIcon } from '@heroicons/react/24/outline';
interface AreaModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (areaData: Partial<Area>) => Promise<void>;
onDelete?: (areaId: number) => Promise<void>;
area?: Area | null;
isOpen: boolean;
onClose: () => void;
onSave: (areaData: Partial<Area>) => Promise<void>;
onDelete?: (areaId: number) => Promise<void>;
area?: Area | null;
}
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave, onDelete }) => {
const { t } = useTranslation();
const [formData, setFormData] = useState<Area>({
id: area?.id || 0,
name: area?.name || '',
description: area?.description || '',
});
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [isClosing, setIsClosing] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
if (isOpen) {
setFormData({
const AreaModal: React.FC<AreaModalProps> = ({
isOpen,
onClose,
area,
onSave,
onDelete,
}) => {
const { t } = useTranslation();
const [formData, setFormData] = useState<Area>({
id: area?.id || 0,
name: area?.name || '',
description: area?.description || '',
});
setError(null);
}
}, [isOpen, area]);
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [isClosing, setIsClosing] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
if (isOpen) {
setFormData({
id: area?.id || 0,
name: area?.name || '',
description: area?.description || '',
});
setError(null);
}
}, [isOpen, area]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleSubmit = async () => {
if (!formData.name.trim()) {
setError(t('errors.areaNameRequired'));
return;
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
setIsSubmitting(true);
setError(null);
try {
await onSave(formData);
showSuccessToast(
formData.id
? t('success.areaUpdated')
: t('success.areaCreated')
);
handleClose();
} catch (err) {
setError((err as Error).message);
showErrorToast(t('errors.failedToSaveArea'));
} finally {
setIsSubmitting(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
};
}, [isOpen]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleDeleteArea = async () => {
if (formData.id && formData.id !== 0 && onDelete) {
try {
await onDelete(formData.id);
showSuccessToast(
t('success.areaDeleted', 'Area deleted successfully!')
);
handleClose();
} catch (err) {
setError((err as Error).message);
showErrorToast(
t('errors.failedToDeleteArea', 'Failed to delete area.')
);
}
}
};
const handleSubmit = async () => {
if (!formData.name.trim()) {
setError(t('errors.areaNameRequired'));
return;
}
if (!isOpen) return null;
setIsSubmitting(true);
setError(null);
try {
await onSave(formData);
showSuccessToast(formData.id ? t('success.areaUpdated') : t('success.areaCreated'));
handleClose();
} catch (err) {
setError((err as Error).message);
showErrorToast(t('errors.failedToSaveArea'));
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
};
const handleDeleteArea = async () => {
if (formData.id && formData.id !== 0 && onDelete) {
try {
await onDelete(formData.id);
showSuccessToast(t('success.areaDeleted', 'Area deleted successfully!'));
handleClose();
} catch (err) {
setError((err as Error).message);
showErrorToast(t('errors.failedToDeleteArea', 'Failed to delete area.'));
}
}
};
if (!isOpen) return null;
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"
}`}
>
<div className="h-full flex items-center justify-center sm:px-4 sm:py-4">
return (
<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"
} h-full sm:h-auto sm:my-4`}
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'
}`}
>
<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' }}>
<form className="h-full">
<fieldset className="h-full flex flex-col">
{/* Area Title Section - Always Visible */}
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 pt-4">
<input
type="text"
id="areaName"
name="name"
value={formData.name}
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')}
/>
</div>
{/* Description Section - Always Visible */}
<div className="flex-1 pb-4 mb-4 px-4">
<textarea
id="areaDescription"
name="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')}
style={{ minHeight: '150px' }}
/>
</div>
{/* Error Message */}
{error && <div className="text-red-500 px-4 mb-4">{error}</div>}
</fieldset>
</form>
</div>
</div>
{/* Action Buttons - Below border with custom layout */}
<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) && (
<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')}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
{t('common.cancel')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
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' : ''
}`}
<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'
} h-full sm:h-auto sm:my-4`}
>
{isSubmitting
? t('modals.submitting')
: formData.id && formData.id !== 0
? t('modals.updateArea')
: t('modals.createArea')}
</button>
</div>
<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' }}
>
<form className="h-full">
<fieldset className="h-full flex flex-col">
{/* Area Title Section - Always Visible */}
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4 pt-4">
<input
type="text"
id="areaName"
name="name"
value={formData.name}
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'
)}
/>
</div>
{/* Description Section - Always Visible */}
<div className="flex-1 pb-4 mb-4 px-4">
<textarea
id="areaDescription"
name="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'
)}
style={{
minHeight: '150px',
}}
/>
</div>
{/* Error Message */}
{error && (
<div className="text-red-500 px-4 mb-4">
{error}
</div>
)}
</fieldset>
</form>
</div>
</div>
{/* Action Buttons - Below border with custom layout */}
<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 && (
<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'
)}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
{t('common.cancel')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
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
? t('modals.submitting')
: formData.id && formData.id !== 0
? t('modals.updateArea')
: t('modals.createArea')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
);
};
export default AreaModal;
export default AreaModal;

View file

@ -2,206 +2,229 @@ import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
PencilSquareIcon,
TrashIcon,
Squares2X2Icon,
PencilSquareIcon,
TrashIcon,
Squares2X2Icon,
} from '@heroicons/react/24/solid';
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 { t } = useTranslation();
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 [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
const [hoveredAreaId, setHoveredAreaId] = useState<number | null>(null);
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
const [hoveredAreaId, setHoveredAreaId] = useState<number | null>(null);
useEffect(() => {
const loadAreas = async () => {
try {
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error('Error fetching areas:', error);
setError(true);
}
useEffect(() => {
const loadAreas = async () => {
try {
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error('Error fetching areas:', error);
setError(true);
}
};
loadAreas();
}, []);
const handleSaveArea = async (areaData: Partial<Area>) => {
setLoading(true);
try {
if (areaData.id) {
await updateArea(areaData.id, {
name: areaData.name,
description: areaData.description,
});
} else {
await createArea({
name: areaData.name,
description: areaData.description,
});
}
const updatedAreas = await fetchAreas();
setAreas(updatedAreas);
} catch (error) {
console.error('Error saving area:', error);
setError(true);
} finally {
setLoading(false);
setIsAreaModalOpen(false);
setSelectedArea(null);
}
};
loadAreas();
}, []);
const handleEditArea = (area: Area) => {
setSelectedArea(area);
setIsAreaModalOpen(true);
};
const handleSaveArea = async (areaData: Partial<Area>) => {
setLoading(true);
try {
if (areaData.id) {
await updateArea(areaData.id, {
name: areaData.name,
description: areaData.description,
});
} else {
await createArea({
name: areaData.name,
description: areaData.description,
});
}
const updatedAreas = await fetchAreas();
setAreas(updatedAreas);
} catch (error) {
console.error('Error saving area:', error);
setError(true);
} finally {
setLoading(false);
setIsAreaModalOpen(false);
setSelectedArea(null);
}
};
const openConfirmDialog = (area: Area) => {
setAreaToDelete(area);
setIsConfirmDialogOpen(true);
};
const handleEditArea = (area: Area) => {
setSelectedArea(area);
setIsAreaModalOpen(true);
};
const handleDeleteArea = async () => {
if (!areaToDelete) return;
const handleCreateArea = () => {
setSelectedArea(null);
setIsAreaModalOpen(true);
};
setLoading(true);
try {
await deleteArea(areaToDelete.id!);
const updatedAreas = await fetchAreas();
setAreas(updatedAreas);
setIsConfirmDialogOpen(false);
setAreaToDelete(null);
} catch (error) {
console.error('Error deleting area:', error);
setError(true);
} finally {
setLoading(false);
}
};
const openConfirmDialog = (area: Area) => {
setAreaToDelete(area);
setIsConfirmDialogOpen(true);
};
const closeConfirmDialog = () => {
setIsConfirmDialogOpen(false);
setAreaToDelete(null);
};
const handleDeleteArea = async () => {
if (!areaToDelete) return;
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Areas Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<Squares2X2Icon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
{t('areas.title')}
</h2>
</div>
</div>
setLoading(true);
try {
await deleteArea(areaToDelete.id!);
const updatedAreas = await fetchAreas();
setAreas(updatedAreas);
setIsConfirmDialogOpen(false);
setAreaToDelete(null);
} catch (error) {
console.error('Error deleting area:', error);
setError(true);
} finally {
setLoading(false);
}
};
const closeConfirmDialog = () => {
setIsConfirmDialogOpen(false);
setAreaToDelete(null);
};
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Areas Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<Squares2X2Icon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
{t('areas.title')}
</h2>
</div>
</div>
{/* Areas List */}
{areas.length === 0 ? (
<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)}
onMouseLeave={() => setHoveredAreaId(null)}
>
{/* Area Content */}
<div className="flex-grow overflow-hidden pr-4">
<Link
to={`/projects?area_id=${area.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
>
{area.name}
</Link>
{area.description && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
{area.description}
{/* Areas List */}
{areas.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">
{t('areas.noAreasFound')}
</p>
)}
</div>
) : (
<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)
}
onMouseLeave={() => setHoveredAreaId(null)}
>
{/* Area Content */}
<div className="flex-grow overflow-hidden pr-4">
<Link
to={`/projects?area_id=${area.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
>
{area.name}
</Link>
{area.description && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
{area.description}
</p>
)}
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<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'
}`}
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'
}`}
aria-label={t('areas.deleteAreaAriaLabel', { name: area.name })}
title={t('areas.deleteAreaTitle', { name: area.name })}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
)}
{/* Action Buttons */}
<div className="flex space-x-2">
<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'
}`}
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'
}`}
aria-label={t(
'areas.deleteAreaAriaLabel',
{ name: area.name }
)}
title={t('areas.deleteAreaTitle', {
name: area.name,
})}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
)}
{/* AreaModal */}
{isAreaModalOpen && (
<AreaModal
isOpen={isAreaModalOpen}
onClose={() => setIsAreaModalOpen(false)}
onSave={handleSaveArea}
onDelete={async (areaId) => {
try {
await deleteArea(areaId);
const updatedAreas = await fetchAreas();
setAreas(updatedAreas);
setIsAreaModalOpen(false);
setSelectedArea(null);
} catch (error) {
console.error('Error deleting area:', error);
setError(true);
}
}}
area={selectedArea}
/>
)}
{/* AreaModal */}
{isAreaModalOpen && (
<AreaModal
isOpen={isAreaModalOpen}
onClose={() => setIsAreaModalOpen(false)}
onSave={handleSaveArea}
onDelete={async (areaId) => {
try {
await deleteArea(areaId);
const updatedAreas = await fetchAreas();
setAreas(updatedAreas);
setIsAreaModalOpen(false);
setSelectedArea(null);
} catch (error) {
console.error('Error deleting area:', error);
setError(true);
}
}}
area={selectedArea}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && areaToDelete && (
<ConfirmDialog
title={t('modals.deleteArea.title')}
message={t('modals.deleteArea.message', { name: areaToDelete.name })}
onConfirm={handleDeleteArea}
onCancel={closeConfirmDialog}
/>
)}
</div>
</div>
);
{/* ConfirmDialog */}
{isConfirmDialogOpen && areaToDelete && (
<ConfirmDialog
title={t('modals.deleteArea.title')}
message={t('modals.deleteArea.message', {
name: areaToDelete.name,
})}
onConfirm={handleDeleteArea}
onCancel={closeConfirmDialog}
/>
)}
</div>
</div>
);
};
export default Areas;
export default Areas;

File diff suppressed because it is too large Load diff

View file

@ -1,162 +1,203 @@
import React from 'react';
import { format, addHours, isToday } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
color?: string;
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
color?: string;
}
interface CalendarDayViewProps {
currentDate: Date;
events: CalendarEvent[];
onEventClick?: (event: CalendarEvent) => void;
onTimeSlotClick?: (date: Date, hour: number) => void;
currentDate: Date;
events: CalendarEvent[];
onEventClick?: (event: CalendarEvent) => void;
onTimeSlotClick?: (date: Date, hour: number) => void;
}
const CalendarDayView: React.FC<CalendarDayViewProps> = ({
currentDate,
events,
onEventClick,
onTimeSlotClick
const CalendarDayView: React.FC<CalendarDayViewProps> = ({
currentDate,
events,
onEventClick,
onTimeSlotClick,
}) => {
const { t } = useTranslation();
const hours = Array.from({ length: 24 }, (_, i) => i);
const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForTimeSlot = (hour: number) => {
return events.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const currentDay = format(currentDate, 'yyyy-MM-dd');
const eventHour = event.start.getHours();
const getEventsForTimeSlot = (hour: number) => {
return events.filter(event => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const currentDay = format(currentDate, 'yyyy-MM-dd');
const eventHour = event.start.getHours();
return eventDay === currentDay && eventHour === hour;
});
};
return eventDay === currentDay && eventHour === hour;
});
};
const handleTimeSlotClick = (hour: number) => {
if (onTimeSlotClick) {
onTimeSlotClick(currentDate, hour);
}
};
const handleTimeSlotClick = (hour: number) => {
if (onTimeSlotClick) {
onTimeSlotClick(currentDate, hour);
}
};
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation();
if (onEventClick) {
onEventClick(event);
}
};
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation();
if (onEventClick) {
onEventClick(event);
}
};
const calculateEventHeight = (event: CalendarEvent) => {
const durationMs = event.end.getTime() - event.start.getTime();
const durationHours = durationMs / (1000 * 60 * 60);
return Math.max(durationHours * 48, 24); // Minimum 24px height
};
const calculateEventHeight = (event: CalendarEvent) => {
const durationMs = event.end.getTime() - event.start.getTime();
const durationHours = durationMs / (1000 * 60 * 60);
return Math.max(durationHours * 48, 24); // Minimum 24px height
};
const calculateEventPosition = (event: CalendarEvent) => {
const minutes = event.start.getMinutes();
return (minutes / 60) * 48; // 48px per hour
};
const calculateEventPosition = (event: CalendarEvent) => {
const minutes = event.start.getMinutes();
return (minutes / 60) * 48; // 48px per hour
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
{/* 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'
}`}>
{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'
}`}>
{format(currentDate, 'd')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{format(currentDate, 'MMMM yyyy')}
</div>
</div>
</div>
{/* 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="space-y-1">
{events
.filter(event => {
const eventDay = format(event.start, '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;
})
.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' : ''
}`}
style={{ backgroundColor: event.color || '#3b82f6' }}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
>
{event.type === 'task' && '📋 '}{event.title}
</div>
))}
</div>
</div>
{/* Time slots */}
<div className="max-h-96 overflow-y-auto">
{hours.map(hour => {
const timeSlotEvents = getEventsForTimeSlot(hour);
return (
<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')}
</div>
{/* Event area */}
<div
onClick={() => handleTimeSlotClick(hour)}
className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative"
>
{timeSlotEvents.map(event => (
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
{/* 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
key={event.id}
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' : ''
}`}
style={{
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')}`}
className={`text-lg font-medium ${
isToday(currentDate)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<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')}
</div>
{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'
}`}
>
{format(currentDate, 'd')}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{format(currentDate, 'MMMM yyyy')}
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
{/* 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="space-y-1">
{events
.filter((event) => {
const eventDay = format(event.start, '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
);
})
.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'
: ''
}`}
style={{
backgroundColor: event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
>
{event.type === 'task' && '📋 '}
{event.title}
</div>
))}
</div>
</div>
{/* Time slots */}
<div className="max-h-96 overflow-y-auto">
{hours.map((hour) => {
const timeSlotEvents = getEventsForTimeSlot(hour);
return (
<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'
)}
</div>
{/* Event area */}
<div
onClick={() => handleTimeSlotClick(hour)}
className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative"
>
{timeSlotEvents.map((event) => (
<div
key={event.id}
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'
: ''
}`}
style={{
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="text-xs opacity-90">
{format(event.start, 'HH:mm')} -{' '}
{format(event.end, 'HH:mm')}
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default CalendarDayView;
export default CalendarDayView;

View file

@ -1,135 +1,159 @@
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 {
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
color?: string;
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
color?: string;
}
interface CalendarMonthViewProps {
currentDate: Date;
events: CalendarEvent[];
onDateClick?: (date: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
currentDate: Date;
events: CalendarEvent[];
onDateClick?: (date: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
}
const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
currentDate,
events,
onDateClick,
onEventClick
const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
currentDate,
events,
onDateClick,
onEventClick,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Start on Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = eachDayOfInterval({
start: calendarStart,
end: calendarEnd
});
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Start on Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const weekDays = [
t('weekdays.monday', 'Mon'),
t('weekdays.tuesday', 'Tue'),
t('weekdays.wednesday', 'Wed'),
t('weekdays.thursday', 'Thu'),
t('weekdays.friday', 'Fri'),
t('weekdays.saturday', 'Sat'),
t('weekdays.sunday', 'Sun')
];
const days = eachDayOfInterval({
start: calendarStart,
end: calendarEnd,
});
const handleDateClick = (date: Date) => {
if (onDateClick) {
onDateClick(date);
}
};
const weekDays = [
t('weekdays.monday', 'Mon'),
t('weekdays.tuesday', 'Tue'),
t('weekdays.wednesday', 'Wed'),
t('weekdays.thursday', 'Thu'),
t('weekdays.friday', 'Fri'),
t('weekdays.saturday', 'Sat'),
t('weekdays.sunday', 'Sun'),
];
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation();
if (onEventClick) {
onEventClick(event);
}
};
const handleDateClick = (date: Date) => {
if (onDateClick) {
onDateClick(date);
}
};
return (
<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">
{day}
</div>
))}
</div>
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation();
if (onEventClick) {
onEventClick(event);
}
};
{/* 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')
);
const isCurrentMonth = isSameMonth(day, currentDate);
const isTodayDate = isToday(day);
return (
<div
key={day.toString()}
onClick={() => handleDateClick(day)}
className={`min-h-32 p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 ${
!isCurrentMonth
? 'bg-gray-50 dark:bg-gray-800'
: '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 ${
!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 && (
<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')}
</span>
)}
{!isTodayDate && format(day, 'd')}
</div>
{/* Events */}
<div className="space-y-1">
{dayEvents.slice(0, 3).map(event => (
<div
key={event.id}
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' : ''
}`}
style={{ backgroundColor: event.color || '#3b82f6' }}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
>
{event.type === 'task' && '📋 '}{event.title}
</div>
return (
<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"
>
{day}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-xs text-gray-500 dark:text-gray-400 px-1">
+{dayEvents.length - 3} more
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
{/* 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')
);
const isCurrentMonth = isSameMonth(day, currentDate);
const isTodayDate = isToday(day);
return (
<div
key={day.toString()}
onClick={() => handleDateClick(day)}
className={`min-h-32 p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 ${
!isCurrentMonth
? 'bg-gray-50 dark:bg-gray-800'
: '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 ${
!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 && (
<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')}
</span>
)}
{!isTodayDate && format(day, 'd')}
</div>
{/* Events */}
<div className="space-y-1">
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
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'
: ''
}`}
style={{
backgroundColor:
event.color || '#3b82f6',
}}
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
>
{event.type === 'task' && '📋 '}
{event.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-xs text-gray-500 dark:text-gray-400 px-1">
+{dayEvents.length - 3} more
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
export default CalendarMonthView;
export default CalendarMonthView;

View file

@ -1,135 +1,170 @@
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;
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
color?: string;
id: string;
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
color?: string;
}
interface CalendarWeekViewProps {
currentDate: Date;
events: CalendarEvent[];
onDateClick?: (date: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
onTimeSlotClick?: (date: Date, hour: number) => void;
currentDate: Date;
events: CalendarEvent[];
onDateClick?: (date: Date) => void;
onEventClick?: (event: CalendarEvent) => void;
onTimeSlotClick?: (date: Date, hour: number) => void;
}
const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
currentDate,
events,
onDateClick,
onEventClick,
onTimeSlotClick
const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
currentDate,
events,
onEventClick,
onTimeSlotClick,
}) => {
const { t } = useTranslation();
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
const weekDays = eachDayOfInterval({ start: weekStart, end: weekEnd });
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
const weekDays = eachDayOfInterval({ start: weekStart, end: weekEnd });
const hours = Array.from({ length: 24 }, (_, i) => i);
const hours = Array.from({ length: 24 }, (_, i) => i);
const getEventsForTimeSlot = (day: Date, hour: number) => {
return events.filter((event) => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const slotDay = format(day, 'yyyy-MM-dd');
const eventHour = event.start.getHours();
const getEventsForTimeSlot = (day: Date, hour: number) => {
return events.filter(event => {
const eventDay = format(event.start, 'yyyy-MM-dd');
const slotDay = format(day, 'yyyy-MM-dd');
const eventHour = event.start.getHours();
return eventDay === slotDay && eventHour === hour;
});
};
return eventDay === slotDay && eventHour === hour;
});
};
const handleTimeSlotClick = (day: Date, hour: number) => {
if (onTimeSlotClick) {
onTimeSlotClick(day, hour);
}
};
const handleTimeSlotClick = (day: Date, hour: number) => {
if (onTimeSlotClick) {
onTimeSlotClick(day, hour);
}
};
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation();
if (onEventClick) {
onEventClick(event);
}
};
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
e.stopPropagation();
if (onEventClick) {
onEventClick(event);
}
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
{/* Header with days */}
<div className="grid grid-cols-8 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<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 ${
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'
}`}>
{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'
}`}>
{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')}
</span>
) : (
format(day, 'd')
)}
</div>
</div>
))}
</div>
{/* 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">
{/* 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')}
</div>
{/* Day columns */}
{weekDays.map(day => {
const timeSlotEvents = getEventsForTimeSlot(day, hour);
return (
<div
key={`${day.toString()}-${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' : ''
}`}
>
{timeSlotEvents.map(event => (
<div
key={event.id}
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' : ''
}`}
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}
</div>
))}
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
{/* Header with days */}
<div className="grid grid-cols-8 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
Time
</div>
);
})}
</div>
))}
</div>
</div>
);
{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'
}`}
>
{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'
}`}
>
{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')}
</span>
) : (
format(day, 'd')
)}
</div>
</div>
))}
</div>
{/* 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"
>
{/* 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'
)}
</div>
{/* Day columns */}
{weekDays.map((day) => {
const timeSlotEvents = getEventsForTimeSlot(
day,
hour
);
return (
<div
key={`${day.toString()}-${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'
: ''
}`}
>
{timeSlotEvents.map((event) => (
<div
key={event.id}
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'
: ''
}`}
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}
</div>
))}
</div>
);
})}
</div>
))}
</div>
</div>
);
};
export default CalendarWeekView;
export default CalendarWeekView;

View file

@ -1,253 +1,275 @@
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';
interface InboxItemDetailProps {
item: InboxItem;
onProcess: (id: number) => void;
onDelete: (id: number) => void;
onUpdate?: (id: number, content: string) => Promise<void>;
openTaskModal: (task: Task, inboxItemId?: number) => void;
openProjectModal: (project: Project | null, inboxItemId?: number) => void;
openNoteModal: (note: Note | null, inboxItemId?: number) => void;
item: InboxItem;
onProcess: (id: number) => void;
onDelete: (id: number) => void;
onUpdate?: (id: number, content: string) => Promise<void>;
openTaskModal: (task: Task, inboxItemId?: number) => void;
openProjectModal: (project: Project | null, inboxItemId?: number) => void;
openNoteModal: (note: Note | null, inboxItemId?: number) => void;
}
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
item,
onProcess,
onDelete,
onUpdate,
openTaskModal,
openProjectModal,
openNoteModal
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
item,
onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars
onDelete,
onUpdate,
openTaskModal,
openProjectModal,
openNoteModal,
}) => {
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
const { tagsStore: { tags } } = useStore();
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [loading, setLoading] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// Helper function to parse hashtags from text
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)) : [];
};
const hashtags = parseHashtags(item.content);
const handleConvertToTask = () => {
// Convert hashtags to Tag objects
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());
return existingTag || { name: hashtagName };
});
const { t } = useTranslation();
const {
tagsStore: { tags },
} = useStore();
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [loading, setLoading] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const newTask: Task = {
name: item.content,
status: 'not_started',
priority: 'medium',
tags: taskTags
// Helper function to parse hashtags from text
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)) : [];
};
if (item.id !== undefined) {
openTaskModal(newTask, item.id);
} else {
openTaskModal(newTask);
}
};
const handleConvertToProject = () => {
// Convert hashtags to Tag objects
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());
return existingTag || { name: hashtagName };
});
const hashtags = parseHashtags(item.content);
const newProject: Project = {
name: item.content,
description: '',
active: true,
tags: projectTags
};
const handleConvertToTask = () => {
// Convert hashtags to Tag objects
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()
);
return existingTag || { name: hashtagName };
});
if (item.id !== undefined) {
openProjectModal(newProject, item.id);
} else {
openProjectModal(newProject);
}
};
const handleConvertToNote = async () => {
let title = item.content.split('\n')[0] || item.content.substring(0, 50);
let content = item.content;
let isBookmark = false;
try {
const { isUrl, extractUrlTitle } = await import("../../utils/urlService");
if (isUrl(item.content.trim())) {
setLoading(true);
try {
// Add a timeout to prevent infinite loading
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 10000) // 10 second timeout
);
const result = await Promise.race([
extractUrlTitle(item.content.trim()),
timeoutPromise
]) as any;
if (result && result.title) {
title = result.title;
content = item.content;
isBookmark = true;
}
} catch (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;
} finally {
setLoading(false);
const newTask: Task = {
name: item.content,
status: 'not_started',
priority: 'medium',
tags: taskTags,
};
if (item.id !== undefined) {
openTaskModal(newTask, item.id);
} else {
openTaskModal(newTask);
}
}
} catch (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 => {
// Find existing tag or create a placeholder for new tag
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 tagObjects = [...hashtagTags, ...bookmarkTag];
const newNote: Note = {
title: title,
content: content,
tags: tagObjects
};
if (item.id !== undefined) {
openNoteModal(newNote, item.id);
} else {
openNoteModal(newNote);
}
};
const handleDelete = () => {
setShowConfirmDialog(true);
};
const confirmDelete = () => {
if (item.id !== undefined) {
onDelete(item.id);
}
setShowConfirmDialog(false);
};
return (
<div
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
<div className="flex-1">
<p className="text-base font-medium text-gray-900 dark:text-gray-300 break-words">
{item.content}
</p>
{/* Tags display */}
{hashtags.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>{hashtags.join(', ')}</span>
const handleConvertToProject = () => {
// Convert hashtags to Tag objects
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()
);
return existingTag || { name: hashtagName };
});
const newProject: Project = {
name: item.content,
description: '',
active: true,
tags: projectTags,
};
if (item.id !== undefined) {
openProjectModal(newProject, item.id);
} else {
openProjectModal(newProject);
}
};
const handleConvertToNote = async () => {
let title =
item.content.split('\n')[0] || item.content.substring(0, 50);
let content = item.content;
let isBookmark = false;
try {
const { isUrl, extractUrlTitle } = await import(
'../../utils/urlService'
);
if (isUrl(item.content.trim())) {
setLoading(true);
try {
// Add a timeout to prevent infinite loading
const timeoutPromise = new Promise(
(_, reject) =>
setTimeout(
() => reject(new Error('Timeout')),
10000
) // 10 second timeout
);
const result = (await Promise.race([
extractUrlTitle(item.content.trim()),
timeoutPromise,
])) as any;
if (result && result.title) {
title = result.title;
content = item.content;
isBookmark = true;
}
} catch (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;
} finally {
setLoading(false);
}
}
} catch (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) => {
// Find existing tag or create a placeholder for new tag
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 tagObjects = [...hashtagTags, ...bookmarkTag];
const newNote: Note = {
title: title,
content: content,
tags: tagObjects,
};
if (item.id !== undefined) {
openNoteModal(newNote, item.id);
} else {
openNoteModal(newNote);
}
};
const handleDelete = () => {
setShowConfirmDialog(true);
};
const confirmDelete = () => {
if (item.id !== undefined) {
onDelete(item.id);
}
setShowConfirmDialog(false);
};
return (
<div
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
<div className="flex-1">
<p className="text-base font-medium text-gray-900 dark:text-gray-300 break-words">
{item.content}
</p>
{/* Tags display */}
{hashtags.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>{hashtags.join(', ')}</span>
</div>
)}
</div>
<div className="flex items-center justify-start space-x-1 shrink-0">
{loading && <div className="spinner" />}
{/* Edit Button */}
<button
onClick={() => {
if (onUpdate && item.id !== undefined) {
onUpdate(item.id, item.content);
}
}}
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('common.edit')}
>
<PencilIcon className="h-4 w-4" />
</button>
{/* Convert to Task Button */}
<button
onClick={handleConvertToTask}
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createTask')}
>
<ClipboardDocumentListIcon className="h-4 w-4" />
</button>
{/* Convert to Project Button */}
<button
onClick={handleConvertToProject}
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createProject')}
>
<FolderIcon className="h-4 w-4" />
</button>
{/* Convert to Note Button */}
<button
onClick={handleConvertToNote}
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createNote', 'Create Note')}
>
<DocumentTextIcon className="h-4 w-4" />
</button>
{/* Delete Button */}
<button
onClick={handleDelete}
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('common.delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
)}
{showConfirmDialog && (
<ConfirmDialog
title={t('inbox.deleteConfirmTitle', 'Delete Item')}
message={t(
'inbox.deleteConfirmMessage',
'Are you sure you want to delete this inbox item? This action cannot be undone.'
)}
onConfirm={confirmDelete}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
<div className="flex items-center justify-start space-x-1 shrink-0">
{loading && <div className="spinner" />}
{/* Edit Button */}
<button
onClick={() => {
if (onUpdate && item.id !== undefined) {
onUpdate(item.id, item.content);
}
}}
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('common.edit')}
>
<PencilIcon className="h-4 w-4" />
</button>
{/* Convert to Task Button */}
<button
onClick={handleConvertToTask}
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createTask')}
>
<ClipboardDocumentListIcon className="h-4 w-4" />
</button>
{/* Convert to Project Button */}
<button
onClick={handleConvertToProject}
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createProject')}
>
<FolderIcon className="h-4 w-4" />
</button>
{/* Convert to Note Button */}
<button
onClick={handleConvertToNote}
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createNote', 'Create Note')}
>
<DocumentTextIcon className="h-4 w-4" />
</button>
{/* Delete Button */}
<button
onClick={handleDelete}
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('common.delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
{showConfirmDialog && (
<ConfirmDialog
title={t('inbox.deleteConfirmTitle', 'Delete Item')}
message={t('inbox.deleteConfirmMessage', 'Are you sure you want to delete this inbox item? This action cannot be undone.')}
onConfirm={confirmDelete}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</div>
);
);
};
export default InboxItemDetail;

View file

@ -2,11 +2,11 @@ import React, { useState, useEffect, useCallback } from 'react';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import { Note } from '../../entities/Note';
import {
loadInboxItemsToStore,
processInboxItemWithStore,
deleteInboxItemWithStore,
updateInboxItemWithStore
import {
loadInboxItemsToStore,
processInboxItemWithStore,
deleteInboxItemWithStore,
updateInboxItemWithStore,
} from '../../utils/inboxService';
import InboxItemDetail from './InboxItemDetail';
import { useToast } from '../Shared/ToastContext';
@ -25,397 +25,449 @@ import { isUrl } from '../../utils/urlService';
import { useStore } from '../../store/useStore';
const InboxItems: React.FC = () => {
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
// Access store data
const { inboxItems, isLoading } = useStore(state => state.inboxStore);
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// Data for modals
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
// Track the current inbox item ID being converted (for task/project/note conversion)
const [currentConversionItemId, setCurrentConversionItemId] = useState<number | null>(null);
// Track the current inbox item being edited
const [itemToEdit, setItemToEdit] = useState<number | null>(null);
// Fetch projects for modals
const [projects, setProjects] = useState<Project[]>([]);
// Wrapped in useCallback to prevent dependency issues in useEffect
const refreshInboxItems = useCallback(() => {
loadInboxItemsToStore();
}, []);
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
// Initial data loading
refreshInboxItems();
// Set up an event listener for force reload
const handleForceReload = () => {
// Wait a short time to ensure the backend has processed the new item
setTimeout(() => {
// Access store data
const { inboxItems, isLoading } = useStore((state) => state.inboxStore);
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// Data for modals
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
// Track the current inbox item ID being converted (for task/project/note conversion)
const [currentConversionItemId, setCurrentConversionItemId] = useState<
number | null
>(null);
// Track the current inbox item being edited
const [itemToEdit, setItemToEdit] = useState<number | null>(null);
// Fetch projects for modals
const [projects, setProjects] = useState<Project[]>([]);
// Wrapped in useCallback to prevent dependency issues in useEffect
const refreshInboxItems = useCallback(() => {
loadInboxItemsToStore();
}, []);
useEffect(() => {
// Initial data loading
refreshInboxItems();
}, 500);
};
// Handler for the inboxItemsUpdated custom event
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
}));
// If multiple new items, show a summary notification as well
if (event.detail.count > 1) {
showSuccessToast(t('inbox.multipleNewItems', '{{count}} more new items added', {
count: event.detail.count - 1
}));
}
}
};
// Set up polling for new inbox items (especially from Telegram)
// This ensures real-time updates when items are added externally
const pollInterval = setInterval(() => {
refreshInboxItems();
}, 5000); // Check for new items every 5 seconds
// Add event listeners
window.addEventListener('forceInboxReload', handleForceReload);
window.addEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
return () => {
clearInterval(pollInterval);
window.removeEventListener('forceInboxReload', handleForceReload);
window.removeEventListener('inboxItemsUpdated', handleInboxItemsUpdated as EventListener);
};
}, [refreshInboxItems]);
const handleProcessItem = async (id: number) => {
try {
await processInboxItemWithStore(id);
showSuccessToast(t('inbox.itemProcessed'));
} catch (error) {
console.error('Failed to process inbox item:', error);
showErrorToast(t('inbox.processError'));
}
};
const handleUpdateItem = async (id: number): Promise<void> => {
// When edit button is clicked, we open the InboxModal instead of doing inline editing
setItemToEdit(id);
setIsEditModalOpen(true);
};
const handleSaveEditedItem = async (text: string) => {
try {
if (itemToEdit !== null) {
await updateInboxItemWithStore(itemToEdit, text);
showSuccessToast(t('inbox.itemUpdated'));
}
setIsEditModalOpen(false);
setItemToEdit(null);
} catch (error) {
console.error('Failed to update inbox item:', error);
showErrorToast(t('inbox.updateError'));
}
};
const handleDeleteItem = async (id: number) => {
try {
await deleteInboxItemWithStore(id);
showSuccessToast(t('inbox.itemDeleted'));
} catch (error) {
console.error('Failed to delete inbox item:', error);
showErrorToast(t('inbox.deleteError'));
}
};
// Modal handlers
const handleOpenTaskModal = async (task: Task, inboxItemId?: number) => {
// Load projects first before opening the modal
try {
const projectData = await fetchProjects();
// Make sure we always set an array
setProjects(Array.isArray(projectData) ? projectData : []);
} catch (error) {
console.error('Failed to load projects:', error);
showErrorToast(t('project.loadError', 'Failed to load projects'));
setProjects([]); // Ensure we have an empty array even on error
}
setTaskToEdit(task);
if (inboxItemId) {
setCurrentConversionItemId(inboxItemId);
}
setIsTaskModalOpen(true);
};
const handleOpenProjectModal = (project: Project | null, inboxItemId?: number) => {
setProjectToEdit(project);
if (inboxItemId) {
setCurrentConversionItemId(inboxItemId);
}
setIsProjectModalOpen(true);
};
const handleOpenNoteModal = async (note: Note | null, inboxItemId?: number) => {
// Load projects first before opening the modal
try {
const projectData = await fetchProjects();
// Make sure we always set an array
setProjects(Array.isArray(projectData) ? projectData : []);
} catch (error) {
console.error('Failed to load projects:', error);
showErrorToast(t('project.loadError', 'Failed to load projects'));
setProjects([]); // Ensure we have an empty array even on error
}
// If note has content that's a URL, ensure it has a bookmark tag
if (note && note.content && isUrl(note.content.trim())) {
if (!note.tags) {
note.tags = [{ name: 'bookmark' }];
} else if (!note.tags.some(tag => tag.name === 'bookmark')) {
note.tags.push({ name: 'bookmark' });
}
}
setNoteToEdit(note);
if (inboxItemId) {
setCurrentConversionItemId(inboxItemId);
}
setIsNoteModalOpen(true);
};
const handleSaveTask = async (task: Task) => {
try {
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!')}
</span>
);
showSuccessToast(taskLink);
// Process the inbox item after successful task creation
if (currentConversionItemId !== null) {
await handleProcessItem(currentConversionItemId);
setCurrentConversionItemId(null);
}
setIsTaskModalOpen(false);
} catch (error) {
console.error('Failed to create task:', error);
showErrorToast(t('task.createError'));
}
};
const handleSaveProject = async (project: Project) => {
try {
await createProject(project);
showSuccessToast(t('project.createSuccess'));
// Process the inbox item after successful project creation
if (currentConversionItemId !== null) {
await handleProcessItem(currentConversionItemId);
setCurrentConversionItemId(null);
}
setIsProjectModalOpen(false);
} catch (error) {
console.error('Failed to create project:', error);
showErrorToast(t('project.createError'));
}
};
const handleSaveNote = async (note: Note) => {
try {
// Check if the content appears to be a URL and add the bookmark tag
const noteContent = note.content || '';
const isBookmarkContent = isUrl(noteContent.trim());
// Ensure tags property exists
if (!note.tags) {
note.tags = [];
}
// Add a bookmark tag if content is a URL and doesn't already have the tag
if (isBookmarkContent && !note.tags.some(tag => tag.name === 'bookmark')) {
// Use spread operator to create a new array with the bookmark tag added
note.tags = [...note.tags, { name: 'bookmark' }];
}
// Create the note with proper tags
await createNote(note);
showSuccessToast(t('note.createSuccess', 'Note created successfully'));
// Process the inbox item after successful note creation
if (currentConversionItemId !== null) {
await handleProcessItem(currentConversionItemId);
setCurrentConversionItemId(null);
}
setIsNoteModalOpen(false);
} catch (error) {
console.error('Failed to create note:', error);
showErrorToast(t('note.createError', 'Failed to create note'));
}
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const project = await createProject({ name, active: true });
showSuccessToast(t('project.createSuccess'));
return project;
} catch (error) {
console.error('Failed to create project:', error);
showErrorToast(t('project.createError'));
throw error;
}
};
if (isLoading) {
return <LoadingScreen />;
}
if (inboxItems.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 space-y-4 text-center text-gray-600 dark:text-gray-300">
<InboxIcon className="h-16 w-16" />
<h3 className="text-xl font-semibold">{t('inbox.empty')}</h3>
<p>{t('inbox.emptyDescription')}</p>
</div>
);
}
return (
<div className="container mx-auto p-4">
<div className="flex items-center mb-8">
<InboxIcon className="h-6 w-6 mr-2" />
<h1 className="text-2xl font-light">{t('inbox.title')}</h1>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{t('taskViews.inbox', 'Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don\'t have a due date will appear here. This is your \'brain dump\' area where you can quickly note down tasks and organize them later.')}
</p>
<div className="space-y-2">
{inboxItems.map((item) => (
<InboxItemDetail
key={item.id}
item={item}
onProcess={handleProcessItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
openTaskModal={handleOpenTaskModal}
openProjectModal={handleOpenProjectModal}
openNoteModal={handleOpenNoteModal}
/>
))}
</div>
{/* Task Modal - Always render it but control visibility with isOpen */}
{/* Add error boundary protection for modal rendering */}
{(() => {
// Set up an event listener for force reload
const handleForceReload = () => {
// Wait a short time to ensure the backend has processed the new item
setTimeout(() => {
refreshInboxItems();
}, 500);
};
// Handler for the inboxItemsUpdated custom event
const handleInboxItemsUpdated = (
event: CustomEvent<{ count: number; firstItemContent: string }>
) => {
// Show toast notifications for new items
if (event.detail.count > 0) {
// Show notification for the first new item
showSuccessToast(
t(
'inbox.newTelegramItem',
'New item from Telegram: {{content}}',
{
content: event.detail.firstItemContent,
}
)
);
// If multiple new items, show a summary notification as well
if (event.detail.count > 1) {
showSuccessToast(
t(
'inbox.multipleNewItems',
'{{count}} more new items added',
{
count: event.detail.count - 1,
}
)
);
}
}
};
// Set up polling for new inbox items (especially from Telegram)
// This ensures real-time updates when items are added externally
const pollInterval = setInterval(() => {
refreshInboxItems();
}, 5000); // Check for new items every 5 seconds
// Add event listeners
window.addEventListener('forceInboxReload', handleForceReload);
window.addEventListener(
'inboxItemsUpdated',
handleInboxItemsUpdated as EventListener
);
return () => {
clearInterval(pollInterval);
window.removeEventListener('forceInboxReload', handleForceReload);
window.removeEventListener(
'inboxItemsUpdated',
handleInboxItemsUpdated as EventListener
);
};
}, [refreshInboxItems]);
const handleProcessItem = async (id: number) => {
try {
return (
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => {
setIsTaskModalOpen(false);
setTaskToEdit(null);
}}
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 : []}
onCreateProject={handleCreateProject}
/>
);
await processInboxItemWithStore(id);
showSuccessToast(t('inbox.itemProcessed'));
} catch (error) {
console.error('TaskModal rendering error:', error);
return null;
console.error('Failed to process inbox item:', error);
showErrorToast(t('inbox.processError'));
}
})()}
{/* Project Modal - Only render when needed to prevent infinite loops */}
{isProjectModalOpen && (() => {
};
const handleUpdateItem = async (id: number): Promise<void> => {
// When edit button is clicked, we open the InboxModal instead of doing inline editing
setItemToEdit(id);
setIsEditModalOpen(true);
};
const handleSaveEditedItem = async (text: string) => {
try {
return (
<ProjectModal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setProjectToEdit(null);
}}
onSave={handleSaveProject}
project={projectToEdit || undefined}
areas={[]}
/>
);
} catch (error) {
console.error('ProjectModal rendering error:', error);
return null;
}
})()}
{/* Note Modal - Always render it but control visibility with isOpen */}
{(() => {
try {
return (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {
setIsNoteModalOpen(false);
setNoteToEdit(null);
}}
onSave={handleSaveNote}
note={noteToEdit}
projects={Array.isArray(projects) ? projects : []}
onCreateProject={handleCreateProject}
/>
);
} catch (error) {
console.error('NoteModal rendering error:', error);
return null;
}
})()}
{/* Edit Inbox Item Modal */}
{isEditModalOpen && itemToEdit !== null && (
<InboxModal
isOpen={isEditModalOpen}
onClose={() => {
if (itemToEdit !== null) {
await updateInboxItemWithStore(itemToEdit, text);
showSuccessToast(t('inbox.itemUpdated'));
}
setIsEditModalOpen(false);
setItemToEdit(null);
}}
onSave={async () => {}} // Not used in edit mode
initialText={inboxItems.find(item => item.id === itemToEdit)?.content || ""}
editMode={true}
onEdit={handleSaveEditedItem}
/>
)}
</div>
);
} catch (error) {
console.error('Failed to update inbox item:', error);
showErrorToast(t('inbox.updateError'));
}
};
const handleDeleteItem = async (id: number) => {
try {
await deleteInboxItemWithStore(id);
showSuccessToast(t('inbox.itemDeleted'));
} catch (error) {
console.error('Failed to delete inbox item:', error);
showErrorToast(t('inbox.deleteError'));
}
};
// Modal handlers
const handleOpenTaskModal = async (task: Task, inboxItemId?: number) => {
// Load projects first before opening the modal
try {
const projectData = await fetchProjects();
// Make sure we always set an array
setProjects(Array.isArray(projectData) ? projectData : []);
} catch (error) {
console.error('Failed to load projects:', error);
showErrorToast(t('project.loadError', 'Failed to load projects'));
setProjects([]); // Ensure we have an empty array even on error
}
setTaskToEdit(task);
if (inboxItemId) {
setCurrentConversionItemId(inboxItemId);
}
setIsTaskModalOpen(true);
};
const handleOpenProjectModal = (
project: Project | null,
inboxItemId?: number
) => {
setProjectToEdit(project);
if (inboxItemId) {
setCurrentConversionItemId(inboxItemId);
}
setIsProjectModalOpen(true);
};
const handleOpenNoteModal = async (
note: Note | null,
inboxItemId?: number
) => {
// Load projects first before opening the modal
try {
const projectData = await fetchProjects();
// Make sure we always set an array
setProjects(Array.isArray(projectData) ? projectData : []);
} catch (error) {
console.error('Failed to load projects:', error);
showErrorToast(t('project.loadError', 'Failed to load projects'));
setProjects([]); // Ensure we have an empty array even on error
}
// If note has content that's a URL, ensure it has a bookmark tag
if (note && note.content && isUrl(note.content.trim())) {
if (!note.tags) {
note.tags = [{ name: 'bookmark' }];
} else if (!note.tags.some((tag) => tag.name === 'bookmark')) {
note.tags.push({ name: 'bookmark' });
}
}
setNoteToEdit(note);
if (inboxItemId) {
setCurrentConversionItemId(inboxItemId);
}
setIsNoteModalOpen(true);
};
const handleSaveTask = async (task: Task) => {
try {
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!')}
</span>
);
showSuccessToast(taskLink);
// Process the inbox item after successful task creation
if (currentConversionItemId !== null) {
await handleProcessItem(currentConversionItemId);
setCurrentConversionItemId(null);
}
setIsTaskModalOpen(false);
} catch (error) {
console.error('Failed to create task:', error);
showErrorToast(t('task.createError'));
}
};
const handleSaveProject = async (project: Project) => {
try {
await createProject(project);
showSuccessToast(t('project.createSuccess'));
// Process the inbox item after successful project creation
if (currentConversionItemId !== null) {
await handleProcessItem(currentConversionItemId);
setCurrentConversionItemId(null);
}
setIsProjectModalOpen(false);
} catch (error) {
console.error('Failed to create project:', error);
showErrorToast(t('project.createError'));
}
};
const handleSaveNote = async (note: Note) => {
try {
// Check if the content appears to be a URL and add the bookmark tag
const noteContent = note.content || '';
const isBookmarkContent = isUrl(noteContent.trim());
// Ensure tags property exists
if (!note.tags) {
note.tags = [];
}
// Add a bookmark tag if content is a URL and doesn't already have the tag
if (
isBookmarkContent &&
!note.tags.some((tag) => tag.name === 'bookmark')
) {
// Use spread operator to create a new array with the bookmark tag added
note.tags = [...note.tags, { name: 'bookmark' }];
}
// Create the note with proper tags
await createNote(note);
showSuccessToast(
t('note.createSuccess', 'Note created successfully')
);
// Process the inbox item after successful note creation
if (currentConversionItemId !== null) {
await handleProcessItem(currentConversionItemId);
setCurrentConversionItemId(null);
}
setIsNoteModalOpen(false);
} catch (error) {
console.error('Failed to create note:', error);
showErrorToast(t('note.createError', 'Failed to create note'));
}
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const project = await createProject({ name, active: true });
showSuccessToast(t('project.createSuccess'));
return project;
} catch (error) {
console.error('Failed to create project:', error);
showErrorToast(t('project.createError'));
throw error;
}
};
if (isLoading) {
return <LoadingScreen />;
}
if (inboxItems.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 space-y-4 text-center text-gray-600 dark:text-gray-300">
<InboxIcon className="h-16 w-16" />
<h3 className="text-xl font-semibold">{t('inbox.empty')}</h3>
<p>{t('inbox.emptyDescription')}</p>
</div>
);
}
return (
<div className="container mx-auto p-4">
<div className="flex items-center mb-8">
<InboxIcon className="h-6 w-6 mr-2" />
<h1 className="text-2xl font-light">{t('inbox.title')}</h1>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{t(
'taskViews.inbox',
"Inbox is where all uncategorized tasks are located. Tasks that have not been assigned to a project or don't have a due date will appear here. This is your 'brain dump' area where you can quickly note down tasks and organize them later."
)}
</p>
<div className="space-y-2">
{inboxItems.map((item) => (
<InboxItemDetail
key={item.id}
item={item}
onProcess={handleProcessItem}
onDelete={handleDeleteItem}
onUpdate={handleUpdateItem}
openTaskModal={handleOpenTaskModal}
openProjectModal={handleOpenProjectModal}
openNoteModal={handleOpenNoteModal}
/>
))}
</div>
{/* Task Modal - Always render it but control visibility with isOpen */}
{/* Add error boundary protection for modal rendering */}
{(() => {
try {
return (
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => {
setIsTaskModalOpen(false);
setTaskToEdit(null);
}}
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 : []}
onCreateProject={handleCreateProject}
/>
);
} catch (error) {
console.error('TaskModal rendering error:', error);
return null;
}
})()}
{/* Project Modal - Only render when needed to prevent infinite loops */}
{isProjectModalOpen &&
(() => {
try {
return (
<ProjectModal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setProjectToEdit(null);
}}
onSave={handleSaveProject}
project={projectToEdit || undefined}
areas={[]}
/>
);
} catch (error) {
console.error('ProjectModal rendering error:', error);
return null;
}
})()}
{/* Note Modal - Always render it but control visibility with isOpen */}
{(() => {
try {
return (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {
setIsNoteModalOpen(false);
setNoteToEdit(null);
}}
onSave={handleSaveNote}
note={noteToEdit}
projects={Array.isArray(projects) ? projects : []}
onCreateProject={handleCreateProject}
/>
);
} catch (error) {
console.error('NoteModal rendering error:', error);
return null;
}
})()}
{/* Edit Inbox Item Modal */}
{isEditModalOpen && itemToEdit !== null && (
<InboxModal
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setItemToEdit(null);
}}
onSave={async () => {}} // Not used in edit mode
initialText={
inboxItems.find((item) => item.id === itemToEdit)
?.content || ''
}
editMode={true}
onEdit={handleSaveEditedItem}
/>
)}
</div>
);
};
export default InboxItems;

File diff suppressed because it is too large Load diff

View file

@ -4,100 +4,98 @@ import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const { t } = useTranslation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
credentials: 'include'
});
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
credentials: 'include',
});
const data = await response.json();
const data = await response.json();
if (response.ok) {
if (data.user && data.user.language) {
await i18n.changeLanguage(data.user.language);
if (response.ok) {
if (data.user && data.user.language) {
await i18n.changeLanguage(data.user.language);
}
window.dispatchEvent(
new CustomEvent('userLoggedIn', { detail: data.user })
);
navigate('/today');
} else {
setError(data.errors[0] || 'Login failed. Please try again.');
}
} catch (err) {
setError('An error occurred. Please try again.');
console.error('Error during login:', err);
}
window.dispatchEvent(new CustomEvent('userLoggedIn', { detail: data.user }));
navigate('/today');
} else {
setError(data.errors[0] || 'Login failed. Please try again.');
}
} catch (err) {
setError('An error occurred. Please try again.');
console.error('Error during login:', err);
}
};
};
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>
<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>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="email"
className="block text-gray-600 mb-1"
>
{t('auth.email', 'Email')}
</label>
<input
type="email"
id="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div className="mb-4">
<label
htmlFor="password"
className="block text-gray-600 mb-1"
>
{t('auth.password', 'Password')}
</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 transition-colors"
>
{t('auth.login', 'Login')}
</button>
</form>
</div>
</div>
);
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>
<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>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="email"
className="block text-gray-600 mb-1"
>
{t('auth.email', 'Email')}
</label>
<input
type="email"
id="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div className="mb-4">
<label
htmlFor="password"
className="block text-gray-600 mb-1"
>
{t('auth.password', 'Password')}
</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 transition-colors"
>
{t('auth.login', 'Login')}
</button>
</form>
</div>
</div>
);
};
export default Login;

View file

@ -1,191 +1,211 @@
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;
toggleDarkMode: () => void;
currentUser: {
email: string;
avatarUrl?: string;
};
setCurrentUser: React.Dispatch<React.SetStateAction<any>>;
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
openTaskModal: (type?: 'simplified' | 'full') => void;
isDarkMode: boolean;
toggleDarkMode: () => void;
currentUser: {
email: string;
avatarUrl?: string;
};
setCurrentUser: React.Dispatch<React.SetStateAction<any>>;
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
openTaskModal: (type?: 'simplified' | 'full') => void;
}
const Navbar: React.FC<NavbarProps> = ({
isDarkMode,
toggleDarkMode,
currentUser,
setCurrentUser,
isSidebarOpen,
setIsSidebarOpen,
openTaskModal,
currentUser,
setCurrentUser,
isSidebarOpen,
setIsSidebarOpen,
openTaskModal,
}) => {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
const dropdownRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
const dropdownRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Fetch user's pomodoro setting
useEffect(() => {
const fetchProfile = async () => {
try {
const response = await fetch('/api/profile', {
credentials: 'include'
});
if (response.ok) {
const profile = await response.json();
setPomodoroEnabled(profile.pomodoro_enabled !== undefined ? profile.pomodoro_enabled : true);
// Fetch user's pomodoro setting
useEffect(() => {
const fetchProfile = async () => {
try {
const response = await fetch('/api/profile', {
credentials: 'include',
});
if (response.ok) {
const profile = await response.json();
setPomodoroEnabled(
profile.pomodoro_enabled !== undefined
? profile.pomodoro_enabled
: true
);
}
} catch (error) {
console.error('Error fetching profile:', error);
// Keep default value (true) if fetch fails
}
};
fetchProfile();
// Listen for Pomodoro setting changes from ProfileSettings
const handlePomodoroSettingChange = (event: CustomEvent) => {
setPomodoroEnabled(event.detail.enabled);
};
window.addEventListener(
'pomodoroSettingChanged',
handlePomodoroSettingChange as EventListener
);
return () => {
window.removeEventListener(
'pomodoroSettingChanged',
handlePomodoroSettingChange as EventListener
);
};
}, []);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const handleLogout = async () => {
try {
const response = await fetch('/api/logout', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
setCurrentUser(null);
navigate('/login');
} else {
console.error('Logout failed:', await response.json());
}
} catch (error) {
console.error('Error during logout:', error);
}
} catch (error) {
console.error('Error fetching profile:', error);
// Keep default value (true) if fetch fails
}
};
fetchProfile();
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-md h-16">
<div className="px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
<div className="flex items-center">
<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'
}
>
<Bars3Icon className="h-6 mt-1 w-6 mr-2" />
</button>
// Listen for Pomodoro setting changes from ProfileSettings
const handlePomodoroSettingChange = (event: CustomEvent) => {
setPomodoroEnabled(event.detail.enabled);
};
window.addEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
return () => {
window.removeEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
};
}, []);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const handleLogout = async () => {
try {
const response = await fetch('/api/logout', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
setCurrentUser(null);
navigate('/login');
} else {
console.error('Logout failed:', await response.json());
}
} catch (error) {
console.error('Error during logout:', error);
}
};
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-md h-16">
<div className="px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
<div className="flex items-center">
<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"}
>
<Bars3Icon className="h-6 mt-1 w-6 mr-2" />
</button>
<Link
to="/"
className="flex items-center no-underline text-gray-900 dark:text-white"
>
<span className="text-2xl font-bold">tududi</span>
</Link>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => openTaskModal('simplified')}
className="flex items-center bg-blue-500 hover:bg-blue-600 text-white rounded-full focus:outline-none transition-all duration-200 px-2 py-2 md:px-3 md:py-2"
aria-label="Quick Inbox Capture"
title="Quick Inbox Capture"
>
<BoltIcon className="h-4 w-4 text-white" />
<InboxIcon className="hidden md:inline-block ml-1.5 h-4 w-4 text-blue-200" />
</button>
{pomodoroEnabled && <PomodoroTimer />}
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center focus:outline-none"
aria-label="User Menu"
>
{currentUser?.avatarUrl ? (
<img
src={currentUser.avatarUrl}
alt="User Avatar"
className="h-8 w-8 rounded-full object-cover border-2 border-green-500"
/>
) : (
<div className="h-8 w-8 rounded-full border-2 border-green-500 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<UserIcon className="h-6 w-6 text-gray-500 dark:text-gray-300" />
<Link
to="/"
className="flex items-center no-underline text-gray-900 dark:text-white"
>
<span className="text-2xl font-bold">tududi</span>
</Link>
</div>
)}
</button>
{isDropdownOpen && (
<div
ref={dropdownRef}
className="absolute right-4 top-16 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 border border-gray-200 dark:border-gray-700"
>
<Link
to="/profile"
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')}
</Link>
<Link
to="/about"
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.about', 'About')}
</Link>
<hr className="my-1 border-gray-200 dark:border-gray-600" />
<button
onClick={() => {
setIsDropdownOpen(false);
handleLogout();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t('navigation.logout', 'Logout')}
</button>
</div>
)}
</div>
</div>
</div>
</nav>
);
<div className="flex items-center space-x-4">
<button
onClick={() => openTaskModal('simplified')}
className="flex items-center bg-blue-500 hover:bg-blue-600 text-white rounded-full focus:outline-none transition-all duration-200 px-2 py-2 md:px-3 md:py-2"
aria-label="Quick Inbox Capture"
title="Quick Inbox Capture"
>
<BoltIcon className="h-4 w-4 text-white" />
<InboxIcon className="hidden md:inline-block ml-1.5 h-4 w-4 text-blue-200" />
</button>
{pomodoroEnabled && <PomodoroTimer />}
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center focus:outline-none"
aria-label="User Menu"
>
{currentUser?.avatarUrl ? (
<img
src={currentUser.avatarUrl}
alt="User Avatar"
className="h-8 w-8 rounded-full object-cover border-2 border-green-500"
/>
) : (
<div className="h-8 w-8 rounded-full border-2 border-green-500 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<UserIcon className="h-6 w-6 text-gray-500 dark:text-gray-300" />
</div>
)}
</button>
{isDropdownOpen && (
<div
ref={dropdownRef}
className="absolute right-4 top-16 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 border border-gray-200 dark:border-gray-700"
>
<Link
to="/profile"
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'
)}
</Link>
<Link
to="/about"
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.about', 'About')}
</Link>
<hr className="my-1 border-gray-200 dark:border-gray-600" />
<button
onClick={() => {
setIsDropdownOpen(false);
handleLogout();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
{t('navigation.logout', 'Logout')}
</button>
</div>
)}
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View file

@ -1,198 +1,232 @@
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 [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const navigate = useNavigate();
// Dispatch global modal events
const { id } = useParams<{ id: string }>();
const [note, setNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] =
useState<boolean>(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const fetchNote = async () => {
try {
setIsLoading(true);
const notes = await fetchNotes();
const foundNote = notes.find((n: Note) => n.id === Number(id));
setNote(foundNote || null);
if (!foundNote) {
setIsError(true);
// Dispatch global modal events
useEffect(() => {
const fetchNote = async () => {
try {
setIsLoading(true);
const notes = await fetchNotes();
const foundNote = notes.find((n: Note) => n.id === Number(id));
setNote(foundNote || null);
if (!foundNote) {
setIsError(true);
}
} catch (err) {
setIsError(true);
console.error('Error fetching note:', err);
} finally {
setIsLoading(false);
}
};
fetchNote();
}, [id]);
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await apiDeleteNote(noteToDelete.id!);
navigate('/notes');
} catch (err) {
console.error('Error deleting note:', err);
}
} catch (err) {
setIsError(true);
console.error('Error fetching note:', err);
} finally {
setIsLoading(false);
}
};
fetchNote();
}, [id]);
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await apiDeleteNote(noteToDelete.id!);
navigate('/notes');
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleSaveNote = async (updatedNote: Note) => {
try {
if (updatedNote.id !== undefined) {
const savedNote = await apiUpdateNote(
updatedNote.id,
updatedNote
);
setNote(savedNote);
} else {
console.error('Error: Note ID is undefined.');
}
} catch (err) {
console.error('Error saving note:', err);
}
setIsNoteModalOpen(false);
};
const handleSaveNote = async (updatedNote: Note) => {
try {
if (updatedNote.id !== undefined) {
const savedNote = await apiUpdateNote(updatedNote.id, updatedNote);
setNote(savedNote);
} else {
console.error("Error: Note ID is undefined.");
}
} catch (err) {
console.error('Error saving note:', err);
}
setIsNoteModalOpen(false);
};
const handleEditNote = () => {
setIsNoteModalOpen(true);
};
const handleEditNote = () => {
setIsNoteModalOpen(true);
};
const handleOpenConfirmDialog = (note: Note) => {
setNoteToDelete(note);
setIsConfirmDialogOpen(true);
};
const handleOpenConfirmDialog = (note: Note) => {
setNoteToDelete(note);
setIsConfirmDialogOpen(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading note details...
</div>
</div>
);
}
if (isError || !note) {
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.'}
</div>
</div>
);
}
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Header Section with Title and Action Buttons */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<DocumentTextIcon className="h-6 w-6 text-xl mr-2" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
{note.title}
</h2>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
onClick={handleEditNote}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
aria-label={`Edit ${note.title}`}
title={`Edit ${note.title}`}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleOpenConfirmDialog(note)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
aria-label={`Delete ${note.title}`}
title={`Delete ${note.title}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
{/* Tags and 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)) && (
<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>
<div className="flex flex-wrap gap-2 mt-1">
{(note.tags || note.Tags || []).map((tag) => (
<button
key={tag.id}
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>
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading note details...
</div>
</div>
)}
{/* Note Project */}
{(note.project || note.Project) && (
<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>
<Link
to={`/project/${(note.project || note.Project)?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{(note.project || note.Project)?.name}
</Link>
</div>
)}
</div>
) : null}
{/* Note Content */}
<div className="mb-6 bg-white dark:bg-gray-900 shadow-md rounded-lg p-6">
<MarkdownRenderer content={note.content} />
</div>
);
}
if (isError || !note) {
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.'}
</div>
</div>
);
}
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Header Section with Title and Action Buttons */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<DocumentTextIcon className="h-6 w-6 text-xl mr-2" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
{note.title}
</h2>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
onClick={handleEditNote}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
aria-label={`Edit ${note.title}`}
title={`Edit ${note.title}`}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => handleOpenConfirmDialog(note)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
aria-label={`Delete ${note.title}`}
title={`Delete ${note.title}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
{/* Tags and 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)) && (
<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>
<div className="flex flex-wrap gap-2 mt-1">
{(note.tags || note.Tags || []).map(
(tag) => (
<button
key={tag.id}
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>
</div>
)}
{/* Note Project */}
{(note.project || note.Project) && (
<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>
<Link
to={`/project/${(note.project || note.Project)?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{(note.project || note.Project)?.name}
</Link>
</div>
)}
</div>
) : null}
{/* Note Content */}
<div className="mb-6 bg-white dark:bg-gray-900 shadow-md rounded-lg p-6">
<MarkdownRenderer content={note.content} />
</div>
{/* NoteModal for editing */}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => setIsNoteModalOpen(false)}
onSave={handleSaveNote}
note={note}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title="Delete Note"
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
onConfirm={handleDeleteNote}
onCancel={() => {
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
}}
/>
)}
</div>
</div>
{/* NoteModal for editing */}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => setIsNoteModalOpen(false)}
onSave={handleSaveNote}
note={note}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title="Delete Note"
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
onConfirm={handleDeleteNote}
onCancel={() => {
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
}}
/>
)}
</div>
</div>
);
);
};
export default NoteDetails;
export default NoteDetails;

File diff suppressed because it is too large Load diff

View file

@ -1,303 +1,387 @@
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,
PencilSquareIcon,
TrashIcon,
MagnifyingGlassIcon,
TagIcon,
FolderIcon,
BookOpenIcon,
PencilSquareIcon,
TrashIcon,
MagnifyingGlassIcon,
TagIcon,
FolderIcon,
} from '@heroicons/react/24/solid';
import NoteModal from './Note/NoteModal';
import ConfirmDialog from './Shared/ConfirmDialog';
import { Note } from '../entities/Note';
import {
fetchNotes,
createNote,
updateNote,
deleteNote as apiDeleteNote,
fetchNotes,
createNote,
updateNote,
deleteNote as apiDeleteNote,
} from '../utils/notesService';
import { useStore } from '../store/useStore';
import { createProject, fetchProjects } from '../utils/projectsService';
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);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
// Get projects from store
const projects = useStore((state) => state.projectsStore.projects);
const { setProjects } = useStore((state) => state.projectsStore);
// 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 });
const [isError, setIsError] = useState(false);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
console.log('Notes component rendering...');
useEffect(() => {
const loadNotes = async () => {
setIsLoading(true);
try {
const fetchedNotes = await fetchNotes();
setNotes(fetchedNotes);
} catch (error) {
console.error('Error loading notes:', error);
setIsError(true);
} finally {
setIsLoading(false);
}
};
const { t } = useTranslation();
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
loadNotes();
}, []);
// Get projects from store
const projects = useStore((state) => state.projectsStore.projects);
const { setProjects } = useStore((state) => state.projectsStore);
// 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('Force loading projects in Notes component...');
try {
// Fetch all projects (active and inactive)
const fetchedProjects = await fetchProjects("all", "");
console.log('Raw API response:', fetchedProjects);
console.log('Projects loaded:', fetchedProjects.length, fetchedProjects.map(p => p.name));
setProjects(fetchedProjects);
console.log('setProjects called');
} catch (error) {
console.error('Error loading projects:', error);
}
};
// Memoize projects to ensure stable reference
const memoizedProjects = useMemo(() => projects || [], [projects]);
loadProjectsIfNeeded();
}, []); // Remove dependencies to force it to run once
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await apiDeleteNote(noteToDelete.id!);
setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id));
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleEditNote = (note: Note) => {
console.log('Opening note modal with projects:', {
projectsLength: projects?.length,
memoizedLength: memoizedProjects?.length,
projectsExist: !!projects,
memoizedExist: !!memoizedProjects
console.log('Notes component render - projects:', {
projectsLength: projects?.length,
projects: projects?.map((p) => p.name),
});
console.log('Memoized projects:', {
memoizedLength: memoizedProjects?.length,
});
setSelectedNote(note);
setIsNoteModalOpen(true);
};
const handleSaveNote = async (noteData: Note) => {
try {
let updatedNotes;
if (noteData.id) {
const savedNote = await updateNote(noteData.id, noteData);
updatedNotes = notes.map((note) =>
note.id === noteData.id ? savedNote : note
const [isError, setIsError] = useState(false);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
useEffect(() => {
const loadNotes = async () => {
setIsLoading(true);
try {
const fetchedNotes = await fetchNotes();
setNotes(fetchedNotes);
} catch (error) {
console.error('Error loading notes:', error);
setIsError(true);
} finally {
setIsLoading(false);
}
};
loadNotes();
}, []);
// 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('Force loading projects in Notes component...');
try {
// Fetch all projects (active and inactive)
const fetchedProjects = await fetchProjects('all', '');
console.log('Raw API response:', fetchedProjects);
console.log(
'Projects loaded:',
fetchedProjects.length,
fetchedProjects.map((p) => p.name)
);
setProjects(fetchedProjects);
console.log('setProjects called');
} catch (error) {
console.error('Error loading projects:', error);
}
};
loadProjectsIfNeeded();
}, []); // Remove dependencies to force it to run once
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await apiDeleteNote(noteToDelete.id!);
setNotes((prev) =>
prev.filter((note) => note.id !== noteToDelete.id)
);
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleEditNote = (note: Note) => {
console.log('Opening note modal with projects:', {
projectsLength: projects?.length,
memoizedLength: memoizedProjects?.length,
projectsExist: !!projects,
memoizedExist: !!memoizedProjects,
});
setSelectedNote(note);
setIsNoteModalOpen(true);
};
const handleSaveNote = async (noteData: Note) => {
try {
let updatedNotes;
if (noteData.id) {
const savedNote = await updateNote(noteData.id, noteData);
updatedNotes = notes.map((note) =>
note.id === noteData.id ? savedNote : note
);
} else {
const newNote = await createNote(noteData);
updatedNotes = [...notes, newNote];
}
setNotes(updatedNotes);
setIsNoteModalOpen(false);
setSelectedNote(null);
} catch (err) {
console.error('Error saving note:', err);
}
};
const handleCreateProject = async (name: string) => {
try {
const newProject = await createProject({
name,
priority: 'medium',
});
return newProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
};
const filteredNotes = notes.filter(
(note) =>
note.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
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">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('notes.loading')}
</div>
</div>
);
} else {
const newNote = await createNote(noteData);
updatedNotes = [...notes, newNote];
}
setNotes(updatedNotes);
setIsNoteModalOpen(false);
setSelectedNote(null);
} catch (err) {
console.error('Error saving note:', err);
}
};
const handleCreateProject = async (name: string) => {
try {
const newProject = await createProject({ name, priority: 'medium' });
return newProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
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('notes.error')}</div>
</div>
);
}
};
const filteredNotes = notes.filter(
(note) =>
note.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
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">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('notes.loading')}
</div>
</div>
);
}
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('notes.error')}</div>
</div>
);
}
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Notes Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
{t('notes.title')}
</h2>
</div>
</div>
{/* Search Bar with Icon */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={t('notes.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{/* Notes List */}
{filteredNotes.length === 0 ? (
<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)}
onMouseLeave={() => setHoveredNoteId(null)}
>
<div className="flex-grow overflow-hidden pr-4">
<div className="flex flex-col">
<Link
to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline mb-1"
>
{note.title}
</Link>
{/* Project and Tags */}
{((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) && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<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.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>
</div>
)}
</div>
)}
</div>
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Notes Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
{t('notes.title')}
</h2>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditNote(note)}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={t('notes.editNoteAriaLabel', { noteTitle: note.title })}
title={t('notes.editNoteTitle', { noteTitle: note.title })}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => {
setNoteToDelete(note);
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 })}
>
<TrashIcon className="h-5 w-5" />
</button>
{/* Search Bar with Icon */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={t('notes.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
</li>
))}
</ul>
)}
{/* NoteModal */}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {
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));
setIsNoteModalOpen(false);
setSelectedNote(null);
} catch (err) {
console.error('Error deleting note:', err);
}
}}
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}
onCreateProject={handleCreateProject}
/>
)}
{/* Notes List */}
{filteredNotes.length === 0 ? (
<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)
}
onMouseLeave={() => setHoveredNoteId(null)}
>
<div className="flex-grow overflow-hidden pr-4">
<div className="flex flex-col">
<Link
to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline mb-1"
>
{note.title}
</Link>
{/* Project and Tags */}
{(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) && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<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.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>
</div>
)}
</div>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditNote(note)}
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
aria-label={t(
'notes.editNoteAriaLabel',
{ noteTitle: note.title }
)}
title={t('notes.editNoteTitle', {
noteTitle: note.title,
})}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => {
setNoteToDelete(note);
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,
})}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title={t('modals.deleteNote.title')}
message={t('modals.deleteNote.message', { noteTitle: noteToDelete.title })}
onConfirm={handleDeleteNote}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
);
{/* NoteModal */}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => {
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)
);
setIsNoteModalOpen(false);
setSelectedNote(null);
} catch (err) {
console.error('Error deleting note:', err);
}
}}
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)
}
onCreateProject={handleCreateProject}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title={t('modals.deleteNote.title')}
message={t('modals.deleteNote.message', {
noteTitle: noteToDelete.title,
})}
onConfirm={handleDeleteNote}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
);
};
export default Notes;
export default Notes;

View file

@ -2,377 +2,497 @@ import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
AcademicCapIcon,
ExclamationTriangleIcon,
ClockIcon,
FolderIcon,
ChevronDownIcon,
ChevronRightIcon
AcademicCapIcon,
ExclamationTriangleIcon,
ClockIcon,
FolderIcon,
ChevronDownIcon,
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';
title: string;
description: string;
items: (Task | Project)[];
icon: React.ComponentType<any>;
color: string;
type:
| 'stalled_projects'
| 'completed_no_next'
| 'tasks_are_projects'
| 'vague_tasks'
| 'overdue_tasks'
| 'stuck_projects';
title: string;
description: string;
items: (Task | Project)[];
icon: React.ComponentType<any>;
color: string;
}
interface ProductivityAssistantProps {
tasks: Task[];
projects: Project[];
tasks: Task[];
projects: Project[];
}
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());
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [allProjects, setAllProjects] = useState<Project[]>(projects);
const [loading, setLoading] = useState(false);
const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({
tasks,
projects,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { showSuccessToast, showErrorToast } = useToast();
const PROJECT_VERBS = ['plan', 'organize', 'set up', 'setup', 'fix', 'review', 'implement', 'create', 'build', 'develop'];
const OVERDUE_THRESHOLD_DAYS = 30;
const [isExpanded, setIsExpanded] = useState(false);
const [insights, setInsights] = useState<ProductivityInsight[]>([]);
const [expandedInsights, setExpandedInsights] = useState<Set<number>>(
new Set()
);
useEffect(() => {
const generateInsights = () => {
const newInsights: ProductivityInsight[] = [];
// Modal states
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [allProjects, setAllProjects] = useState<Project[]>(projects);
const [loading, setLoading] = useState(false);
// Filter to only include non-completed tasks
const activeTasks = tasks.filter(task => task.status !== 'done' && task.status !== 'archived');
const PROJECT_VERBS = [
'plan',
'organize',
'set up',
'setup',
'fix',
'review',
'implement',
'create',
'build',
'develop',
];
const OVERDUE_THRESHOLD_DAYS = 30;
// 1. Stalled Projects (no tasks/actions)
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'),
items: stalledProjects,
icon: FolderIcon,
color: 'text-red-500'
});
}
useEffect(() => {
const generateInsights = () => {
const newInsights: ProductivityInsight[] = [];
// 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')
);
return project.active && hasCompletedTasks && !hasNextAction;
});
// Filter to only include non-completed tasks
const activeTasks = tasks.filter(
(task) => task.status !== 'done' && task.status !== 'archived'
);
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'),
items: projectsNeedingNextAction,
icon: ExclamationTriangleIcon,
color: 'text-yellow-500'
});
}
// 1. Stalled Projects (no tasks/actions)
const stalledProjects = projects.filter(
(project) =>
project.active &&
!activeTasks.some((task) => task.project_id === project.id)
);
// 3. Tasks that are actually projects
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
});
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'
),
items: stalledProjects,
icon: FolderIcon,
color: 'text-red-500',
});
}
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'),
items: tasksAreProjects,
icon: AcademicCapIcon,
color: 'text-blue-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')
);
return project.active && hasCompletedTasks && !hasNextAction;
});
// 4. Tasks without clear verbs
const vagueTasks = getVagueTasks(activeTasks);
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'
),
items: projectsNeedingNextAction,
icon: ExclamationTriangleIcon,
color: 'text-yellow-500',
});
}
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'),
items: vagueTasks,
icon: ExclamationTriangleIcon,
color: 'text-orange-500'
});
}
// 3. Tasks that are actually projects
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
});
// 5. Overdue or stale tasks
const now = new Date();
const thresholdDate = new Date(now.getTime() - (OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000));
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;
return taskDate && taskDate < thresholdDate;
});
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'
),
items: tasksAreProjects,
icon: AcademicCapIcon,
color: 'text-blue-500',
});
}
if (staleTasks.length > 0) {
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 }),
items: staleTasks,
icon: ClockIcon,
color: 'text-gray-500'
});
}
// 4. Tasks without clear verbs
const vagueTasks = getVagueTasks(activeTasks);
// 6. Stuck projects (not updated in a month)
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);
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;
if (!taskDate) return latest;
return !latest || taskDate > latest ? taskDate : latest;
}, null as Date | null);
return mostRecentTaskDate && mostRecentTaskDate < thresholdDate;
});
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'
),
items: vagueTasks,
icon: ExclamationTriangleIcon,
color: 'text-orange-500',
});
}
if (stuckProjects.length > 0) {
newInsights.push({
type: 'stuck_projects',
title: t('productivity.stuckProjects', 'Stuck Projects'),
description: t('productivity.stuckProjectsDesc', 'Projects not updated recently'),
items: stuckProjects,
icon: FolderIcon,
color: 'text-purple-500'
});
}
// 5. Overdue or stale tasks
const now = new Date();
const thresholdDate = new Date(
now.getTime() - OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
);
setInsights(newInsights);
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;
return taskDate && taskDate < thresholdDate;
});
if (staleTasks.length > 0) {
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 }
),
items: staleTasks,
icon: ClockIcon,
color: 'text-gray-500',
});
}
// 6. Stuck projects (not updated in a month)
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
);
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;
if (!taskDate) return latest;
return !latest || taskDate > latest ? taskDate : latest;
},
null as Date | null
);
return mostRecentTaskDate && mostRecentTaskDate < thresholdDate;
});
if (stuckProjects.length > 0) {
newInsights.push({
type: 'stuck_projects',
title: t('productivity.stuckProjects', 'Stuck Projects'),
description: t(
'productivity.stuckProjectsDesc',
'Projects not updated recently'
),
items: stuckProjects,
icon: FolderIcon,
color: 'text-purple-500',
});
}
setInsights(newInsights);
};
generateInsights();
}, [tasks, projects, t]);
const totalIssues = insights.reduce(
(sum, insight) => sum + insight.items.length,
0
);
const toggleInsightExpansion = (index: number) => {
const newExpanded = new Set(expandedInsights);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedInsights(newExpanded);
};
generateInsights();
}, [tasks, projects, t]);
const handleItemClick = async (item: Task | Project) => {
const isTask = 'status' in item;
const totalIssues = insights.reduce((sum, insight) => sum + insight.items.length, 0);
const toggleInsightExpansion = (index: number) => {
const newExpanded = new Set(expandedInsights);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedInsights(newExpanded);
};
const handleItemClick = async (item: Task | Project) => {
const isTask = 'status' in item;
if (isTask) {
// Handle task click - open task modal
try {
setLoading(true);
const fullTask = await fetchTaskById(item.id!);
setSelectedTask(fullTask);
setIsTaskModalOpen(true);
} catch (error) {
console.error('Failed to fetch task:', error);
showErrorToast(t('errors.failedToLoadTask', 'Failed to load task'));
} finally {
setLoading(false);
}
} else {
// Handle project click - navigate to project page
navigate(`/project/${item.id}`);
}
};
const handleTaskSave = async (updatedTask: Task) => {
try {
if (updatedTask.id) {
await updateTask(updatedTask.id, updatedTask);
setIsTaskModalOpen(false);
setSelectedTask(null);
showSuccessToast(t('task.updateSuccess', 'Task updated successfully'));
// Optionally refresh the parent component data
}
} catch (error) {
console.error('Failed to update task:', error);
showErrorToast(t('task.updateError', 'Failed to update task'));
}
};
const handleTaskDelete = async () => {
try {
if (selectedTask?.id) {
await deleteTask(selectedTask.id);
setIsTaskModalOpen(false);
setSelectedTask(null);
showSuccessToast(t('task.deleteSuccess', 'Task deleted successfully'));
// Optionally refresh the parent component data
}
} catch (error) {
console.error('Failed to delete task:', error);
showErrorToast(t('task.deleteError', 'Failed to delete task'));
}
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const project = await createProject({ name, active: true });
setAllProjects(prev => [...prev, project]);
return project;
} catch (error) {
console.error('Failed to create project:', error);
throw error;
}
};
// Load projects when component mounts
useEffect(() => {
const loadProjects = async () => {
try {
const projectsData = await fetchProjects();
setAllProjects(Array.isArray(projectsData) ? projectsData : []);
} catch (error) {
console.error('Failed to load projects:', error);
}
if (isTask) {
// Handle task click - open task modal
try {
setLoading(true);
const fullTask = await fetchTaskById(item.id!);
setSelectedTask(fullTask);
setIsTaskModalOpen(true);
} catch (error) {
console.error('Failed to fetch task:', error);
showErrorToast(
t('errors.failedToLoadTask', 'Failed to load task')
);
} finally {
setLoading(false);
}
} else {
// Handle project click - navigate to project page
navigate(`/project/${item.id}`);
}
};
if (projects.length === 0) {
loadProjects();
} else {
setAllProjects(projects);
const handleTaskSave = async (updatedTask: Task) => {
try {
if (updatedTask.id) {
await updateTask(updatedTask.id, updatedTask);
setIsTaskModalOpen(false);
setSelectedTask(null);
showSuccessToast(
t('task.updateSuccess', 'Task updated successfully')
);
// Optionally refresh the parent component data
}
} catch (error) {
console.error('Failed to update task:', error);
showErrorToast(t('task.updateError', 'Failed to update task'));
}
};
const handleTaskDelete = async () => {
try {
if (selectedTask?.id) {
await deleteTask(selectedTask.id);
setIsTaskModalOpen(false);
setSelectedTask(null);
showSuccessToast(
t('task.deleteSuccess', 'Task deleted successfully')
);
// Optionally refresh the parent component data
}
} catch (error) {
console.error('Failed to delete task:', error);
showErrorToast(t('task.deleteError', 'Failed to delete task'));
}
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const project = await createProject({ name, active: true });
setAllProjects((prev) => [...prev, project]);
return project;
} catch (error) {
console.error('Failed to create project:', error);
throw error;
}
};
// Load projects when component mounts
useEffect(() => {
const loadProjects = async () => {
try {
const projectsData = await fetchProjects();
setAllProjects(Array.isArray(projectsData) ? projectsData : []);
} catch (error) {
console.error('Failed to load projects:', error);
}
};
if (projects.length === 0) {
loadProjects();
} else {
setAllProjects(projects);
}
}, [projects]);
if (totalIssues === 0) {
return null;
}
}, [projects]);
if (totalIssues === 0) {
return null;
}
return (
<div className="mb-2 p-4 bg-white dark:bg-gray-900 border-l-4 border-yellow-500 rounded-lg shadow">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center w-full"
>
<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 })}
</p>
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
{t('productivity.reviewItems', 'Click to review and improve your workflow')}
</p>
</div>
{isExpanded ? (
<ChevronDownIcon className="h-5 w-5 text-yellow-500" />
) : (
<ChevronRightIcon className="h-5 w-5 text-yellow-500" />
)}
</button>
{isExpanded && (
<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 className="flex items-start space-x-3">
<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})
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{insight.description}
return (
<div className="mb-2 p-4 bg-white dark:bg-gray-900 border-l-4 border-yellow-500 rounded-lg shadow">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center w-full"
>
<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 }
)}
</p>
<p className="text-yellow-600 dark:text-yellow-400 text-sm">
{t(
'productivity.reviewItems',
'Click to review and improve your workflow'
)}
</p>
<div className="space-y-1">
{(expandedInsights.has(index) ? insight.items : insight.items.slice(0, 3)).map((item, itemIndex) => {
return (
<div key={itemIndex} className="text-sm">
<button
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}
>
{item.name}
</button>
</div>
);
})}
{insight.items.length > 3 && (
<button
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`
}
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
<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.')}
</p>
</div>
{isExpanded ? (
<ChevronDownIcon className="h-5 w-5 text-yellow-500" />
) : (
<ChevronRightIcon className="h-5 w-5 text-yellow-500" />
)}
</button>
{isExpanded && (
<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 className="flex items-start space-x-3">
<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})
</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) => {
return (
<div
key={itemIndex}
className="text-sm"
>
<button
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}
>
{item.name}
</button>
</div>
);
})}
{insight.items.length > 3 && (
<button
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`}
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
<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.'
)}
</p>
</div>
</div>
)}
{/* Task Modal */}
{selectedTask && (
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => {
setIsTaskModalOpen(false);
setSelectedTask(null);
}}
task={selectedTask}
onSave={handleTaskSave}
onDelete={handleTaskDelete}
projects={allProjects}
onCreateProject={handleCreateProject}
/>
)}
</div>
)}
{/* Task Modal */}
{selectedTask && (
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => {
setIsTaskModalOpen(false);
setSelectedTask(null);
}}
task={selectedTask}
onSave={handleTaskSave}
onDelete={handleTaskDelete}
projects={allProjects}
onCreateProject={handleCreateProject}
/>
)}
</div>
);
);
};
export default ProductivityAssistant;
export default ProductivityAssistant;

File diff suppressed because it is too large Load diff

View file

@ -1,139 +1,168 @@
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;
onDismiss: () => void;
projectName: string;
onAddAction: (actionDescription: string) => void;
onDismiss: () => void;
projectName: string;
}
const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
onAddAction,
onDismiss,
projectName,
onAddAction,
onDismiss,
projectName, // eslint-disable-line @typescript-eslint/no-unused-vars
}) => {
const [actionDescription, setActionDescription] = useState("");
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const { showSuccessToast } = useToast();
const [actionDescription, setActionDescription] = useState('');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// Focus the input when component mounts
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}, []);
const { t } = useTranslation();
const { showSuccessToast } = useToast();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (actionDescription.trim()) {
onAddAction(actionDescription.trim());
showSuccessToast(t('success.nextActionAdded'));
setActionDescription("");
}
};
useEffect(() => {
// Focus the input when component mounts
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onDismiss();
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (actionDescription.trim()) {
onAddAction(actionDescription.trim());
showSuccessToast(t('success.nextActionAdded'));
setActionDescription('');
}
};
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className="bg-blue-100 dark:bg-blue-800 p-2 rounded-lg mr-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</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?')}
</h3>
</div>
</div>
<button
onClick={onDismiss}
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>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<input
ref={inputRef}
type="text"
value={actionDescription}
onChange={(e) => setActionDescription(e.target.value)}
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...')}
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'
: 'border-blue-200 dark:border-blue-700 hover:border-blue-300 dark:hover:border-blue-600'
}`}
/>
{actionDescription && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<kbd className="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-600 dark:text-blue-300 rounded border border-blue-200 dark:border-blue-700">
Enter
</kbd>
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onDismiss();
}
};
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-6 mb-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className="bg-blue-100 dark:bg-blue-800 p-2 rounded-lg mr-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</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?"
)}
</h3>
</div>
</div>
<button
onClick={onDismiss}
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>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<input
ref={inputRef}
type="text"
value={actionDescription}
onChange={(e) => setActionDescription(e.target.value)}
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...'
)}
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'
: 'border-blue-200 dark:border-blue-700 hover:border-blue-300 dark:hover:border-blue-600'
}`}
/>
{actionDescription && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<kbd className="px-2 py-1 text-xs bg-blue-100 dark:bg-blue-800 text-blue-600 dark:text-blue-300 rounded border border-blue-200 dark:border-blue-700">
Enter
</kbd>
</div>
)}
</div>
<div className="flex justify-between items-center">
<button
type="button"
onClick={onDismiss}
className="px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-transparent hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded-lg transition-colors"
>
{t('profile.skipNextAction', 'Skip for now')}
</button>
<button
type="submit"
disabled={!actionDescription.trim()}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('profile.addNextAction', 'Add Next Action')}
</button>
</div>
</form>
<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>
<span>
{t(
'profile.nextActionHint',
'Think of the smallest, most concrete step you can take right now to move this project forward.'
)}
</span>
</p>
</div>
)}
</div>
<div className="flex justify-between items-center">
<button
type="button"
onClick={onDismiss}
className="px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-transparent hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded-lg transition-colors"
>
{t('profile.skipNextAction', 'Skip for now')}
</button>
<button
type="submit"
disabled={!actionDescription.trim()}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-800 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('profile.addNextAction', 'Add Next Action')}
</button>
</div>
</form>
<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>
<span>
{t('profile.nextActionHint', 'Think of the smallest, most concrete step you can take right now to move this project forward.')}
</span>
</p>
</div>
</div>
);
);
};
export default AutoSuggestNextActionBox;
export default AutoSuggestNextActionBox;

File diff suppressed because it is too large Load diff

View file

@ -1,166 +1,172 @@
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";
color: string;
getCompletionPercentage: () => number;
activeDropdown: number | null;
setActiveDropdown: React.Dispatch<React.SetStateAction<number | null>>;
handleEditProject: (project: Project) => void;
setProjectToDelete: React.Dispatch<React.SetStateAction<Project | null>>;
setIsConfirmDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
project: Project;
viewMode: 'cards' | 'list';
color: string;
getCompletionPercentage: () => number;
activeDropdown: number | null;
setActiveDropdown: React.Dispatch<React.SetStateAction<number | null>>;
handleEditProject: (project: Project) => void;
setProjectToDelete: React.Dispatch<React.SetStateAction<Project | null>>;
setIsConfirmDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const getProjectInitials = (name: string) => {
const words = name
.trim()
.split(" ")
.filter((word) => word.length > 0);
if (words.length === 1) {
return name.toUpperCase();
}
return words.map((word) => word[0].toUpperCase()).join("");
const words = name
.trim()
.split(' ')
.filter((word) => word.length > 0);
if (words.length === 1) {
return name.toUpperCase();
}
return words.map((word) => word[0].toUpperCase()).join('');
};
const ProjectItem: React.FC<ProjectItemProps> = ({
project,
viewMode,
color,
getCompletionPercentage,
activeDropdown,
setActiveDropdown,
handleEditProject,
setProjectToDelete,
setIsConfirmDialogOpen,
project,
viewMode,
color,
getCompletionPercentage,
activeDropdown,
setActiveDropdown,
handleEditProject,
setProjectToDelete,
setIsConfirmDialogOpen,
}) => {
const { t } = useTranslation();
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"
}`}
style={{
minHeight: viewMode === "cards" ? "250px" : "auto",
maxHeight: viewMode === "cards" ? "250px" : "auto",
}}
>
{viewMode === "cards" && (
const { t } = useTranslation();
return (
<div
className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg relative"
style={{ height: "140px" }}
>
{project.image_url ? (
<img
src={project.image_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
<span
className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20"
aria-label={t("projectItem.projectInitials")}
>
{getProjectInitials(project.name)}
</span>
)}
<div
className={`absolute top-2 left-2 w-3 h-3 rounded-full ${color}`}
></div>
</div>
)}
{viewMode === "list" && project.image_url && (
<div className="w-16 h-16 mr-4 flex-shrink-0">
<img
src={project.image_url}
alt={project.name}
className="w-full h-full object-cover rounded-md"
/>
</div>
)}
<div
className={`flex justify-between items-start ${
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>
)}
<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'
? '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'
}`}
>
{project.name}
</Link>
</div>
<div className="relative">
<button
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
)
}
aria-label={t("projectItem.toggleDropdownMenu")}
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
style={{
minHeight: viewMode === 'cards' ? '250px' : 'auto',
maxHeight: viewMode === 'cards' ? '250px' : 'auto',
}}
>
{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' }}
>
{project.image_url ? (
<img
src={project.image_url}
alt={project.name}
className="w-full h-full object-cover"
/>
) : (
<span
className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20"
aria-label={t('projectItem.projectInitials')}
>
{getProjectInitials(project.name)}
</span>
)}
<div
className={`absolute top-2 left-2 w-3 h-3 rounded-full ${color}`}
></div>
</div>
)}
{activeDropdown === project.id && (
<div className="absolute right-0 mt-2 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-10">
<button
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")}
</button>
<button
onClick={() => {
setProjectToDelete(project);
setIsConfirmDialogOpen(true);
setActiveDropdown(null);
}}
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")}
</button>
</div>
)}
</div>
</div>
{viewMode === 'list' && project.image_url && (
<div className="w-16 h-16 mr-4 flex-shrink-0">
<img
src={project.image_url}
alt={project.name}
className="w-full h-full object-cover rounded-md"
/>
</div>
)}
{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() })}
<div
className={`flex justify-between items-start ${
viewMode === 'cards' ? 'p-4 flex-1' : 'flex-1'
}`}
>
<div
className="bg-blue-500 h-2 rounded-full"
style={{
width: `${getCompletionPercentage()}%`,
}}
></div>
<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>
)}
<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'
}`}
>
{project.name}
</Link>
</div>
<div className="relative">
<button
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)
)
}
aria-label={t('projectItem.toggleDropdownMenu')}
>
<EllipsisVerticalIcon className="h-5 w-5" />
</button>
{activeDropdown === project.id && (
<div className="absolute right-0 mt-2 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-10">
<button
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')}
</button>
<button
onClick={() => {
setProjectToDelete(project);
setIsConfirmDialogOpen(true);
setActiveDropdown(null);
}}
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')}
</button>
</div>
)}
</div>
</div>
</div>
{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(),
})}
>
<div
className="bg-blue-500 h-2 rounded-full"
style={{
width: `${getCompletionPercentage()}%`,
}}
></div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
);
};
export default ProjectItem;

File diff suppressed because it is too large Load diff

View file

@ -1,376 +1,446 @@
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";
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';
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" };
default:
return { color: "bg-gray-500" };
}
switch (priority) {
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' };
}
};
const Projects: React.FC = () => {
const { t } = useTranslation();
const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore);
const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore);
const { isLoading, isError } = useStore((state) => state.projectsStore);
const { t } = useTranslation();
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 [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
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 [searchParams, setSearchParams] = useSearchParams();
const activeFilter = searchParams.get("active") || "all";
// Dispatch global modal events
const areaFilter = searchParams.get("area_id") || "";
useEffect(() => {
const loadAreas = async () => {
try {
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error("Failed to fetch areas:", error);
setAreasError(true);
}
};
loadAreas();
}, []);
useEffect(() => {
const loadProjects = async () => {
try {
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
setGroupedProjects(groupedProjectsData);
} catch (error) {
console.error("Failed to fetch projects:", error);
setProjectsError(true);
}
};
loadProjects();
}, [activeFilter, areaFilter]);
const handleSaveProject = async (project: Project) => {
setProjectsLoading(true);
try {
if (project.id) {
await updateProject(project.id, project);
} else {
await createProject(project);
}
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
setGroupedProjects(groupedProjectsData);
} catch (error) {
console.error("Error saving project:", error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
setIsProjectModalOpen(false);
}
};
const handleEditProject = (project: Project) => {
setProjectToEdit(project);
setIsProjectModalOpen(true);
};
const handleDeleteProject = async () => {
if (!projectToDelete) return;
try {
if (projectToDelete.id !== undefined) {
setProjectsLoading(true);
await deleteProject(projectToDelete.id);
const groupedProjectsData = await fetchGroupedProjects(activeFilter, areaFilter);
setGroupedProjects(groupedProjectsData);
} else {
console.error("Cannot delete project: ID is undefined.");
}
} catch (error) {
console.error("Error deleting project:", error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
setIsConfirmDialogOpen(false);
setProjectToDelete(null);
}
};
const getCompletionPercentage = (project: Project) => {
// Now the completion percentage comes directly from the backend
return (project as any).completion_percentage || 0;
};
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newActiveFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newActiveFilter === "all") {
params.delete("active");
} else {
params.set("active", newActiveFilter);
}
setSearchParams(params);
};
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newAreaFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newAreaFilter === "") {
params.delete("area_id");
} else {
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 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);
return acc;
}
const filteredProjects = projectsInArea.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
if (filteredProjects.length > 0) {
acc[areaName] = filteredProjects;
}
return acc;
},
{}
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('projects.loading')}
</div>
</div>
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 [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
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>
);
}
const [searchParams, setSearchParams] = useSearchParams();
const activeFilter = searchParams.get('active') || 'all';
const areaFilter = searchParams.get('area_id') || '';
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-6xl">
<div className="flex items-center mb-8">
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
{t('projects.title')}
</h2>
</div>
{/* View Mode and Filters */}
<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")}
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"
}`}
aria-label={t("projects.cardViewAriaLabel")}
>
<Squares2X2Icon className="h-5 w-5" />
</button>
<button
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"
}`}
aria-label={t("projects.listViewAriaLabel")}
>
<Bars3Icon className="h-5 w-5" />
</button>
</div>
<div className="flex flex-col md:flex-row md:items-center md:space-x-4">
<div className="w-full md:w-auto">
<label
htmlFor="activeFilter"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{t('common.status')}
</label>
<select
id="activeFilter"
value={activeFilter}
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>
</select>
</div>
<div className="w-full md:w-auto">
<label
htmlFor="areaFilter"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{t('common.area')}
</label>
<select
id="areaFilter"
value={areaFilter}
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>
{areas.map((area) => (
<option key={area.id} value={area.id?.toString()}>
{area.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* Search Bar */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{/* 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"
}`}
>
{Object.keys(searchFilteredGroupedProjects).length === 0 ? (
<div className="text-gray-700 dark:text-gray-300">
{t('projects.noProjectsFound')}
</div>
) : (
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"
}`}>
{areaName}
</h3>
{searchFilteredGroupedProjects[areaName].map((project) => {
const { color } = getPriorityStyles(project.priority || "low");
return (
<ProjectItem
key={project.id}
project={project}
viewMode={viewMode}
color={color}
getCompletionPercentage={() => getCompletionPercentage(project)}
activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown}
handleEditProject={handleEditProject}
setProjectToDelete={setProjectToDelete}
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
/>
);
})}
</React.Fragment>
))
)}
</div>
</div>
{isProjectModalOpen && (
<ProjectModal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setProjectToEdit(null);
}}
onSave={handleSaveProject}
onDelete={async (projectId) => {
useEffect(() => {
const loadAreas = async () => {
try {
await deleteProject(projectId);
setProjects(projects.filter((p: Project) => p.id !== projectId));
setIsProjectModalOpen(false);
setProjectToEdit(null);
const areasData = await fetchAreas();
setAreas(areasData);
} catch (error) {
console.error('Error deleting project:', error);
console.error('Failed to fetch areas:', error);
setAreasError(true);
}
}}
project={projectToEdit || undefined}
areas={areas}
/>
)}
};
{isConfirmDialogOpen && (
<ConfirmDialog
title={t('modals.deleteProject.title')}
message={t('modals.deleteProject.message', { projectName: projectToDelete?.name })}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
);
loadAreas();
}, []);
useEffect(() => {
const loadProjects = async () => {
try {
const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData);
} catch (error) {
console.error('Failed to fetch projects:', error);
setProjectsError(true);
}
};
loadProjects();
}, [activeFilter, areaFilter]);
const handleSaveProject = async (project: Project) => {
setProjectsLoading(true);
try {
if (project.id) {
await updateProject(project.id, project);
} else {
await createProject(project);
}
const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData);
} catch (error) {
console.error('Error saving project:', error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
setIsProjectModalOpen(false);
}
};
const handleEditProject = (project: Project) => {
setProjectToEdit(project);
setIsProjectModalOpen(true);
};
const handleDeleteProject = async () => {
if (!projectToDelete) return;
try {
if (projectToDelete.id !== undefined) {
setProjectsLoading(true);
await deleteProject(projectToDelete.id);
const groupedProjectsData = await fetchGroupedProjects(
activeFilter,
areaFilter
);
setGroupedProjects(groupedProjectsData);
} else {
console.error('Cannot delete project: ID is undefined.');
}
} catch (error) {
console.error('Error deleting project:', error);
setProjectsError(true);
} finally {
setProjectsLoading(false);
setIsConfirmDialogOpen(false);
setProjectToDelete(null);
}
};
const getCompletionPercentage = (project: Project) => {
// Now the completion percentage comes directly from the backend
return (project as any).completion_percentage || 0;
};
const handleActiveFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>
) => {
const newActiveFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newActiveFilter === 'all') {
params.delete('active');
} else {
params.set('active', newActiveFilter);
}
setSearchParams(params);
};
const handleAreaFilterChange = (
e: React.ChangeEvent<HTMLSelectElement>
) => {
const newAreaFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newAreaFilter === '') {
params.delete('area_id');
} else {
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 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
);
return acc;
}
const filteredProjects = projectsInArea.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
if (filteredProjects.length > 0) {
acc[areaName] = filteredProjects;
}
return acc;
}, {});
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('projects.loading')}
</div>
</div>
);
}
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>
);
}
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-6xl">
<div className="flex items-center mb-8">
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
{t('projects.title')}
</h2>
</div>
{/* View Mode and Filters */}
<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')}
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'
}`}
aria-label={t('projects.cardViewAriaLabel')}
>
<Squares2X2Icon className="h-5 w-5" />
</button>
<button
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'
}`}
aria-label={t('projects.listViewAriaLabel')}
>
<Bars3Icon className="h-5 w-5" />
</button>
</div>
<div className="flex flex-col md:flex-row md:items-center md:space-x-4">
<div className="w-full md:w-auto">
<label
htmlFor="activeFilter"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{t('common.status')}
</label>
<select
id="activeFilter"
value={activeFilter}
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>
</select>
</div>
<div className="w-full md:w-auto">
<label
htmlFor="areaFilter"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
{t('common.area')}
</label>
<select
id="areaFilter"
value={areaFilter}
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>
{areas.map((area) => (
<option
key={area.id}
value={area.id?.toString()}
>
{area.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* Search Bar */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={t('projects.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{/* 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'
}`}
>
{Object.keys(searchFilteredGroupedProjects).length === 0 ? (
<div className="text-gray-700 dark:text-gray-300">
{t('projects.noProjectsFound')}
</div>
) : (
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'
}`}
>
{areaName}
</h3>
{searchFilteredGroupedProjects[
areaName
].map((project) => {
const { color } = getPriorityStyles(
project.priority || 'low'
);
return (
<ProjectItem
key={project.id}
project={project}
viewMode={viewMode}
color={color}
getCompletionPercentage={() =>
getCompletionPercentage(
project
)
}
activeDropdown={activeDropdown}
setActiveDropdown={
setActiveDropdown
}
handleEditProject={
handleEditProject
}
setProjectToDelete={
setProjectToDelete
}
setIsConfirmDialogOpen={
setIsConfirmDialogOpen
}
/>
);
})}
</React.Fragment>
)
)
)}
</div>
</div>
{isProjectModalOpen && (
<ProjectModal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setProjectToEdit(null);
}}
onSave={handleSaveProject}
onDelete={async (projectId) => {
try {
await deleteProject(projectId);
setProjects(
projects.filter(
(p: Project) => p.id !== projectId
)
);
setIsProjectModalOpen(false);
setProjectToEdit(null);
} catch (error) {
console.error('Error deleting project:', error);
}
}}
project={projectToEdit || undefined}
areas={areas}
/>
)}
{isConfirmDialogOpen && (
<ConfirmDialog
title={t('modals.deleteProject.title')}
message={t('modals.deleteProject.message', {
projectName: projectToDelete?.name,
})}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
);
};
export default Projects;

View file

@ -1,47 +1,51 @@
import React from 'react';
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface CollapsibleSectionProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
className?: string;
title: string;
isExpanded: boolean;
onToggle: () => void;
children: React.ReactNode;
className?: string;
}
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
isExpanded,
onToggle,
children,
className = ""
title,
isExpanded,
onToggle,
children,
className = '',
}) => {
return (
<div className={`border-b border-gray-200 dark:border-gray-700 ${className}`}>
<button
type="button"
onClick={onToggle}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{title}
</span>
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-500" />
)}
</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}
return (
<div
className={`border-b border-gray-200 dark:border-gray-700 ${className}`}
>
<button
type="button"
onClick={onToggle}
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{title}
</span>
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-500" />
)}
</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>
</div>
</div>
</div>
);
);
};
export default CollapsibleSection;
export default CollapsibleSection;

View file

@ -2,37 +2,46 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
interface ConfirmDialogProps {
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => {
const { t } = useTranslation();
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>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 focus:outline-none"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 focus:outline-none"
>
{t('common.delete', 'Delete')}
</button>
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>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 focus:outline-none"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 focus:outline-none"
>
{t('common.delete', 'Delete')}
</button>
</div>
</div>
</div>
</div>
</div>
);
);
};
export default ConfirmDialog;

View file

@ -1,25 +1,25 @@
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";
});
const [darkMode, setDarkMode] = useState<boolean>(() => {
return localStorage.getItem('darkMode') === 'true';
});
useEffect(() => {
document.body.classList.toggle("dark-mode", darkMode);
localStorage.setItem("darkMode", darkMode.toString());
}, [darkMode]);
useEffect(() => {
document.body.classList.toggle('dark-mode', darkMode);
localStorage.setItem('darkMode', darkMode.toString());
}, [darkMode]);
return (
<button onClick={() => setDarkMode(!darkMode)}>
{darkMode ? (
<SunIcon className="h-6 w-6" />
) : (
<MoonIcon className="h-6 w-6" />
)}
</button>
);
return (
<button onClick={() => setDarkMode(!darkMode)}>
{darkMode ? (
<SunIcon className="h-6 w-6" />
) : (
<MoonIcon className="h-6 w-6" />
)}
</button>
);
};
export default DarkModeToggle;

View file

@ -1,319 +1,367 @@
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;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
label?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
const DatePicker: React.FC<DatePickerProps> = ({
value,
onChange,
placeholder = 'Select date',
disabled = false,
className = '',
label
value,
onChange,
placeholder = 'Select date',
disabled = false,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(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'
];
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const parseDate = (dateString: string) => {
return dateString ? new Date(dateString + 'T00:00:00') : null;
};
const formatDisplayDate = (dateString: string) => {
if (!dateString) return placeholder;
const date = parseDate(dateString);
if (!date || isNaN(date.getTime())) return placeholder;
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
const [isOpen, setIsOpen] = useState(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 handleToggle = () => {
if (disabled) return;
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 320; // Calendar height
const padding = 16; // Extra padding from viewport edges
// Determine if we should open upward
const wouldFitBelow = spaceBelow >= menuHeight + padding;
const wouldFitAbove = spaceAbove >= menuHeight + padding;
let openUpward = false;
let top = rect.bottom + 8;
if (!wouldFitBelow && wouldFitAbove) {
// Open upward if it fits above but not below
openUpward = true;
top = rect.top - menuHeight - 8;
} else if (!wouldFitBelow && !wouldFitAbove) {
// If it doesn't fit in either direction, choose the side with more space
if (spaceAbove > spaceBelow) {
openUpward = true;
top = Math.max(padding, rect.top - menuHeight - 8);
} else {
top = Math.min(window.innerHeight - menuHeight - padding, rect.bottom + 8);
}
}
// Ensure left position doesn't go off screen
const left = Math.min(
Math.max(padding, rect.left),
window.innerWidth - Math.max(rect.width, 280) - padding
);
setPosition({
top,
left,
width: Math.max(rect.width, 280), // Minimum width for calendar
openUpward
});
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
// 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));
}
} else {
setCurrentMonth(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
}
}
setIsOpen(!isOpen);
};
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleDateSelect = (date: Date) => {
try {
onChange(formatDate(date));
setIsOpen(false);
} catch (error) {
console.error('Error in date selection:', error);
setIsOpen(false);
}
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setIsOpen(false);
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth(prev => {
const newMonth = new Date(prev);
if (direction === 'prev') {
newMonth.setMonth(newMonth.getMonth() - 1);
} else {
newMonth.setMonth(newMonth.getMonth() + 1);
}
return newMonth;
});
};
const getDaysInMonth = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
// Add days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
return days;
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isSelected = (date: Date) => {
if (!value) return false;
const selectedDate = parseDate(value);
return selectedDate && date.toDateString() === selectedDate.toDateString();
};
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
}, [isOpen]);
return (
<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'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<span className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}>
{formatDisplayDate(value)}
</span>
<div className="flex items-center space-x-1">
{value && (
<button
type="button"
onClick={handleClear}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs"
>
×
</button>
)}
<CalendarDaysIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</div>
</button>
const parseDate = (dateString: string) => {
return dateString ? new Date(dateString + 'T00:00:00') : null;
};
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
const formatDisplayDate = (dateString: string) => {
if (!dateString) return placeholder;
const date = parseDate(dateString);
if (!date || isNaN(date.getTime())) return placeholder;
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const handleToggle = () => {
if (disabled) return;
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 320; // Calendar height
const padding = 16; // Extra padding from viewport edges
// Determine if we should open upward
const wouldFitBelow = spaceBelow >= menuHeight + padding;
const wouldFitAbove = spaceAbove >= menuHeight + padding;
let openUpward = false;
let top = rect.bottom + 8;
if (!wouldFitBelow && wouldFitAbove) {
// Open upward if it fits above but not below
openUpward = true;
top = rect.top - menuHeight - 8;
} else if (!wouldFitBelow && !wouldFitAbove) {
// If it doesn't fit in either direction, choose the side with more space
if (spaceAbove > spaceBelow) {
openUpward = true;
top = Math.max(padding, rect.top - menuHeight - 8);
} else {
top = Math.min(
window.innerHeight - menuHeight - padding,
rect.bottom + 8
);
}
}
// Ensure left position doesn't go off screen
const left = Math.min(
Math.max(padding, rect.left),
window.innerWidth - Math.max(rect.width, 280) - padding
);
setPosition({
top,
left,
width: Math.max(rect.width, 280), // Minimum width for calendar
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
)
);
}
} else {
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)
) {
setIsOpen(false);
}
};
const handleDateSelect = (date: Date) => {
try {
onChange(formatDate(date));
setIsOpen(false);
} catch (error) {
console.error('Error in date selection:', error);
setIsOpen(false);
}
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setIsOpen(false);
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth((prev) => {
const newMonth = new Date(prev);
if (direction === 'prev') {
newMonth.setMonth(newMonth.getMonth() - 1);
} else {
newMonth.setMonth(newMonth.getMonth() + 1);
}
return newMonth;
});
};
const getDaysInMonth = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const days = [];
// Add empty cells for days before the first day of the month
for (let i = 0; i < startingDayOfWeek; i++) {
days.push(null);
}
// Add days of the month
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
return days;
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isSelected = (date: Date) => {
if (!value) return false;
const selectedDate = parseDate(value);
return (
selectedDate && date.toDateString() === selectedDate.toDateString()
);
};
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
{/* Calendar Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-600">
<button
type="button"
onClick={() => navigateMonth('prev')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<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()}
</span>
<button
type="button"
onClick={() => navigateMonth('next')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<ChevronRightIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
</div>
{/* Calendar Grid */}
<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">
{day}
</div>
))}
</div>
{/* Calendar Days */}
<div className="grid grid-cols-7 gap-1">
{getDaysInMonth().map((date, index) => (
<div key={index} className="aspect-square">
{date && (
<button
type="button"
onClick={() => handleDateSelect(date)}
className={`w-full h-full text-xs rounded transition-colors ${
isSelected(date)
? 'bg-blue-600 text-white'
: isToday(date)
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'hover:bg-gray-100 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
}`}
>
{date.getDate()}
</button>
)}
</div>
))}
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-200 dark:border-gray-600 p-3 flex justify-between">
<button
type="button"
onClick={() => handleDateSelect(new Date())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Today
</button>
{value && (
<button
type="button"
onClick={handleClear}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Clear
</button>
)}
</div>
</div>,
document.body
)}
</div>
);
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'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<span
className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{formatDisplayDate(value)}
</span>
<div className="flex items-center space-x-1">
{value && (
<button
type="button"
onClick={handleClear}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs"
>
×
</button>
)}
<CalendarDaysIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</div>
</button>
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Calendar Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-600">
<button
type="button"
onClick={() => navigateMonth('prev')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<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()}
</span>
<button
type="button"
onClick={() => navigateMonth('next')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<ChevronRightIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
</div>
{/* Calendar Grid */}
<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"
>
{day}
</div>
))}
</div>
{/* Calendar Days */}
<div className="grid grid-cols-7 gap-1">
{getDaysInMonth().map((date, index) => (
<div key={index} className="aspect-square">
{date && (
<button
type="button"
onClick={() =>
handleDateSelect(date)
}
className={`w-full h-full text-xs rounded transition-colors ${
isSelected(date)
? 'bg-blue-600 text-white'
: isToday(date)
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'hover:bg-gray-100 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
}`}
>
{date.getDate()}
</button>
)}
</div>
))}
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-200 dark:border-gray-600 p-3 flex justify-between">
<button
type="button"
onClick={() => handleDateSelect(new Date())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Today
</button>
{value && (
<button
type="button"
onClick={handleClear}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Clear
</button>
)}
</div>
</div>,
document.body
)}
</div>
);
};
export default DatePicker;
export default DatePicker;

View file

@ -1,10 +1,9 @@
import React from 'react';
const LoadingScreen: React.FC = () => (
<div className="flex h-screen w-screen items-center justify-center">
<div className="text-lg">Loading application... Please wait.</div>
</div>
<div className="flex h-screen w-screen items-center justify-center">
<div className="text-lg">Loading application... Please wait.</div>
</div>
);
export default LoadingScreen;

View file

@ -5,96 +5,233 @@ import rehypeHighlight from 'rehype-highlight';
import hljs from 'highlight.js';
interface MarkdownRendererProps {
content: string;
className?: string;
content: string;
className?: string;
}
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']
});
// Manual highlighting for any missed code blocks
const timer = setTimeout(() => {
const codeBlocks = document.querySelectorAll('pre code:not(.hljs)');
codeBlocks.forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}, 100);
return () => clearTimeout(timer);
}, [content]);
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',
],
});
return (
<div className={`markdown-content ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
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} />,
// Customize paragraph styles
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} />,
li: ({...props}) => <li className="ml-4" {...props} />,
// Customize link styles
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-');
if (isCodeBlock) {
// This is a code block - add hljs class to ensure our styles apply
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';
if (parentIsPre) {
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>;
}
},
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} />,
// 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} />,
// Customize horizontal rule
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} />,
// Customize italic text
em: ({...props}) => <em className="italic text-gray-700 dark:text-gray-300" {...props} />
}}
>
{content}
</ReactMarkdown>
</div>
);
// Manual highlighting for any missed code blocks
const timer = setTimeout(() => {
const codeBlocks = document.querySelectorAll('pre code:not(.hljs)');
codeBlocks.forEach((block) => {
hljs.highlightElement(block as HTMLElement);
});
}, 100);
return () => clearTimeout(timer);
}, [content]);
return (
<div className={`markdown-content ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
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}
/>
),
// Customize paragraph styles
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}
/>
),
li: ({ ...props }) => <li className="ml-4" {...props} />,
// Customize link styles
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-');
if (isCodeBlock) {
// This is a code block - add hljs class to ensure our styles apply
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
// 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="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}
/>
),
// 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}
/>
),
// 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}
/>
),
// Customize horizontal rule
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}
/>
),
// Customize italic text
em: ({ ...props }) => (
<em
className="italic text-gray-700 dark:text-gray-300"
{...props}
/>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;
export default MarkdownRenderer;

View file

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

View file

@ -3,144 +3,164 @@ import { createPortal } from 'react-dom';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
interface NumberSelectDropdownProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
placeholder?: string;
disabled?: boolean;
className?: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
placeholder?: string;
disabled?: boolean;
className?: string;
}
const NumberSelectDropdown: React.FC<NumberSelectDropdownProps> = ({
value,
onChange,
min = 1,
max = 99,
placeholder = 'Select number',
disabled = false,
className = ''
value,
onChange,
min = 1,
max = 99,
placeholder = 'Select number',
disabled = false,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Generate options from min to max
const options = Array.from({ length: max - min + 1 }, (_, i) => {
const num = min + i;
return { value: num, label: num.toString() };
});
// Generate options from min to max
const options = Array.from({ length: max - min + 1 }, (_, i) => {
const num = min + i;
return { value: num, label: num.toString() };
});
const handleToggle = () => {
if (disabled) return;
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
});
}
setIsOpen(!isOpen);
};
const handleToggle = () => {
if (disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const handleSelect = (selectedValue: number) => {
try {
onChange(selectedValue);
setIsOpen(false);
} catch (error) {
console.error('Error in number dropdown selection:', error);
setIsOpen(false);
}
};
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward,
});
}
setIsOpen(!isOpen);
};
}, [isOpen]);
const selectedOption = options.find(option => option.value === value);
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
return (
<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'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<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' : ''}`} />
</button>
const handleSelect = (selectedValue: number) => {
try {
onChange(selectedValue);
setIsOpen(false);
} catch (error) {
console.error('Error in number dropdown selection:', error);
setIsOpen(false);
}
};
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const selectedOption = options.find((option) => option.value === value);
return (
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(option.value);
}}
className={`flex items-center justify-between px-4 py-2 text-sm w-full text-left hover:bg-gray-100 dark:hover:bg-gray-600 first:rounded-t-md last:rounded-b-md ${
option.value === value
? 'bg-blue-50 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
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'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<span>{option.label}</span>
{option.value === value && (
<span className="text-blue-600 dark:text-blue-400"></span>
)}
<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' : ''}`}
/>
</button>
))}
</div>,
document.body
)}
</div>
);
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(option.value);
}}
className={`flex items-center justify-between px-4 py-2 text-sm w-full text-left hover:bg-gray-100 dark:hover:bg-gray-600 first:rounded-t-md last:rounded-b-md ${
option.value === value
? 'bg-blue-50 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span>{option.label}</span>
{option.value === value && (
<span className="text-blue-600 dark:text-blue-400">
</span>
)}
</button>
))}
</div>,
document.body
)}
</div>
);
};
export default NumberSelectDropdown;
export default NumberSelectDropdown;

View file

@ -1,226 +1,247 @@
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 {
className?: string;
className?: string;
}
const POMODORO_STORAGE_KEY = 'tududi_pomodoro_timer';
const DEFAULT_TIME = 25 * 60; // 25 minutes in seconds
interface PomodoroState {
isActive: boolean;
timeLeft: number;
isRunning: boolean;
startTime?: number;
isActive: boolean;
timeLeft: number;
isRunning: boolean;
startTime?: number;
}
const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
const { t } = useTranslation();
const [isActive, setIsActive] = useState(false);
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME);
const [isRunning, setIsRunning] = useState(false);
const [showCompletionMessage, setShowCompletionMessage] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const { t } = useTranslation();
const [isActive, setIsActive] = useState(false);
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME);
const [isRunning, setIsRunning] = useState(false);
const [showCompletionMessage, setShowCompletionMessage] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Load state from localStorage on mount
useEffect(() => {
const savedState = localStorage.getItem(POMODORO_STORAGE_KEY);
if (savedState) {
try {
const state: PomodoroState = JSON.parse(savedState);
if (state.isActive) {
setIsActive(true);
setTimeLeft(state.timeLeft);
setIsRunning(state.isRunning);
// 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);
setTimeLeft(newTimeLeft);
if (newTimeLeft > 0) {
setIsRunning(true);
} else {
setIsRunning(false);
// Load state from localStorage on mount
useEffect(() => {
const savedState = localStorage.getItem(POMODORO_STORAGE_KEY);
if (savedState) {
try {
const state: PomodoroState = JSON.parse(savedState);
if (state.isActive) {
setIsActive(true);
setTimeLeft(state.timeLeft);
setIsRunning(state.isRunning);
// 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
);
setTimeLeft(newTimeLeft);
if (newTimeLeft > 0) {
setIsRunning(true);
} else {
setIsRunning(false);
}
}
}
} catch (error) {
console.error('Failed to load pomodoro state:', error);
}
}
}
} catch (error) {
console.error('Failed to load pomodoro state:', error);
}
}
}, []);
}, []);
// Save state to localStorage whenever it changes
useEffect(() => {
const state: PomodoroState = {
isActive,
timeLeft,
isRunning,
startTime: isRunning ? Date.now() - (DEFAULT_TIME - timeLeft) * 1000 : undefined
// Save state to localStorage whenever it changes
useEffect(() => {
const state: PomodoroState = {
isActive,
timeLeft,
isRunning,
startTime: isRunning
? Date.now() - (DEFAULT_TIME - timeLeft) * 1000
: undefined,
};
localStorage.setItem(POMODORO_STORAGE_KEY, JSON.stringify(state));
}, [isActive, timeLeft, isRunning]);
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
setIsRunning(false);
setShowCompletionMessage(true);
return 0;
}
return prev - 1;
});
}, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning, timeLeft]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
localStorage.setItem(POMODORO_STORAGE_KEY, JSON.stringify(state));
}, [isActive, timeLeft, isRunning]);
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
setIsRunning(false);
setShowCompletionMessage(true);
return 0;
}
return prev - 1;
});
}, 1000);
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
const handleTomatoClick = () => {
setIsActive(true);
setTimeLeft(DEFAULT_TIME);
setIsRunning(false);
};
}, [isRunning, timeLeft]);
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const handlePlayPause = () => {
setIsRunning(!isRunning);
};
const handleTomatoClick = () => {
setIsActive(true);
setTimeLeft(DEFAULT_TIME);
setIsRunning(false);
};
const handleReset = () => {
setIsRunning(false);
setTimeLeft(DEFAULT_TIME);
setShowCompletionMessage(false);
};
const handlePlayPause = () => {
setIsRunning(!isRunning);
};
const handleClose = () => {
setIsActive(false);
setIsRunning(false);
setTimeLeft(DEFAULT_TIME);
setShowCompletionMessage(false);
localStorage.removeItem(POMODORO_STORAGE_KEY);
};
const handleReset = () => {
setIsRunning(false);
setTimeLeft(DEFAULT_TIME);
setShowCompletionMessage(false);
};
const handleClose = () => {
setIsActive(false);
setIsRunning(false);
setTimeLeft(DEFAULT_TIME);
setShowCompletionMessage(false);
localStorage.removeItem(POMODORO_STORAGE_KEY);
};
// Tomato SVG Icon
const TomatoIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className="cursor-pointer hover:scale-110 transition-transform"
>
{/* Tomato body */}
<path
d="M12 22c-4.5 0-8-3-8-7 0-2 1-4 2-5.5C7 8 8.5 7 10 7c1 0 2 .5 2 .5s1-.5 2-.5c1.5 0 3 1 4 2.5 1 1.5 2 3.5 2 5.5 0 4-3.5 7-8 7z"
fill="#e74c3c"
stroke="#c0392b"
strokeWidth="1"
/>
{/* Tomato stem */}
<path
d="M10 7c0-1 .5-2 1-3 .5 1 1.5 2 1.5 3"
fill="none"
stroke="#27ae60"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Tomato leaf */}
<path
d="M11 4c-1 0-2 1-2 2"
fill="none"
stroke="#27ae60"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
if (!isActive) {
return (
<div className={`flex items-center ${className}`} onClick={handleTomatoClick}>
<TomatoIcon />
</div>
// Tomato SVG Icon
const TomatoIcon = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className="cursor-pointer hover:scale-110 transition-transform"
>
{/* Tomato body */}
<path
d="M12 22c-4.5 0-8-3-8-7 0-2 1-4 2-5.5C7 8 8.5 7 10 7c1 0 2 .5 2 .5s1-.5 2-.5c1.5 0 3 1 4 2.5 1 1.5 2 3.5 2 5.5 0 4-3.5 7-8 7z"
fill="#e74c3c"
stroke="#c0392b"
strokeWidth="1"
/>
{/* Tomato stem */}
<path
d="M10 7c0-1 .5-2 1-3 .5 1 1.5 2 1.5 3"
fill="none"
stroke="#27ae60"
strokeWidth="2"
strokeLinecap="round"
/>
{/* Tomato leaf */}
<path
d="M11 4c-1 0-2 1-2 2"
fill="none"
stroke="#27ae60"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
return (
<div className={`relative flex items-center space-x-2 ${className}`}>
<div className="flex items-center space-x-2 bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-1">
<span className="text-sm font-mono font-semibold text-gray-900 dark:text-white">
{formatTime(timeLeft)}
</span>
<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')}
>
{isRunning ? (
<PauseIcon className="h-3 w-3" />
) : (
<PlayIcon className="h-3 w-3" />
)}
</button>
<button
onClick={handleReset}
className="flex items-center justify-center p-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
aria-label={t('pomodoro.reset')}
>
<ArrowPathIcon className="h-3 w-3" />
</button>
<button
onClick={handleClose}
className="flex items-center justify-center p-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
aria-label={t('pomodoro.close')}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
{/* Completion Message */}
{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>
</div>
<p className="text-xs mb-3">{t('pomodoro.completeMessage')}</p>
<button
onClick={() => {
setShowCompletionMessage(false);
setIsActive(false);
setTimeLeft(DEFAULT_TIME);
localStorage.removeItem(POMODORO_STORAGE_KEY);
}}
className="w-full text-xs px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('pomodoro.done')}
</button>
if (!isActive) {
return (
<div
className={`flex items-center ${className}`}
onClick={handleTomatoClick}
>
<TomatoIcon />
</div>
);
}
return (
<div className={`relative flex items-center space-x-2 ${className}`}>
<div className="flex items-center space-x-2 bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-1">
<span className="text-sm font-mono font-semibold text-gray-900 dark:text-white">
{formatTime(timeLeft)}
</span>
<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')
}
>
{isRunning ? (
<PauseIcon className="h-3 w-3" />
) : (
<PlayIcon className="h-3 w-3" />
)}
</button>
<button
onClick={handleReset}
className="flex items-center justify-center p-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
aria-label={t('pomodoro.reset')}
>
<ArrowPathIcon className="h-3 w-3" />
</button>
<button
onClick={handleClose}
className="flex items-center justify-center p-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
aria-label={t('pomodoro.close')}
>
<XMarkIcon className="h-3 w-3" />
</button>
</div>
{/* Completion Message */}
{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>
</div>
<p className="text-xs mb-3">
{t('pomodoro.completeMessage')}
</p>
<button
onClick={() => {
setShowCompletionMessage(false);
setIsActive(false);
setTimeLeft(DEFAULT_TIME);
localStorage.removeItem(POMODORO_STORAGE_KEY);
}}
className="w-full text-xs px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
>
{t('pomodoro.done')}
</button>
</div>
)}
</div>
)}
</div>
);
);
};
export default PomodoroTimer;
export default PomodoroTimer;

View file

@ -1,116 +1,159 @@
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';
interface PriorityDropdownProps {
value: PriorityType;
onChange: (value: PriorityType) => void;
value: PriorityType;
onChange: (value: PriorityType) => void;
}
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => {
const { t } = useTranslation();
const priorities = [
{ value: 'low', label: t('priority.low', 'Low'), icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'medium', label: t('priority.medium', 'Medium'), icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'high', label: t('priority.high', 'High'), icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
];
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation();
const handleToggle = () => {
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 120;
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
});
}
setIsOpen(!isOpen);
};
const priorities = [
{
value: 'low',
label: t('priority.low', 'Low'),
icon: (
<ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'medium',
label: t('priority.medium', 'Medium'),
icon: (
<ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'high',
label: t('priority.high', 'High'),
icon: (
<FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
];
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleToggle = () => {
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 120;
const handleSelect = (priority: PriorityType) => {
onChange(priority);
setIsOpen(false);
};
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward,
});
}
setIsOpen(!isOpen);
};
}, [isOpen]);
const selectedPriority = priorities.find(p => p.value === value);
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
return (
<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"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{selectedPriority ? selectedPriority.icon : ''}
<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>
const handleSelect = (priority: PriorityType) => {
onChange(priority);
setIsOpen(false);
};
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const selectedPriority = priorities.find((p) => p.value === value);
return (
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
{priorities.map((priority) => (
<button
key={priority.value}
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"
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"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{priority.icon} <span>{priority.label}</span>
</span>
<span className="flex items-center space-x-2">
{selectedPriority ? selectedPriority.icon : ''}
<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>
))}
</div>,
document.body
)}
</div>
);
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
>
{priorities.map((priority) => (
<button
key={priority.value}
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>
</span>
</button>
))}
</div>,
document.body
)}
</div>
);
};
export default PriorityDropdown;

View file

@ -1,115 +1,184 @@
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';
interface RecurrenceDropdownProps {
value: RecurrenceType;
onChange: (value: RecurrenceType) => void;
value: RecurrenceType;
onChange: (value: RecurrenceType) => void;
}
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" /> }
];
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({
value,
onChange,
}) => {
const { t } = useTranslation();
const handleToggle = () => {
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 240;
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
});
}
setIsOpen(!isOpen);
};
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" />
),
},
];
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleSelect = (recurrence: RecurrenceType) => {
onChange(recurrence);
setIsOpen(false);
};
const handleToggle = () => {
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 240;
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
return () => {
document.removeEventListener('mousedown', handleClickOutside);
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward,
});
}
setIsOpen(!isOpen);
};
}, [isOpen]);
const selectedRecurrence = recurrenceOptions.find(r => r.value === value);
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
return (
<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"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{selectedRecurrence ? selectedRecurrence.icon : ''}
<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>
const handleSelect = (recurrence: RecurrenceType) => {
onChange(recurrence);
setIsOpen(false);
};
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const selectedRecurrence = recurrenceOptions.find((r) => r.value === value);
return (
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
{recurrenceOptions.map((recurrence) => (
<button
key={recurrence.value}
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"
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"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{recurrence.icon} <span>{recurrence.label}</span>
</span>
<span className="flex items-center space-x-2">
{selectedRecurrence ? selectedRecurrence.icon : ''}
<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>
))}
</div>,
document.body
)}
</div>
);
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
>
{recurrenceOptions.map((recurrence) => (
<button
key={recurrence.value}
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>
</span>
</button>
))}
</div>,
document.body
)}
</div>
);
};
export default RecurrenceDropdown;
export default RecurrenceDropdown;

View file

@ -3,141 +3,161 @@ import { createPortal } from 'react-dom';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
interface Option {
value: string | number;
label: string;
value: string | number;
label: string;
}
interface RecurrenceSelectDropdownProps {
value: string | number;
onChange: (value: string | number) => void;
options: Option[];
placeholder?: string;
disabled?: boolean;
className?: string;
value: string | number;
onChange: (value: string | number) => void;
options: Option[];
placeholder?: string;
disabled?: boolean;
className?: string;
}
const RecurrenceSelectDropdown: React.FC<RecurrenceSelectDropdownProps> = ({
value,
onChange,
options,
placeholder = 'Select option',
disabled = false,
className = ''
value,
onChange,
options,
placeholder = 'Select option',
disabled = false,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 0, openUpward: false });
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleToggle = () => {
if (disabled) return;
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const openUpward = spaceAbove > spaceBelow && spaceBelow < menuHeight;
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward
});
}
setIsOpen(!isOpen);
};
const handleToggle = () => {
if (disabled) return;
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = Math.min(options.length * 40 + 16, 200); // Max height with scroll
const handleSelect = (selectedValue: string | number) => {
try {
onChange(selectedValue);
setIsOpen(false);
} catch (error) {
console.error('Error in dropdown selection:', error);
setIsOpen(false);
}
};
const openUpward =
spaceAbove > spaceBelow && spaceBelow < menuHeight;
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
setPosition({
top: openUpward ? rect.top - menuHeight - 8 : rect.bottom + 8,
left: rect.left,
width: rect.width,
openUpward,
});
}
setIsOpen(!isOpen);
};
}, [isOpen]);
const selectedOption = options.find(option => option.value === value);
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
return (
<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'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<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' : ''}`} />
</button>
const handleSelect = (selectedValue: string | number) => {
try {
onChange(selectedValue);
setIsOpen(false);
} catch (error) {
console.error('Error in dropdown selection:', error);
setIsOpen(false);
}
};
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const selectedOption = options.find((option) => option.value === value);
return (
<div
ref={dropdownRef}
className={`relative inline-block text-left w-full ${className}`}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(option.value);
}}
className={`flex items-center justify-between px-4 py-2 text-sm w-full text-left hover:bg-gray-100 dark:hover:bg-gray-600 first:rounded-t-md last:rounded-b-md ${
option.value === value
? 'bg-blue-50 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
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'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<span>{option.label}</span>
{option.value === value && (
<span className="text-blue-600 dark:text-blue-400"></span>
)}
<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' : ''}`}
/>
</button>
))}
</div>,
document.body
)}
</div>
);
{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"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
>
{options.map((option) => (
<button
key={option.value}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleSelect(option.value);
}}
className={`flex items-center justify-between px-4 py-2 text-sm w-full text-left hover:bg-gray-100 dark:hover:bg-gray-600 first:rounded-t-md last:rounded-b-md ${
option.value === value
? 'bg-blue-50 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span>{option.label}</span>
{option.value === value && (
<span className="text-blue-600 dark:text-blue-400">
</span>
)}
</button>
))}
</div>,
document.body
)}
</div>
);
};
export default RecurrenceSelectDropdown;
export default RecurrenceSelectDropdown;

View file

@ -1,85 +1,127 @@
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';
interface StatusDropdownProps {
value: StatusType;
onChange: (value: StatusType) => void;
value: StatusType;
onChange: (value: StatusType) => void;
}
const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
const { t } = useTranslation();
const statuses = [
{ value: 'not_started', label: t('status.notStarted', 'Not Started'), icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'in_progress', label: t('status.inProgress', 'In Progress'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'done', label: t('status.done', 'Done'), icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'archived', label: t('status.archived', 'Archived'), icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
];
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const handleToggle = () => {
setIsOpen(!isOpen);
};
const statuses = [
{
value: 'not_started',
label: t('status.notStarted', 'Not Started'),
icon: (
<MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'in_progress',
label: t('status.inProgress', 'In Progress'),
icon: (
<ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'done',
label: t('status.done', 'Done'),
icon: (
<CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
{
value: 'archived',
label: t('status.archived', 'Archived'),
icon: (
<ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" />
),
},
];
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleSelect = (status: StatusType) => {
onChange(status);
setIsOpen(false);
};
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
const handleToggle = () => {
setIsOpen(!isOpen);
};
}, [isOpen]);
const selectedStatus = statuses.find(s => s.value === value);
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
return (
<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"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{selectedStatus ? selectedStatus.icon : ''}
<span>{selectedStatus ? selectedStatus.label : 'Select Status'}</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
const handleSelect = (status: StatusType) => {
onChange(status);
setIsOpen(false);
};
{isOpen && (
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md">
{statuses.map((status) => (
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const selectedStatus = statuses.find((s) => s.value === value);
return (
<div
ref={dropdownRef}
className="relative inline-block text-left w-full"
>
<button
key={status.value}
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"
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"
onClick={handleToggle}
>
<span className="flex items-center space-x-2">
{status.icon} <span>{status.label}</span>
</span>
<span className="flex items-center space-x-2">
{selectedStatus ? selectedStatus.icon : ''}
<span>
{selectedStatus
? selectedStatus.label
: 'Select Status'}
</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
))}
{isOpen && (
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md">
{statuses.map((status) => (
<button
key={status.value}
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">
{status.icon} <span>{status.label}</span>
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
);
};
export default StatusDropdown;

View file

@ -1,29 +1,27 @@
import React from 'react';
interface SwitchProps {
isChecked: boolean;
onToggle: () => void;
isChecked: boolean;
onToggle: () => void;
}
const Switch: React.FC<SwitchProps> = ({ isChecked, onToggle }) => {
return (
<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'
}`}
onClick={onToggle}
>
<div
className={`bg-white w-4 h-4 rounded-full shadow-md transform transition-transform duration-300 ${
isChecked ? 'translate-x-6' : ''
}`}
></div>
</div>
</div>
);
return (
<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'
}`}
onClick={onToggle}
>
<div
className={`bg-white w-4 h-4 rounded-full shadow-md transform transition-transform duration-300 ${
isChecked ? 'translate-x-6' : ''
}`}
></div>
</div>
</div>
);
};
export default Switch;
export default Switch;

View file

@ -1,57 +1,77 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
interface ToastContextProps {
showSuccessToast: (message: string | React.ReactNode) => void;
showErrorToast: (message: string | React.ReactNode) => void;
showSuccessToast: (message: string | React.ReactNode) => void;
showErrorToast: (message: string | React.ReactNode) => void;
}
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
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');
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) => {
setToastMessage(message);
setToastType('success');
setTimeout(() => setToastMessage(null), 4000);
}, []);
const showSuccessToast = useCallback(
(message: string | React.ReactNode) => {
setToastMessage(message);
setToastType('success');
setTimeout(() => setToastMessage(null), 4000);
},
[]
);
const showErrorToast = useCallback((message: string | React.ReactNode) => {
setToastMessage(message);
setToastType('error');
setTimeout(() => setToastMessage(null), 4000);
}, []);
const showErrorToast = useCallback((message: string | React.ReactNode) => {
setToastMessage(message);
setToastType('error');
setTimeout(() => setToastMessage(null), 4000);
}, []);
return (
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
{children}
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />}
</ToastContext.Provider>
);
return (
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
{children}
{toastMessage && (
<Toast
message={toastMessage}
type={toastType}
onClose={() => setToastMessage(null)}
/>
)}
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
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 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`}
>
<div className="flex items-center">
<div className="flex-1">{message}</div>
<button onClick={onClose} className="ml-4 text-xl leading-none hover:opacity-75">
&times;
</button>
</div>
</div>
);
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 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`}
>
<div className="flex items-center">
<div className="flex-1">{message}</div>
<button
onClick={onClose}
className="ml-4 text-xl leading-none hover:opacity-75"
>
&times;
</button>
</div>
</div>
);
};

View file

@ -1,80 +1,83 @@
import React from 'react';
interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description?: string;
disabled?: boolean;
className?: string;
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
description?: string;
disabled?: boolean;
className?: string;
}
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
checked,
onChange,
label,
description,
disabled = false,
className = ''
checked,
onChange,
label,
description,
disabled = false,
className = '',
}) => {
const handleToggle = () => {
if (!disabled) {
onChange(!checked);
}
};
const handleToggle = () => {
if (!disabled) {
onChange(!checked);
}
};
return (
<div className={`flex items-start space-x-3 ${className}`}>
<button
type="button"
onClick={handleToggle}
disabled={disabled}
className={`
return (
<div className={`flex items-start space-x-3 ${className}`}>
<button
type="button"
onClick={handleToggle}
disabled={disabled}
className={`
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
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-600'
${
checked
? 'bg-blue-600 dark:bg-blue-500'
: 'bg-gray-200 dark:bg-gray-600'
}
`}
role="switch"
aria-checked={checked}
aria-disabled={disabled}
>
<span className="sr-only">{label}</span>
<span
className={`
role="switch"
aria-checked={checked}
aria-disabled={disabled}
>
<span className="sr-only">{label}</span>
<span
className={`
pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0
transition duration-200 ease-in-out
${checked ? 'translate-x-5' : 'translate-x-0'}
`}
/>
</button>
<div className="flex-1">
<label
className={`text-sm font-medium cursor-pointer ${
disabled
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-300'
}`}
onClick={handleToggle}
>
{label}
</label>
{description && (
<p className={`text-xs mt-1 ${
disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-500 dark:text-gray-400'
}`}>
{description}
</p>
)}
</div>
</div>
);
/>
</button>
<div className="flex-1">
<label
className={`text-sm font-medium cursor-pointer ${
disabled
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'text-gray-700 dark:text-gray-300'
}`}
onClick={handleToggle}
>
{label}
</label>
{description && (
<p
className={`text-xs mt-1 ${
disabled
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{description}
</p>
)}
</div>
</div>
);
};
export default ToggleSwitch;
export default ToggleSwitch;

View file

@ -1,124 +1,132 @@
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;
onPreviewChange?: (preview: UrlTitleResult | null) => void;
text: string;
onPreviewChange?: (preview: UrlTitleResult | null) => void;
}
const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
const [preview, setPreview] = useState<UrlTitleResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [imageError, setImageError] = useState(false);
const [preview, setPreview] = useState<UrlTitleResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isVisible, setIsVisible] = useState(true);
const [imageError, setImageError] = useState(false);
useEffect(() => {
const extractPreview = async () => {
if (!text.trim()) {
useEffect(() => {
const extractPreview = async () => {
if (!text.trim()) {
setPreview(null);
onPreviewChange?.(null);
return;
}
setIsLoading(true);
try {
const result = await extractTitleFromText(text);
setPreview(result);
onPreviewChange?.(result);
} catch (error) {
console.error('Failed to extract URL preview:', error);
setPreview(null);
onPreviewChange?.(null);
} finally {
setIsLoading(false);
}
};
const timeoutId = setTimeout(extractPreview, 300);
return () => clearTimeout(timeoutId);
}, [text, onPreviewChange]);
const handleDismiss = () => {
setIsVisible(false);
setPreview(null);
onPreviewChange?.(null);
return;
}
setIsLoading(true);
try {
const result = await extractTitleFromText(text);
setPreview(result);
onPreviewChange?.(result);
} catch (error) {
console.error('Failed to extract URL preview:', error);
setPreview(null);
onPreviewChange?.(null);
} finally {
setIsLoading(false);
}
};
const timeoutId = setTimeout(extractPreview, 300);
return () => clearTimeout(timeoutId);
}, [text, onPreviewChange]);
const handleImageError = () => {
setImageError(true);
};
const handleDismiss = () => {
setIsVisible(false);
setPreview(null);
onPreviewChange?.(null);
};
if (!isVisible || (!preview && !isLoading)) {
return null;
}
const handleImageError = () => {
setImageError(true);
};
if (isLoading) {
return (
<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>
</div>
</div>
);
}
if (!isVisible || (!preview && !isLoading)) {
return null;
}
if (!preview) {
return null;
}
if (isLoading) {
return (
<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>
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 z-10"
aria-label="Dismiss preview"
>
<XMarkIcon className="h-4 w-4" />
</button>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{preview.image && !imageError ? (
<img
src={preview.image}
alt="Preview"
className="w-16 h-16 object-cover rounded-md"
onError={handleImageError}
/>
) : (
<div className="w-16 h-16 bg-gray-200 dark:bg-gray-600 rounded-md flex items-center justify-center">
<PhotoIcon className="h-8 w-8 text-gray-400" />
</div>
)}
</div>
<div className="flex-1 min-w-0 pr-6">
<div
className="text-sm font-medium text-gray-900 dark:text-gray-100"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{preview.title || 'Untitled'}
</div>
{preview.description && (
<div
className="text-xs text-gray-600 dark:text-gray-300 mt-1"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{preview.description}
</div>
)}
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-1">
{preview.url}
</div>
</div>
</div>
</div>
</div>
);
}
if (!preview) {
return null;
}
return (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 relative">
<button
onClick={handleDismiss}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 z-10"
aria-label="Dismiss preview"
>
<XMarkIcon className="h-4 w-4" />
</button>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{preview.image && !imageError ? (
<img
src={preview.image}
alt="Preview"
className="w-16 h-16 object-cover rounded-md"
onError={handleImageError}
/>
) : (
<div className="w-16 h-16 bg-gray-200 dark:bg-gray-600 rounded-md flex items-center justify-center">
<PhotoIcon className="h-8 w-8 text-gray-400" />
</div>
)}
</div>
<div className="flex-1 min-w-0 pr-6">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100" style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{preview.title || 'Untitled'}
</div>
{preview.description && (
<div className="text-xs text-gray-600 dark:text-gray-300 mt-1" style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{preview.description}
</div>
)}
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-1">
{preview.url}
</div>
</div>
</div>
</div>
);
};
export default UrlPreview;
export default UrlPreview;

View file

@ -11,116 +11,116 @@ import SidebarProjects from './Sidebar/SidebarProjects';
import SidebarTags from './Sidebar/SidebarTags';
interface SidebarProps {
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
openTaskModal: (type?: 'simplified' | 'full') => void;
openProjectModal: () => void;
openNoteModal: (note: Note | null) => void;
openAreaModal: (area: Area | null) => void;
openTagModal: (tag: Tag | null) => void;
notes: Note[];
areas: Area[];
tags: Tag[];
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
openTaskModal: (type?: 'simplified' | 'full') => void;
openProjectModal: () => void;
openNoteModal: (note: Note | null) => void;
openAreaModal: (area: Area | null) => void;
openTagModal: (tag: Tag | null) => void;
notes: Note[];
areas: Area[];
tags: Tag[];
}
const Sidebar: React.FC<SidebarProps> = ({
isSidebarOpen,
setIsSidebarOpen,
currentUser,
isDarkMode,
toggleDarkMode,
openTaskModal,
openProjectModal,
openNoteModal,
openAreaModal,
openTagModal,
notes,
areas,
tags,
isSidebarOpen,
setIsSidebarOpen,
currentUser,
isDarkMode,
toggleDarkMode,
openTaskModal,
openProjectModal,
openNoteModal,
openAreaModal,
openTagModal,
notes,
areas,
tags,
}) => {
const navigate = useNavigate();
const location = useLocation();
const navigate = useNavigate();
const location = useLocation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const handleNavClick = (path: string, title: string, icon: JSX.Element) => {
navigate(path, { state: { title } });
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
}
};
const handleNavClick = (path: string, title: string) => {
navigate(path, { state: { title } });
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
}
};
return (
<div
className={`fixed top-16 left-0 ${isSidebarOpen ? 'w-full sm:w-72' : 'w-0'} h-[calc(100vh-4rem)] bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-width duration-300 ease-in-out z-40`}
style={{
visibility: isSidebarOpen ? 'visible' : 'hidden',
overflow: 'hidden',
}}
>
{isSidebarOpen && (
<div className="flex flex-col h-full overflow-y-auto">
<div className="px-3 pb-3 pt-8">
{/* Sidebar Contents */}
<SidebarNav
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
/>
<SidebarProjects
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
openProjectModal={openProjectModal}
/>
<SidebarNotes
handleNavClick={handleNavClick}
openNoteModal={openNoteModal}
notes={notes}
location={location}
isDarkMode={isDarkMode}
/>
<SidebarAreas
handleNavClick={handleNavClick}
areas={areas}
location={location}
isDarkMode={isDarkMode}
openAreaModal={openAreaModal}
/>
<SidebarTags
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
openTagModal={openTagModal}
tags={tags}
/>
</div>
return (
<div
className={`fixed top-16 left-0 ${isSidebarOpen ? 'w-full sm:w-72' : 'w-0'} h-[calc(100vh-4rem)] bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-width duration-300 ease-in-out z-40`}
style={{
visibility: isSidebarOpen ? 'visible' : 'hidden',
overflow: 'hidden',
}}
>
{isSidebarOpen && (
<div className="flex flex-col h-full overflow-y-auto">
<div className="px-3 pb-3 pt-8">
{/* Sidebar Contents */}
<SidebarNav
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
/>
<SidebarProjects
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
openProjectModal={openProjectModal}
/>
<SidebarNotes
handleNavClick={handleNavClick}
openNoteModal={openNoteModal}
notes={notes}
location={location}
isDarkMode={isDarkMode}
/>
<SidebarAreas
handleNavClick={handleNavClick}
areas={areas}
location={location}
isDarkMode={isDarkMode}
openAreaModal={openAreaModal}
/>
<SidebarTags
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
openTagModal={openTagModal}
tags={tags}
/>
</div>
<SidebarFooter
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
isDropdownOpen={isDropdownOpen}
toggleDropdown={toggleDropdown}
openTaskModal={openTaskModal}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
/>
<SidebarFooter
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
isDropdownOpen={isDropdownOpen}
toggleDropdown={toggleDropdown}
openTaskModal={openTaskModal}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
/>
</div>
)}
</div>
)}
</div>
);
);
};
export default Sidebar;

View file

@ -1,64 +1,64 @@
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;
location: Location;
isDarkMode: boolean;
openAreaModal: (area: Area | null) => void;
areas: Area[];
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openAreaModal: (area: Area | null) => void;
areas: Area[];
}
const SidebarAreas: React.FC<SidebarAreasProps> = ({
handleNavClick,
location,
openAreaModal,
handleNavClick,
location,
openAreaModal,
}) => {
const { t } = useTranslation();
const isActiveArea = (path: string) => {
return location.pathname === path
? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300";
};
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';
};
return (
<>
<ul className="flex flex-col space-y-1">
{/* "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"
)}`}
onClick={() =>
handleNavClick(
"/areas",
"Areas",
<Squares2X2Icon className="h-5 w-5 mr-2" />
)
}
>
<span className="flex items-center">
<Squares2X2Icon className="h-5 w-5 mr-2" />
{t('sidebar.areas')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openAreaModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label={t('sidebar.addAreaAriaLabel')}
title={t('sidebar.addAreaTitle')}
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
return (
<>
<ul className="flex flex-col space-y-1">
{/* "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'
)}`}
onClick={() =>
handleNavClick(
'/areas',
'Areas',
<Squares2X2Icon className="h-5 w-5 mr-2" />
)
}
>
<span className="flex items-center">
<Squares2X2Icon className="h-5 w-5 mr-2" />
{t('sidebar.areas')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openAreaModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label={t('sidebar.addAreaAriaLabel')}
title={t('sidebar.addAreaTitle')}
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarAreas;

View file

@ -1,206 +1,255 @@
import React, { useState, useEffect, useRef } from 'react';
import {
SunIcon,
MoonIcon,
PlusIcon,
CheckIcon,
FolderIcon,
BookOpenIcon,
Squares2X2Icon,
TagIcon,
InboxIcon,
import {
SunIcon,
MoonIcon,
PlusIcon,
CheckIcon,
FolderIcon,
BookOpenIcon,
Squares2X2Icon,
TagIcon,
InboxIcon,
} from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
import { Note } from '../../entities/Note';
import { Area } from '../../entities/Area';
interface SidebarFooterProps {
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
isDropdownOpen: boolean;
toggleDropdown: () => void;
openTaskModal: (type?: 'simplified' | 'full') => void;
openProjectModal: () => void;
openNoteModal: (note: Note | null) => void;
openAreaModal: (area: Area | null) => void;
openTagModal: (tag: any | null) => void;
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
isDropdownOpen: boolean;
toggleDropdown: () => void;
openTaskModal: (type?: 'simplified' | 'full') => void;
openProjectModal: () => void;
openNoteModal: (note: Note | null) => void;
openAreaModal: (area: Area | null) => void;
openTagModal: (tag: any | null) => void;
}
const SidebarFooter: React.FC<SidebarFooterProps> = ({
isDarkMode,
toggleDarkMode,
isSidebarOpen,
setIsSidebarOpen,
openTaskModal,
openProjectModal,
openNoteModal,
openAreaModal,
openTagModal,
isDarkMode,
toggleDarkMode,
isSidebarOpen,
openTaskModal,
openProjectModal,
openNoteModal,
openAreaModal,
openTagModal,
}) => {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
if (isDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isDropdownOpen]);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Ctrl/Cmd key combinations
if (event.ctrlKey || event.metaKey) {
switch (event.key.toLowerCase()) {
case 'i':
event.preventDefault();
handleDropdownSelect('Inbox');
break;
case 't':
event.preventDefault();
handleDropdownSelect('Task');
break;
case 'p':
event.preventDefault();
handleDropdownSelect('Project');
break;
case 'n':
event.preventDefault();
handleDropdownSelect('Note');
break;
case 'a':
event.preventDefault();
handleDropdownSelect('Area');
break;
case 'g':
event.preventDefault();
handleDropdownSelect('Tag');
break;
default:
break;
if (isDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isDropdownOpen]);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check for Ctrl/Cmd key combinations
if (event.ctrlKey || event.metaKey) {
switch (event.key.toLowerCase()) {
case 'i':
event.preventDefault();
handleDropdownSelect('Inbox');
break;
case 't':
event.preventDefault();
handleDropdownSelect('Task');
break;
case 'p':
event.preventDefault();
handleDropdownSelect('Project');
break;
case 'n':
event.preventDefault();
handleDropdownSelect('Note');
break;
case 'a':
event.preventDefault();
handleDropdownSelect('Area');
break;
case 'g':
event.preventDefault();
handleDropdownSelect('Tag');
break;
default:
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
const handleDropdownSelect = (type: string) => {
switch (type) {
case 'Inbox':
openTaskModal('simplified');
break;
case 'Task':
openTaskModal('full');
break;
case 'Project':
openProjectModal();
break;
case 'Note':
openNoteModal(null);
break;
case 'Area':
openAreaModal(null);
break;
case 'Tag':
openTagModal(null);
break;
default:
break;
}
setIsDropdownOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
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',
},
];
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}
>
{/* Plus Icon Button - Left */}
<div className="relative">
<button
onClick={toggleDropdown}
className="group flex items-center focus:outline-none text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-300 ease-out rounded-lg px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md"
aria-label="Create New"
>
<PlusIcon className="h-6 w-6 flex-shrink-0 transition-transform duration-300 ease-out group-hover:rotate-90" />
<span className="ml-2 text-sm font-medium whitespace-nowrap opacity-0 max-w-0 overflow-hidden group-hover:opacity-100 group-hover:max-w-[100px] transition-all duration-300 ease-out transform translate-x-[-10px] group-hover:translate-x-0">
{t('dropdown.createNew', 'Create new')}
</span>
</button>
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
const handleDropdownSelect = (type: string) => {
switch (type) {
case 'Inbox':
openTaskModal('simplified');
break;
case 'Task':
openTaskModal('full');
break;
case 'Project':
openProjectModal();
break;
case 'Note':
openNoteModal(null);
break;
case 'Area':
openAreaModal(null);
break;
case 'Tag':
openTagModal(null);
break;
default:
break;
}
setIsDropdownOpen(false);
};
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' },
];
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}>
{/* Plus Icon Button - Left */}
<div className="relative">
<button
onClick={toggleDropdown}
className="group flex items-center focus:outline-none text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-all duration-300 ease-out rounded-lg px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-md"
aria-label="Create New"
>
<PlusIcon className="h-6 w-6 flex-shrink-0 transition-transform duration-300 ease-out group-hover:rotate-90" />
<span className="ml-2 text-sm font-medium whitespace-nowrap opacity-0 max-w-0 overflow-hidden group-hover:opacity-100 group-hover:max-w-[100px] transition-all duration-300 ease-out transform translate-x-[-10px] group-hover:translate-x-0">
{t('dropdown.createNew', 'Create new')}
</span>
</button>
{/* Dropdown Menu */}
{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 }) => (
<button
key={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)}
{/* Dropdown Menu */}
{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,
}) => (
<button
key={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
)}
</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>
)}
</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>
)}
</div>
{/* Dark Mode Toggle - Right */}
<button
onClick={toggleDarkMode}
className="flex items-center justify-center focus:outline-none text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg px-2 py-1 transition-colors duration-200"
aria-label="Toggle Dark Mode"
>
{isDarkMode ? (
<SunIcon className="h-6 w-6 text-yellow-500" />
) : (
<MoonIcon className="h-6 w-6 text-gray-500" />
)}
</button>
</div>
)}
</div>
</div>
);
{/* Dark Mode Toggle - Right */}
<button
onClick={toggleDarkMode}
className="flex items-center justify-center focus:outline-none text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg px-2 py-1 transition-colors duration-200"
aria-label="Toggle Dark Mode"
>
{isDarkMode ? (
<SunIcon className="h-6 w-6 text-yellow-500" />
) : (
<MoonIcon className="h-6 w-6 text-gray-500" />
)}
</button>
</div>
)}
</div>
</div>
);
};
export default SidebarFooter;

View file

@ -1,16 +1,16 @@
import React from 'react';
const SidebarHeader: React.FC = () => {
return (
<div className="flex justify-center mb-6 mt-2">
<a
href="/"
className="flex justify-center items-center mb-2 no-underline text-gray-900 dark:text-white"
>
<span className="text-2xl font-bold mt-1">tududi</span>
</a>
</div>
);
return (
<div className="flex justify-center mb-6 mt-2">
<a
href="/"
className="flex justify-center items-center mb-2 no-underline text-gray-900 dark:text-white"
>
<span className="text-2xl font-bold mt-1">tududi</span>
</a>
</div>
);
};
export default SidebarHeader;

View file

@ -2,85 +2,110 @@ import React, { useEffect } from 'react';
import { Location } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
CalendarDaysIcon,
ArrowRightCircleIcon,
InboxIcon,
CheckCircleIcon,
ListBulletIcon,
ClockIcon,
CalendarDaysIcon,
InboxIcon,
ListBulletIcon,
ClockIcon,
} from '@heroicons/react/24/solid';
import { useStore } from '../../store/useStore';
import { loadInboxItemsToStore } from '../../utils/inboxService';
interface SidebarNavProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
}
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) => {
const { t } = useTranslation();
const store = useStore();
// Get inbox items count for badge
const inboxItemsCount = store.inboxStore.inboxItems.length;
const SidebarNav: React.FC<SidebarNavProps> = ({
handleNavClick,
location,
}) => {
const { t } = useTranslation();
const store = useStore();
// Load inbox items when component mounts to ensure badge shows correct count
useEffect(() => {
loadInboxItemsToStore().catch(console.error);
}, []);
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" /> },
];
// Get inbox items count for badge
const inboxItemsCount = store.inboxStore.inboxItems.length;
const isActive = (path: string, query?: string) => {
// Handle special case for paths without query parameters
if (path === '/inbox' || path === '/today') {
const isPathMatch = location.pathname === path;
return isPathMatch
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
}
// Regular case for /tasks with query params
const isPathMatch = location.pathname === '/tasks';
const isQueryMatch = query ? location.search.includes(query) : location.search === '';
return isPathMatch && isQueryMatch
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
// Load inbox items when component mounts to ensure badge shows correct count
useEffect(() => {
loadInboxItemsToStore().catch(console.error);
}, []);
return (
<ul className="flex flex-col space-y-1">
{navLinks.map((link) => (
<React.Fragment key={link.path}>
<li>
<button
onClick={() => handleNavClick(link.path, link.title, link.icon)}
className={`w-full text-left px-4 py-1 flex items-center justify-between rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
link.path,
link.query
)}`}
>
<div className="flex items-center">
{link.icon}
<span className="ml-2">{link.title}</span>
</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}
</span>
)}
</button>
</li>
</React.Fragment>
))}
</ul>
);
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" />,
},
];
const isActive = (path: string, query?: string) => {
// Handle special case for paths without query parameters
if (path === '/inbox' || path === '/today') {
const isPathMatch = location.pathname === path;
return isPathMatch
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
}
// Regular case for /tasks with query params
const isPathMatch = location.pathname === '/tasks';
const isQueryMatch = query
? location.search.includes(query)
: location.search === '';
return isPathMatch && isQueryMatch
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
return (
<ul className="flex flex-col space-y-1">
{navLinks.map((link) => (
<React.Fragment key={link.path}>
<li>
<button
onClick={() =>
handleNavClick(link.path, link.title, link.icon)
}
className={`w-full text-left px-4 py-1 flex items-center justify-between rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
link.path,
link.query
)}`}
>
<div className="flex items-center">
{link.icon}
<span className="ml-2">{link.title}</span>
</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}
</span>
)}
</button>
</li>
</React.Fragment>
))}
</ul>
);
};
export default SidebarNav;
export default SidebarNav;

View file

@ -5,53 +5,59 @@ import { Note } from '../../entities/Note';
import { useTranslation } from 'react-i18next';
interface SidebarNotesProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openNoteModal: (note: Note | null) => void;
notes: Note[];
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openNoteModal: (note: Note | null) => void;
notes: Note[];
}
const SidebarNotes: React.FC<SidebarNotesProps> = ({
handleNavClick,
location,
openNoteModal,
handleNavClick,
location,
openNoteModal,
}) => {
const { t } = useTranslation();
const isActiveNote = (path: string) => {
return location.pathname === path
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
const { t } = useTranslation();
const isActiveNote = (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';
};
return (
<>
<ul className="flex flex-col space-y-1">
<li
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" />)}
>
<span className="flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />
{t('sidebar.notes')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openNoteModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Note"
title="Add Note"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
return (
<>
<ul className="flex flex-col space-y-1">
<li
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" />
)
}
>
<span className="flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />
{t('sidebar.notes')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openNoteModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Note"
title="Add Note"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarNotes;

View file

@ -4,52 +4,58 @@ import { FolderIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
interface SidebarProjectsProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openProjectModal: () => void;
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openProjectModal: () => void;
}
const SidebarProjects: React.FC<SidebarProjectsProps> = ({
handleNavClick,
location,
openProjectModal,
handleNavClick,
location,
openProjectModal,
}) => {
const { t } = useTranslation();
const isActiveProject = (path: string) => {
return location.pathname === path
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
const { t } = useTranslation();
const isActiveProject = (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';
};
return (
<>
<ul className="flex flex-col space-y-1 mt-4">
<li
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" />)}
>
<span className="flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />
{t('sidebar.projects')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openProjectModal();
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Project"
title="Add Project"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
return (
<>
<ul className="flex flex-col space-y-1 mt-4">
<li
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" />
)
}
>
<span className="flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />
{t('sidebar.projects')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openProjectModal();
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Project"
title="Add Project"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarProjects;

View file

@ -5,55 +5,61 @@ import { Tag } from '../../entities/Tag';
import { useTranslation } from 'react-i18next';
interface SidebarTagsProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openTagModal: (tag: Tag | null) => void;
tags: Tag[];
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openTagModal: (tag: Tag | null) => void;
tags: Tag[];
}
const SidebarTags: React.FC<SidebarTagsProps> = ({
handleNavClick,
location,
openTagModal,
handleNavClick,
location,
openTagModal,
}) => {
const { t } = useTranslation();
const isActiveTag = (path: string) => {
return location.pathname === path
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
const { t } = useTranslation();
return (
<>
<ul className="flex flex-col space-y-1">
{/* "TAGS" Title with Add Button */}
<li
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" />)}
>
<span className="flex items-center">
<TagIcon className="h-5 w-5 mr-2" />
{t('sidebar.tags')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openTagModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label={t('sidebar.addTagAriaLabel')}
title={t('sidebar.addTagTitle')}
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
const isActiveTag = (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';
};
return (
<>
<ul className="flex flex-col space-y-1">
{/* "TAGS" Title with Add Button */}
<li
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" />
)
}
>
<span className="flex items-center">
<TagIcon className="h-5 w-5 mr-2" />
{t('sidebar.tags')}
</span>
<button
onClick={(e) => {
e.stopPropagation();
openTagModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label={t('sidebar.addTagAriaLabel')}
title={t('sidebar.addTagTitle')}
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarTags;

View file

@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
TagIcon,
CheckIcon,
BookOpenIcon,
FolderIcon,
PencilSquareIcon,
TrashIcon
import {
TagIcon,
CheckIcon,
BookOpenIcon,
FolderIcon,
PencilSquareIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import { Task } from '../../entities/Task';
import { Note } from '../../entities/Note';
@ -16,343 +16,411 @@ import TaskList from '../Task/TaskList';
import ProjectItem from '../Project/ProjectItem';
interface Tag {
id: number;
name: string;
active: boolean;
id: number;
name: string;
active: boolean;
}
const TagDetails: React.FC = () => {
const { t } = useTranslation();
const { identifier } = useParams<{ identifier: string }>();
const [tag, setTag] = useState<Tag | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 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 navigate = useNavigate();
const { t } = useTranslation();
const { identifier } = useParams<{ identifier: string }>();
const [tag, setTag] = useState<Tag | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [notes, setNotes] = useState<Note[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTagData = async () => {
try {
// First fetch tag details
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
]);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData.tasks || []);
}
if (notesResponse.ok) {
const notesData = await notesResponse.json();
setNotes(notesData || []);
}
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)
);
setProjects(filteredProjects);
}
} else {
const tagError = await tagResponse.json();
setError(tagError.error || 'Failed to fetch tag.');
}
} catch (err) {
setError(t('tags.error'));
} finally {
setLoading(false);
}
};
fetchTagData();
}, [identifier, t]);
// Task handlers
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTask),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
}
} catch (error) {
console.error("Error updating task:", error);
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
}
} catch (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" },
});
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
)
);
}
} catch (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);
};
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" };
default:
return { color: "bg-gray-500" };
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('tags.loading')}
</div>
</div>
// State for ProjectItem components
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
const [, setProjectToDelete] = useState<Project | null>(
null
);
}
const [, setIsConfirmDialogOpen] =
useState<boolean>(false);
const navigate = useNavigate();
if (error) {
return <div className="text-red-500 p-4">{error}</div>;
}
if (!tag) {
return <div className="text-gray-700 dark:text-gray-300 p-4">{t('tags.notFound')}</div>;
}
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Tag Header */}
<div className="flex items-center mb-8">
<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">
{tag.name}
</h2>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<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>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<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>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<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>
</div>
</div>
</div>
</div>
{/* Tasks Section */}
{tasks.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<CheckIcon className="h-5 w-5 mr-2" />
{t('tasks.title')} ({tasks.length})
</h3>
<TaskList
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={[]} // Empty since we're viewing by tag
hideProjectName={false}
onToggleToday={handleToggleToday}
/>
</div>
)}
{/* Notes Section */}
{notes.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />
{t('notes.title')} ({notes.length})
</h3>
<ul className="space-y-1">
{notes.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)}
onMouseLeave={() => setHoveredNoteId(null)}
>
<div className="flex-grow overflow-hidden pr-4">
<div className="flex items-center flex-wrap gap-2">
<Link
to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{note.title}
</Link>
{/* Tags */}
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
<>
{(note.tags || note.Tags || []).map((noteTag) => (
<button
key={noteTag.id}
onClick={(e) => {
e.preventDefault();
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"
>
<TagIcon className="h-3 w-3 text-gray-500 dark:text-gray-300" />
<span className="text-gray-700 dark:text-gray-300">
{noteTag.name}
</span>
</button>
))}
</>
)}
</div>
</div>
<div className="flex space-x-2">
<button
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}`}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
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}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
</div>
)}
{/* Projects Section */}
{projects.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />
{t('projects.title')} ({projects.length})
</h3>
<div className="flex flex-col space-y-1">
{projects.map((project) => {
const { color } = getPriorityStyles(project.priority || "low");
return (
<ProjectItem
key={project.id}
project={project}
viewMode="list"
color={color}
getCompletionPercentage={() => getCompletionPercentage(project)}
activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown}
handleEditProject={handleEditProject}
setProjectToDelete={setProjectToDelete}
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
/>
useEffect(() => {
const fetchTagData = async () => {
try {
// First fetch tag details
const tagResponse = await fetch(
`/api/tag/${encodeURIComponent(identifier!)}`
);
})}
</div>
</div>
)}
if (tagResponse.ok) {
const tagData = await tagResponse.json();
setTag(tagData);
{/* Empty State */}
{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}"`)}
</p>
</div>
)}
</div>
</div>
);
// 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
]);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData.tasks || []);
}
if (notesResponse.ok) {
const notesData = await notesResponse.json();
setNotes(notesData || []);
}
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
)
);
setProjects(filteredProjects);
}
} else {
const tagError = await tagResponse.json();
setError(tagError.error || 'Failed to fetch tag.');
}
} catch {
setError(t('tags.error'));
} finally {
setLoading(false);
}
};
fetchTagData();
}, [identifier, t]);
// Task handlers
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
}
} catch (error) {
console.error('Error updating task:', error);
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.filter((task) => task.id !== taskId)
);
}
} catch (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' },
});
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
)
);
}
} catch (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);
};
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' };
default:
return { color: 'bg-gray-500' };
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
{t('tags.loading')}
</div>
</div>
);
}
if (error) {
return <div className="text-red-500 p-4">{error}</div>;
}
if (!tag) {
return (
<div className="text-gray-700 dark:text-gray-300 p-4">
{t('tags.notFound')}
</div>
);
}
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Tag Header */}
<div className="flex items-center mb-8">
<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">
{tag.name}
</h2>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<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>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<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>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
<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>
</div>
</div>
</div>
</div>
{/* Tasks Section */}
{tasks.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<CheckIcon className="h-5 w-5 mr-2" />
{t('tasks.title')} ({tasks.length})
</h3>
<TaskList
tasks={tasks}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={[]} // Empty since we're viewing by tag
hideProjectName={false}
onToggleToday={handleToggleToday}
/>
</div>
)}
{/* Notes Section */}
{notes.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />
{t('notes.title')} ({notes.length})
</h3>
<ul className="space-y-1">
{notes.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)
}
onMouseLeave={() => setHoveredNoteId(null)}
>
<div className="flex-grow overflow-hidden pr-4">
<div className="flex items-center flex-wrap gap-2">
<Link
to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{note.title}
</Link>
{/* Tags */}
{((note.tags &&
note.tags.length > 0) ||
(note.Tags &&
note.Tags.length > 0)) && (
<>
{(
note.tags ||
note.Tags ||
[]
).map((noteTag) => (
<button
key={noteTag.id}
onClick={(e) => {
e.preventDefault();
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"
>
<TagIcon className="h-3 w-3 text-gray-500 dark:text-gray-300" />
<span className="text-gray-700 dark:text-gray-300">
{noteTag.name}
</span>
</button>
))}
</>
)}
</div>
</div>
<div className="flex space-x-2">
<button
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}`}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
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}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
</div>
)}
{/* Projects Section */}
{projects.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />
{t('projects.title')} ({projects.length})
</h3>
<div className="flex flex-col space-y-1">
{projects.map((project) => {
const { color } = getPriorityStyles(
project.priority || 'low'
);
return (
<ProjectItem
key={project.id}
project={project}
viewMode="list"
color={color}
getCompletionPercentage={() =>
getCompletionPercentage(project)
}
activeDropdown={activeDropdown}
setActiveDropdown={setActiveDropdown}
handleEditProject={handleEditProject}
setProjectToDelete={setProjectToDelete}
setIsConfirmDialogOpen={
setIsConfirmDialogOpen
}
/>
);
})}
</div>
</div>
)}
{/* Empty State */}
{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}"`
)}
</p>
</div>
)}
</div>
</div>
);
};
export default TagDetails;

View file

@ -3,225 +3,240 @@ import { Tag } from '../../entities/Tag';
import { useTranslation } from 'react-i18next';
interface TagInputProps {
initialTags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: Tag[];
initialTags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: Tag[];
}
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags }) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(initialTags || []);
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const TagInput: React.FC<TagInputProps> = ({
initialTags,
onTagsChange,
availableTags,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(initialTags || []);
const [filteredTags, setFilteredTags] = useState<Tag[]>([]);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Update internal tags state when initialTags prop changes
useEffect(() => {
// Set the tags state with the initial tags
if (initialTags && initialTags.length > 0) {
// Simply set our internal state to match the initialTags
setTags(initialTags);
}
}, [initialTags]);
// Remove this effect to prevent infinite loops
// onTagsChange is called directly in addNewTag, selectTag, and removeTag
// Update internal tags state when initialTags prop changes
useEffect(() => {
// Set the tags state with the initial tags
if (initialTags && initialTags.length > 0) {
// Simply set our internal state to match the initialTags
setTags(initialTags);
}
}, [initialTags]);
useEffect(() => {
const handler = setTimeout(() => {
if (inputValue.trim() === '') {
setFilteredTags([]);
// Remove this effect to prevent infinite loops
// onTagsChange is called directly in addNewTag, selectTag, and removeTag
useEffect(() => {
const handler = setTimeout(() => {
if (inputValue.trim() === '') {
setFilteredTags([]);
setIsDropdownOpen(false);
return;
}
const filtered = availableTags.filter(
(tag) =>
tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
!tags.includes(tag.name)
);
setFilteredTags(filtered);
setIsDropdownOpen(filtered.length > 0);
setHighlightedIndex(-1);
}, 300);
return () => {
clearTimeout(handler);
};
}, [inputValue, availableTags, tags]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredTags.length - 1 ? prev + 1 : prev
);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (event.key === 'Enter') {
event.preventDefault();
if (
highlightedIndex >= 0 &&
highlightedIndex < filteredTags.length
) {
selectTag(filteredTags[highlightedIndex].name);
} else if (inputValue.trim()) {
addNewTag(inputValue.trim());
}
} else if (event.key === 'Escape') {
setIsDropdownOpen(false);
} else if (event.key === ',') {
if (inputValue.trim()) {
event.preventDefault();
addNewTag(inputValue.trim());
}
}
};
const addNewTag = (tag: string) => {
if (tags.length >= 10) {
return;
}
if (!tags.includes(tag)) {
const updatedTags = [...tags, tag];
setTags(updatedTags);
onTagsChange(updatedTags);
}
setInputValue('');
setIsDropdownOpen(false);
return;
}
const filtered = availableTags.filter(tag =>
tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
!tags.includes(tag.name)
);
setFilteredTags(filtered);
setIsDropdownOpen(filtered.length > 0);
setHighlightedIndex(-1);
}, 300);
return () => {
clearTimeout(handler);
};
}, [inputValue, availableTags, tags]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
const selectTag = (tag: string) => {
if (!tags.includes(tag)) {
const updatedTags = [...tags, tag];
setTags(updatedTags);
onTagsChange(updatedTags);
}
setInputValue('');
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
const removeTag = (index: number) => {
const updatedTags = tags.filter((_, i) => i !== index);
setTags(updatedTags);
onTagsChange(updatedTags);
};
}, []);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setHighlightedIndex(prev => (prev < filteredTags.length - 1 ? prev + 1 : prev));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setHighlightedIndex(prev => (prev > 0 ? prev - 1 : prev));
} else if (event.key === 'Enter') {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredTags.length) {
selectTag(filteredTags[highlightedIndex].name);
} else if (inputValue.trim()) {
addNewTag(inputValue.trim());
}
} else if (event.key === 'Escape') {
setIsDropdownOpen(false);
} else if (event.key === ',') {
if (inputValue.trim()) {
event.preventDefault();
addNewTag(inputValue.trim());
}
}
};
const addNewTag = (tag: string) => {
if (tags.length >= 10) {
return;
}
if (!tags.includes(tag)) {
const updatedTags = [...tags, tag];
setTags(updatedTags);
onTagsChange(updatedTags);
}
setInputValue('');
setIsDropdownOpen(false);
};
const selectTag = (tag: string) => {
if (!tags.includes(tag)) {
const updatedTags = [...tags, tag];
setTags(updatedTags);
onTagsChange(updatedTags);
}
setInputValue('');
setIsDropdownOpen(false);
};
const removeTag = (index: number) => {
const updatedTags = tags.filter((_, i) => i !== index);
setTags(updatedTags);
onTagsChange(updatedTags);
};
return (
<div className="space-y-2 relative">
<div
ref={containerRef}
className="flex flex-wrap items-center border border-gray-300 dark:border-gray-900 bg-white dark:bg-gray-900 rounded-md p-2 min-h-[40px]"
>
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={index}
className="flex items-center bg-gray-200 text-gray-700 text-xs font-medium mr-2 px-2.5 py-0.5 rounded"
return (
<div className="space-y-2 relative">
<div
ref={containerRef}
className="flex flex-wrap items-center border border-gray-300 dark:border-gray-900 bg-white dark:bg-gray-900 rounded-md p-2 min-h-[40px]"
>
{tag}
<button
type="button"
onClick={() => removeTag(index)}
className="ml-1 text-gray-600 hover:text-gray-800 focus:outline-none"
aria-label={`Remove tag ${tag}`}
>
&times;
</button>
</span>
))
) : (
<span className="text-gray-400 text-xs"></span>
)}
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={index}
className="flex items-center bg-gray-200 text-gray-700 text-xs font-medium mr-2 px-2.5 py-0.5 rounded"
>
{tag}
<button
type="button"
onClick={() => removeTag(index)}
className="ml-1 text-gray-600 hover:text-gray-800 focus:outline-none"
aria-label={`Remove tag ${tag}`}
>
&times;
</button>
</span>
))
) : (
<span className="text-gray-400 text-xs"></span>
)}
<input
type="text"
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={t('tags.typeToAdd')}
className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100"
onFocus={() => {
if (filteredTags.length > 0) setIsDropdownOpen(true);
}}
style={{ minWidth: '150px' }}
aria-haspopup="listbox"
aria-expanded={isDropdownOpen}
aria-controls="tag-suggestions"
/>
</div>
<input
type="text"
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={t('tags.typeToAdd')}
className="flex-grow bg-transparent border-none outline-none text-sm text-gray-900 dark:text-gray-100"
onFocus={() => {
if (filteredTags.length > 0) setIsDropdownOpen(true);
}}
style={{ minWidth: '150px' }}
aria-haspopup="listbox"
aria-expanded={isDropdownOpen}
aria-controls="tag-suggestions"
/>
</div>
{isDropdownOpen && (
<div
ref={dropdownRef}
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
role="listbox"
id="tag-suggestions"
>
{filteredTags.map((tag, index) => (
<button
key={tag.id}
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'
}`}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseLeave={() => setHighlightedIndex(-1)}
role="option"
aria-selected={highlightedIndex === index}
>
{highlightedIndex === index ? (
<>
{inputValue.length > 0 && (
<span className="font-semibold">
{tag.name.substring(0, inputValue.length)}
</span>
)}
{tag.name.substring(inputValue.length)}
</>
) : (
tag.name
)}
</button>
))}
{/* Option to add a new tag if no matches */}
{filteredTags.length === 0 && inputValue.trim() !== '' && (
<button
type="button"
onClick={() => addNewTag(inputValue.trim())}
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()}"
</button>
)}
{isDropdownOpen && (
<div
ref={dropdownRef}
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
role="listbox"
id="tag-suggestions"
>
{filteredTags.map((tag, index) => (
<button
key={tag.id}
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'
}`}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseLeave={() => setHighlightedIndex(-1)}
role="option"
aria-selected={highlightedIndex === index}
>
{highlightedIndex === index ? (
<>
{inputValue.length > 0 && (
<span className="font-semibold">
{tag.name.substring(
0,
inputValue.length
)}
</span>
)}
{tag.name.substring(inputValue.length)}
</>
) : (
tag.name
)}
</button>
))}
{/* Option to add a new tag if no matches */}
{filteredTags.length === 0 && inputValue.trim() !== '' && (
<button
type="button"
onClick={() => addNewTag(inputValue.trim())}
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 &quot;{inputValue.trim()}&quot;
</button>
)}
</div>
)}
</div>
)}
</div>
);
);
};
export default TagInput;

View file

@ -5,206 +5,221 @@ import { useToast } from '../Shared/ToastContext';
import { useTranslation } from 'react-i18next';
interface TagModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (tag: Tag) => void;
onDelete?: (tagId: number) => void;
tag?: Tag | null;
isOpen: boolean;
onClose: () => void;
onSave: (tag: Tag) => void;
onDelete?: (tagId: number) => void;
tag?: Tag | null;
}
const TagModal: React.FC<TagModalProps> = ({
isOpen,
onClose,
onSave,
onDelete,
tag,
isOpen,
onClose,
onSave,
onDelete,
tag,
}) => {
const [formData, setFormData] = useState<Tag>(
tag || {
name: '',
}
);
const [formData, setFormData] = useState<Tag>(
tag || {
name: '',
}
);
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation();
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation();
useEffect(() => {
if (tag) {
setFormData(tag);
} else {
setFormData({
name: '',
});
}
}, [tag]);
useEffect(() => {
if (tag) {
setFormData(tag);
} else {
setFormData({
name: '',
});
}
}, [tag]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(event.target as Node)
) {
handleClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
const handleSubmit = async () => {
if (!formData.name.trim()) {
showErrorToast(
t('errors.tagNameRequired', 'Tag name is required.')
);
return;
}
setIsSubmitting(true);
try {
await onSave(formData); // Wait for the save operation to complete
if (tag) {
showSuccessToast(
t('success.tagUpdated', 'Tag updated successfully!')
);
} else {
showSuccessToast(
t('success.tagCreated', 'Tag created successfully!')
);
}
handleClose();
} catch {
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
} finally {
setIsSubmitting(false);
}
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
const handleDeleteTag = async () => {
if (formData.id && onDelete) {
try {
await onDelete(formData.id);
showSuccessToast(
t('success.tagDeleted', 'Tag deleted successfully!')
);
handleClose();
} catch {
showErrorToast(
t('errors.failedToDeleteTag', 'Failed to delete tag.')
);
}
}
};
}, [isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
if (!isOpen) return null;
const handleSubmit = async () => {
if (!formData.name.trim()) {
showErrorToast(t('errors.tagNameRequired', 'Tag name is required.'));
return;
}
setIsSubmitting(true);
try {
await onSave(formData); // Wait for the save operation to complete
if (tag) {
showSuccessToast(t('success.tagUpdated', 'Tag updated successfully!'));
} else {
showSuccessToast(t('success.tagCreated', 'Tag created successfully!'));
}
handleClose();
} catch (err) {
showErrorToast(t('errors.failedToSaveTag', 'Failed to save tag.'));
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
};
const handleDeleteTag = async () => {
if (formData.id && onDelete) {
try {
await onDelete(formData.id);
showSuccessToast(t('success.tagDeleted', 'Tag deleted successfully!'));
handleClose();
} catch (err) {
showErrorToast(t('errors.failedToDeleteTag', 'Failed to delete tag.'));
}
}
};
if (!isOpen) return null;
return (
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border-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'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-auto">
{/* Main Form Section */}
<div className="bg-white dark:bg-gray-800">
<form>
<fieldset>
{/* Tag Title Section - Always Visible */}
<div className="px-4 pt-4 pb-4">
<input
type="text"
id="tagName"
name="name"
value={formData.name}
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')}
/>
</div>
</fieldset>
</form>
</div>
{/* Action Buttons - Below border with custom layout */}
<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) && (
<button
type="button"
onClick={handleDeleteTag}
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')}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
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' : ''
return (
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
{isSubmitting
? t('modals.submitting', 'Submitting...')
: tag
? t('modals.updateTag', 'Update Tag')
: t('modals.createTag', 'Create Tag')}
</button>
>
<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'
} h-full sm:h-auto sm:my-4`}
>
<div className="flex flex-col h-auto">
{/* Main Form Section */}
<div className="bg-white dark:bg-gray-800">
<form>
<fieldset>
{/* Tag Title Section - Always Visible */}
<div className="px-4 pt-4 pb-4">
<input
type="text"
id="tagName"
name="name"
value={formData.name}
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'
)}
/>
</div>
</fieldset>
</form>
</div>
{/* Action Buttons - Below border with custom layout */}
<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 && (
<button
type="button"
onClick={handleDeleteTag}
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')}
>
<TrashIcon className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={handleClose}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 focus:outline-none transition duration-150 ease-in-out text-sm"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
{/* Right side: Save */}
<button
type="button"
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
? t('modals.submitting', 'Submitting...')
: tag
? t('modals.updateTag', 'Update Tag')
: t('modals.createTag', 'Create Tag')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</>
);
</>
);
};
export default TagModal;

View file

@ -1,353 +1,427 @@
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
}
} = useStore();
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 [metricsLoaded, setMetricsLoaded] = useState<boolean>(false);
const [cachedProjects, setCachedProjects] = useState<any[]>([]);
const {
tagsStore: { tags, setTags, isLoading, isError },
} = useStore();
useEffect(() => {
const loadMetrics = async () => {
if (tags.length === 0) {
// Tags not loaded yet, wait for Layout to load them
return;
}
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 [metricsLoaded, setMetricsLoaded] = useState<boolean>(false);
const [, setCachedProjects] = useState<any[]>([]);
try {
// Load all data at once for better performance
const [projectsResponse, tasksResponse, notesResponse] = await Promise.all([
fetch('/api/projects'),
fetch('/api/tasks'),
fetch('/api/notes')
]);
useEffect(() => {
const loadMetrics = async () => {
if (tags.length === 0) {
// Tags not loaded yet, wait for Layout to load them
return;
}
let allProjects: any[] = [];
let allTasks: any[] = [];
let allNotes: any[] = [];
try {
// Load all data at once for better performance
const [projectsResponse, tasksResponse, notesResponse] =
await Promise.all([
fetch('/api/projects'),
fetch('/api/tasks'),
fetch('/api/notes'),
]);
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
allProjects = projectsData.projects || projectsData || [];
setCachedProjects(allProjects);
let allProjects: any[] = [];
let allTasks: any[] = [];
let allNotes: any[] = [];
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
allProjects = projectsData.projects || projectsData || [];
setCachedProjects(allProjects);
}
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
allTasks = tasksData.tasks || tasksData || [];
}
if (notesResponse.ok) {
const notesData = await notesResponse.json();
allNotes = notesData || [];
}
// Calculate metrics for all tags at once
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
)
).length;
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
)
).length;
metricsMap[tag.name] = {
tasks: tasksCount,
notes: notesCount,
projects: projectsCount,
};
});
setTagMetrics(metricsMap);
setMetricsLoaded(true);
} catch (error) {
console.error('Failed to fetch metrics:', error);
}
};
loadMetrics();
}, [tags]); // Only run when tags change
const handleDeleteTag = async () => {
if (!tagToDelete) return;
try {
await apiDeleteTag(tagToDelete.id!);
setTags(tags.filter((tag) => tag.id !== tagToDelete.id));
// Remove the deleted tag from metrics as well
setTagMetrics((prev) => {
const newMetrics = { ...prev };
delete newMetrics[tagToDelete.name];
return newMetrics;
});
setIsConfirmDialogOpen(false);
setTagToDelete(null);
} catch (err) {
console.error('Failed to delete tag:', err);
}
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
allTasks = tasksData.tasks || tasksData || [];
}
if (notesResponse.ok) {
const notesData = await notesResponse.json();
allNotes = notesData || [];
}
// Calculate metrics for all tags at once
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)
).length;
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)
).length;
metricsMap[tag.name] = {
tasks: tasksCount,
notes: notesCount,
projects: projectsCount
};
});
setTagMetrics(metricsMap);
setMetricsLoaded(true);
} catch (error) {
console.error('Failed to fetch metrics:', error);
}
};
loadMetrics();
}, [tags]); // Only run when tags change
const handleEditTag = (tag: Tag) => {
setSelectedTag(tag);
setIsTagModalOpen(true);
};
const handleSaveTag = async (tagData: Tag) => {
try {
if (tagData.id) {
await updateTag(tagData.id, tagData);
setTags(
tags.map((tag) => (tag.id === tagData.id ? tagData : tag))
);
} else {
const newTag = await createTag(tagData);
setTags([...tags, newTag]);
}
setIsTagModalOpen(false);
setSelectedTag(null);
} catch (error) {
console.error('Error saving tag:', error);
}
};
const handleDeleteTag = async () => {
if (!tagToDelete) return;
try {
await apiDeleteTag(tagToDelete.id!);
setTags(tags.filter((tag) => tag.id !== tagToDelete.id));
// Remove the deleted tag from metrics as well
setTagMetrics((prev) => {
const newMetrics = { ...prev };
delete newMetrics[tagToDelete.name];
return newMetrics;
});
setIsConfirmDialogOpen(false);
setTagToDelete(null);
} catch (err) {
console.error('Failed to delete tag:', err);
}
};
const openConfirmDialog = (tag: Tag) => {
setTagToDelete(tag);
setIsConfirmDialogOpen(true);
};
const closeConfirmDialog = () => {
setIsConfirmDialogOpen(false);
setTagToDelete(null);
};
const handleEditTag = (tag: Tag) => {
setSelectedTag(tag);
setIsTagModalOpen(true);
};
const handleSaveTag = async (tagData: Tag) => {
try {
if (tagData.id) {
await updateTag(tagData.id, tagData);
setTags(tags.map(tag => tag.id === tagData.id ? tagData : tag));
} else {
const newTag = await createTag(tagData);
setTags([...tags, newTag]);
}
setIsTagModalOpen(false);
setSelectedTag(null);
} catch (error) {
console.error('Error saving tag:', error);
}
};
const openConfirmDialog = (tag: Tag) => {
setTagToDelete(tag);
setIsConfirmDialogOpen(true);
};
const closeConfirmDialog = () => {
setIsConfirmDialogOpen(false);
setTagToDelete(null);
};
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Group tags alphabetically by first letter
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>);
// 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()));
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading tags...
</div>
</div>
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (isError) {
return <div className="text-red-500 p-4">Error loading tags.</div>;
}
// Group tags alphabetically by first letter
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>
);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Tags Header */}
<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>
</div>
</div>
// 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())
);
});
{/* Search Bar with Icon */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{/* Tags List */}
{filteredTags.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">No tags found.</p>
) : (
<div className="space-y-8">
{sortedGroupKeys.map((letter) => (
<div key={letter}>
{/* Alphabetical Group Header */}
<div className="mb-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{letter}
</h3>
<hr className="border-gray-300 dark:border-gray-600" />
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading tags...
</div>
{/* 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;
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)}
>
<div className="flex items-center justify-between">
{/* Tag Name and Metrics - inline */}
<div className="flex items-center space-x-3 flex-grow">
<Link
to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{tag.name}
</Link>
{/* Metrics - inline with tag name */}
{!metricsLoaded && (
<div className="flex items-center text-sm text-gray-400 dark:text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500"></div>
</div>
)}
{metricsLoaded && hasItems && (
<div className="flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-400">
{metrics.projects > 0 && (
<div className="flex items-center space-x-1">
<FolderIcon className="h-4 w-4 text-purple-500" />
<span>{metrics.projects}</span>
</div>
)}
{metrics.tasks > 0 && (
<div className="flex items-center space-x-1">
<CheckIcon className="h-4 w-4 text-blue-500" />
<span>{metrics.tasks}</span>
</div>
)}
{metrics.notes > 0 && (
<div className="flex items-center space-x-1">
<BookOpenIcon className="h-4 w-4 text-green-500" />
<span>{metrics.notes}</span>
</div>
)}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex space-x-2 ml-2">
<button
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}`}
>
<PencilSquareIcon className="h-4 w-4" />
</button>
<button
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}`}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</div>
);
}
{/* TagModal */}
{isTagModalOpen && (
<TagModal
isOpen={isTagModalOpen}
onClose={() => {
setIsTagModalOpen(false);
setSelectedTag(null);
}}
onSave={handleSaveTag}
onDelete={async (tagId) => {
try {
await apiDeleteTag(tagId);
setTags(tags.filter((tag) => tag.id !== tagId));
setTagMetrics((prev) => {
const newMetrics = { ...prev };
const deletedTag = tags.find(t => t.id === tagId);
if (deletedTag) {
delete newMetrics[deletedTag.name];
}
return newMetrics;
});
setIsTagModalOpen(false);
setSelectedTag(null);
} catch (error) {
console.error('Error deleting tag:', error);
}
}}
tag={selectedTag}
/>
)}
if (isError) {
return <div className="text-red-500 p-4">Error loading tags.</div>;
}
{/* ConfirmDialog */}
{isConfirmDialogOpen && tagToDelete && (
<ConfirmDialog
title="Delete Tag"
message={`Are you sure you want to delete the tag "${tagToDelete.name}"?`}
onConfirm={handleDeleteTag}
onCancel={closeConfirmDialog}
/>
)}
</div>
</div>
);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Tags Header */}
<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>
</div>
</div>
{/* Search Bar with Icon */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{/* Tags List */}
{filteredTags.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">
No tags found.
</p>
) : (
<div className="space-y-8">
{sortedGroupKeys.map((letter) => (
<div key={letter}>
{/* Alphabetical Group Header */}
<div className="mb-4">
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
{letter}
</h3>
<hr className="border-gray-300 dark:border-gray-600" />
</div>
{/* 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;
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)
}
>
<div className="flex items-center justify-between">
{/* Tag Name and Metrics - inline */}
<div className="flex items-center space-x-3 flex-grow">
<Link
to={`/tag/${encodeURIComponent(tag.name)}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{tag.name}
</Link>
{/* Metrics - inline with tag name */}
{!metricsLoaded && (
<div className="flex items-center text-sm text-gray-400 dark:text-gray-500">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500"></div>
</div>
)}
{metricsLoaded &&
hasItems && (
<div className="flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-400">
{metrics.projects >
0 && (
<div className="flex items-center space-x-1">
<FolderIcon className="h-4 w-4 text-purple-500" />
<span>
{
metrics.projects
}
</span>
</div>
)}
{metrics.tasks >
0 && (
<div className="flex items-center space-x-1">
<CheckIcon className="h-4 w-4 text-blue-500" />
<span>
{
metrics.tasks
}
</span>
</div>
)}
{metrics.notes >
0 && (
<div className="flex items-center space-x-1">
<BookOpenIcon className="h-4 w-4 text-green-500" />
<span>
{
metrics.notes
}
</span>
</div>
)}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex space-x-2 ml-2">
<button
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}`}
>
<PencilSquareIcon className="h-4 w-4" />
</button>
<button
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}`}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
{/* TagModal */}
{isTagModalOpen && (
<TagModal
isOpen={isTagModalOpen}
onClose={() => {
setIsTagModalOpen(false);
setSelectedTag(null);
}}
onSave={handleSaveTag}
onDelete={async (tagId) => {
try {
await apiDeleteTag(tagId);
setTags(tags.filter((tag) => tag.id !== tagId));
setTagMetrics((prev) => {
const newMetrics = { ...prev };
const deletedTag = tags.find(
(t) => t.id === tagId
);
if (deletedTag) {
delete newMetrics[deletedTag.name];
}
return newMetrics;
});
setIsTagModalOpen(false);
setSelectedTag(null);
} catch (error) {
console.error('Error deleting tag:', error);
}
}}
tag={selectedTag}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && tagToDelete && (
<ConfirmDialog
title="Delete Tag"
message={`Are you sure you want to delete the tag "${tagToDelete.name}"?`}
onConfirm={handleDeleteTag}
onCancel={closeConfirmDialog}
/>
)}
</div>
</div>
);
};
export default Tags;
export default Tags;

View file

@ -1,99 +1,128 @@
import React, { useState, useEffect } from 'react';
import { useToast } from '../../components/Shared/ToastContext';
import { useToast } from '../../components/Shared/ToastContext';
import { useTranslation } from 'react-i18next';
import { PlusCircleIcon } from '@heroicons/react/24/outline';
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
interface NewTaskProps {
onTaskCreate: (taskName: string) => Promise<void>;
onTaskCreate: (taskName: string) => Promise<void>;
}
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 { t } = useTranslation();
const [taskName, setTaskName] = useState<string>('');
const [showNameLengthHelper, setShowNameLengthHelper] = useState(false);
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
useState(true);
const { showErrorToast } = useToast();
const { t } = useTranslation();
// Fetch task intelligence setting when component mounts
useEffect(() => {
const fetchTaskIntelligenceSetting = async () => {
try {
const enabled = await getTaskIntelligenceEnabled();
setTaskIntelligenceEnabled(enabled);
} catch (error) {
console.error('Error fetching task intelligence setting:', error);
setTaskIntelligenceEnabled(true); // Default to enabled
}
// Fetch task intelligence setting when component mounts
useEffect(() => {
const fetchTaskIntelligenceSetting = async () => {
try {
const enabled = await getTaskIntelligenceEnabled();
setTaskIntelligenceEnabled(enabled);
} catch (error) {
console.error(
'Error fetching task intelligence setting:',
error
);
setTaskIntelligenceEnabled(true); // Default to enabled
}
};
fetchTaskIntelligenceSetting();
}, []);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setTaskName(value);
// 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
);
}
};
fetchTaskIntelligenceSetting();
}, []);
const handleKeyDown = async (
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === 'Enter' && taskName.trim()) {
const taskText = taskName.trim();
setTaskName('');
setShowNameLengthHelper(false); // Hide helper when creating task
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setTaskName(value);
// 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);
}
};
try {
await onTaskCreate(taskText);
// Success toast is now handled by the parent component
} catch (error) {
console.error('Error creating task:', error);
setTaskName(taskText);
showErrorToast(
t('errors.taskCreate', 'Failed to create task.')
);
}
}
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && taskName.trim()) {
const taskText = taskName.trim();
setTaskName('');
setShowNameLengthHelper(false); // Hide helper when creating task
try {
await onTaskCreate(taskText);
// Success toast is now handled by the parent component
} catch (error) {
console.error('Error creating task:', error);
setTaskName(taskText);
showErrorToast(t('errors.taskCreate', 'Failed to create task.'));
}
}
};
return (
<div className="mb-2">
<div className="flex items-center justify-between py-3 px-4 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
<span className="text-xl text-gray-500 dark:text-gray-400 mr-4">
<PlusCircleIcon className="h-6 w-6" />
</span>
<input
type="text"
value={taskName}
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', 'Προσθήκη Νέας Εργασίας')}
/>
</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>
return (
<div className="mb-2">
<div className="flex items-center justify-between py-3 px-4 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
<span className="text-xl text-gray-500 dark:text-gray-400 mr-4">
<PlusCircleIcon className="h-6 w-6" />
</span>
<input
type="text"
value={taskName}
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',
'Προσθήκη Νέας Εργασίας'
)}
/>
</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>
</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"')}
</p>
</div>
</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>
</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>
</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"'
)}
</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
);
};
export default NewTask;

View file

@ -1,173 +1,194 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
PlayIcon,
XMarkIcon,
ArrowPathIcon,
SparklesIcon,
PlayIcon,
XMarkIcon,
ArrowPathIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import { FolderIcon } from '@heroicons/react/24/solid';
import { Task } from '../../entities/Task';
import { useToast } from '../Shared/ToastContext';
interface NextTaskSuggestionProps {
metrics: {
tasks_due_today: Task[];
suggested_tasks: Task[];
tasks_in_progress: Task[];
today_plan_tasks?: Task[];
};
projects: any[];
onTaskUpdate: (task: Task) => Promise<void>;
onClose?: () => void;
metrics: {
tasks_due_today: Task[];
suggested_tasks: Task[];
tasks_in_progress: Task[];
today_plan_tasks?: Task[];
};
projects: any[];
onTaskUpdate: (task: Task) => Promise<void>;
onClose?: () => void;
}
const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
metrics,
projects,
onTaskUpdate,
onClose
const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
metrics,
projects,
onTaskUpdate,
onClose,
}) => {
const { t } = useTranslation();
const { showSuccessToast } = useToast();
const [isUpdating, setIsUpdating] = useState(false);
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
const { t } = useTranslation();
const { showSuccessToast } = useToast();
const [isUpdating, setIsUpdating] = useState(false);
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
// Check if there are any tasks in progress
// If there are tasks in progress, don't show the suggestion
if (metrics.tasks_in_progress.length > 0) {
return null;
}
// Helper function to check if task is not started
const isNotStarted = (task: Task) => {
return task.status === 'not_started' || task.status === 0;
};
// Get all available tasks in priority order:
// 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 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' }))
];
if (allAvailableTasks.length === 0) {
return null;
}
// Get current task based on index, wrap around if needed
const currentTaskData = allAvailableTasks[currentTaskIndex % allAvailableTasks.length];
const suggestedTask = currentTaskData.task;
const suggestionSource = currentTaskData.source;
// Helper function to get project name
const getProjectName = (task: Task) => {
if (task.Project) {
return task.Project.name;
// Check if there are any tasks in progress
// If there are tasks in progress, don't show the suggestion
if (metrics.tasks_in_progress.length > 0) {
return null;
}
if (task.project_id) {
const project = projects.find(p => p.id === task.project_id);
return project?.name;
// Helper function to check if task is not started
const isNotStarted = (task: Task) => {
return task.status === 'not_started' || task.status === 0;
};
// Get all available tasks in priority order:
// 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 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' })),
];
if (allAvailableTasks.length === 0) {
return null;
}
return null;
};
const handleStartTask = async () => {
if (!suggestedTask || !suggestedTask.id) return;
// Get current task based on index, wrap around if needed
const currentTaskData =
allAvailableTasks[currentTaskIndex % allAvailableTasks.length];
const suggestedTask = currentTaskData.task;
const suggestionSource = currentTaskData.source;
setIsUpdating(true);
try {
// Universal rule: when setting status to in_progress, also add to today
const updatedTask = {
...suggestedTask,
status: 'in_progress' as const,
today: true
};
await onTaskUpdate(updatedTask);
showSuccessToast(t('task.startedSuccessfully', 'Task started successfully!'));
} catch (error) {
console.error('Error starting task:', error);
} finally {
setIsUpdating(false);
}
};
// Helper function to get project name
const getProjectName = (task: Task) => {
if (task.Project) {
return task.Project.name;
}
if (task.project_id) {
const project = projects.find((p) => p.id === task.project_id);
return project?.name;
}
return null;
};
const handleGiveMeSomethingElse = () => {
setCurrentTaskIndex(prev => prev + 1);
};
const handleStartTask = async () => {
if (!suggestedTask || !suggestedTask.id) return;
return (
<div className="mb-6 p-4 bg-white dark:bg-gray-900 border-l-4 border-purple-500 rounded-lg shadow relative">
{onClose && (
<button
onClick={onClose}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label={t('common.close', 'Close')}
>
<XMarkIcon className="h-5 w-5" />
</button>
)}
<div className="flex items-start">
<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')}
</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">
{suggestedTask.name}
</p>
{getProjectName(suggestedTask) && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
{getProjectName(suggestedTask)}
</p>
setIsUpdating(true);
try {
// Universal rule: when setting status to in_progress, also add to today
const updatedTask = {
...suggestedTask,
status: 'in_progress' as const,
today: true,
};
await onTaskUpdate(updatedTask);
showSuccessToast(
t('task.startedSuccessfully', 'Task started successfully!')
);
} catch (error) {
console.error('Error starting task:', error);
} finally {
setIsUpdating(false);
}
};
const handleGiveMeSomethingElse = () => {
setCurrentTaskIndex((prev) => prev + 1);
};
return (
<div className="mb-6 p-4 bg-white dark:bg-gray-900 border-l-4 border-purple-500 rounded-lg shadow relative">
{onClose && (
<button
onClick={onClose}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label={t('common.close', 'Close')}
>
<XMarkIcon className="h-5 w-5" />
</button>
)}
{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()}
</p>
)}
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleStartTask}
disabled={isUpdating}
className="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white text-sm font-medium rounded-md transition-colors"
>
<PlayIcon className="h-4 w-4 mr-2" />
{isUpdating
? t('nextTask.starting', 'Starting...')
: t('nextTask.letsDoIt', "Yes, let's do it!")
}
</button>
{allAvailableTasks.length > 1 && (
<button
onClick={handleGiveMeSomethingElse}
disabled={isUpdating}
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')}
</button>
)}
</div>
<div className="flex items-start">
<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'
)}
</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">
{suggestedTask.name}
</p>
{getProjectName(suggestedTask) && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
{getProjectName(suggestedTask)}
</p>
)}
{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()}
</p>
)}
</div>
<div className="flex items-center space-x-3">
<button
onClick={handleStartTask}
disabled={isUpdating}
className="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white text-sm font-medium rounded-md transition-colors"
>
<PlayIcon className="h-4 w-4 mr-2" />
{isUpdating
? t('nextTask.starting', 'Starting...')
: t('nextTask.letsDoIt', "Yes, let's do it!")}
</button>
{allAvailableTasks.length > 1 && (
<button
onClick={handleGiveMeSomethingElse}
disabled={isUpdating}
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'
)}
</button>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
);
};
export default NextTaskSuggestion;
export default NextTaskSuggestion;

View file

@ -7,348 +7,509 @@ import ToggleSwitch from '../Shared/ToggleSwitch';
import DatePicker from '../Shared/DatePicker';
interface RecurrenceInputProps {
recurrenceType: RecurrenceType;
recurrenceInterval: number;
recurrenceEndDate?: string;
recurrenceWeekday?: number;
recurrenceMonthDay?: number;
recurrenceWeekOfMonth?: number;
completionBased: boolean;
onChange: (field: string, value: any) => void;
disabled?: boolean;
isChildTask?: boolean;
parentTaskLoading?: boolean;
onEditParent?: () => void;
onParentRecurrenceChange?: (field: string, value: any) => void;
recurrenceType: RecurrenceType;
recurrenceInterval: number;
recurrenceEndDate?: string;
recurrenceWeekday?: number;
recurrenceMonthDay?: number;
recurrenceWeekOfMonth?: number;
completionBased: boolean;
onChange: (field: string, value: any) => void;
disabled?: boolean;
isChildTask?: boolean;
parentTaskLoading?: boolean;
onEditParent?: () => void;
onParentRecurrenceChange?: (field: string, value: any) => void;
}
const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
recurrenceType,
recurrenceInterval,
recurrenceEndDate,
recurrenceWeekday,
recurrenceMonthDay,
recurrenceWeekOfMonth,
completionBased,
onChange,
disabled = false,
isChildTask = false,
parentTaskLoading = false,
onEditParent,
onParentRecurrenceChange,
recurrenceType,
recurrenceInterval,
recurrenceEndDate,
recurrenceWeekday,
recurrenceMonthDay,
recurrenceWeekOfMonth,
completionBased,
onChange,
disabled = false,
isChildTask = false,
parentTaskLoading = false,
onEditParent, // eslint-disable-line @typescript-eslint/no-unused-vars
onParentRecurrenceChange,
}) => {
const { t } = useTranslation();
const [editingParentRecurrence, setEditingParentRecurrence] = useState(false);
const { t } = useTranslation();
const [editingParentRecurrence, setEditingParentRecurrence] =
useState(false);
const weekdays = [
{ value: 0, label: t('weekdays.sunday', 'Sunday') },
{ value: 1, label: t('weekdays.monday', 'Monday') },
{ value: 2, label: t('weekdays.tuesday', 'Tuesday') },
{ value: 3, label: t('weekdays.wednesday', 'Wednesday') },
{ value: 4, label: t('weekdays.thursday', 'Thursday') },
{ value: 5, label: t('weekdays.friday', 'Friday') },
{ value: 6, label: t('weekdays.saturday', 'Saturday') },
];
const weekdays = [
{ value: 0, label: t('weekdays.sunday', 'Sunday') },
{ value: 1, label: t('weekdays.monday', 'Monday') },
{ value: 2, label: t('weekdays.tuesday', 'Tuesday') },
{ value: 3, label: t('weekdays.wednesday', 'Wednesday') },
{ value: 4, label: t('weekdays.thursday', 'Thursday') },
{ value: 5, label: t('weekdays.friday', 'Friday') },
{ value: 6, label: t('weekdays.saturday', 'Saturday') },
];
const weekOfMonthOptions = [
{ value: 1, label: t('recurrence.firstWeek', 'First') },
{ value: 2, label: t('recurrence.secondWeek', 'Second') },
{ value: 3, label: t('recurrence.thirdWeek', 'Third') },
{ value: 4, label: t('recurrence.fourthWeek', 'Fourth') },
{ value: 5, label: t('recurrence.lastWeek', 'Last') },
];
const weekOfMonthOptions = [
{ value: 1, label: t('recurrence.firstWeek', 'First') },
{ value: 2, label: t('recurrence.secondWeek', 'Second') },
{ value: 3, label: t('recurrence.thirdWeek', 'Third') },
{ value: 4, label: t('recurrence.fourthWeek', 'Fourth') },
{ value: 5, label: t('recurrence.lastWeek', 'Last') },
];
const recurrenceTypeOptions = [
{ value: 'none', label: t('recurrence.none', 'No repeat') },
{ 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') }
];
const recurrenceTypeOptions = [
{ value: 'none', label: t('recurrence.none', 'No repeat') },
{ 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'),
},
];
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)}
options={recurrenceTypeOptions}
disabled={isDisabled}
/>
</div>
);
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
)
}
options={recurrenceTypeOptions}
disabled={isDisabled}
/>
</div>
);
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)
return 99;
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)
return 99;
};
return (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
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')}
</span>
</div>
</div>
);
};
return (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
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')}
</span>
</div>
</div>
);
};
const renderWeekdaySelect = (
customOnChange?: (field: string, value: any) => void,
isDisabled?: boolean
) => {
const weekdayOptions = [
{ value: '', label: t('recurrence.anyDay', 'Any day') },
...weekdays,
];
const renderWeekdaySelect = (customOnChange?: (field: string, value: any) => void, isDisabled?: boolean) => {
const weekdayOptions = [
{ value: '', label: t('recurrence.anyDay', 'Any day') },
...weekdays
];
return (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{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)}
options={weekdayOptions}
disabled={isDisabled}
/>
</div>
);
};
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')}
</label>
<input
type="number"
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')}
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}
/>
</div>
);
const renderMonthlyWeekdayInputs = () => (
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekOfMonth', 'Week of month')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekOfMonth || 1}
onChange={(value) => onChange('recurrence_week_of_month', parseInt(value as string))}
options={weekOfMonthOptions}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekday', 'Weekday')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekday || 1}
onChange={(value) => onChange('recurrence_weekday', parseInt(value as string))}
options={weekdays}
/>
</div>
</div>
);
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)')}
</label>
<DatePicker
value={recurrenceEndDate || ''}
onChange={(value) => (customOnChange || onChange)('recurrence_end_date', value || null)}
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')}
disabled={isDisabled}
/>
</div>
);
const renderCompletionBasedToggle = () => (
<div className="mb-4">
<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')}
/>
</div>
);
// Show message for child tasks
if (isChildTask && parentTaskLoading) {
return (
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400">
Loading parent task recurrence settings...
</div>
</div>
);
}
if (isChildTask) {
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md p-3 mb-4">
<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.
</p>
{onParentRecurrenceChange && (
<button
type="button"
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'}
</button>
)}
</div>
</div>
<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.
</div>
</div>
)}
{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)}
{renderCompletionBasedToggle()}
</>
)}
</div>
</div>
);
}
if (recurrenceType === 'none') {
return (
<div className="pb-3">
{renderRecurrenceTypeSelect()}
</div>
);
}
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
{/* Main recurrence settings in one row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<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) => onChange('recurrence_type', value as RecurrenceType)}
options={recurrenceTypeOptions}
disabled={disabled}
/>
</div>
{(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')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
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
}
disabled={disabled}
return (
<div className="mb-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{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
)
}
options={weekdayOptions}
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')}
</span>
</div>
</div>
)}
);
};
<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)')}
</label>
<DatePicker
value={recurrenceEndDate || ''}
onChange={(value) => onChange('recurrence_end_date', value || null)}
placeholder={t('forms.task.endDatePlaceholder', 'Select end date')}
disabled={disabled}
/>
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')}
</label>
<input
type="number"
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'
)}
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}
/>
</div>
</div>
{/* Additional settings for specific recurrence types */}
{recurrenceType === 'weekly' && renderWeekdaySelect()}
{recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()}
{renderCompletionBasedToggle()}
</div>
);
);
const renderMonthlyWeekdayInputs = () => (
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekOfMonth', 'Week of month')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekOfMonth || 1}
onChange={(value) =>
onChange(
'recurrence_week_of_month',
parseInt(value as string)
)
}
options={weekOfMonthOptions}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.weekday', 'Weekday')}
</label>
<RecurrenceSelectDropdown
value={recurrenceWeekday || 1}
onChange={(value) =>
onChange(
'recurrence_weekday',
parseInt(value as string)
)
}
options={weekdays}
/>
</div>
</div>
);
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)'
)}
</label>
<DatePicker
value={recurrenceEndDate || ''}
onChange={(value) =>
(customOnChange || onChange)(
'recurrence_end_date',
value || null
)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={isDisabled}
/>
</div>
);
const renderCompletionBasedToggle = () => (
<div className="mb-4">
<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'
)}
/>
</div>
);
// Show message for child tasks
if (isChildTask && parentTaskLoading) {
return (
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400">
Loading parent task recurrence settings...
</div>
</div>
);
}
if (isChildTask) {
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-md p-3 mb-4">
<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.
</p>
{onParentRecurrenceChange && (
<button
type="button"
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'}
</button>
)}
</div>
</div>
<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&apos;s
recurrence settings. Changes will affect all
future instances of this recurring task.
</div>
</div>
)}
{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
)}
{renderCompletionBasedToggle()}
</>
)}
</div>
</div>
);
}
if (recurrenceType === 'none') {
return <div className="pb-3">{renderRecurrenceTypeSelect()}</div>;
}
return (
<div className="pb-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
{/* Main recurrence settings in one row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<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) =>
onChange('recurrence_type', value as RecurrenceType)
}
options={recurrenceTypeOptions}
disabled={disabled}
/>
</div>
{(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')}
</label>
<div className="flex items-center space-x-2">
<div className="w-20">
<NumberSelectDropdown
value={recurrenceInterval || 1}
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
}
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')}
</span>
</div>
</div>
)}
<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)'
)}
</label>
<DatePicker
value={recurrenceEndDate || ''}
onChange={(value) =>
onChange('recurrence_end_date', value || null)
}
placeholder={t(
'forms.task.endDatePlaceholder',
'Select end date'
)}
disabled={disabled}
/>
</div>
</div>
{/* Additional settings for specific recurrence types */}
{recurrenceType === 'weekly' && renderWeekdaySelect()}
{recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' &&
renderMonthlyWeekdayInputs()}
{renderCompletionBasedToggle()}
</div>
);
};
export default RecurrenceInput;
export default RecurrenceInput;

View file

@ -3,44 +3,49 @@ import { useTranslation } from 'react-i18next';
import { TrashIcon } from '@heroicons/react/24/outline';
interface TaskActionsProps {
taskId: number | undefined;
onDelete: () => void;
onSave: () => void;
onCancel: () => void;
taskId: number | undefined;
onDelete: () => void;
onSave: () => void;
onCancel: () => void;
}
const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onCancel }) => {
const { t } = useTranslation();
const TaskActions: React.FC<TaskActionsProps> = ({
taskId,
onDelete,
onSave,
onCancel,
}) => {
const { t } = useTranslation();
return (
<div className="p-3 flex-shrink-0 flex justify-end space-x-2">
{taskId && (
<button
type="button"
onClick={onDelete}
className="p-2 bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 flex items-center justify-center"
title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')}
>
<TrashIcon className="h-5 w-5" />
</button>
)}
<button
type="button"
onClick={onCancel}
className="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-sm"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={onSave}
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-sm"
>
{t('common.save', 'Save')}
</button>
</div>
);
return (
<div className="p-3 flex-shrink-0 flex justify-end space-x-2">
{taskId && (
<button
type="button"
onClick={onDelete}
className="p-2 bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 flex items-center justify-center"
title={t('common.delete', 'Delete')}
aria-label={t('common.delete', 'Delete')}
>
<TrashIcon className="h-5 w-5" />
</button>
)}
<button
type="button"
onClick={onCancel}
className="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-sm"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={onSave}
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-sm"
>
{t('common.save', 'Save')}
</button>
</div>
);
};
export default TaskActions;

View file

@ -2,43 +2,53 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
interface TaskDueDateProps {
dueDate: string;
className?: string;
dueDate: string;
className?: string;
}
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 { 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];
if (dueDate === today) return 'border-blue-700 dark:text-white';
if (dueDate === tomorrow) return 'border-blue-700 dark:text-white';
if (dueDate < today) return 'border-red-700 dark:text-white';
return 'border-gray-300 dark:text-white';
};
if (dueDate === today) return 'border-blue-700 dark:text-white';
if (dueDate === tomorrow) return 'border-blue-700 dark:text-white';
if (dueDate < today) return 'border-red-700 dark:text-white';
return 'border-gray-300 dark:text-white';
};
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 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];
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 === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow)
return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return (
<div className={`flex items-center text-xs py-1 px-2 rounded-md border ${getDueDateClass()} ${className}`}>
{formatDueDate()}
</div>
);
return (
<div
className={`flex items-center text-xs py-1 px-2 rounded-md border ${getDueDateClass()} ${className}`}
>
{formatDueDate()}
</div>
);
};
export default TaskDueDate;

View file

@ -2,30 +2,33 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
interface TaskContentSectionProps {
taskId: number | undefined;
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
taskId: number | undefined;
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
}
const TaskContentSection: React.FC<TaskContentSectionProps> = ({
taskId,
value,
onChange
taskId,
value,
onChange,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700 mb-4 flex-1 flex flex-col">
<textarea
id={`task_note_${taskId}`}
name="note"
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...')}
/>
</div>
);
return (
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700 mb-4 flex-1 flex flex-col">
<textarea
id={`task_note_${taskId}`}
name="note"
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...'
)}
/>
</div>
);
};
export default TaskContentSection;
export default TaskContentSection;

View file

@ -1,57 +1,61 @@
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';
interface TaskMetadataSectionProps {
priority: PriorityType;
dueDate: string;
taskId: number | undefined;
onStatusChange: (value: StatusType) => void;
onPriorityChange: (value: PriorityType) => void;
onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
priority: PriorityType;
dueDate: string;
taskId?: number;
onStatusChange: (value: StatusType) => void;
onPriorityChange: (value: PriorityType) => void;
onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
priority,
dueDate,
taskId,
onStatusChange,
onPriorityChange,
onDueDateChange
priority,
dueDate,
taskId, // eslint-disable-line @typescript-eslint/no-unused-vars
onStatusChange, // eslint-disable-line @typescript-eslint/no-unused-vars
onPriorityChange,
onDueDateChange,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 overflow-visible">
<div className="overflow-visible">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.priority', 'Priority')}
</label>
<PriorityDropdown
value={priority}
onChange={onPriorityChange}
/>
</div>
<div className="overflow-visible">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.dueDate', 'Due Date')}
</label>
<DatePicker
value={dueDate}
onChange={(value) => {
const event = {
target: { name: 'due_date', value }
} as React.ChangeEvent<HTMLInputElement>;
onDueDateChange(event);
}}
placeholder={t('forms.task.dueDatePlaceholder', 'Select due date')}
/>
</div>
</div>
);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 overflow-visible">
<div className="overflow-visible">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.priority', 'Priority')}
</label>
<PriorityDropdown
value={priority}
onChange={onPriorityChange}
/>
</div>
<div className="overflow-visible">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.dueDate', 'Due Date')}
</label>
<DatePicker
value={dueDate}
onChange={(value) => {
const event = {
target: { name: 'due_date', value },
} as React.ChangeEvent<HTMLInputElement>;
onDueDateChange(event);
}}
placeholder={t(
'forms.task.dueDatePlaceholder',
'Select due date'
)}
/>
</div>
</div>
);
};
export default TaskMetadataSection;
export default TaskMetadataSection;

View file

@ -3,69 +3,76 @@ import { useTranslation } from 'react-i18next';
import { Project } from '../../../entities/Project';
interface TaskProjectSectionProps {
newProjectName: string;
onProjectSearch: (e: React.ChangeEvent<HTMLInputElement>) => void;
dropdownOpen: boolean;
filteredProjects: Project[];
onProjectSelection: (project: Project) => void;
onCreateProject: () => void;
isCreatingProject: boolean;
newProjectName: string;
onProjectSearch: (e: React.ChangeEvent<HTMLInputElement>) => void;
dropdownOpen: boolean;
filteredProjects: Project[];
onProjectSelection: (project: Project) => void;
onCreateProject: () => void;
isCreatingProject: boolean;
}
const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
newProjectName,
onProjectSearch,
dropdownOpen,
filteredProjects,
onProjectSelection,
onCreateProject,
isCreatingProject
newProjectName,
onProjectSearch,
dropdownOpen,
filteredProjects,
onProjectSelection,
onCreateProject,
isCreatingProject,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<div className="relative">
<input
type="text"
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"
/>
{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) => (
<button
key={project.id}
type="button"
onClick={() => onProjectSelection(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}
</button>
))
) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{t('forms.task.noMatchingProjects', 'No matching projects')}
</div>
)}
{newProjectName && (
<button
type="button"
onClick={onCreateProject}
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}"`}
</button>
)}
return (
<div className="relative">
<input
type="text"
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"
/>
{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) => (
<button
key={project.id}
type="button"
onClick={() => onProjectSelection(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}
</button>
))
) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
{t(
'forms.task.noMatchingProjects',
'No matching projects'
)}
</div>
)}
{newProjectName && (
<button
type="button"
onClick={onCreateProject}
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}"`}
</button>
)}
</div>
)}
</div>
)}
</div>
);
);
};
export default TaskProjectSection;
export default TaskProjectSection;

View file

@ -1,41 +1,71 @@
import React from 'react';
import { RecurrenceType, Task } from '../../../entities/Task';
import { Task } from '../../../entities/Task';
import RecurrenceInput from '../RecurrenceInput';
interface TaskRecurrenceSectionProps {
formData: Task;
parentTask: Task | null;
parentTaskLoading: boolean;
onRecurrenceChange: (field: string, value: any) => void;
onEditParent?: () => void;
onParentRecurrenceChange?: (field: string, value: any) => void;
formData: Task;
parentTask: Task | null;
parentTaskLoading: boolean;
onRecurrenceChange: (field: string, value: any) => void;
onEditParent?: () => void;
onParentRecurrenceChange?: (field: string, value: any) => void;
}
const TaskRecurrenceSection: React.FC<TaskRecurrenceSectionProps> = ({
formData,
parentTask,
parentTaskLoading,
onRecurrenceChange,
onEditParent,
onParentRecurrenceChange
formData,
parentTask,
parentTaskLoading,
onRecurrenceChange,
onEditParent,
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)}
onChange={onRecurrenceChange}
disabled={!!parentTask}
isChildTask={!!parentTask}
parentTaskLoading={parentTaskLoading}
onEditParent={parentTask ? onEditParent : undefined}
onParentRecurrenceChange={parentTask ? onParentRecurrenceChange : undefined}
/>
);
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
}
onChange={onRecurrenceChange}
disabled={!!parentTask}
isChildTask={!!parentTask}
parentTaskLoading={parentTaskLoading}
onEditParent={parentTask ? onEditParent : undefined}
onParentRecurrenceChange={
parentTask ? onParentRecurrenceChange : undefined
}
/>
);
};
export default TaskRecurrenceSection;
export default TaskRecurrenceSection;

View file

@ -1,27 +1,24 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import TagInput from '../../Tag/TagInput';
interface TaskTagsSectionProps {
tags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: Array<{name: string}>;
tags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: Array<{ name: string }>;
}
const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
tags,
onTagsChange,
availableTags
tags,
onTagsChange,
availableTags,
}) => {
const { t } = useTranslation();
return (
<TagInput
onTagsChange={onTagsChange}
initialTags={tags}
availableTags={availableTags}
/>
);
return (
<TagInput
onTagsChange={onTagsChange}
initialTags={tags}
availableTags={availableTags}
/>
);
};
export default TaskTagsSection;
export default TaskTagsSection;

View file

@ -2,92 +2,125 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
interface TaskAnalysis {
isVague: boolean;
severity: 'low' | 'medium' | 'high';
reason: string;
suggestion?: string;
isVague: boolean;
severity: 'low' | 'medium' | 'high';
reason: string;
suggestion?: string;
}
interface TaskTitleSectionProps {
taskId: number | undefined;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
taskAnalysis: TaskAnalysis | null;
taskIntelligenceEnabled: boolean;
taskId: number | undefined;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
taskAnalysis: TaskAnalysis | null;
taskIntelligenceEnabled: boolean;
}
const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
taskId,
value,
onChange,
taskAnalysis,
taskIntelligenceEnabled
taskId,
value,
onChange,
taskAnalysis,
taskIntelligenceEnabled,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<input
type="text"
id={`task_name_${taskId}`}
name="name"
value={value}
onChange={onChange}
required
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.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 ${
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" />
</svg>
</div>
<div className="ml-2">
<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!')}
</strong>
</p>
{taskAnalysis.suggestion && (
<p className={`text-xs mt-1 ${
taskAnalysis.severity === 'high'
? 'text-red-700 dark:text-red-300'
: taskAnalysis.severity === 'medium'
? 'text-yellow-700 dark:text-yellow-300'
: 'text-blue-700 dark:text-blue-300'
}`}>
{t(taskAnalysis.suggestion, taskAnalysis.suggestion)}
</p>
)}
</div>
</div>
return (
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
<input
type="text"
id={`task_name_${taskId}`}
name="name"
value={value}
onChange={onChange}
required
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.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 ${
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"
/>
</svg>
</div>
<div className="ml-2">
<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!'
)}
</strong>
</p>
{taskAnalysis.suggestion && (
<p
className={`text-xs mt-1 ${
taskAnalysis.severity === 'high'
? 'text-red-700 dark:text-red-300'
: taskAnalysis.severity ===
'medium'
? 'text-yellow-700 dark:text-yellow-300'
: 'text-blue-700 dark:text-blue-300'
}`}
>
{t(
taskAnalysis.suggestion,
taskAnalysis.suggestion
)}
</p>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
);
};
export default TaskTitleSection;
export default TaskTitleSection;

View file

@ -1,298 +1,397 @@
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;
project?: Project;
onTaskClick: (e: React.MouseEvent) => void;
onToggleCompletion?: () => void;
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
onTaskUpdate?: (task: Task) => Promise<void>;
isOverdue?: boolean;
task: Task;
project?: Project;
onTaskClick: (e: React.MouseEvent) => void;
onToggleCompletion?: () => void;
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
onTaskUpdate?: (task: Task) => Promise<void>;
isOverdue?: boolean;
}
const TaskHeader: React.FC<TaskHeaderProps> = ({
task,
project,
onTaskClick,
onToggleCompletion,
hideProjectName = false,
onToggleToday,
onTaskUpdate,
isOverdue = false,
task,
project,
onTaskClick,
onToggleCompletion,
hideProjectName = false,
onToggleToday,
onTaskUpdate,
isOverdue = false,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
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 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];
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 === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow)
return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return new Date(dueDate).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const formatRecurrence = (recurrenceType: string) => {
switch (recurrenceType) {
case 'daily':
return t('recurrence.daily', 'Daily');
case 'weekly':
return t('recurrence.weekly', 'Weekly');
case 'monthly':
return t('recurrence.monthly', 'Monthly');
case 'monthly_weekday':
return t('recurrence.monthlyWeekday', 'Monthly');
case 'monthly_last_day':
return t('recurrence.monthlyLastDay', 'Monthly');
default:
return t('recurrence.recurring', 'Recurring');
}
};
const formatRecurrence = (recurrenceType: string) => {
switch (recurrenceType) {
case 'daily':
return t('recurrence.daily', 'Daily');
case 'weekly':
return t('recurrence.weekly', 'Weekly');
case 'monthly':
return t('recurrence.monthly', 'Monthly');
case 'monthly_weekday':
return t('recurrence.monthlyWeekday', 'Monthly');
case 'monthly_last_day':
return t('recurrence.monthlyLastDay', 'Monthly');
default:
return t('recurrence.recurring', 'Recurring');
}
};
const handleTodayToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening task modal
if (onToggleToday && task.id) {
try {
await onToggleToday(task.id);
} catch (error) {
console.error('Failed to toggle today status:', error);
}
}
};
const handleTodayToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening task modal
if (onToggleToday && task.id) {
try {
await onToggleToday(task.id);
} catch (error) {
console.error('Failed to toggle today status:', error);
}
}
};
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) {
try {
const isCurrentlyInProgress = task.status === 'in_progress' || task.status === 1;
const updatedTask = {
...task,
status: (isCurrentlyInProgress ? 'not_started' : 'in_progress') as StatusType,
// Automatically add to today plan when setting to in_progress
today: isCurrentlyInProgress ? task.today : true
};
await onTaskUpdate(updatedTask);
} catch (error) {
console.error('Failed to toggle in progress status:', error);
}
}
};
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
) {
try {
const isCurrentlyInProgress =
task.status === 'in_progress' || task.status === 1;
const updatedTask = {
...task,
status: (isCurrentlyInProgress
? 'not_started'
: 'in_progress') as StatusType,
// Automatically add to today plan when setting to in_progress
today: isCurrentlyInProgress ? task.today : true,
};
await onTaskUpdate(updatedTask);
} catch (error) {
console.error('Failed to toggle in progress status:', error);
}
}
};
return (
<div className="py-2 px-4 cursor-pointer group" onClick={onTaskClick}>
{/* 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} />
<div className="flex flex-col">
<div className="flex items-center">
<span className="text-md text-gray-900 dark:text-gray-100">
{task.name}
</span>
{isOverdue && (
<span
className="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-md"
title="Task has been in today plan for a while"
>
overdue
</span>
)}
</div>
{/* Project, tags, due date, and recurrence in same row, with spacing when they exist */}
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
{project && !hideProjectName && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<span>{project.name}</span>
return (
<div className="py-2 px-4 cursor-pointer group" onClick={onTaskClick}>
{/* 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}
/>
<div className="flex flex-col">
<div className="flex items-center">
<span className="text-md text-gray-900 dark:text-gray-100">
{task.name}
</span>
{isOverdue && (
<span
className="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-md"
title="Task has been in today plan for a while"
>
overdue
</span>
)}
</div>
{/* Project, tags, due date, and recurrence in same row, with spacing when they exist */}
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
{project && !hideProjectName && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<span>{project.name}</span>
</div>
)}
{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>
</div>
)}
{((project && !hideProjectName) ||
(task.tags && task.tags.length > 0)) &&
task.due_date && (
<span className="mx-2"></span>
)}
{task.due_date && (
<div className="flex items-center">
<CalendarIcon className="h-3 w-3 mr-1" />
<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' && (
<span className="mx-2"></span>
)}
{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>
</div>
)}
</div>
</div>
</div>
)}
{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>
</div>
)}
{((project && !hideProjectName) || (task.tags && task.tags.length > 0)) && task.due_date && (
<span className="mx-2"></span>
)}
{task.due_date && (
<div className="flex items-center">
<CalendarIcon className="h-3 w-3 mr-1" />
<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' && (
<span className="mx-2"></span>
)}
{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>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
{/* Today Plan Controls */}
{onToggleToday && (
<button
onClick={handleTodayToggle}
className={`items-center justify-center ${
Number(task.today_move_count) > 1
? 'px-2 h-6'
: 'w-6 h-6'
} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`}
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" />
) : (
<CalendarIcon className="h-3 w-3" />
)}
{Number(task.today_move_count) > 1 && (
<span className="ml-1 text-xs font-medium">
{Number(task.today_move_count)}
</span>
)}
</button>
)}
{/* 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'
} 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')}
>
{task.today ? (
<CalendarDaysIcon className="h-3 w-3" />
) : (
<CalendarIcon className="h-3 w-3" />
)}
{Number(task.today_move_count) > 1 && (
<span className="ml-1 text-xs font-medium">
{Number(task.today_move_count)}
</span>
)}
</button>
)}
{/* Play/In Progress Controls */}
{(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)
? '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')}
>
<PlayIcon className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Mobile view (below md breakpoint) */}
<div className="block md:hidden">
<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} />
</div>
{/* All content - Task name and metadata */}
<div className="ml-2 flex-1">
{/* Task Title */}
<div className="flex items-center font-light text-md text-gray-900 dark:text-gray-100">
<span>{task.name}</span>
{isOverdue && (
<span
className="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-md"
title="Task has been in today plan for a while"
>
overdue
</span>
)}
{/* Play/In Progress Controls */}
{(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
? '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'
)
}
>
<PlayIcon className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Project, tags, due date, and recurrence */}
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-1">
{project && !hideProjectName && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<span>{project.name}</span>
{/* Mobile view (below md breakpoint) */}
<div className="block md:hidden">
<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}
/>
</div>
{/* All content - Task name and metadata */}
<div className="ml-2 flex-1">
{/* Task Title */}
<div className="flex items-center font-light text-md text-gray-900 dark:text-gray-100">
<span>{task.name}</span>
{isOverdue && (
<span
className="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-md"
title="Task has been in today plan for a while"
>
overdue
</span>
)}
</div>
{/* Project, tags, due date, and recurrence */}
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-1">
{project && !hideProjectName && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<span>{project.name}</span>
</div>
)}
{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>
</div>
)}
{task.due_date && (
<div className="flex items-center">
<CalendarIcon className="h-3 w-3 mr-1" />
<span>{formatDueDate(task.due_date)}</span>
</div>
)}
{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>
</div>
)}
</div>
</div>
</div>
)}
{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>
{/* 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) && (
<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
? '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'
)
}
>
<PlayIcon className="h-3 w-3" />
</button>
)}
{/* Today Plan Controls - Mobile */}
{onToggleToday && (
<button
onClick={handleTodayToggle}
className={`items-center justify-center ${Number(task.today_move_count) > 1 ? 'px-2 h-6' : 'w-6 h-6'} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`}
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" />
) : (
<CalendarIcon className="h-3 w-3" />
)}
{Number(task.today_move_count) > 1 && (
<span className="ml-1 text-xs font-medium">
{Number(task.today_move_count)}
</span>
)}
</button>
)}
</div>
)}
{task.due_date && (
<div className="flex items-center">
<CalendarIcon className="h-3 w-3 mr-1" />
<span>{formatDueDate(task.due_date)}</span>
</div>
)}
{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>
</div>
)}
</div>
</div>
</div>
{/* 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) && (
<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)
? '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')}
>
<PlayIcon className="h-3 w-3" />
</button>
)}
{/* Today Plan Controls - Mobile */}
{onToggleToday && (
<button
onClick={handleTodayToggle}
className={`items-center justify-center ${Number(task.today_move_count) > 1 ? 'px-2 h-6' : 'w-6 h-6'} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
}`}
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" />
) : (
<CalendarIcon className="h-3 w-3" />
)}
{Number(task.today_move_count) > 1 && (
<span className="ml-1 text-xs font-medium">
{Number(task.today_move_count)}
</span>
)}
</button>
)}
</div>
</div>
</div>
);
);
};
export default TaskHeader;

View file

@ -7,113 +7,116 @@ import { toggleTaskCompletion } from '../../utils/tasksService';
import { isTaskOverdue } from '../../utils/dateUtils';
interface TaskItemProps {
task: Task;
onTaskUpdate: (task: Task) => Promise<void>;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
task: Task;
onTaskUpdate: (task: Task) => Promise<void>;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
}
const TaskItem: React.FC<TaskItemProps> = ({
task,
onTaskUpdate,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
task,
onTaskUpdate,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectList, setProjectList] = useState<Project[]>(projects);
// Dispatch global modal events
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectList, setProjectList] = useState<Project[]>(projects);
const handleTaskClick = () => {
setIsModalOpen(true);
};
// Dispatch global modal events
const handleSave = async (updatedTask: Task) => {
await onTaskUpdate(updatedTask);
setIsModalOpen(false);
};
const handleDelete = async (taskId: number) => {
if (task.id) {
await onTaskDelete(task.id);
}
};
const handleTaskClick = () => {
setIsModalOpen(true);
};
const handleToggleCompletion = async () => {
if (task.id) {
try {
const updatedTask = await toggleTaskCompletion(task.id);
const handleSave = async (updatedTask: Task) => {
await onTaskUpdate(updatedTask);
} catch (error) {
}
}
};
setIsModalOpen(false);
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const response = await fetch('/api/project', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, active: true }),
});
const handleDelete = async () => {
if (task.id) {
await onTaskDelete(task.id);
}
};
if (!response.ok) {
throw new Error('Failed to create project');
}
const handleToggleCompletion = async () => {
if (task.id) {
try {
const updatedTask = await toggleTaskCompletion(task.id);
await onTaskUpdate(updatedTask);
} catch (error) {
console.error('Error toggling task completion:', error);
}
}
};
const newProject = await response.json();
setProjectList((prevProjects) => [...prevProjects, newProject]);
return newProject;
} catch (error) {
throw error;
}
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
const response = await fetch('/api/project', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, active: true }),
});
// 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);
if (!response.ok) {
throw new Error('Failed to create project');
}
// Check if task is in progress to apply pulsing border animation
const isInProgress = task.status === 'in_progress' || task.status === 1;
// Check if task is overdue (created yesterday or earlier and not completed)
const isOverdue = isTaskOverdue(task);
const newProject = await response.json();
setProjectList((prevProjects) => [...prevProjects, newProject]);
return newProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
};
return (
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 ${
isInProgress
? 'border-2 border-green-400/60 dark:border-green-500/60'
: 'border-2 border-gray-50 dark:border-gray-800'
}`}
>
<TaskHeader
task={task}
project={project}
onTaskClick={handleTaskClick}
onToggleCompletion={handleToggleCompletion}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
onTaskUpdate={onTaskUpdate}
isOverdue={isOverdue}
/>
// 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);
<TaskModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
task={task}
onSave={handleSave}
onDelete={handleDelete}
projects={projectList}
onCreateProject={handleCreateProject}
/>
</div>
);
// Check if task is in progress to apply pulsing border animation
const isInProgress = task.status === 'in_progress' || task.status === 1;
// Check if task is overdue (created yesterday or earlier and not completed)
const isOverdue = isTaskOverdue(task);
return (
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 ${
isInProgress
? 'border-2 border-green-400/60 dark:border-green-500/60'
: 'border-2 border-gray-50 dark:border-gray-800'
}`}
>
<TaskHeader
task={task}
project={project}
onTaskClick={handleTaskClick}
onToggleCompletion={handleToggleCompletion}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
onTaskUpdate={onTaskUpdate}
isOverdue={isOverdue}
/>
<TaskModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
task={task}
onSave={handleSave}
onDelete={handleDelete}
projects={projectList}
onCreateProject={handleCreateProject}
/>
</div>
);
};
export default TaskItem;

View file

@ -4,44 +4,44 @@ import { Project } from '../../entities/Project';
import { Task } from '../../entities/Task';
interface TaskListProps {
tasks: Task[];
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCreate?: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
tasks: Task[];
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCreate?: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
}
const TaskList: React.FC<TaskListProps> = ({
tasks,
onTaskUpdate,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
tasks,
onTaskUpdate,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
}) => {
return (
<div>
{tasks.length > 0 ? (
tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onTaskUpdate={onTaskUpdate}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
))
) : (
<p className="text-gray-500 dark:text-gray-400 text-center mt-4">
No tasks available.
</p>
)}
</div>
);
return (
<div>
{tasks.length > 0 ? (
tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onTaskUpdate={onTaskUpdate}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
))
) : (
<p className="text-gray-500 dark:text-gray-400 text-center mt-4">
No tasks available.
</p>
)}
</div>
);
};
export default TaskList;

File diff suppressed because it is too large Load diff

View file

@ -1,64 +1,66 @@
import React from 'react';
import { CheckCircleIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
interface TaskPriorityIconProps {
priority: string | number | undefined;
status: string | number;
onToggleCompletion?: () => void;
priority: string | number | undefined;
status: string | number;
onToggleCompletion?: () => void;
}
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status, onToggleCompletion }) => {
const { t } = useTranslation();
const getIconColor = () => {
if (status === 'done' || status === 2) return 'text-green-500';
// Handle both string and numeric priority values
let priorityStr = priority;
if (typeof priority === 'number') {
const priorityNames = ['low', 'medium', 'high'];
priorityStr = priorityNames[priority] || 'low';
}
switch (priorityStr) {
case 'high':
case 2:
return 'text-red-500';
case 'medium':
case 1:
return 'text-yellow-500';
case 'low':
case 0:
default:
return 'text-gray-300';
}
};
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
priority,
status,
onToggleCompletion,
}) => {
const getIconColor = () => {
if (status === 'done' || status === 2) return 'text-green-500';
const colorClass = getIconColor();
// Handle both string and numeric priority values
let priorityStr = priority;
if (typeof priority === 'number') {
const priorityNames = ['low', 'medium', 'high'];
priorityStr = priorityNames[priority] || 'low';
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering TaskHeader onClick
if (onToggleCompletion) {
onToggleCompletion();
switch (priorityStr) {
case 'high':
case 2:
return 'text-red-500';
case 'medium':
case 1:
return 'text-yellow-500';
case 'low':
case 0:
default:
return 'text-gray-300';
}
};
const colorClass = getIconColor();
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering TaskHeader onClick
if (onToggleCompletion) {
onToggleCompletion();
}
};
if (status === 'done' || status === 2) {
return (
<CheckCircleIcon
className={`h-5 w-5 ${colorClass} cursor-pointer hover:scale-110 transition-transform`}
onClick={handleClick}
/>
);
} else {
return (
<div
className={`h-5 w-5 ${colorClass} cursor-pointer hover:scale-110 transition-transform border-2 border-current rounded-full flex-shrink-0`}
style={{ width: '16px', height: '16px' }}
onClick={handleClick}
/>
);
}
};
if (status === 'done' || status === 2) {
return (
<CheckCircleIcon
className={`h-5 w-5 ${colorClass} cursor-pointer hover:scale-110 transition-transform`}
onClick={handleClick}
/>
);
} else {
return (
<div
className={`h-5 w-5 ${colorClass} cursor-pointer hover:scale-110 transition-transform border-2 border-current rounded-full flex-shrink-0`}
style={{ width: '16px', height: '16px' }}
onClick={handleClick}
/>
);
}
};
export default TaskPriorityIcon;

View file

@ -4,43 +4,45 @@ import { useTranslation } from 'react-i18next';
import { ArrowPathIcon } from '@heroicons/react/24/outline';
interface TaskRecurrenceBadgeProps {
recurrenceType: RecurrenceType;
recurrenceType: RecurrenceType;
}
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({ recurrenceType }) => {
const { t } = useTranslation();
const TaskRecurrenceBadge: React.FC<TaskRecurrenceBadgeProps> = ({
recurrenceType,
}) => {
const { t } = useTranslation();
if (!recurrenceType || recurrenceType === 'none') {
return null;
}
const getRecurrenceIcon = () => {
return <ArrowPathIcon className="w-3 h-3 mr-1" />;
};
const getRecurrenceLabel = (type: RecurrenceType) => {
switch (type) {
case 'daily':
return t('recurrence.daily', 'DAILY');
case 'weekly':
return t('recurrence.weekly', 'WEEKLY');
case 'monthly':
return t('recurrence.monthly', 'MONTHLY');
case 'monthly_weekday':
return t('recurrence.monthlyWeekday', 'MONTHLY');
case 'monthly_last_day':
return t('recurrence.monthlyLastDay', 'MONTHLY');
default:
return t('recurrence.recurring', 'RECURRING');
if (!recurrenceType || recurrenceType === 'none') {
return null;
}
};
return (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100">
{getRecurrenceIcon()}
{getRecurrenceLabel(recurrenceType)}
</span>
);
const getRecurrenceIcon = () => {
return <ArrowPathIcon className="w-3 h-3 mr-1" />;
};
const getRecurrenceLabel = (type: RecurrenceType) => {
switch (type) {
case 'daily':
return t('recurrence.daily', 'DAILY');
case 'weekly':
return t('recurrence.weekly', 'WEEKLY');
case 'monthly':
return t('recurrence.monthly', 'MONTHLY');
case 'monthly_weekday':
return t('recurrence.monthlyWeekday', 'MONTHLY');
case 'monthly_last_day':
return t('recurrence.monthlyLastDay', 'MONTHLY');
default:
return t('recurrence.recurring', 'RECURRING');
}
};
return (
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100">
{getRecurrenceIcon()}
{getRecurrenceLabel(recurrenceType)}
</span>
);
};
export default TaskRecurrenceBadge;
export default TaskRecurrenceBadge;

View file

@ -1,56 +1,61 @@
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 {
status: StatusType | number;
className?: string;
status: StatusType | number;
className?: string;
}
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
const { t } = useTranslation();
// Convert numeric status to string
const getStatusString = (status: StatusType | number): StatusType => {
if (typeof status === 'number') {
const statusNames: StatusType[] = ['not_started', 'in_progress', 'done', 'archived'];
return statusNames[status] || 'not_started';
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',
];
return statusNames[status] || 'not_started';
}
return status;
};
const statusString = getStatusString(status);
let statusIcon;
switch (statusString) {
case 'not_started':
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
break;
case 'in_progress':
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
break;
case 'done':
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
break;
case 'archived':
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
break;
default:
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
}
return status;
};
const statusString = getStatusString(status);
let statusIcon, statusLabel;
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 (
<div className={`flex items-center md:px-2 ${className}`}>
{statusIcon}
{/* <span className="ml-2 text-xs font-medium inline md:hidden">{statusLabel}</span> */}
</div>
);
return (
<div className={`flex items-center md:px-2 ${className}`}>
{statusIcon}
{/* <span className="ml-2 text-xs font-medium inline md:hidden">{statusLabel}</span> */}
</div>
);
};
export default TaskStatusBadge;

View file

@ -4,52 +4,58 @@ import { Tag } from '../../entities/Tag';
import { TagIcon, XMarkIcon } from '@heroicons/react/24/solid';
interface TaskTagsProps {
tags: Tag[];
onTagRemove?: (tagId: string | number | undefined) => void;
className?: string;
tags: Tag[];
onTagRemove?: (tagId: string | number | undefined) => void;
className?: string;
}
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }) => {
const navigate = useNavigate();
const TaskTags: React.FC<TaskTagsProps> = ({
tags = [],
onTagRemove,
className,
}) => {
const navigate = useNavigate();
const handleTagClick = (tag: Tag) => {
navigate(`/tag/${encodeURIComponent(tag.name)}`);
};
const handleTagClick = (tag: Tag) => {
navigate(`/tag/${encodeURIComponent(tag.name)}`);
};
// Don't render anything if there are no tags
if (!tags || tags.length === 0) {
return null;
}
// Don't render anything if there are no tags
if (!tags || tags.length === 0) {
return null;
}
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{tags.map((tag, index) => (
<div
key={tag.id || index}
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium px-2 py-1.5 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
>
<button
type="button"
onClick={() => handleTagClick(tag)}
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>
</button>
{onTagRemove && (
<button
type="button"
onClick={() => onTagRemove(tag.id)}
className="ml-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 focus:outline-none"
aria-label={`Remove tag ${tag.name}`}
>
<XMarkIcon className="h-4 w-4" />
</button>
)}
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{tags.map((tag, index) => (
<div
key={tag.id || index}
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium px-2 py-1.5 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
>
<button
type="button"
onClick={() => handleTagClick(tag)}
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>
</button>
{onTagRemove && (
<button
type="button"
onClick={() => onTagRemove(tag.id)}
className="ml-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 focus:outline-none"
aria-label={`Remove tag ${tag.name}`}
>
<XMarkIcon className="h-4 w-4" />
</button>
)}
</div>
))}
</div>
))}
</div>
);
);
};
export default TaskTags;

View file

@ -1,237 +1,300 @@
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 {
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
PencilIcon,
TagIcon,
CalendarIcon,
FolderIcon,
PlayIcon,
PauseIcon,
ArchiveBoxIcon,
SparklesIcon,
AdjustmentsHorizontalIcon
getTaskTimeline,
getEventTypeLabel,
getStatusLabel,
getPriorityLabel,
} from '../../utils/taskEventService';
import {
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
PencilIcon,
TagIcon,
CalendarIcon,
FolderIcon,
PlayIcon,
ArchiveBoxIcon,
SparklesIcon,
AdjustmentsHorizontalIcon,
} from '@heroicons/react/24/outline';
interface TaskTimelineProps {
taskId: number | undefined;
taskId: number | undefined;
}
const TaskTimeline: React.FC<TaskTimelineProps> = ({ taskId }) => {
const { t } = useTranslation();
const [events, setEvents] = useState<TaskEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { t } = useTranslation();
const [events, setEvents] = useState<TaskEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTimeline = async () => {
if (!taskId || taskId === undefined) {
setLoading(false);
setEvents([]);
return;
}
setLoading(true);
setError(null);
try {
const timeline = await getTaskTimeline(taskId);
setEvents(timeline);
} catch (err) {
console.error('Error fetching task timeline:', err);
setError(t('timeline.failedToLoad', 'Failed to load timeline'));
} finally {
setLoading(false);
}
useEffect(() => {
const fetchTimeline = async () => {
if (!taskId || taskId === undefined) {
setLoading(false);
setEvents([]);
return;
}
setLoading(true);
setError(null);
try {
const timeline = await getTaskTimeline(taskId);
setEvents(timeline);
} catch (err) {
console.error('Error fetching task timeline:', err);
setError(t('timeline.failedToLoad', 'Failed to load timeline'));
} finally {
setLoading(false);
}
};
fetchTimeline();
}, [taskId]);
const getEventIcon = (eventType: string, newValue?: any) => {
const iconClass = 'h-3.5 w-3.5';
switch (eventType) {
case 'created':
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`}
/>
);
case 'completed':
return (
<CheckCircleIcon
className={`${iconClass} text-green-500`}
/>
);
case 'priority_changed':
return (
<ExclamationTriangleIcon
className={`${iconClass} text-orange-500`}
/>
);
case 'due_date_changed':
return (
<CalendarIcon className={`${iconClass} text-purple-500`} />
);
case 'project_changed':
return (
<FolderIcon className={`${iconClass} text-indigo-500`} />
);
case 'name_changed':
case 'description_changed':
case 'note_changed':
return <PencilIcon className={`${iconClass} text-gray-500`} />;
case 'tags_changed':
return <TagIcon className={`${iconClass} text-pink-500`} />;
case 'archived':
return (
<ArchiveBoxIcon className={`${iconClass} text-gray-500`} />
);
case 'today_changed':
return (
<CalendarIcon className={`${iconClass} text-blue-600`} />
);
default:
return <ClockIcon className={`${iconClass} text-gray-400`} />;
}
};
fetchTimeline();
}, [taskId]);
const getEventDescription = (event: TaskEvent) => {
const { event_type, old_value, new_value } = event;
const getEventIcon = (eventType: string, newValue?: any) => {
const iconClass = "h-3.5 w-3.5";
switch (eventType) {
case 'created':
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`} />;
case 'completed':
return <CheckCircleIcon className={`${iconClass} text-green-500`} />;
case 'priority_changed':
return <ExclamationTriangleIcon className={`${iconClass} text-orange-500`} />;
case 'due_date_changed':
return <CalendarIcon className={`${iconClass} text-purple-500`} />;
case 'project_changed':
return <FolderIcon className={`${iconClass} text-indigo-500`} />;
case 'name_changed':
case 'description_changed':
case 'note_changed':
return <PencilIcon className={`${iconClass} text-gray-500`} />;
case 'tags_changed':
return <TagIcon className={`${iconClass} text-pink-500`} />;
case 'archived':
return <ArchiveBoxIcon className={`${iconClass} text-gray-500`} />;
case 'today_changed':
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;
switch (event_type) {
case 'created':
return t('timeline.events.taskCreated');
case 'status_changed':
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)}`;
switch (event_type) {
case 'created':
return t('timeline.events.taskCreated');
case 'status_changed':
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': {
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': {
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':
return t('timeline.events.descriptionUpdated');
case 'note_changed':
return t('timeline.events.noteUpdated');
case 'project_changed':
return t('timeline.events.projectChanged');
case 'tags_changed':
return t('timeline.events.tagsUpdated');
case 'archived':
return t('timeline.events.taskArchived');
case 'today_changed':
return t('timeline.events.todayFlagChanged');
default:
return getEventTypeLabel(event_type);
}
return t('timeline.events.statusChanged');
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':
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':
return t('timeline.events.descriptionUpdated');
case 'note_changed':
return t('timeline.events.noteUpdated');
case 'project_changed':
return t('timeline.events.projectChanged');
case 'tags_changed':
return t('timeline.events.tagsUpdated');
case 'archived':
return t('timeline.events.taskArchived');
case 'today_changed':
return t('timeline.events.todayFlagChanged');
default:
return getEventTypeLabel(event_type);
}
};
};
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const formatTimeAgo = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<ClockIcon className="h-6 w-6 mb-2 animate-spin" />
<span className="text-sm">Loading timeline...</span>
</div>
);
}
return date.toLocaleDateString();
};
if (error) {
return (
<div className="flex flex-col items-center justify-center h-32 text-red-500">
<ExclamationTriangleIcon className="h-6 w-6 mb-2" />
<span className="text-sm">{error}</span>
</div>
);
}
if (!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>
</div>
);
}
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<ClockIcon className="h-6 w-6 mb-2" />
<span className="text-sm">No activity yet</span>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="space-y-2">
{events.map((event, index) => (
<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>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 leading-tight">
{getEventDescription(event)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatTimeAgo(event.created_at)}
</div>
{/* Additional details for certain events */}
{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) => (
<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>
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<ClockIcon className="h-6 w-6 mb-2 animate-spin" />
<span className="text-sm">Loading timeline...</span>
</div>
</div>
))}
</div>
</div>
);
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-32 text-red-500">
<ExclamationTriangleIcon className="h-6 w-6 mb-2" />
<span className="text-sm">{error}</span>
</div>
);
}
if (!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>
</div>
);
}
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<ClockIcon className="h-6 w-6 mb-2" />
<span className="text-sm">No activity yet</span>
</div>
);
}
return (
<div className="h-full overflow-y-auto">
<div className="space-y-2">
{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>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 dark:text-gray-100 leading-tight">
{getEventDescription(event)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatTimeAgo(event.created_at)}
</div>
{/* Additional details for certain events */}
{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
) => (
<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>
</div>
</div>
))}
</div>
</div>
);
};
export default TaskTimeline;
export default TaskTimeline;

View file

@ -1,114 +1,118 @@
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 }>();
const navigate = useNavigate();
const store = useStore();
const [task, setTask] = useState<Task | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const store = useStore();
const [task, setTask] = useState<Task | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchTask = async () => {
if (!uuid) {
setError("No task UUID provided");
setLoading(false);
return;
}
useEffect(() => {
const fetchTask = async () => {
if (!uuid) {
setError('No task UUID provided');
setLoading(false);
return;
}
try {
const taskData = await fetchTaskByUuid(uuid);
setTask(taskData);
} catch (err) {
setError("An error occurred while fetching the task");
} finally {
setLoading(false);
}
try {
const taskData = await fetchTaskByUuid(uuid);
setTask(taskData);
} catch {
setError('An error occurred while fetching the task');
} finally {
setLoading(false);
}
};
fetchTask();
}, [uuid]);
const handleClose = () => {
navigate(-1); // Go back to previous page
};
fetchTask();
}, [uuid]);
const handleTaskUpdate = async (updatedTask: Task) => {
try {
if (task?.id) {
const updated = await updateTask(task.id, updatedTask);
setTask(updated);
}
} catch (error) {
console.error('Error updating task:', error);
}
};
const handleClose = () => {
navigate(-1); // Go back to previous page
};
const handleTaskDelete = async (taskId: number) => {
try {
await deleteTask(taskId);
navigate('/today'); // Navigate back to today view after deletion
} catch (error) {
console.error('Error deleting task:', error);
throw error;
}
};
const handleTaskUpdate = async (updatedTask: Task) => {
try {
if (task?.id) {
const updated = await updateTask(task.id, updatedTask);
setTask(updated);
}
} catch (error) {
console.error("Error updating task:", error);
const handleCreateProject = async (name: string): Promise<Project> => {
try {
return await createProject({ name });
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading task...
</div>
</div>
);
}
};
const handleTaskDelete = async (taskId: number) => {
try {
await deleteTask(taskId);
navigate('/today'); // Navigate back to today view after deletion
} catch (error) {
console.error("Error deleting task:", error);
throw error;
if (error || !task) {
return (
<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'}
</div>
<button
onClick={() => navigate('/')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Go Home
</button>
</div>
</div>
);
}
};
const handleCreateProject = async (name: string): Promise<Project> => {
try {
return await createProject({ name });
} catch (error) {
console.error("Error creating project:", error);
throw error;
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading task...
</div>
</div>
<TaskModal
isOpen={true}
task={task}
onClose={handleClose}
onSave={handleTaskUpdate}
onDelete={handleTaskDelete}
projects={store.projectsStore.projects}
onCreateProject={handleCreateProject}
/>
);
}
if (error || !task) {
return (
<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"}
</div>
<button
onClick={() => navigate("/")}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Go Home
</button>
</div>
</div>
);
}
return (
<TaskModal
isOpen={true}
task={task}
onClose={handleClose}
onSave={handleTaskUpdate}
onDelete={handleTaskDelete}
projects={store.projectsStore.projects}
onCreateProject={handleCreateProject}
/>
);
};
export default TaskView;
export default TaskView;

File diff suppressed because it is too large Load diff

View file

@ -4,59 +4,60 @@ import TaskTimeline from './TaskTimeline';
import { ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
interface TimelinePanelProps {
taskId: number | undefined;
isExpanded: boolean;
onToggle: () => void;
taskId: number | undefined;
isExpanded: boolean;
onToggle: () => void;
}
const TimelinePanel: React.FC<TimelinePanelProps> = ({
taskId,
isExpanded,
onToggle
taskId,
isExpanded,
onToggle,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<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`}>
{/* Collapsed state - envelope icon */}
{!isExpanded && (
<div className="hidden lg:flex flex-col items-center justify-center h-full p-2">
<span className="text-xs text-gray-500 dark:text-gray-400 mt-2 transform rotate-90 whitespace-nowrap">
{t('timeline.activityTimeline')}
</span>
return (
<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`}
>
{/* Collapsed state - envelope icon */}
{!isExpanded && (
<div className="hidden lg:flex flex-col items-center justify-center h-full p-2">
<span className="text-xs text-gray-500 dark:text-gray-400 mt-2 transform rotate-90 whitespace-nowrap">
{t('timeline.activityTimeline')}
</span>
</div>
)}
{/* Expanded state - full timeline */}
{isExpanded && (
<>
<div className="p-3 lg:p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center">
<ClockIcon className="h-4 w-4 mr-2 text-gray-500" />
{t('timeline.activityTimeline')}
</h3>
<button
onClick={() => onToggle()}
className="lg:hidden p-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title={t('timeline.hideTimeline')}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="p-3 lg:p-4 flex-1 overflow-hidden">
<TaskTimeline taskId={taskId} />
</div>
</>
)}
</div>
)}
{/* Expanded state - full timeline */}
{isExpanded && (
<>
<div className="p-3 lg:p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center">
<ClockIcon className="h-4 w-4 mr-2 text-gray-500" />
{t('timeline.activityTimeline')}
</h3>
<button
onClick={() => onToggle()}
className="lg:hidden p-1 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title={t('timeline.hideTimeline')}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="p-3 lg:p-4 flex-1 overflow-hidden">
<TaskTimeline taskId={taskId} />
</div>
</>
)}
</div>
);
);
};
export default TimelinePanel;
export default TimelinePanel;

View file

@ -1,57 +1,63 @@
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;
projects: Project[];
onTaskUpdate: (task: Task) => Promise<void>;
onTaskDelete: (taskId: number) => Promise<void>;
onToggleToday?: (taskId: number) => Promise<void>;
todayPlanTasks: Task[] | undefined;
projects: Project[];
onTaskUpdate: (task: Task) => Promise<void>;
onTaskDelete: (taskId: number) => Promise<void>;
onToggleToday?: (taskId: number) => Promise<void>;
}
const TodayPlan: React.FC<TodayPlanProps> = ({
todayPlanTasks,
projects,
onTaskUpdate,
onTaskDelete,
onToggleToday,
todayPlanTasks,
projects,
onTaskUpdate,
onTaskDelete,
onToggleToday,
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
// Handle undefined or null todayPlanTasks
const safeTodayPlanTasks = todayPlanTasks || [];
// Handle undefined or null todayPlanTasks
const safeTodayPlanTasks = todayPlanTasks || [];
if (safeTodayPlanTasks.length === 0) {
return (
<>
<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'
)}
</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'
)}
</p>
</div>
</>
);
}
if (safeTodayPlanTasks.length === 0) {
return (
<>
<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')}
</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')}
</p>
</div>
</>
<>
<TaskList
tasks={safeTodayPlanTasks}
onTaskUpdate={onTaskUpdate}
onTaskDelete={onTaskDelete}
projects={projects}
onToggleToday={onToggleToday}
/>
</>
);
}
return (
<>
<TaskList
tasks={safeTodayPlanTasks}
onTaskUpdate={onTaskUpdate}
onTaskDelete={onTaskDelete}
projects={projects}
onToggleToday={onToggleToday}
/>
</>
);
};
export default TodayPlan;
export default TodayPlan;

View file

@ -1,158 +1,202 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
ChartBarIcon,
LightBulbIcon,
SparklesIcon,
ClockIcon,
TrophyIcon,
ChatBubbleBottomCenterTextIcon,
ChartBarIcon,
LightBulbIcon,
SparklesIcon,
ClockIcon,
TrophyIcon,
ChatBubbleBottomCenterTextIcon,
} from '@heroicons/react/24/outline';
interface TodaySettingsDropdownProps {
isOpen: boolean;
onClose: () => void;
settings: {
showMetrics: boolean;
showProductivity: boolean;
showIntelligence: boolean;
showDueToday: boolean;
showCompleted: boolean;
showProgressBar: boolean;
showDailyQuote: boolean;
};
onSettingsChange: (settings: any) => void;
isOpen: boolean;
onClose: () => void;
settings: {
showMetrics: boolean;
showProductivity: boolean;
showIntelligence: boolean;
showDueToday: boolean;
showCompleted: boolean;
showProgressBar: boolean;
showDailyQuote: boolean;
};
onSettingsChange: (settings: any) => void;
}
const TodaySettingsDropdown: React.FC<TodaySettingsDropdownProps> = ({
isOpen,
onClose,
settings,
onSettingsChange,
isOpen,
onClose,
settings,
onSettingsChange,
}) => {
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState(settings);
const [isSaving, setIsSaving] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [localSettings, setLocalSettings] = useState(settings);
const [isSaving, setIsSaving] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
useEffect(() => {
setLocalSettings(settings);
}, [settings]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onClose();
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
const handleToggle = (key: keyof typeof localSettings) => {
const newSettings = {
...localSettings,
[key]: !localSettings[key],
};
setLocalSettings(newSettings);
// Auto-save on change
saveSettings(newSettings);
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
const saveSettings = async (settingsToSave: typeof localSettings) => {
setIsSaving(true);
try {
const response = await fetch('/api/profile/today-settings', {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settingsToSave),
});
return () => {
document.removeEventListener('mousedown', handleClickOutside);
if (response.ok) {
onSettingsChange(settingsToSave);
} else {
console.error('Failed to save settings');
}
} catch (error) {
console.error('Error saving settings:', error);
} finally {
setIsSaving(false);
}
};
}, [isOpen, onClose]);
const handleToggle = (key: keyof typeof localSettings) => {
const newSettings = {
...localSettings,
[key]: !localSettings[key]
};
setLocalSettings(newSettings);
// Auto-save on change
saveSettings(newSettings);
};
if (!isOpen) return null;
const saveSettings = async (settingsToSave: typeof localSettings) => {
setIsSaving(true);
try {
const response = await fetch('/api/profile/today-settings', {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
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,
},
body: JSON.stringify(settingsToSave),
});
{
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,
},
];
if (response.ok) {
onSettingsChange(settingsToSave);
} else {
console.error('Failed to save settings');
}
} catch (error) {
console.error('Error saving settings:', error);
} finally {
setIsSaving(false);
}
};
return (
<div
ref={dropdownRef}
className="absolute right-0 top-full mt-2 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"
>
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t('settings.todayPageSettings', 'Today Page Settings')}
</h3>
if (!isOpen) return null;
<div className="space-y-3">
{settingsOptions.map((option) => {
const IconComponent = option.icon;
const isDisabled = option.disabled || isSaving;
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 (
<div
ref={dropdownRef}
className="absolute right-0 top-full mt-2 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50"
>
<div className="p-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t('settings.todayPageSettings', 'Today Page Settings')}
</h3>
<div className="space-y-3">
{settingsOptions.map((option) => {
const IconComponent = option.icon;
const isDisabled = option.disabled || isSaving;
return (
<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`}>
{option.label}
</label>
return (
<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`}
>
{option.label}
</label>
</div>
<button
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]
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
localSettings[option.key]
? 'translate-x-5'
: 'translate-x-1'
}`}
/>
</button>
</div>
);
})}
</div>
<button
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]
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
localSettings[option.key] ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
</div>
);
})}
{isSaving && (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
{t('common.saving', 'Saving...')}
</div>
)}
</div>
</div>
{isSaving && (
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400">
{t('common.saving', 'Saving...')}
</div>
)}
</div>
</div>
);
);
};
export default TodaySettingsDropdown;
export default TodaySettingsDropdown;

View file

@ -1,74 +1,89 @@
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';
interface WeeklyCompletionChartProps {
data: WeeklyCompletion[];
data: WeeklyCompletion[];
}
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({ data }) => {
const { t } = useTranslation();
const WeeklyCompletionChart: React.FC<WeeklyCompletionChartProps> = ({
data,
}) => {
const { t } = useTranslation();
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{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')}
</p>
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{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')}
</p>
</div>
);
}
return null;
};
return (
<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('tasks.weeklyCompletions')}
</h3>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
>
<XAxis
dataKey="dayName"
axisLine={false}
tickLine={false}
tick={{
fontSize: 10,
fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400',
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fontSize: 10,
fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400',
}}
allowDecimals={false}
width={25}
domain={[0, 'dataMax']}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="count"
fill="#3b82f6"
radius={[2, 2, 0, 0]}
minPointSize={2}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
return null;
};
return (
<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('tasks.weeklyCompletions')}
</h3>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<XAxis
dataKey="dayName"
axisLine={false}
tickLine={false}
tick={{
fontSize: 10,
fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400'
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{
fontSize: 10,
fill: 'currentColor',
className: 'text-gray-600 dark:text-gray-400'
}}
allowDecimals={false}
width={25}
domain={[0, 'dataMax']}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="count"
fill="#3b82f6"
radius={[2, 2, 0, 0]}
minPointSize={2}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
);
};
export default WeeklyCompletionChart;
export default WeeklyCompletionChart;

View file

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

View file

@ -1,72 +1,92 @@
import { Project } from "../../entities/Project";
import { Project } from '../../entities/Project';
import {
FolderIcon,
CalendarIcon,
InboxIcon,
ArrowRightIcon,
ClockIcon,
MoonIcon,
CheckCircleIcon,
Bars4Icon,
FolderIcon,
CalendarIcon,
InboxIcon,
ArrowRightIcon,
ClockIcon,
MoonIcon,
CheckCircleIcon,
Bars4Icon,
} from '@heroicons/react/24/outline';
export const getTitleAndIcon = (
query: URLSearchParams,
projects: Project[],
t: (key: string, options?: any) => string
query: URLSearchParams,
projects: Project[],
t: (key: string, options?: any) => string
) => {
try {
// Default titles as fallbacks in case translation function fails
const defaultTitles = {
project: 'Project',
today: 'Today',
inbox: 'Inbox',
next: 'Next Actions',
upcoming: 'Upcoming',
someday: 'Someday',
completed: 'Completed',
allTasks: 'All Tasks'
};
const projectId = query.get('project_id');
if (projectId) {
const project = projects.find((p) => p.id?.toString() === projectId);
return { title: project ? project.name : t('sidebar.projects'), icon: FolderIcon };
}
try {
// Default titles as fallbacks in case translation function fails
const defaultTitles = {
project: 'Project',
today: 'Today',
inbox: 'Inbox',
next: 'Next Actions',
upcoming: 'Upcoming',
someday: 'Someday',
completed: 'Completed',
allTasks: 'All Tasks',
};
const projectId = query.get('project_id');
if (projectId) {
const project = projects.find(
(p) => p.id?.toString() === projectId
);
return {
title: project ? project.name : t('sidebar.projects'),
icon: FolderIcon,
};
}
try {
if (query.get('type') === 'today') {
return { title: t('tasks.today'), icon: CalendarIcon };
try {
if (query.get('type') === 'today') {
return { title: t('tasks.today'), icon: CalendarIcon };
}
if (query.get('type') === 'inbox') {
return { title: t('sidebar.inbox'), icon: InboxIcon };
}
if (query.get('type') === 'next') {
return {
title: t('sidebar.nextActions'),
icon: ArrowRightIcon,
};
}
if (query.get('type') === 'upcoming') {
return { title: t('sidebar.upcoming'), icon: ClockIcon };
}
if (query.get('type') === 'someday') {
return {
title: t('taskViews.someday') || defaultTitles.someday,
icon: MoonIcon,
};
}
if (query.get('status') === 'done') {
return { title: t('sidebar.completed'), icon: CheckCircleIcon };
}
return { title: t('sidebar.allTasks'), icon: Bars4Icon };
} catch (e) {
console.error('Translation error for task view title:', e);
// Return appropriate fallback based on type or status
if (query.get('type') === 'today')
return { title: defaultTitles.today, icon: CalendarIcon };
if (query.get('type') === 'inbox')
return { title: defaultTitles.inbox, icon: InboxIcon };
if (query.get('type') === 'next')
return { title: defaultTitles.next, icon: ArrowRightIcon };
if (query.get('type') === 'upcoming')
return { title: defaultTitles.upcoming, icon: ClockIcon };
if (query.get('type') === 'someday')
return { title: defaultTitles.someday, icon: MoonIcon };
if (query.get('status') === 'done')
return {
title: defaultTitles.completed,
icon: CheckCircleIcon,
};
return { title: defaultTitles.allTasks, icon: Bars4Icon };
}
} catch (error) {
console.error('Error in getTitleAndIcon:', error);
return { title: 'Tasks', icon: Bars4Icon };
}
if (query.get('type') === 'inbox') {
return { title: t('sidebar.inbox'), icon: InboxIcon };
}
if (query.get('type') === 'next') {
return { title: t('sidebar.nextActions'), icon: ArrowRightIcon };
}
if (query.get('type') === 'upcoming') {
return { title: t('sidebar.upcoming'), icon: ClockIcon };
}
if (query.get('type') === 'someday') {
return { title: t('taskViews.someday') || defaultTitles.someday, icon: MoonIcon };
}
if (query.get('status') === 'done') {
return { title: t('sidebar.completed'), icon: CheckCircleIcon };
}
return { title: t('sidebar.allTasks'), icon: Bars4Icon };
} catch (e) {
console.error("Translation error for task view title:", e);
// Return appropriate fallback based on type or status
if (query.get('type') === 'today') return { title: defaultTitles.today, icon: CalendarIcon };
if (query.get('type') === 'inbox') return { title: defaultTitles.inbox, icon: InboxIcon };
if (query.get('type') === 'next') return { title: defaultTitles.next, icon: ArrowRightIcon };
if (query.get('type') === 'upcoming') return { title: defaultTitles.upcoming, icon: ClockIcon };
if (query.get('type') === 'someday') return { title: defaultTitles.someday, icon: MoonIcon };
if (query.get('status') === 'done') return { title: defaultTitles.completed, icon: CheckCircleIcon };
return { title: defaultTitles.allTasks, icon: Bars4Icon };
}
} catch (error) {
console.error("Error in getTitleAndIcon:", error);
return { title: "Tasks", icon: Bars4Icon };
}
};

View file

@ -1,374 +1,418 @@
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";
TagIcon,
XMarkIcon,
ChevronDownIcon,
ChevronDoubleDownIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/solid';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
// Helper function to get search placeholder by language
const getSearchPlaceholder = (language: string): string => {
const placeholders: Record<string, string> = {
en: 'Search tasks...',
el: 'Αναζήτηση εργασιών...',
es: 'Buscar tareas...',
de: 'Aufgaben suchen...',
jp: 'タスクを検索...',
ua: 'Пошук завдань...'
};
return placeholders[language] || 'Search tasks...';
const placeholders: Record<string, string> = {
en: 'Search tasks...',
el: 'Αναζήτηση εργασιών...',
es: 'Buscar tareas...',
de: 'Aufgaben suchen...',
jp: 'タスクを検索...',
ua: 'Пошук завдань...',
};
return placeholders[language] || 'Search tasks...';
};
const Tasks: React.FC = () => {
const { t, i18n } = useTranslation();
const { showSuccessToast } = useToast();
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
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 { t, i18n } = useTranslation();
const { showSuccessToast } = useToast();
const [tasks, setTasks] = useState<Task[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
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 dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useNavigate();
const query = new URLSearchParams(location.search);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useNavigate();
const query = new URLSearchParams(location.search);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const { title, icon } =
stateTitle && stateIcon
? { title: stateTitle, icon: stateIcon }
: getTitleAndIcon(query, projects, t);
const { title, icon } =
stateTitle && stateIcon
? { title: stateTitle, icon: stateIcon }
: getTitleAndIcon(query, projects, t);
const IconComponent =
typeof icon === "string" ? React.createElement(icon) : icon;
const IconComponent =
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";
setOrderBy(savedOrderBy);
useEffect(() => {
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({
pathname: location.pathname,
search: `?${params.toString()}`,
}, { replace: true });
}
}, [location.pathname]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const tagId = query.get("tag");
const [tasksResponse, projectsResponse] = await Promise.all([
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.");
const params = new URLSearchParams(location.search);
if (!params.get('order_by')) {
params.set('order_by', savedOrderBy);
navigate(
{
pathname: location.pathname,
search: `?${params.toString()}`,
},
{ replace: true }
);
}
}, [location.pathname]);
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []);
} else {
throw new Error("Failed to fetch projects.");
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
fetchData();
}, [location]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const tagId = query.get('tag');
const [tasksResponse, projectsResponse] = await Promise.all([
fetch(
`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ''}`
),
fetch('/api/projects'),
]);
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete("tag");
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
};
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const newTask = await createTask(taskData as Task);
// Add the new task optimistically to avoid race conditions
setTasks((prevTasks) => [newTask, ...prevTasks]);
// 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!')}
</span>
);
showSuccessToast(taskLink);
} catch (error) {
console.error("Error creating task:", error);
setError("Error creating task.");
throw error; // Re-throw to allow proper error handling
}
};
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTask),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
} else {
const errorData = await response.json();
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.");
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
});
if (response.ok) {
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.");
}
} catch (error) {
console.error("Error deleting task:", error);
setError("Error deleting task.");
}
};
const handleToggleToday = async (taskId: number): Promise<void> => {
try {
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");
let apiPath = `/api/tasks?type=${type}&order_by=${orderBy}`;
if (tag) apiPath += `&tag=${tag}`;
if (project) apiPath += `&project=${project}`;
if (priority) apiPath += `&priority=${priority}`;
const response = await fetch(apiPath, {
credentials: "include",
});
if (response.ok) {
const data = await response.json();
setTasks(data.tasks || data);
}
} catch (error) {
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);
const params = new URLSearchParams(location.search);
params.set("order_by", order);
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
}, { replace: true });
setDropdownOpen(false);
};
const description = getDescription(query, projects, t);
const isNewTaskAllowed = () => {
return status !== "done";
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(taskSearchQuery.toLowerCase())
);
return (
<div className="flex justify-center px-4 lg:px-2">
<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" />}
<h2 className="text-2xl font-light">{title}</h2>
{tag && (
<div className="ml-4 flex items-center space-x-2">
<button
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={handleRemoveTag}
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">
{capitalize(tag)}
</span>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
</button>
</div>
)}
</div>
<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"
id="menu-button"
aria-expanded={dropdownOpen}
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("_", " ")))}
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
</button>
{dropdownOpen && (
<div
className="origin-top-right absolute left-0 sm:right-0 sm:left-auto mt-2 w-full sm:w-56 max-w-full rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
>
<div className="py-1 max-h-60 overflow-y-auto" role="none">
{[
"due_date:asc",
"name:asc",
"priority:desc",
"status:desc",
"created_at:desc",
].map((order) => (
<button
key={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("_", " ")))}
</button>
))}
</div>
</div>
)}
</div>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={getSearchPlaceholder(i18n.language)}
value={taskSearchQuery}
onChange={(e) => setTaskSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{loading ? (
<p>{t('common.loading', 'Loading...')}</p>
) : error ? (
<p className="text-red-500">{error}</p>
) : (
<>
{/* New Task Form */}
{isNewTaskAllowed() && (
<NewTask
onTaskCreate={async (taskName: string) =>
await handleTaskCreate({ name: taskName, status: "not_started" })
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData.tasks || []);
} else {
throw new Error('Failed to fetch tasks.');
}
/>
)}
{filteredTasks.length > 0 ? (
<TaskList
tasks={filteredTasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
onToggleToday={handleToggleToday}
/>
) : (
<p className="text-gray-500 text-center mt-4">
{t('tasks.noTasksAvailable', 'Δεν υπάρχουν διαθέσιμες εργασίες.')}
</p>
)}
</>
)}
</div>
</div>
);
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []);
} else {
throw new Error('Failed to fetch projects.');
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, [location]);
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete('tag');
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
};
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const newTask = await createTask(taskData as Task);
// Add the new task optimistically to avoid race conditions
setTasks((prevTasks) => [newTask, ...prevTasks]);
// 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!')}
</span>
);
showSuccessToast(taskLink);
} catch (error) {
console.error('Error creating task:', error);
setError('Error creating task.');
throw error; // Re-throw to allow proper error handling
}
};
const handleTaskUpdate = async (updatedTask: Task) => {
try {
const response = await fetch(`/api/task/${updatedTask.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTask),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
} else {
const errorData = await response.json();
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.');
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
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.');
}
} catch (error) {
console.error('Error deleting task:', error);
setError('Error deleting task.');
}
};
const handleToggleToday = async (taskId: number): Promise<void> => {
try {
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');
let apiPath = `/api/tasks?type=${type}&order_by=${orderBy}`;
if (tag) apiPath += `&tag=${tag}`;
if (project) apiPath += `&project=${project}`;
if (priority) apiPath += `&priority=${priority}`;
const response = await fetch(apiPath, {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setTasks(data.tasks || data);
}
} catch (error) {
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);
const params = new URLSearchParams(location.search);
params.set('order_by', order);
navigate(
{
pathname: location.pathname,
search: `?${params.toString()}`,
},
{ replace: true }
);
setDropdownOpen(false);
};
const description = getDescription(query, projects, t);
const isNewTaskAllowed = () => {
return status !== 'done';
};
const filteredTasks = tasks.filter((task) =>
task.name.toLowerCase().includes(taskSearchQuery.toLowerCase())
);
return (
<div className="flex justify-center px-4 lg:px-2">
<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" />
)}
<h2 className="text-2xl font-light">{title}</h2>
{tag && (
<div className="ml-4 flex items-center space-x-2">
<button
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={handleRemoveTag}
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">
{capitalize(tag)}
</span>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
</button>
</div>
)}
</div>
<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"
id="menu-button"
aria-expanded={dropdownOpen}
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('_', ' ')
)
)}
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
</button>
{dropdownOpen && (
<div
className="origin-top-right absolute left-0 sm:right-0 sm:left-auto mt-2 w-full sm:w-56 max-w-full rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
>
<div
className="py-1 max-h-60 overflow-y-auto"
role="none"
>
{[
'due_date:asc',
'name:asc',
'priority:desc',
'status:desc',
'created_at:desc',
].map((order) => (
<button
key={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('_', ' ')
)
)}
</button>
))}
</div>
</div>
)}
</div>
</div>
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder={getSearchPlaceholder(i18n.language)}
value={taskSearchQuery}
onChange={(e) => setTaskSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{loading ? (
<p>{t('common.loading', 'Loading...')}</p>
) : error ? (
<p className="text-red-500">{error}</p>
) : (
<>
{/* New Task Form */}
{isNewTaskAllowed() && (
<NewTask
onTaskCreate={async (taskName: string) =>
await handleTaskCreate({
name: taskName,
status: 'not_started',
})
}
/>
)}
{filteredTasks.length > 0 ? (
<TaskList
tasks={filteredTasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
onToggleToday={handleToggleToday}
/>
) : (
<p className="text-gray-500 text-center mt-4">
{t(
'tasks.noTasksAvailable',
'Δεν υπάρχουν διαθέσιμες εργασίες.'
)}
</p>
)}
</>
)}
</div>
</div>
);
};
export default Tasks;
export default Tasks;

View file

@ -1,42 +1,44 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ModalContextType {
isAnyModalOpen: boolean;
openModal: () => void;
closeModal: () => void;
modalCount: number;
isAnyModalOpen: boolean;
openModal: () => void;
closeModal: () => void;
modalCount: number;
}
const ModalContext = createContext<ModalContextType | undefined>(undefined);
export const useModal = () => {
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider');
}
return context;
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider');
}
return context;
};
interface ModalProviderProps {
children: ReactNode;
children: ReactNode;
}
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [modalCount, setModalCount] = useState(0);
const [modalCount, setModalCount] = useState(0);
const openModal = () => {
setModalCount(prev => prev + 1);
};
const openModal = () => {
setModalCount((prev) => prev + 1);
};
const closeModal = () => {
setModalCount(prev => Math.max(0, prev - 1));
};
const closeModal = () => {
setModalCount((prev) => Math.max(0, prev - 1));
};
const isAnyModalOpen = modalCount > 0;
const isAnyModalOpen = modalCount > 0;
return (
<ModalContext.Provider value={{ isAnyModalOpen, openModal, closeModal, modalCount }}>
{children}
</ModalContext.Provider>
);
};
return (
<ModalContext.Provider
value={{ isAnyModalOpen, openModal, closeModal, modalCount }}
>
{children}
</ModalContext.Provider>
);
};

View file

@ -1,5 +1,5 @@
export interface Area {
id?: number;
name: string;
description?: string;
}
id?: number;
name: string;
description?: string;
}

View file

@ -1,8 +1,8 @@
export interface InboxItem {
id?: number;
content: string;
status?: string; // 'added' | 'processed' | 'deleted'
source?: string; // 'telegram'
created_at?: string;
updated_at?: string;
}
id?: number;
content: string;
status?: string; // 'added' | 'processed' | 'deleted'
source?: string; // 'telegram'
created_at?: string;
updated_at?: string;
}

View file

@ -1,19 +1,19 @@
import { Task } from "./Task";
import { Task } from './Task';
export interface WeeklyCompletion {
date: string;
count: number;
dayName: string;
date: string;
count: number;
dayName: string;
}
export interface Metrics {
total_open_tasks: number;
tasks_pending_over_month: number;
tasks_in_progress_count: number;
tasks_in_progress: Task[];
tasks_due_today: Task[];
today_plan_tasks?: Task[];
suggested_tasks: Task[];
tasks_completed_today: Task[];
weekly_completions: WeeklyCompletion[];
}
total_open_tasks: number;
tasks_pending_over_month: number;
tasks_in_progress_count: number;
tasks_in_progress: Task[];
tasks_due_today: Task[];
today_plan_tasks?: Task[];
suggested_tasks: Task[];
tasks_completed_today: Task[];
weekly_completions: WeeklyCompletion[];
}

View file

@ -1,20 +1,20 @@
import { Tag } from "./Tag";
import { Tag } from './Tag';
export interface Note {
id?: number;
title: string;
content: string;
created_at?: string;
updated_at?: string;
project_id?: number; // Foreign key for project
tags?: Tag[];
Tags?: Tag[]; // Sequelize association naming (capitalized)
project?: {
id: number;
name: string;
};
Project?: {
id: number;
name: string;
}; // Sequelize association naming (capitalized)
id?: number;
title: string;
content: string;
created_at?: string;
updated_at?: string;
project_id?: number; // Foreign key for project
tags?: Tag[];
Tags?: Tag[]; // Sequelize association naming (capitalized)
project?: {
id: number;
name: string;
};
Project?: {
id: number;
name: string;
}; // Sequelize association naming (capitalized)
}

View file

@ -1,22 +1,22 @@
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;
name: string;
description?: string;
active: boolean;
pin_to_sidebar?: boolean;
area?: Area;
area_id?: number | null;
tags?: Tag[];
priority?: PriorityType;
tasks?: Task[];
Tasks?: Task[]; // Sequelize association naming (capitalized)
notes?: Note[];
Notes?: Note[]; // Sequelize association naming (capitalized)
due_date_at?: string;
image_url?: string;
}
id?: number;
name: string;
description?: string;
active: boolean;
pin_to_sidebar?: boolean;
area?: Area;
area_id?: number | null;
tags?: Tag[];
priority?: PriorityType;
tasks?: Task[];
Tasks?: Task[]; // Sequelize association naming (capitalized)
notes?: Note[];
Notes?: Note[]; // Sequelize association naming (capitalized)
due_date_at?: string;
image_url?: string;
}

View file

@ -1,4 +1,4 @@
export interface Tag {
id?: number;
name: string;
id?: number;
name: string;
}

View file

@ -1,32 +1,38 @@
import { Tag } from "./Tag";
import { Project } from "./Project";
import { Tag } from './Tag';
import { Project } from './Project';
export interface Task {
id?: number;
uuid?: string;
name: string;
status: StatusType | number;
priority?: PriorityType | number;
due_date?: string;
note?: string;
today?: boolean;
today_move_count?: number;
tags?: Tag[];
project_id?: number;
Project?: Project;
created_at?: string;
updated_at?: string;
recurrence_type?: RecurrenceType;
recurrence_interval?: number;
recurrence_end_date?: string;
recurrence_weekday?: number;
recurrence_month_day?: number;
recurrence_week_of_month?: number;
completion_based?: boolean;
recurring_parent_id?: number;
completed_at?: string;
id?: number;
uuid?: string;
name: string;
status: StatusType | number;
priority?: PriorityType | number;
due_date?: string;
note?: string;
today?: boolean;
today_move_count?: number;
tags?: Tag[];
project_id?: number;
Project?: Project;
created_at?: string;
updated_at?: string;
recurrence_type?: RecurrenceType;
recurrence_interval?: number;
recurrence_end_date?: string;
recurrence_weekday?: number;
recurrence_month_day?: number;
recurrence_week_of_month?: number;
completion_based?: boolean;
recurring_parent_id?: number;
completed_at?: string;
}
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';

View file

@ -1,71 +1,83 @@
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';
old_value?: any;
new_value?: any;
field_name?: string;
metadata?: {
source?: 'web' | 'api' | 'telegram';
action?: string;
[key: string]: any;
};
created_at: string;
user?: {
id: number;
username: string;
email: string;
};
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';
old_value?: any;
new_value?: any;
field_name?: string;
metadata?: {
source?: 'web' | 'api' | 'telegram';
action?: string;
[key: string]: any;
};
created_at: string;
user?: {
id: number;
username: string;
email: string;
};
}
export interface TaskCompletionTime {
task_id: number;
started_at: string;
completed_at: string;
duration_ms: number;
duration_hours: number;
duration_days: number;
task_id: number;
started_at: string;
completed_at: string;
duration_ms: number;
duration_hours: number;
duration_days: number;
}
export interface TaskCompletionAnalytics {
task_id: number;
task_name: string;
project_name?: string;
started_at: string;
completed_at: string;
duration_ms: number;
duration_hours: number;
duration_days: number;
task_id: number;
task_name: string;
project_name?: string;
started_at: string;
completed_at: string;
duration_ms: number;
duration_hours: number;
duration_days: number;
}
export interface ProductivityMetrics {
total_events: number;
tasks_created: number;
tasks_completed: number;
status_changes: number;
average_completion_time?: number;
completion_times: TaskCompletionTime[];
total_events: number;
tasks_created: number;
tasks_completed: number;
status_changes: number;
average_completion_time?: number;
completion_times: TaskCompletionTime[];
}
export interface CompletionAnalyticsSummary {
total_tasks: number;
average_completion_hours: number;
median_completion_hours: number;
fastest_completion: number;
slowest_completion: number;
total_tasks: number;
average_completion_hours: number;
median_completion_hours: number;
fastest_completion: number;
slowest_completion: number;
}
export interface CompletionAnalyticsResponse {
tasks: TaskCompletionAnalytics[];
summary: CompletionAnalyticsSummary;
tasks: TaskCompletionAnalytics[];
summary: CompletionAnalyticsSummary;
}
export interface TaskActivitySummary {
event_type: string;
count: number;
date: string;
}
event_type: string;
count: number;
date: string;
}

View file

@ -1,8 +1,8 @@
export interface User {
id: number;
email: string;
language: string;
appearance: string;
timezone: string;
avatarUrl?: string;
id: number;
email: string;
language: string;
appearance: string;
timezone: string;
avatarUrl?: string;
}

View file

@ -7,22 +7,22 @@ import { useModal } from '../contexts/ModalContext';
* @returns Object with the modal context functions
*/
export const useModalManager = (isOpen: boolean) => {
const modalContext = useModal();
const modalContext = useModal();
useEffect(() => {
if (isOpen) {
modalContext.openModal();
} else {
modalContext.closeModal();
}
useEffect(() => {
if (isOpen) {
modalContext.openModal();
} else {
modalContext.closeModal();
}
// Cleanup function to ensure we close the modal if component unmounts while open
return () => {
if (isOpen) {
modalContext.closeModal();
}
};
}, [isOpen, modalContext]);
// Cleanup function to ensure we close the modal if component unmounts while open
return () => {
if (isOpen) {
modalContext.closeModal();
}
};
}, [isOpen, modalContext]);
return modalContext;
};
return modalContext;
};

View file

@ -1,197 +1,251 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
const isDevelopment = process.env.NODE_ENV === 'development';
const fallbackResources = {
en: {
translation: {
common: {
loading: 'Loading...',
appLoading: 'Loading application... Please wait.',
error: 'Error',
},
auth: {
login: 'Login',
register: 'Register',
},
errors: {
somethingWentWrong: 'Something went wrong, please try again',
},
},
},
};
const devResources = isDevelopment ? {
en: {
translation: fallbackResources.en.translation,
},
} : undefined;
const i18nInstance = i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next);
i18nInstance.init({
fallbackLng: 'en',
debug: isDevelopment,
load: 'languageOnly',
supportedLngs: ['en', 'es', 'el', 'jp', 'ua', 'de'],
nonExplicitSupportedLngs: true,
resources: devResources,
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie']
},
interpolation: {
escapeValue: false,
},
defaultNS: 'translation',
ns: ['translation'],
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
queryStringParams: { v: '1' },
requestOptions: {
cache: 'default',
credentials: 'same-origin',
mode: 'cors'
}
},
})
.then(() => {
const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`;
fetch(loadPath)
.then(response => {
if (!response.ok) {
if (isDevelopment) {
return fetch(`/locales/${i18n.language}/translation.json`);
}
throw new Error(`Failed to fetch translation: ${response.status}`);
}
return response.json();
})
.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'
})
.then(res => res.json())
.then(data => {
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
})
.catch(() => {});
}, 1000);
} catch (e) {}
}
});
});
i18n.on('initialized', () => {});
i18n.on('loaded', () => {});
i18n.on('failedLoading', () => {});
i18n.on('missingKey', () => {});
const dispatchLanguageChangeEvent = (lng: string) => {
const event = new CustomEvent('app-language-changed', { detail: { language: lng } });
window.dispatchEvent(event);
};
i18n.on('languageChanged', (lng) => {
localStorage.setItem('i18nextLng', lng);
document.documentElement.lang = lng;
const handleTranslationsLoaded = () => {
dispatchLanguageChangeEvent(lng);
if (i18n.services && i18n.services.resourceStore) {
const currentNS = i18n.options.defaultNS || 'translation';
i18n.reloadResources(lng, currentNS);
}
};
if (!i18n.hasResourceBundle(lng, 'translation')) {
const loadPath = isDevelopment
? `./locales/${lng}/translation.json`
: `/locales/${lng}/translation.json`;
fetch(loadPath)
.then(response => {
if (!response.ok) {
return fetch(`/locales/${lng}/translation.json`);
}
return response;
})
.then(response => response.json())
.then(data => {
if (data) {
i18n.addResourceBundle(lng, 'translation', data, true, true);
handleTranslationsLoaded();
}
})
.catch(() => {
handleTranslationsLoaded();
});
} else {
handleTranslationsLoaded();
}
});
declare global {
interface WindowEventMap {
'app-language-changed': CustomEvent<{ language: string }>;
}
interface Window {
checkTranslation: (key: string) => void;
forceLanguageReload: (lng?: string) => void;
}
}
window.checkTranslation = (key: string) => {
try {
const translation = i18n.t(key);
return translation;
} catch {
return null;
}
};
window.forceLanguageReload = (lng?: string) => {
const targetLng = lng || i18n.language;
i18n.reloadResources(targetLng, 'translation')
.then(() => {
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>};
lang.translation = temp;
}
});
}
if (lng) {
setTimeout(() => {
i18n.changeLanguage(targetLng);
}, 50);
}
})
.catch(() => {});
};
export default i18n;
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
const isDevelopment = process.env.NODE_ENV === 'development';
const fallbackResources = {
en: {
translation: {
common: {
loading: 'Loading...',
appLoading: 'Loading application... Please wait.',
error: 'Error',
},
auth: {
login: 'Login',
register: 'Register',
},
errors: {
somethingWentWrong: 'Something went wrong, please try again',
},
},
},
};
const devResources = isDevelopment
? {
en: {
translation: fallbackResources.en.translation,
},
}
: undefined;
const i18nInstance = i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next);
i18nInstance
.init({
fallbackLng: 'en',
debug: isDevelopment,
load: 'languageOnly',
supportedLngs: ['en', 'es', 'el', 'jp', 'ua', 'de'],
nonExplicitSupportedLngs: true,
resources: devResources,
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie'],
},
interpolation: {
escapeValue: false,
},
defaultNS: 'translation',
ns: ['translation'],
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
queryStringParams: { v: '1' },
requestOptions: {
cache: 'default',
credentials: 'same-origin',
mode: 'cors',
},
},
})
.then(() => {
const loadPath = isDevelopment
? `./locales/${i18n.language}/translation.json`
: `/locales/${i18n.language}/translation.json`;
fetch(loadPath)
.then((response) => {
if (!response.ok) {
if (isDevelopment) {
return fetch(
`/locales/${i18n.language}/translation.json`
);
}
throw new Error(
`Failed to fetch translation: ${response.status}`
);
}
return response.json();
})
.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',
}
)
.then((res) => res.json())
.then((data) => {
i18n.addResourceBundle(
i18n.language,
'translation',
data,
true,
true
);
})
.catch((error) => {
console.error(
'Error loading translation:',
error
);
});
}, 1000);
} catch (e) {
console.error('Error in retry mechanism:', e);
}
}
});
});
i18n.on('initialized', () => {});
i18n.on('loaded', () => {});
i18n.on('failedLoading', () => {});
i18n.on('missingKey', () => {});
const dispatchLanguageChangeEvent = (lng: string) => {
const event = new CustomEvent('app-language-changed', {
detail: { language: lng },
});
window.dispatchEvent(event);
};
i18n.on('languageChanged', (lng) => {
localStorage.setItem('i18nextLng', lng);
document.documentElement.lang = lng;
const handleTranslationsLoaded = () => {
dispatchLanguageChangeEvent(lng);
if (i18n.services && i18n.services.resourceStore) {
const currentNS = i18n.options.defaultNS || 'translation';
i18n.reloadResources(lng, currentNS);
}
};
if (!i18n.hasResourceBundle(lng, 'translation')) {
const loadPath = isDevelopment
? `./locales/${lng}/translation.json`
: `/locales/${lng}/translation.json`;
fetch(loadPath)
.then((response) => {
if (!response.ok) {
return fetch(`/locales/${lng}/translation.json`);
}
return response;
})
.then((response) => response.json())
.then((data) => {
if (data) {
i18n.addResourceBundle(
lng,
'translation',
data,
true,
true
);
handleTranslationsLoaded();
}
})
.catch((error) => {
console.error('Error loading translations:', error);
handleTranslationsLoaded();
});
} else {
handleTranslationsLoaded();
}
});
declare global {
interface WindowEventMap {
'app-language-changed': CustomEvent<{ language: string }>;
}
interface Window {
checkTranslation: (key: string) => void;
forceLanguageReload: (lng?: string) => void;
}
}
window.checkTranslation = (key: string) => {
try {
const translation = i18n.t(key);
return translation;
} catch (error) {
console.error('Error checking translation:', error);
return null;
}
};
window.forceLanguageReload = (lng?: string) => {
const targetLng = lng || i18n.language;
i18n.reloadResources(targetLng, 'translation')
.then(() => {
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
>),
};
lang.translation = temp;
}
}
);
}
if (lng) {
setTimeout(() => {
i18n.changeLanguage(targetLng);
}, 50);
}
})
.catch((error) => {
console.error('Error reloading language:', error);
});
};
export default i18n;

View file

@ -1,65 +1,67 @@
// Add type declaration for module.hot
declare const module: {
hot?: {
accept: (path: string, callback: () => void) => void;
};
hot?: {
accept: (path: string, callback: () => void) => void;
};
};
import React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { ToastProvider } from "./components/Shared/ToastContext";
import 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"
: prefersDarkMode;
? 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;
if (container) {
root = createRoot(container);
root.render(
<I18nextProvider i18n={i18n}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</I18nextProvider>
);
root = createRoot(container);
root.render(
<I18nextProvider i18n={i18n}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</I18nextProvider>
);
}
// Hot Module Replacement (HMR) - Remove this snippet to remove HMR.
// Learn more: https://www.webpackjs.com/concepts/hot-module-replacement/
if (module.hot) {
module.hot.accept('./App', () => {
// New version of App component imported
if (root) {
root.render(
<I18nextProvider i18n={i18n}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</I18nextProvider>
);
}
});
module.hot.accept('./App', () => {
// New version of App component imported
if (root) {
root.render(
<I18nextProvider i18n={i18n}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</I18nextProvider>
);
}
});
}

View file

@ -1,162 +1,211 @@
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[];
isLoading: boolean;
isError: boolean;
setNotes: (notes: Note[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
notes: Note[];
isLoading: boolean;
isError: boolean;
setNotes: (notes: Note[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
}
interface AreasStore {
areas: Area[];
isLoading: boolean;
isError: boolean;
setAreas: (areas: Area[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
areas: Area[];
isLoading: boolean;
isError: boolean;
setAreas: (areas: Area[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
}
interface ProjectsStore {
projects: Project[];
isLoading: boolean;
isError: boolean;
setProjects: (projects: Project[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
projects: Project[];
isLoading: boolean;
isError: boolean;
setProjects: (projects: Project[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
}
interface TagsStore {
tags: Tag[];
isLoading: boolean;
isError: boolean;
setTags: (tags: Tag[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
loadTags: () => Promise<void>;
tags: Tag[];
isLoading: boolean;
isError: boolean;
setTags: (tags: Tag[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
loadTags: () => Promise<void>;
}
interface TasksStore {
tasks: Task[];
isLoading: boolean;
isError: boolean;
setTasks: (tasks: Task[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
tasks: Task[];
isLoading: boolean;
isError: boolean;
setTasks: (tasks: Task[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
}
interface InboxStore {
inboxItems: InboxItem[];
isLoading: boolean;
isError: boolean;
setInboxItems: (inboxItems: InboxItem[]) => void;
addInboxItem: (inboxItem: InboxItem) => void;
updateInboxItem: (inboxItem: InboxItem) => void;
removeInboxItem: (id: number) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
inboxItems: InboxItem[];
isLoading: boolean;
isError: boolean;
setInboxItems: (inboxItems: InboxItem[]) => void;
addInboxItem: (inboxItem: InboxItem) => void;
updateInboxItem: (inboxItem: InboxItem) => void;
removeInboxItem: (id: number) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
}
interface StoreState {
notesStore: NotesStore;
areasStore: AreasStore;
projectsStore: ProjectsStore;
tagsStore: TagsStore;
tasksStore: TasksStore;
inboxStore: InboxStore;
notesStore: NotesStore;
areasStore: AreasStore;
projectsStore: ProjectsStore;
tagsStore: TagsStore;
tasksStore: TasksStore;
inboxStore: InboxStore;
}
export const useStore = create<StoreState>((set) => ({
notesStore: {
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 } })),
},
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 } })),
},
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 } })),
},
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 } })),
loadTags: async () => {
const { fetchTags } = require("../utils/tagsService");
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading: true, isError: false } }));
try {
const tags = await fetchTags();
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 } }));
}
notesStore: {
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 } })),
},
},
tasksStore: {
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 } })),
},
inboxStore: {
inboxItems: [],
isLoading: false,
isError: false,
setInboxItems: (inboxItems) => set((state) => ({
inboxStore: { ...state.inboxStore, inboxItems }
})),
addInboxItem: (inboxItem) => set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: [...state.inboxStore.inboxItems, inboxItem]
}
})),
updateInboxItem: (inboxItem) => set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.map(item =>
item.id === inboxItem.id ? inboxItem : item
)
}
})),
removeInboxItem: (id) => set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.filter(item => item.id !== id)
}
})),
setLoading: (isLoading) => set((state) => ({
inboxStore: { ...state.inboxStore, isLoading }
})),
setError: (isError) => set((state) => ({
inboxStore: { ...state.inboxStore, isError }
})),
},
}));
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 } })),
},
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 },
})),
},
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 } })),
loadTags: async () => {
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 },
}));
} catch (error) {
console.error('loadTags: Failed to load tags:', error);
set((state) => ({
tagsStore: {
...state.tagsStore,
isError: true,
isLoading: false,
},
}));
}
},
},
tasksStore: {
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 } })),
},
inboxStore: {
inboxItems: [],
isLoading: false,
isError: false,
setInboxItems: (inboxItems) =>
set((state) => ({
inboxStore: { ...state.inboxStore, inboxItems },
})),
addInboxItem: (inboxItem) =>
set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: [...state.inboxStore.inboxItems, inboxItem],
},
})),
updateInboxItem: (inboxItem) =>
set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.map((item) =>
item.id === inboxItem.id ? inboxItem : item
),
},
})),
removeInboxItem: (id) =>
set((state) => ({
inboxStore: {
...state.inboxStore,
inboxItems: state.inboxStore.inboxItems.filter(
(item) => item.id !== id
),
},
})),
setLoading: (isLoading) =>
set((state) => ({
inboxStore: { ...state.inboxStore, isLoading },
})),
setError: (isError) =>
set((state) => ({
inboxStore: { ...state.inboxStore, isError },
})),
},
}));

View file

@ -1,55 +1,58 @@
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", {
credentials: 'include',
headers: {
'Accept': 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch areas.');
return await response.json();
const response = await fetch('/api/areas?active=true', {
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to fetch areas.');
return await response.json();
};
export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
const response = await fetch('/api/areas', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(areaData),
});
const response = await fetch('/api/areas', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(areaData),
});
await handleAuthResponse(response, 'Failed to create area.');
return await response.json();
await handleAuthResponse(response, 'Failed to create area.');
return await response.json();
};
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',
},
body: JSON.stringify(areaData),
});
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',
},
body: JSON.stringify(areaData),
});
await handleAuthResponse(response, 'Failed to update area.');
return await response.json();
await handleAuthResponse(response, 'Failed to update area.');
return await response.json();
};
export const deleteArea = async (areaId: number): Promise<void> => {
const response = await fetch(`/api/areas/${areaId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json',
},
});
const response = await fetch(`/api/areas/${areaId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
await handleAuthResponse(response, 'Failed to delete area.');
};
await handleAuthResponse(response, 'Failed to delete area.');
};

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