Lint frontend (#131)
* Add lint-fix npm target * Sync eslint+plugins with backend * Add prettier * Ignore no-explicit-any lint rule for now * Silence eslint react warning * Format frontend via prettier * Lint frontend. --------- Co-authored-by: antanst <>
This commit is contained in:
parent
f433dbffe3
commit
220bc92b4a
114 changed files with 30271 additions and 48239 deletions
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
|
|
@ -10,4 +10,14 @@ export default [
|
|||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
pluginReact.configs.flat.recommended,
|
||||
];
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off"
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: "18"
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
420
frontend/App.tsx
420
frontend/App.tsx
|
|
@ -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;
|
||||
|
|
|
|||
1073
frontend/Layout.tsx
1073
frontend/Layout.tsx
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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're looking for doesn't exist.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
>
|
||||
×
|
||||
</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}`}
|
||||
>
|
||||
×
|
||||
</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 "{inputValue.trim()}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default TagInput;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export interface Area {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface Tag {
|
||||
id?: number;
|
||||
name: string;
|
||||
id?: number;
|
||||
name: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
448
frontend/i18n.ts
448
frontend/i18n.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue