Move to React

Add .gitignore

Removed node_modules from previous commit

Fix task modes

Fix task modes

Fix task modes

Remove node_modules

Update basic task modal

Add notes functionality

Improve UI

Setup views

Add scopes

Fix projects layout

Restructure

Fix rest of the UI issues

Cleanup old views

Add .env to .gitignore
This commit is contained in:
Chris Veleris 2024-10-05 21:11:53 +03:00
parent d06e124e5b
commit dfcb97a355
125 changed files with 18516 additions and 1134 deletions

8
.babelrc Normal file
View file

@ -0,0 +1,8 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react",
"@babel/preset-typescript"
],
"plugins": ["react-refresh/babel"]
}

4
.gitignore vendored
View file

@ -4,4 +4,6 @@
certs/
.DS_Store
.byebug_history
.byebug_history
node_modules
.env

View file

@ -1,24 +1,26 @@
source 'https://rubygems.org'
gem 'sinatra'
gem 'puma'
gem 'rake'
gem 'sinatra'
# DB
gem 'sinatra-activerecord'
gem 'sinatra-cross_origin'
gem 'sinatra-namespace'
gem 'sqlite3'
# Authentication
gem 'bcrypt'
# Other
gem 'rerun'
gem 'byebug'
gem 'rerun'
# Development
gem 'faker'
gem 'rubocop'
# Testing
gem 'minitest', group: :test
gem 'rack-test', group: :test
gem 'minitest', group: :test

View file

@ -37,6 +37,7 @@ GEM
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
minitest (5.20.0)
multi_json (1.15.0)
mustermann (3.0.0)
ruby2_keywords (~> 0.0.1)
mutex_m (0.2.0)
@ -85,6 +86,15 @@ GEM
sinatra-activerecord (2.0.27)
activerecord (>= 4.1)
sinatra (>= 1.0)
sinatra-contrib (3.1.0)
multi_json
mustermann (~> 3.0)
rack-protection (= 3.1.0)
sinatra (= 3.1.0)
tilt (~> 2.0)
sinatra-cross_origin (0.4.0)
sinatra-namespace (1.0)
sinatra-contrib
sqlite3 (1.6.8-arm64-darwin)
sqlite3 (1.6.8-x86_64-linux)
tilt (2.3.0)
@ -109,6 +119,8 @@ DEPENDENCIES
rubocop
sinatra
sinatra-activerecord
sinatra-cross_origin
sinatra-namespace
sqlite3
BUNDLED WITH

View file

@ -5,6 +5,10 @@
![image](screenshots/all-light.png)
![image](screenshots/all-dark.png)
## How It Works
This app allows users to manage their tasks, projects, areas, notes, and tags in an organized way. A user can create tasks, projects, areas (to group projects), notes, and tags. Each task can be associated with a project, and both tasks and notes can be tagged for better organization. Projects can belong to areas, and can also have multiple notes and tags. This structure helps users categorize and track their work efficiently, whether theyre managing individual tasks, larger projects, or keeping detailed notes.
## Features
- **Task Management**: Create, update, and delete tasks. Mark tasks as completed and view them by different filters (Today, Upcoming, Someday). Order them by Name, Due date, Date created or Priority.
@ -13,6 +17,8 @@
- **Project Tracking**: Organize tasks into projects. Each project can contain multiple tasks and/or multiple notes.
- **Area Categorization**: Group projects into areas for better organization and focus.
- **Due Date Tracking**: Set due dates for tasks and view them based on due date categories.
## Roadmap (planned or in progress)
- **Responsive Design (in progress)**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones.
## Getting Started
@ -94,7 +100,7 @@ Pull the latest image:
docker pull chrisvel/tududi:0.20
```
In order to start the docker container you need 3 enviromental variables:
In order to start the docker container you need 4 enviromental variables:
```bash
TUDUDI_USER_EMAIL
@ -130,7 +136,7 @@ TUDUDI_INTERNAL_SSL_ENABLED
To run tests:
```bash
```bash
bundle exec ruby -Itest test/test_app.rb
```

51
app.rb
View file

@ -18,6 +18,10 @@ require './app/routes/tasks_routes'
require './app/routes/projects_routes'
require './app/routes/areas_routes'
require './app/routes/notes_routes'
require './app/routes/tags_routes'
require './app/routes/users_routes'
require 'sinatra/cross_origin'
helpers AuthenticationHelper
@ -30,7 +34,8 @@ set :public_folder, 'public'
configure do
enable :sessions
set :sessions, httponly: true, secure: (production? && ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true'),
expire_after: 2_592_000
expire_after: 2_592_000,
same_site: production? ? :none : :lax
set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) }
# Auto-create user if not exists
@ -49,6 +54,21 @@ before do
require_login
end
configure do
enable :cross_origin
end
before do
response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8080' # Adjust based on frontend URL
response.headers['Access-Control-Allow-Credentials'] = 'true' # Important for sending session cookies
end
options '*' do
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept'
200
end
helpers TaskHelper
helpers do
@ -100,17 +120,24 @@ helpers do
end
end
get '/' do
redirect '/tasks?due_date=today'
get '/*' do
# redirect '/tasks?due_date=today'
erb :index
end
get '/inbox' do
@tasks = current_user.tasks
.incomplete
.left_joins(:tags)
.where(project_id: nil, due_date: nil)
.where(tags: { id: nil }) # Filter tasks with no tags
.order('tasks.created_at DESC')
erb :inbox
not_found do
content_type :json
status 404
{ error: 'Not Found', message: 'The requested resource could not be found.' }.to_json
end
# get '/inbox' do
# @tasks = current_user.tasks
# .incomplete
# .left_joins(:tags)
# .where(project_id: nil, due_date: nil)
# .where(tags: { id: nil }) # Filter tasks with no tags
# .order('tasks.created_at DESC')
# erb :inbox
# end

146
app/frontend/App.tsx Normal file
View file

@ -0,0 +1,146 @@
import React, { useEffect, useLayoutEffect, useState } from "react";
import { Routes, Route, useNavigate, Navigate } from "react-router-dom";
import Login from "./Login";
import Tasks from "./Tasks";
import NotFound from "./NotFound";
import ProjectDetails from "./components/Project/ProjectDetails";
import Projects from "./Projects";
import AreaDetails from "./components/Area/AreaDetails";
import Areas from "./Areas";
import TagDetails from "./components/Tag/TagDetails";
import Tags from "./Tags";
import Notes from "./Notes";
import NoteDetails from "./components/Note/NoteDetails";
import ProfileSettings from "./components/Profile/ProfileSettings";
import Layout from "./Layout";
import { DataProvider } from './contexts/DataContext'; // Import the DataProvider
import { CalendarDaysIcon } from "@heroicons/react/24/solid";
interface User {
email: string;
id: number;
}
const App: React.FC = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const storedPreference = localStorage.getItem("isDarkMode");
if (storedPreference !== null) {
return storedPreference === "true";
} else {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
});
const navigate = useNavigate();
useEffect(() => {
const fetchCurrentUser = async () => {
try {
const response = await fetch("/api/current_user", {
credentials: "include",
headers: {
Accept: "application/json",
},
});
const data = await response.json();
if (data.user) {
setCurrentUser(data.user);
} else {
navigate("/login");
}
} catch (err) {
console.error("Failed to fetch current user:", err);
navigate("/login");
} finally {
setLoading(false);
}
};
fetchCurrentUser();
}, [navigate]);
useLayoutEffect(() => {
const root = document.documentElement;
if (isDarkMode) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
}, [isDarkMode]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
if (localStorage.getItem("isDarkMode") === null) {
setIsDarkMode(e.matches);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
useEffect(() => {
if (currentUser && location.pathname === "/") {
const options = { path: '/tasks?type=today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" /> }
navigate(options.path, { state: { title: options.title, icon: options.icon }, replace: true });
}
}, [currentUser, location.pathname, navigate]);
const toggleDarkMode = () => {
const newValue = !isDarkMode;
setIsDarkMode(newValue);
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading...
</div>
</div>
);
}
return (
<DataProvider>
{currentUser ? (
<Layout
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
>
<Routes>
<Route
path="/"
element={<Navigate to="/tasks" replace />}
/>
<Route path="/tasks" element={<Tasks />} />
<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/:id" element={<TagDetails />} />
<Route path="/notes" element={<Notes />} />
<Route path="/note/:id" element={<NoteDetails />} />
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>
) : (
<Login />
)}
</DataProvider>
);
};
export default App;

177
app/frontend/Areas.tsx Normal file
View file

@ -0,0 +1,177 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import {
PencilSquareIcon,
TrashIcon,
Squares2X2Icon,
} from '@heroicons/react/24/solid';
import ConfirmDialog from './components/Shared/ConfirmDialog';
import AreaModal from './components/Area/AreaModal';
import { useDataContext } from './contexts/DataContext';
import { Area } from './entities/Area';
const Areas: React.FC = () => {
const { areas, isLoading, isError, createArea, updateArea, deleteArea } = useDataContext();
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 handleSaveArea = async (areaData: Area) => {
try {
if (areaData.id) {
await updateArea(areaData.id, {
name: areaData.name,
description: areaData.description,
});
} else {
await createArea({
name: areaData.name,
description: areaData.description,
});
}
} catch (error) {
console.error('Error saving area:', error);
} finally {
setIsAreaModalOpen(false);
setSelectedArea(null);
}
};
const handleEditArea = (area: Area) => {
setSelectedArea(area);
setIsAreaModalOpen(true);
};
const handleCreateArea = () => {
setSelectedArea(null);
setIsAreaModalOpen(true);
};
const openConfirmDialog = (area: Area) => {
setAreaToDelete(area);
setIsConfirmDialogOpen(true);
};
const handleDeleteArea = async () => {
if (!areaToDelete) return;
try {
await deleteArea(areaToDelete.id);
setIsConfirmDialogOpen(false);
setAreaToDelete(null);
} catch (error) {
console.error('Error deleting area:', error);
}
};
const closeConfirmDialog = () => {
setIsConfirmDialogOpen(false);
setAreaToDelete(null);
};
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 areas...
</div>
</div>
);
}
if (isError) {
return (
<div className="text-red-500 p-4">
An error occurred while fetching areas.
</div>
);
}
return (
<div className="flex justify-center px-4">
<div className="w-full max-w-4xl">
{/* Areas Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<Squares2X2Icon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
Areas
</h2>
</div>
</div>
{/* Areas List */}
{areas.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">No areas found.</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"
>
{/* 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"
aria-label={`Edit ${area.name}`}
title={`Edit ${area.name}`}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => openConfirmDialog(area)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
aria-label={`Delete ${area.name}`}
title={`Delete ${area.name}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
)}
{/* AreaModal */}
{isAreaModalOpen && (
<AreaModal
isOpen={isAreaModalOpen}
onClose={() => setIsAreaModalOpen(false)}
onSave={handleSaveArea}
area={selectedArea}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && areaToDelete && (
<ConfirmDialog
title="Delete Area"
message={`Are you sure you want to delete the area "${areaToDelete.name}"?`}
onConfirm={handleDeleteArea}
onCancel={closeConfirmDialog}
/>
)}
</div>
</div>
);
};
export default Areas;

View file

@ -0,0 +1,20 @@
import React, { useEffect, useState } from 'react';
const DarkModeToggle: React.FC = () => {
const [darkMode, setDarkMode] = useState<boolean>(() => {
return localStorage.getItem('darkMode') === 'true';
});
useEffect(() => {
document.body.classList.toggle('dark-mode', darkMode);
localStorage.setItem('darkMode', darkMode.toString());
}, [darkMode]);
return (
<button onClick={() => setDarkMode(!darkMode)}>
<i className={`bi ${darkMode ? 'bi-sun' : 'bi-moon'}`}></i>
</button>
);
};
export default DarkModeToggle;

272
app/frontend/Layout.tsx Normal file
View file

@ -0,0 +1,272 @@
import React, { useState } from 'react';
import Sidebar from './Sidebar';
import './styles/tailwind.css';
import ProjectModal from './components/Project/ProjectModal';
import NoteModal from './components/Note/NoteModal';
import AreaModal from './components/Area/AreaModal';
import TagModal from './components/Tag/TagModal';
import { Note } from './entities/Note';
import { Area } from './entities/Area';
import { Tag } from './entities/Tag';
import { useDataContext } from './contexts/DataContext'; // Import the data context
interface LayoutProps {
currentUser: {
email: string;
};
isDarkMode: boolean;
toggleDarkMode: () => void;
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
currentUser,
isDarkMode,
toggleDarkMode,
children,
}) => {
// State for modals
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
const [isTagModalOpen, setIsTagModalOpen] = useState(false);
// State for selected entities
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
// Use context to fetch data
const {
tags,
areas,
notes,
isLoading,
isError,
createNote,
updateNote,
deleteNote,
createArea,
updateArea,
deleteArea,
createTag,
updateTag,
deleteTag,
createProject,
updateProject,
deleteProject
} = useDataContext(); // Now includes project management functions
// Handler functions for modals
const openNoteModal = (note: Note | null = null) => {
setSelectedNote(note);
setIsNoteModalOpen(true);
};
const closeNoteModal = () => {
setIsNoteModalOpen(false);
setSelectedNote(null);
};
const openProjectModal = () => {
setIsProjectModalOpen(true);
};
const closeProjectModal = () => {
setIsProjectModalOpen(false);
};
const openAreaModal = (area: Area | null = null) => {
setSelectedArea(area);
setIsAreaModalOpen(true);
};
const closeAreaModal = () => {
setIsAreaModalOpen(false);
setSelectedArea(null);
};
const openTagModal = (tag: Tag | null = null) => {
setSelectedTag(tag);
setIsTagModalOpen(true);
};
const closeTagModal = () => {
setIsTagModalOpen(false);
setSelectedTag(null);
};
// Handler for saving notes
const handleSaveNote = async (noteData: Note) => {
try {
if (noteData.id) {
// Update existing note
await updateNote(noteData.id, {
title: noteData.title,
content: noteData.content,
tags: noteData.tags?.map((tag) => tag.name),
project_id: noteData.project?.id,
});
} else {
// Create new note
await createNote({
title: noteData.title,
content: noteData.content,
tags: noteData.tags?.map((tag) => tag.name),
project_id: noteData.project?.id,
});
}
} catch (error) {
console.error('Error saving note:', error);
}
closeNoteModal();
};
// Handler for saving projects
const handleSaveProject = async (projectData: Project) => {
try {
if (projectData.id) {
await updateProject(projectData.id, projectData);
} else {
await createProject(projectData);
}
} catch (error) {
console.error('Error saving project:', error);
}
closeProjectModal();
};
// Handler for saving areas
const handleSaveArea = async (areaData: Area) => {
try {
if (areaData.id) {
await updateArea(areaData.id, areaData);
} else {
await createArea(areaData);
}
} catch (error) {
console.error('Error saving area:', error);
}
closeAreaModal();
};
// Handler for saving tags
const handleSaveTag = async (tagData: Tag) => {
try {
if (tagData.id) {
await updateTag(tagData.id, tagData);
} else {
await createTag(tagData);
}
} catch (error) {
console.error('Error saving tag:', error);
}
closeTagModal();
};
if (isLoading) {
return (
<div className={`min-h-screen flex ${isDarkMode ? 'dark' : ''}`}>
<Sidebar
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
notes={notes}
areas={areas}
tags={tags}
/>
<div className="flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800">
<div className="text-xl text-gray-700 dark:text-gray-200">Loading...</div>
</div>
</div>
);
}
if (isError) {
return (
<div className={`min-h-screen flex ${isDarkMode ? 'dark' : ''}`}>
<Sidebar
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
notes={notes}
areas={areas}
tags={tags}
/>
<div className="flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800">
<div className="text-xl text-red-500">Error fetching data.</div>
</div>
</div>
);
}
return (
<div className={`min-h-screen flex ${isDarkMode ? 'dark' : ''}`}>
<Sidebar
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
notes={notes}
areas={areas}
tags={tags}
/>
<div className="flex-1 flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 overflow-y-auto h-screen">
<div className="flex-grow p-6 pt-20 overflow-y-auto">
<div className="w-full max-w-5xl mx-auto">
{children}
</div>
</div>
</div>
{isProjectModalOpen && (
<ProjectModal
isOpen={isProjectModalOpen}
onClose={closeProjectModal}
onSave={handleSaveProject}
areas={areas}
/>
)}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={closeNoteModal}
onSave={handleSaveNote}
note={selectedNote}
/>
)}
{isAreaModalOpen && (
<AreaModal
isOpen={isAreaModalOpen}
onClose={closeAreaModal}
onSave={handleSaveArea}
area={selectedArea}
/>
)}
{isTagModalOpen && (
<TagModal
isOpen={isTagModalOpen}
onClose={closeTagModal}
onSave={handleSaveTag}
tag={selectedTag}
/>
)}
</div>
);
};
export default Layout;

101
app/frontend/Login.tsx Normal file
View file

@ -0,0 +1,101 @@
// src/Login.tsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
credentials: 'include'
});
const data = await response.json();
if (response.ok) {
console.log('Login successful:', data);
navigate('/tasks?type=today&order_by=due_date%3Aasc');
} 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 items-center justify-center min-h-screen px-4">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
<h2 className="text-2xl font-bold mb-6 text-center text-gray-700">
Login
</h2>
{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"
>
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"
>
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"
>
Login
</button>
</form>
<div className="mt-6 text-center">
<a href="#" className="text-blue-500 hover:underline">
Forgot Password?
</a>
</div>
</div>
</div>
);
};
export default Login;

46
app/frontend/NewTask.tsx Normal file
View file

@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { useToast } from './components/Shared/ToastContext'; // Adjust the import path accordingly
interface NewTaskProps {
onTaskCreate: (taskName: string) => void;
}
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const [taskName, setTaskName] = useState<string>('');
const { showSuccessToast, showErrorToast } = useToast(); // Use the toast functions
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setTaskName(event.target.value);
};
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && taskName.trim()) {
try {
await onTaskCreate(taskName.trim());
setTaskName('');
showSuccessToast('Task created successfully!');
} catch (error) {
console.error('Error creating task:', error);
showErrorToast('Failed to create task.');
}
}
};
return (
<div className="flex items-center justify-between py-3 px-4 mb-2 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">
<i className="bi bi-plus-circle"></i>
</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="Add New Task"
/>
</div>
);
};
export default NewTask;

12
app/frontend/NotFound.tsx Normal file
View file

@ -0,0 +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>
);
};
export default NotFound;

164
app/frontend/Notes.tsx Normal file
View file

@ -0,0 +1,164 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { BookOpenIcon, PencilSquareIcon, TrashIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import NoteModal from './components/Note/NoteModal';
import ConfirmDialog from './components/Shared/ConfirmDialog';
import { useDataContext } from './contexts/DataContext';
import { Note } from './entities/Note';
const Notes: React.FC = () => {
const { notes, createNote, updateNote, deleteNote, isLoading, isError } = useDataContext();
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 handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await deleteNote(noteToDelete.id);
setIsConfirmDialogOpen(false);
setNoteToDelete(null);
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleEditNote = (note: Note) => {
setSelectedNote(note);
setIsNoteModalOpen(true);
};
const handleSaveNote = async (noteData: { id: number; }) => {
try {
if (noteData.id) {
await updateNote(noteData.id, noteData);
} else {
await createNote(noteData);
}
setIsNoteModalOpen(false);
setSelectedNote(null);
} catch (err) {
console.error('Error saving note:', err);
}
};
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">
Loading notes...
</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">Error loading notes.</div>
</div>
);
}
return (
<div className="flex justify-center px-4">
<div className="w-full max-w-4xl">
{/* 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">Notes</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 notes..."
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">No notes found.</p>
) : (
<ul className="space-y-2">
{filteredNotes.map((note) => (
<li key={note.id} className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center">
<div className="flex-grow overflow-hidden pr-4">
<Link
to={`/note/${note.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
>
{note.title}
</Link>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
{note.content}
</p>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditNote(note)}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
aria-label={`Edit ${note.title}`}
title={`Edit ${note.title}`}
>
<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"
aria-label={`Delete ${note.title}`}
title={`Delete ${note.title}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
)}
{/* NoteModal */}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => setIsNoteModalOpen(false)}
onSave={handleSaveNote}
note={selectedNote}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog
title="Delete Note"
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
onConfirm={handleDeleteNote}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
);
};
export default Notes;

313
app/frontend/Projects.tsx Normal file
View file

@ -0,0 +1,313 @@
import React, { useState, useEffect } from "react";
import { Project } from "./entities/Project";
import {
Link,
useNavigate,
useSearchParams,
} from "react-router-dom";
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
import ConfirmDialog from "./components/Shared/ConfirmDialog";
import ProjectModal from "./components/Project/ProjectModal";
import { useDataContext } from "./contexts/DataContext";
import useFetchProjects from "./hooks/useFetchProjects";
// Utility function to generate initials
const getProjectInitials = (name: string) => {
const words = name.trim().split(' ').filter(word => word.length > 0); // Filter out any empty strings
if (words.length === 1) {
return name.toUpperCase();
}
return words.map(word => word[0].toUpperCase()).join('');
};
const Projects: React.FC = () => {
const { areas, createProject, updateProject, deleteProject } = useDataContext();
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<number, any>>({});
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); // To track which dropdown is active
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Get filters from URL query parameters
const activeFilter = searchParams.get("active");
const areaFilter = searchParams.get("area_id") || "";
// Fetch projects with current filters
const {
projects,
taskStatusCounts: fetchedTaskStatusCounts,
isLoading,
isError,
mutate,
} = useFetchProjects(activeFilter, areaFilter);
// Update local task status counts when fetched data changes
useEffect(() => {
setTaskStatusCounts(fetchedTaskStatusCounts);
}, [fetchedTaskStatusCounts]);
// Calculate the completion percentage for the project
const getCompletionPercentage = (projectId: number) => {
const taskStatus = taskStatusCounts[projectId] || {};
const totalTasks =
(taskStatus.done || 0) +
(taskStatus.not_started || 0) +
(taskStatus.in_progress || 0);
if (totalTasks === 0) return 0;
return Math.round((taskStatus.done / totalTasks) * 100);
};
// Handle project save (either create or update)
const handleSaveProject = async (project: Project) => {
if (project.id) {
await updateProject(project.id, project);
} else {
await createProject(project);
}
setIsProjectModalOpen(false);
mutate(); // Refetch projects after save
};
// Open edit modal and populate form data
const handleEditProject = (project: Project) => {
setProjectToEdit(project);
setIsProjectModalOpen(true);
};
// Handle delete project
const handleDeleteProject = async () => {
if (!projectToDelete) return;
await deleteProject(projectToDelete.id);
setIsConfirmDialogOpen(false);
setProjectToDelete(null);
mutate();
};
// Handle filter changes
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newActiveFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newActiveFilter === "all") {
params.delete("active"); // Remove 'active' filter when "All" is selected
} else {
params.set("active", newActiveFilter); // Set 'active' filter to 'true' or 'false'
}
setSearchParams(params);
};
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newAreaFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newAreaFilter) {
params.set("area_id", newAreaFilter);
} else {
params.delete("area_id");
}
setSearchParams(params);
};
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 projects...
</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">Error loading projects.</div>
</div>
);
}
// Group projects by area
const groupedProjects = projects.reduce<Record<string, Project[]>>(
(acc, project) => {
const areaName = project.area ? project.area.name : "Uncategorized";
if (!acc[areaName]) acc[areaName] = [];
acc[areaName].push(project);
return acc;
},
{}
);
return (
<div className="flex justify-center px-4">
<div className="w-full max-w-6xl">
<div className="flex items-center mb-8">
<i className="bi bi-folder-fill text-xl mr-2"></i>
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
Projects
</h2>
</div>
{/* Filters for Active Status and Area */}
<div className="flex flex-col md:flex-row md:items-center md:space-x-4 mb-6">
<div className="mb-4 md:mb-0 w-full md:w-1/3">
<label
htmlFor="activeFilter"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Status
</label>
<select
id="activeFilter"
value={activeFilter || "all"} // Use "all" when no active filter is selected
onChange={handleActiveFilterChange}
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="true">Active</option>
<option value="false">Inactive</option>
<option value="all">All</option>
</select>
</div>
<div className="w-full md:w-1/3">
<label
htmlFor="areaFilter"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
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="">All Areas</option>
{areas.map((area) => (
<option key={area.id} value={area.id.toString()}>
{area.name}
</option>
))}
</select>
</div>
</div>
{/* Project Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.keys(groupedProjects).length === 0 ? (
<div className="text-gray-700 dark:text-gray-300">
No projects found.
</div>
) : (
Object.keys(groupedProjects).map((areaName) => (
<React.Fragment key={areaName}>
<h3 className="col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-4">
{areaName}
</h3>
{groupedProjects[areaName].map((project) => (
<div
key={project.id}
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative"
style={{ minHeight: "280px", maxHeight: "280px" }} // Increased card height for image space
>
<div className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg" style={{ height: "160px" }}>
<span className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20">
{getProjectInitials(project.name)}
</span>
</div>
<div className="flex justify-between items-start p-4">
<Link
to={`/project/${project.id}`}
className="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2"
style={{ minHeight: "3.3rem", maxHeight: "3.3rem" }} // Fixed title height
>
{project.name}
</Link>
<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)}
>
<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"
>
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"
>
Delete
</button>
</div>
)}
</div>
</div>
<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">
<div
className="bg-blue-500 h-2 rounded-full"
style={{
width: `${getCompletionPercentage(project.id)}%`,
}}
></div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{getCompletionPercentage(project.id)}%
</span>
</div>
</div>
</div>
))}
</React.Fragment>
))
)}
</div>
</div>
{/* Project Modal */}
{isProjectModalOpen && (
<ProjectModal
isOpen={isProjectModalOpen}
onClose={() => {
setIsProjectModalOpen(false);
setProjectToEdit(null);
}}
onSave={handleSaveProject}
project={projectToEdit || undefined}
areas={areas}
/>
)}
{/* Delete Confirmation Dialog */}
{isConfirmDialogOpen && (
<ConfirmDialog
title="Delete Project"
message={`Are you sure you want to delete the project "${projectToDelete?.name}"?`}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
);
};
export default Projects;

124
app/frontend/Sidebar.tsx Normal file
View file

@ -0,0 +1,124 @@
// src/components/Sidebar.tsx
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
import SidebarAreas from './components/Sidebar/SidebarAreas';
import SidebarFooter from './components/Sidebar/SidebarFooter';
import SidebarHeader from './components/Sidebar/SidebarHeader';
import SidebarNav from './components/Sidebar/SidebarNav';
import SidebarProjects from './components/Sidebar/SidebarProjects';
import SidebarTags from './components/Sidebar/SidebarTags';
import SidebarNotes from './components/Sidebar/SidebarNotes';
import { Note } from './entities/Note';
import { Area } from './entities/Area';
import { Tag } from './entities/Tag';
interface SidebarProps {
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => 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> = ({
currentUser,
isDarkMode,
toggleDarkMode,
openProjectModal,
openNoteModal,
openAreaModal,
openTagModal,
notes,
areas,
tags,
}) => {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const handleNavClick = (path: string, title: string, icon: string) => {
navigate(path, { state: { title, icon } });
setIsSidebarOpen(false);
};
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
return (
<>
<div className="lg:hidden p-4 bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<button
className="flex items-center focus:outline-none"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
>
{isSidebarOpen ? (
<XMarkIcon className="h-6 w-6" />
) : (
<Bars3Icon className="h-6 w-6" />
)}
</button>
</div>
<div
className={`fixed lg:static z-50 h-screen w-72 bg-white dark:bg-gray-900 text-gray-900 dark:text-white lg:translate-x-0 transform transition-transform duration-300 ease-in-out ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
} lg:flex lg:flex-col flex-shrink-0`}
>
<div className="flex flex-col h-full overflow-y-auto p-3">
<SidebarHeader />
<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}
/>
<SidebarFooter
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
isDropdownOpen={isDropdownOpen}
toggleDropdown={toggleDropdown}
/>
</div>
</div>
</>
);
};
export default Sidebar;

67
app/frontend/TagInput.tsx Normal file
View file

@ -0,0 +1,67 @@
import { TagIcon } from '@heroicons/react/24/solid';
import React, { useState } from 'react';
import TaskTags from './components/Task/TaskTags'; // Import TaskTags
interface TagInputProps {
initialTags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: string[]; // Available tags to preload
}
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags = [] }) => {
const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(initialTags || []);
// Handle input change
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
// Handle key press (Enter) to add the tag
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && inputValue.trim()) {
event.preventDefault(); // Prevent form submission
const trimmedValue = inputValue.trim();
if (!tags.includes(trimmedValue)) {
const updatedTags = [...tags, trimmedValue];
setTags(updatedTags); // Update internal state
onTagsChange(updatedTags); // Notify parent
}
setInputValue(''); // Clear the input
}
};
// Handle removing a tag
const removeTag = (tagToRemoveId: number) => {
const updatedTags = tags.filter((_, index) => index !== tagToRemoveId);
setTags(updatedTags); // Update internal state
onTagsChange(updatedTags); // Notify parent
};
return (
<div className="space-y-2">
<TaskTags
tags={tags.map((tag, index) => ({ id: index, name: tag }))}
onTagRemove={removeTag}
className="flex flex-wrap gap-2"
/>
<input
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyPress}
list="available-tags"
placeholder="Type to select an existing tag or add a new one"
className="w-full px-2 border border-gray-300 dark:border-gray-900 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 py-2 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
/>
<datalist id="available-tags">
{availableTags.map((tag, index) => (
<option key={index} value={tag} />
))}
</datalist>
</div>
);
};
export default TagInput;

174
app/frontend/Tags.tsx Normal file
View file

@ -0,0 +1,174 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { PencilSquareIcon, TrashIcon, PlusCircleIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import ConfirmDialog from './components/Shared/ConfirmDialog';
import TagModal from './components/Tag/TagModal';
import { useDataContext } from './contexts/DataContext';
const Tags: React.FC = () => {
const { tags, createTag, updateTag, deleteTag, isLoading, isError } = useDataContext();
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
const [searchQuery, setSearchQuery] = useState<string>(''); // State for search input
const handleDeleteTag = async () => {
if (!tagToDelete) return;
try {
await deleteTag(tagToDelete.id);
setIsConfirmDialogOpen(false);
setTagToDelete(null);
} catch (err) {
console.error('Failed to delete tag:', err);
}
};
const handleEditTag = (tag: Tag) => {
setSelectedTag(tag);
setIsTagModalOpen(true);
};
const handleCreateTag = () => {
setSelectedTag(null);
setIsTagModalOpen(true);
};
const handleSaveTag = async (tagData: Tag) => {
try {
if (tagData.id) {
await updateTag(tagData.id, tagData);
} else {
await createTag(tagData);
}
} catch (err) {
console.error('Failed to save tag:', err);
}
setIsTagModalOpen(false);
setSelectedTag(null);
};
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())
);
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>
);
}
if (isError) {
return <div className="text-red-500 p-4">Error loading tags</div>;
}
return (
<div className="flex justify-center px-4">
<div className="w-full max-w-4xl">
{/* 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>
) : (
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{filteredTags.map((tag) => (
<li
key={tag.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
>
{/* Tag Content */}
<div className="flex-grow overflow-hidden pr-4">
<Link
to={`/tag/${tag.id}`}
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
>
{tag.name}
</Link>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
onClick={() => handleEditTag(tag)}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
aria-label={`Edit ${tag.name}`}
title={`Edit ${tag.name}`}
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => openConfirmDialog(tag)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
aria-label={`Delete ${tag.name}`}
title={`Delete ${tag.name}`}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
)}
{/* TagModal */}
{isTagModalOpen && (
<TagModal
isOpen={isTagModalOpen}
onClose={() => setIsTagModalOpen(false)}
onSave={handleSaveTag}
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;

321
app/frontend/Tasks.tsx Normal file
View file

@ -0,0 +1,321 @@
import React, { useEffect, useState, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import TaskList from "./components/Task/TaskList";
import NewTask from "./NewTask";
import { Task } from "./entities/Task";
import { Project } from "./entities/Project";
import { getTitleAndIcon } from "./components/Task/getTitleAndIcon";
import { getDescription } from "./components/Task/getDescription";
import { TagIcon, XMarkIcon } from "@heroicons/react/24/solid"; // Import X icon for removing tag
// Helper function to capitalize the first letter of a string
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const Tasks: React.FC = () => {
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"); // State for sorting
const dropdownRef = useRef<HTMLDivElement>(null); // Reference to the dropdown
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);
// Extract tag from query params
const tag = query.get("tag");
// Load orderBy from localStorage or use default
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); // Set the default to URL if not present
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
}
}, [location, navigate]);
// Close dropdown if clicking outside
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]);
// Fetch data when location changes
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
// Fetch tasks with the selected tag if present
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 || []);
} else {
throw new Error("Failed to fetch tasks.");
}
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData?.projects || []);
} else {
throw new Error("Failed to fetch projects.");
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchData();
}, [location]);
// Function to remove the tag from the URL
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete("tag"); // Remove tag from query params
navigate({
pathname: location.pathname,
search: `?${params.toString()}`, // Update the URL without the tag parameter
});
};
// Function to create a new task
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const response = await fetch("/api/task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(taskData),
});
if (response.ok) {
const newTask = await response.json();
setTasks((prevTasks) => [newTask, ...prevTasks]);
} else {
const errorData = await response.json();
console.error("Failed to create task:", errorData.error);
setError("Failed to create task.");
}
} catch (error) {
console.error("Error creating task:", error);
setError("Error creating task.");
}
};
// Function to update an existing task
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);
setError("Error updating task.");
}
};
// Function to delete a 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.");
}
};
// Handle sorting changes
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem("order_by", order); // Save the selected order to localStorage
const params = new URLSearchParams(location.search);
params.set("order_by", order); // Update or add the order_by param
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
setDropdownOpen(false); // Close dropdown on selection
};
// Get the description for the current task view
const description = getDescription(query, projects);
return (
<div className="flex justify-center px-4">
{" "}
{/* Center the content with padding */}
<div className="w-full max-w-4xl">
{" "}
{/* Limit the width to 3xl (48rem) */}
{/* Title and Icon */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<i className={`bi ${icon} text-xl mr-2`}></i>
<h2 className="text-2xl font-light">
{title}
</h2>
{/* If tag exists, display it as a styled button with an X to remove */}
{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>
{/* Sort Dropdown */}
<div className="relative inline-block text-left" ref={dropdownRef}>
<div>
<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"
id="menu-button"
aria-expanded="true"
aria-haspopup="true"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
<i className="bi bi-sort-alpha-down me-2"></i>{" "}
{capitalize(orderBy.split(":")[0].replace("_", " "))}
<svg
className="-mr-1 ml-2 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.292 7.707a1 1 0 011.414 0L10 11.414l3.293-3.707a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
{dropdownOpen && (
<div
className="origin-top-right absolute right-0 mt-2 w-56 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" 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"
>
{capitalize(order.split(":")[0].replace("_", " "))}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Description */}
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{loading ? (
<p>Loading...</p>
) : error ? (
<p className="text-red-500">{error}</p>
) : (
<>
{/* New Task Form */}
<NewTask
onTaskCreate={(taskName: string) =>
handleTaskCreate({ name: taskName, status: "not_started" })
}
/>
{/* Task List */}
{tasks.length > 0 ? (
<TaskList
tasks={tasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
/>
) : (
<p className="text-gray-500 text-center mt-4">
No tasks available.
</p>
)}
</>
)}
</div>
</div>
);
};
export default Tasks;

View file

@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useDataContext } from '../../contexts/DataContext'; // Import the DataContext
const AreaDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { areas, isLoading, isError } = useDataContext(); // Get areas and loading/error state from DataContext
const [area, setArea] = useState<any | null>(null); // Allow flexibility in the type for now
useEffect(() => {
// Find the area with the matching ID from the DataContext
const foundArea = areas.find((a) => a.id === Number(id));
setArea(foundArea || null);
}, [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">
Loading area details...
</div>
</div>
);
}
if (isError || !area) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="textpro-red-500 text-lg">
{isError ? 'Error loading area details.' : 'Area not found.'}
</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-4xl mx-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Area: {area?.name}
</h2>
<p className="text-md text-gray-700 dark:text-gray-300">{area?.description}</p>
<Link
to={`/projects?area_id=${area?.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"
>
View Projects in {area?.name}
</Link>
</div>
</div>
);
};
export default AreaDetails;

View file

@ -0,0 +1,175 @@
import React, { useState, useEffect, useRef } from 'react';
import { Area } from '../../entities/Area';
import { useDataContext } from '../../contexts/DataContext'; // Import DataContext
interface AreaModalProps {
isOpen: boolean;
onClose: () => void;
area?: Area | null;
}
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area }) => {
const { createArea, updateArea } = useDataContext(); // Use create and update methods from DataContext
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);
// Synchronize formData with the area prop when the modal opens or area changes
useEffect(() => {
if (isOpen) {
setFormData({
id: area?.id || 0,
name: area?.name || '',
description: area?.description || '',
});
setError(null); // Reset error when modal opens
}
}, [isOpen, area]);
// Close modal when clicking outside of it
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// Handle input changes
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Basic validation
if (!formData.name.trim()) {
setError('Area name is required.');
return;
}
setIsSubmitting(true);
setError(null);
try {
if (formData.id && formData.id !== 0) {
await updateArea(formData.id, formData); // Call updateArea from DataContext
} else {
await createArea(formData); // Call createArea from DataContext
}
onClose(); // Close modal after success
} catch (err) {
setError((err as Error).message);
} finally {
setIsSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-50 z-50">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-auto overflow-hidden"
>
<form onSubmit={handleSubmit}>
<fieldset>
<div className="p-4 space-y-4">
<h3 id="modal-title" className="text-lg font-medium text-gray-900 dark:text-white">
{formData.id && formData.id !== 0 ? 'Edit Area' : 'Create Area'}
</h3>
{/* Area Name */}
<div>
<label
htmlFor="areaName"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Area Name
</label>
<input
type="text"
id="areaName"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter area name"
/>
</div>
{/* Area Description */}
<div>
<label
htmlFor="areaDescription"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Description
</label>
<textarea
id="areaDescription"
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter area description"
/>
</div>
{/* Error Message */}
{error && <div className="text-red-500">{error}</div>}
</div>
{/* Modal Actions */}
<div className="flex justify-end items-center p-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 mr-2 text-xs py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 focus:outline-none"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className={`px-4 text-xs py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{isSubmitting ? 'Submitting...' : formData.id && formData.id !== 0 ? 'Update Area' : 'Create Area'}
</button>
</div>
</fieldset>
</form>
</div>
</div>
);
};
export default AreaModal;

View file

@ -0,0 +1,176 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { PencilSquareIcon, TrashIcon, TagIcon } from '@heroicons/react/24/solid';
import { useDataContext } from '../../contexts/DataContext'; // Import the DataContext
import ConfirmDialog from '../Shared/ConfirmDialog';
import NoteModal from './NoteModal';
import { Note } from '../../entities/Note'; // Adjust path as necessary
const NoteDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { notes, deleteNote, isLoading, isError } = useDataContext(); // Get notes and deleteNote from context
const [note, setNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); // State for the modal
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const navigate = useNavigate();
useEffect(() => {
// Find the note with the matching ID from the context
const foundNote = notes.find((n) => n.id === Number(id));
setNote(foundNote || null);
}, [id, notes]);
const handleDeleteNote = async () => {
if (!noteToDelete) return;
try {
await deleteNote(noteToDelete.id);
navigate('/notes'); // Navigate back to the notes list after deletion
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleSaveNote = (updatedNote: Note) => {
setNote(updatedNote); // Update the note after saving
setIsNoteModalOpen(false); // Close modal after saving
};
const handleEditNote = () => {
setIsNoteModalOpen(true); // Open the modal when editing
};
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">
<div className="w-full max-w-4xl">
{/* Header Section with Title and Action Buttons */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<i className="bi bi-journal-text text-xl mr-2"></i>
<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>
{/* Card with Tags and Metadata */}
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
{/* Note Tags */}
{note.tags && note.tags.length > 0 && (
<div className="mb-4">
<div className="mt-2 flex flex-wrap space-x-2">
{note.tags.map((tag) => (
<button
key={tag.id}
onClick={() => navigate(`/tasks?tag=${tag.name}`)}
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"
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
</button>
))}
</div>
</div>
)}
{/* Note Metadata */}
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
</div>
{/* Note Project */}
{note.project && (
<div className="mt-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Project</h3>
<Link
to={`/project/${note.project.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
>
{note.project.name}
</Link>
</div>
)}
</div>
{/* Note Content */}
<div className="mb-6">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
{note.content}
</p>
</div>
{/* NoteModal for editing */}
{isNoteModalOpen && (
<NoteModal
isOpen={isNoteModalOpen}
onClose={() => setIsNoteModalOpen(false)}
onSave={handleSaveNote}
note={note} // Pass the current note to the modal for editing
/>
)}
{/* 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;

View file

@ -0,0 +1,198 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import TagInput from '../../TagInput'; // Adjust the import path
import { Note } from '../../entities/Note'; // Import the centralized Note type
import { useDataContext } from '../../contexts/DataContext'; // Use DataContext
interface NoteModalProps {
isOpen: boolean;
onClose: () => void;
note?: Note | null; // If null, it's for new note creation
onSave?: (note: Note) => void; // Optional callback for saving
}
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note }) => {
const { createNote, updateNote } = useDataContext(); // Use create and update methods from DataContext
const [formData, setFormData] = useState<Note>(
note || {
title: '',
content: '',
tags: [],
}
);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
// Fetch available tags when the modal opens
useEffect(() => {
if (isOpen) {
const fetchAvailableTags = async () => {
try {
const response = await fetch('/api/tags', {
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
if (response.ok) {
const data = await response.json();
setAvailableTags(data.map((tag: { name: string }) => tag.name));
} else {
console.error('Failed to fetch available tags');
}
} catch (err) {
console.error('Error fetching available tags:', err);
}
};
fetchAvailableTags();
}
}, [isOpen]);
// Close modal if clicked outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// Handle input change
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
// Handle tags change
const handleTagsChange = useCallback((newTags: string[]) => {
setFormData((prev) => ({
...prev,
tags: newTags.map((tagName) => ({ id: null, name: tagName })),
}));
}, []);
// Handle form submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim() || !formData.content.trim()) {
setError('Title and Content are required.');
return;
}
try {
if (note?.id) {
await updateNote(note.id, formData); // Call updateNote if editing
} else {
await createNote(formData); // Call createNote if creating
}
onClose(); // Close modal after saving
} catch (err) {
console.error('Error saving note:', err);
setError('Failed to save note.');
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div
ref={modalRef}
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-2xl mx-auto overflow-hidden"
>
<form onSubmit={handleSubmit}>
<fieldset>
<div className="p-4 space-y-4">
{/* Note Title */}
<div>
<label
htmlFor="noteTitle"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Note Title
</label>
<input
type="text"
id="noteTitle"
name="title"
value={formData.title}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter note title"
/>
</div>
{/* Note Content */}
<div>
<label
htmlFor="noteContent"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Content
</label>
<textarea
id="noteContent"
name="content"
value={formData.content}
onChange={handleChange}
required
rows={5}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter note content"
/>
</div>
{/* Tags Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Tags
</label>
<TagInput
initialTags={formData?.tags?.map((tag) => tag.name) || []}
onTagsChange={handleTagsChange}
availableTags={availableTags}
/>
</div>
{/* Error Message */}
{error && <div className="text-red-500 mb-4">{error}</div>}
</div>
{/* Modal Actions */}
<div className="flex justify-end items-center p-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 mr-2"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
{note?.id ? 'Update Note' : 'Create Note'}
</button>
</div>
</fieldset>
</form>
</div>
</div>
);
};
export default NoteModal;

View file

@ -0,0 +1,212 @@
import React, { useState, useEffect, ChangeEvent, FormEvent } from 'react';
interface ProfileSettingsProps {
currentUser: { id: number; email: string };
}
interface Profile {
id: number;
email: string;
appearance: 'light' | 'dark';
language: string;
timezone: string;
avatar_image: string | null;
}
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [formData, setFormData] = useState({
appearance: 'light',
language: 'en',
timezone: 'UTC',
avatar_image: '',
});
useEffect(() => {
const fetchProfile = async () => {
try {
const response = await fetch('/api/profile', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to fetch profile.');
}
const data: Profile = await response.json();
setProfile(data);
setFormData({
appearance: data.appearance,
language: data.language,
timezone: data.timezone,
avatar_image: data.avatar_image || '',
});
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
fetchProfile();
}, []);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
const reader = new FileReader();
reader.onloadend = () => {
setFormData((prev) => ({ ...prev, avatar_image: reader.result as string }));
};
reader.readAsDataURL(e.target.files[0]);
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSuccess(null);
try {
const response = await fetch('/api/profile', {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(formData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update profile.');
}
const updatedProfile: Profile = await response.json();
setProfile(updatedProfile);
setSuccess('Profile updated successfully.');
} catch (err) {
setError((err as Error).message);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading profile settings...
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">{error}</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
Profile Settings
</h2>
{success && <div className="mb-4 text-green-500">{success}</div>}
{error && <div className="mb-4 text-red-500">{error}</div>}
<form onSubmit={handleSubmit}>
{/* Appearance Selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Appearance
</label>
<select
name="appearance"
value={formData.appearance}
onChange={handleChange}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
{/* Language Selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Language
</label>
<select
name="language"
value={formData.language}
onChange={handleChange}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="en">English</option>
<option value="es">Spanish</option>
{/* Add more languages if necessary */}
</select>
</div>
{/* Timezone Selection */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Timezone
</label>
<select
name="timezone"
value={formData.timezone}
onChange={handleChange}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="UTC">UTC</option>
<option value="America/New_York">America/New_York</option>
<option value="Europe/London">Europe/London</option>
<option value="Asia/Tokyo">Asia/Tokyo</option>
</select>
</div>
{/* Avatar Image Upload */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Avatar Image
</label>
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="mt-1 block w-full text-sm text-gray-500 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-200 dark:hover:file:bg-gray-600"
/>
{formData.avatar_image && (
<img
src={formData.avatar_image}
alt="Avatar Preview"
className="mt-2 h-24 w-24 rounded-full object-cover"
/>
)}
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
Save Changes
</button>
</div>
</form>
</div>
);
};
export default ProfileSettings;

View file

@ -0,0 +1,205 @@
import React, { useEffect, useState } from 'react';
import { useParams, useLocation, useNavigate, Link } from 'react-router-dom';
import { PencilSquareIcon, TrashIcon, Squares2X2Icon } from '@heroicons/react/24/solid';
import NewTask from '../../NewTask';
import TaskList from '../Task/TaskList';
import ProjectModal from '../Project/ProjectModal';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useDataContext } from '../../contexts/DataContext';
const ProjectDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { areas, createArea, updateArea, deleteArea } = useDataContext();
const { projects, updateProject, deleteProject } = useDataContext();
const [project, setProject] = useState<any>(null);
const [tasks, setTasks] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const projectTitle = stateTitle || project?.name || 'Project';
const projectIcon = stateIcon || 'bi-folder-fill';
useEffect(() => {
const fetchProject = async () => {
try {
const response = await fetch(`/api/project/${id}`, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
const data = await response.json();
if (response.ok) {
setProject(data);
setTasks(data.tasks || []);
} else {
throw new Error(data.error || 'Failed to fetch project.');
}
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
};
fetchProject();
}, [id]);
const handleTaskCreate = async (taskData: Partial<any>) => {
if (!project?.id) {
console.error('Project ID is missing');
return;
}
// Add the project_id to the taskData payload
const taskPayload = {
...taskData,
project_id: project.id,
};
try {
const response = await fetch(`/api/task`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
credentials: 'include',
body: JSON.stringify(taskPayload),
});
const newTask = await response.json();
if (response.ok) {
setTasks([...tasks, newTask]);
} else {
throw new Error(newTask.error || 'Failed to create task');
}
} catch (err) {
console.error('Error creating task:', err);
}
};
const handleTaskUpdate = async (updatedTask: any) => {
// Simulated function for task update
};
const handleTaskDelete = async (taskId: number) => {
// Simulated function for task deletion
};
const handleEditProject = () => {
setIsModalOpen(true);
};
const handleSaveProject = async (updatedProject: any) => {
try {
await updateProject(updatedProject.id, updatedProject);
setProject(updatedProject);
setIsModalOpen(false);
} catch (err) {
console.error('Error saving project:', err);
}
};
const handleDeleteProject = async () => {
if (!project) return;
try {
await deleteProject(project.id);
navigate('/projects');
} catch (err) {
console.error('Error deleting project:', err);
}
};
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 project details...
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">{error}</div>
</div>
);
}
return (
<div className="flex justify-center px-4">
<div className="w-full max-w-4xl">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<i className={`bi ${projectIcon} text-xl mr-2`}></i>
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">{projectTitle}</h2>
</div>
<div className="flex space-x-2">
<button
onClick={handleEditProject}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
{project?.area && (
<div className="flex items-center mb-4">
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<Link
to={`/projects/?area_id=${project?.area.id}`}
className="text-gray-600 dark:text-gray-400 hover:underline"
>
{project.area.name.toUpperCase()}
</Link>
</div>
)}
{project?.description && (
<p className="text-gray-700 dark:text-gray-300 mb-6">{project.description}</p>
)}
{/* Create a new task for this project */}
<NewTask onTaskCreate={(taskName: string) => handleTaskCreate({ name: taskName, status: 'not_started', project: project })} />
<TaskList tasks={tasks} onTaskCreate={handleTaskCreate} onTaskUpdate={handleTaskUpdate} onTaskDelete={handleTaskDelete} projects={[project]} />
<ProjectModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveProject}
project={project || undefined}
areas={areas}
/>
{isConfirmDialogOpen && (
<ConfirmDialog
title="Delete Project"
message={`Are you sure you want to delete the project "${project?.name}"?`}
onConfirm={handleDeleteProject}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
</div>
);
};
export default ProjectDetails;

View file

@ -0,0 +1,191 @@
import React, { useState, useEffect, useRef } from 'react';
import { Area } from '../../entities/Area';
import { Project } from '../../entities/Project';
interface ProjectModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (project: Project) => void;
onDelete?: () => void;
project?: Project;
areas: Area[];
}
const ProjectModal: React.FC<ProjectModalProps> = ({ isOpen, onClose, onSave, onDelete, project, areas }) => {
const [formData, setFormData] = useState<Project>(
project || {
name: '',
description: '',
area_id: null,
active: true,
pin_to_sidebar: false,
}
);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
useEffect(() => {
if (project) {
setFormData(project);
}
}, [project]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div ref={modalRef} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-lg mx-auto overflow-hidden">
<form onSubmit={handleSubmit}>
<fieldset>
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Project Name */}
<div>
<label htmlFor="projectName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Project Name
</label>
<input
type="text"
id="projectName"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter project name"
/>
</div>
{/* Description */}
<div>
<label htmlFor="projectDescription" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<textarea
id="projectDescription"
name="description"
rows={3}
value={formData.description || ''}
onChange={handleChange}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter project description (optional)"
></textarea>
</div>
{/* Area */}
<div>
<label htmlFor="projectArea" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Area (optional)
</label>
<select
id="projectArea"
name="area_id"
value={formData.area_id || ''}
onChange={handleChange}
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
>
<option value="">No Area</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</div>
{/* Custom Active Checkbox */}
<div className="flex items-center">
<input
type="checkbox"
id="active"
name="active"
checked={formData.active}
onChange={handleChange}
className="h-5 w-5 appearance-none border border-gray-300 rounded-md bg-white dark:bg-gray-700 checked:bg-blue-600 checked:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label htmlFor="active" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Active
</label>
</div>
{/* Custom Pin to Sidebar Checkbox */}
{/* <div className="flex items-center">
<input
type="checkbox"
id="pin_to_sidebar"
name="pin_to_sidebar"
checked={formData.pin_to_sidebar}
onChange={handleChange}
className="h-5 w-5 appearance-none border border-gray-300 rounded-md bg-white dark:bg-gray-700 checked:bg-blue-600 checked:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label htmlFor="pin_to_sidebar" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Pin to Sidebar
</label>
</div> */}
</div>
{/* Modal Actions */}
<div className="flex justify-between items-center p-4 border-t border-gray-200 dark:border-gray-700">
{project && (
<button
type="button"
onClick={onDelete}
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600"
>
Delete
</button>
)}
<div className={`flex space-x-2 ${!project ? 'ml-auto' : ''}`}>
<button
type="button"
onClick={onClose}
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
className="px-3 py-1 text-sm bg-blue-600 dark:bg-blue-500 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600"
>
{project ? 'Update Project' : 'Create Project'}
</button>
</div>
</div>
</fieldset>
</form>
</div>
</div>
);
};
export default ProjectModal;

View file

@ -0,0 +1,37 @@
// src/components/ConfirmDialog.tsx
import React from 'react';
interface ConfirmDialogProps {
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded shadow-lg">
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{title}</h3>
<p className="text-gray-700 dark:text-gray-300 mb-6">{message}</p>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 focus:outline-none"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 focus:outline-none"
>
Delete
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View file

@ -0,0 +1,81 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline'; // Import the icons
interface PriorityDropdownProps {
value: string;
onChange: (value: string) => void;
}
const priorities = [
{ value: 'low', label: 'Low', icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'medium', label: 'Medium', icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'high', label: 'High', icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
];
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleSelect = (priority: string) => {
onChange(priority);
setIsOpen(false);
};
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">
<button
type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-800 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 : 'Select Priority'}</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">
{priorities.map((priority) => (
<button
key={priority.value}
onClick={() => handleSelect(priority.value)}
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"
>
<span className="flex items-center space-x-2">
{priority.icon} <span>{priority.label}</span>
</span>
</button>
))}
</div>
)}
</div>
);
};
export default PriorityDropdown;

View file

@ -0,0 +1,82 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'; // Import Heroicons
interface StatusDropdownProps {
value: string;
onChange: (value: string) => void;
}
const statuses = [
{ value: 'not_started', label: 'Not Started', icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'in_progress', label: 'In Progress', icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'done', label: 'Done', icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
{ value: 'archived', label: 'Archived', icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
];
const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const handleSelect = (status: string) => {
onChange(status);
setIsOpen(false);
};
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
type="button"
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-800 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"> {/* Added space-x-3 here */}
{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)}
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>
);
};
export default StatusDropdown;

View file

@ -0,0 +1,62 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
// Define the shape of the context
interface ToastContextProps {
showSuccessToast: (message: string) => void;
showErrorToast: (message: string) => void;
}
// Create a context with default values
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
// Toast provider component
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [toastType, setToastType] = useState<'success' | 'error'>('success');
// Show success toast
const showSuccessToast = useCallback((message: string) => {
setToastMessage(message);
setToastType('success');
setTimeout(() => setToastMessage(null), 3000); // Auto-hide after 3 seconds
}, []);
// Show error toast
const showErrorToast = useCallback((message: string) => {
setToastMessage(message);
setToastType('error');
setTimeout(() => setToastMessage(null), 3000); // Auto-hide after 3 seconds
}, []);
return (
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
{children}
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />}
</ToastContext.Provider>
);
};
// Custom hook to use the ToastContext
export const useToast = () => {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
// Toast component
const Toast: React.FC<{ message: string; type: 'success' | 'error'; onClose: () => void }> = ({ message, type, onClose }) => {
return (
<div
className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`}
>
<span>{message}</span>
<button onClick={onClose} className="ml-4">
&times;
</button>
</div>
);
};

View file

@ -0,0 +1,57 @@
// src/components/Sidebar/SidebarAreas.tsx
import React from 'react';
import { Squares2X2Icon, PlusCircleIcon } from '@heroicons/react/24/solid'; // Using solid style
import { Area } from '../../entities/Area'; // Adjust the import path
interface SidebarAreasProps {
handleNavClick: (path: string, title: string, icon: string) => void;
location: Location;
isDarkMode: boolean;
openAreaModal: () => void; // Modify to not require an area parameter for creation
}
const SidebarAreas: React.FC<SidebarAreasProps> = ({
handleNavClick,
location,
isDarkMode,
openAreaModal,
}) => {
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', 'squares2x2')}
>
<span className="flex items-center">
<Squares2X2Icon className="h-5 w-5 mr-2" />
AREAS
</span>
<button
onClick={(e) => {
e.stopPropagation(); // Prevent triggering the parent onClick
openAreaModal(); // Open modal for creating a new area
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Area"
title="Add Area"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarAreas;

View file

@ -0,0 +1,68 @@
import React from 'react';
import { Link } from 'react-router-dom';
interface SidebarFooterProps {
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
isDropdownOpen: boolean;
toggleDropdown: () => void;
}
const SidebarFooter: React.FC<SidebarFooterProps> = ({
currentUser,
isDarkMode,
toggleDarkMode,
isDropdownOpen,
toggleDropdown,
}) => {
return (
<div className="mt-auto">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<div className="relative">
<button
className="flex justify-center items-center text-gray-700 dark:text-gray-300 w-full focus:outline-none"
onClick={toggleDropdown}
>
<strong>{currentUser.email}</strong>
<i className={`ml-2 bi ${isDropdownOpen ? 'bi-caret-up-fill' : 'bi-caret-down-fill'}`}></i>
</button>
{/* Dropdown menu */}
{isDropdownOpen && (
<ul className="absolute bottom-full mb-2 w-full bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg shadow-lg z-50">
<li className="hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">
<Link
to="/profile"
className="block w-full px-4 py-2 text-left"
>
Profile
</Link>
</li>
<li className="border-t border-gray-200 dark:border-gray-700"></li>
<li className="hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">
<a
href="/logout"
className="block w-full px-4 py-2 text-left text-red-500"
>
Sign out
</a>
</li>
</ul>
)}
</div>
{/* Dark Mode Toggle */}
<button
id="darkModeToggle"
className="mt-3 w-full flex items-center justify-center text-gray-700 dark:text-gray-300"
onClick={toggleDarkMode}
>
<i className={`bi ${isDarkMode ? 'bi-sun' : 'bi-moon'} text-lg`}></i>
</button>
</div>
</div>
);
};
export default SidebarFooter;

View file

@ -0,0 +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>
);
};
export default SidebarHeader;

View file

@ -0,0 +1,60 @@
import React from 'react';
import { Location } from 'react-router-dom';
import {
CalendarDaysIcon,
CalendarIcon,
ArrowRightCircleIcon,
InboxIcon,
ClockIcon,
PauseCircleIcon,
CheckCircleIcon,
ListBulletIcon,
} from '@heroicons/react/24/solid';
interface SidebarNavProps {
handleNavClick: (path: string, title: string) => void;
location: Location;
isDarkMode: boolean;
}
const navLinks = [
{ path: '/tasks?type=today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
{ path: '/tasks?type=upcoming', title: 'Upcoming', icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
{ path: '/tasks?type=next', title: 'Next Actions', icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
{ path: '/tasks?type=inbox', title: 'Inbox', icon: <InboxIcon className="h-5 w-5" />, query: 'type=inbox' },
{ path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
{ path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
{ path: '/tasks?status=done', title: 'Completed', icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
{ path: '/tasks', title: 'All Tasks', icon: <ListBulletIcon className="h-5 w-5" /> },
];
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location, isDarkMode }) => {
const isActive = (path: string, query?: string) => {
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) => (
<li key={link.path}>
<button
onClick={() => handleNavClick(link.path, link.title)}
className={`w-full text-left px-4 py-1 flex items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
link.path,
link.query
)}`}
>
{link.icon}
<span className="ml-2">{link.title}</span>
</button>
</li>
))}
</ul>
);
};
export default SidebarNav;

View file

@ -0,0 +1,59 @@
// src/components/Sidebar/SidebarNotes.tsx
import React from 'react';
import { Location } from 'react-router-dom';
import { BookOpenIcon, PlusCircleIcon } from '@heroicons/react/24/solid';
import { Note } from '../../entities/Note';
interface SidebarNotesProps {
handleNavClick: (path: string, title: string, icon: string) => void;
location: Location;
isDarkMode: boolean;
openNoteModal: (note: Note | null) => void;
notes: Note[];
}
const SidebarNotes: React.FC<SidebarNotesProps> = ({
handleNavClick,
location,
isDarkMode,
openNoteModal,
notes,
}) => {
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', 'book')}
>
<span className="flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />
NOTES
</span>
<button
onClick={(e) => {
e.stopPropagation();
openNoteModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Note"
title="Add Note"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarNotes;

View file

@ -0,0 +1,77 @@
// src/components/Sidebar/SidebarProjects.tsx
import React, { useState, useEffect } from 'react';
import { Location } from 'react-router-dom';
import { FolderIcon, PlusCircleIcon } from '@heroicons/react/24/solid';
import { Project } from '../../entities/Project';
interface SidebarProjectsProps {
handleNavClick: (path: string, title: string, icon: string) => void;
location: Location;
isDarkMode: boolean;
openProjectModal: () => void; // Add this prop
}
const SidebarProjects: React.FC<SidebarProjectsProps> = ({
handleNavClick,
location,
isDarkMode,
openProjectModal,
}) => {
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
const fetchProjects = async () => {
try {
const response = await fetch('/api/projects?pin_to_sidebar=true'); // Fetch only pinned projects
const data = await response.json();
if (response.ok) {
setProjects(data.projects || []);
} else {
console.error('Failed to fetch projects:', data.error);
}
} catch (error) {
console.error('Error fetching projects:', error);
}
};
fetchProjects();
}, []);
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">
{/* "PROJECTS" Title with Add Button */}
<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', 'Projects', 'folder')}
>
<span className="flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />
PROJECTS
</span>
<button
onClick={(e) => {
e.stopPropagation(); // Prevent triggering the parent onClick
openProjectModal(); // Open the modal
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Project"
title="Add Project"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarProjects;

View file

@ -0,0 +1,58 @@
import React from 'react';
import { Location } from 'react-router-dom';
import { TagIcon, PlusCircleIcon } from '@heroicons/react/24/solid';
import { Tag } from '../../entities/Tag';
interface SidebarTagsProps {
handleNavClick: (path: string, title: string, icon: string) => void;
location: Location;
isDarkMode: boolean;
openTagModal: (tag: Tag | null) => void;
tags: Tag[];
}
const SidebarTags: React.FC<SidebarTagsProps> = ({
handleNavClick,
location,
isDarkMode,
openTagModal,
tags,
}) => {
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', 'tag')}
>
<span className="flex items-center">
<TagIcon className="h-5 w-5 mr-2" />
TAGS
</span>
<button
onClick={(e) => {
e.stopPropagation(); // Prevent triggering the parent onClick
openTagModal(null); // Open the modal for creating a new tag
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Tag"
title="Add Tag"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarTags;

View file

@ -0,0 +1,76 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
interface Tag {
id: number;
name: string;
active: boolean;
}
const TagDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const [tag, setTag] = useState<Tag | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate(); // Use the `useNavigate` hook for navigation
useEffect(() => {
const fetchTag = async () => {
try {
const response = await fetch(`/api/tag/${id}`);
const data = await response.json();
if (response.ok) {
setTag(data);
} else {
setError(data.error || 'Failed to fetch tag.');
}
} catch (err) {
setError('Error fetching tag.');
} finally {
setLoading(false);
}
};
fetchTag();
}, [id]);
// Function to handle the redirection to tasks with the tag
const handleViewTasks = () => {
if (tag) {
navigate(`/tasks?tag=${encodeURIComponent(tag.name)}`); // Redirect to the tasks page with the tag as a query param
}
};
if (loading) {
return <div className="text-gray-700 dark:text-gray-300">Loading tag details...</div>;
}
if (error) {
return <div className="text-red-500">{error}</div>;
}
if (!tag) {
return <div className="text-gray-700 dark:text-gray-300">Tag not found.</div>;
}
return (
<div className="p-4">
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">Tag Details</h2>
<p className="text-gray-700 dark:text-gray-300">
<strong>Name:</strong> {tag.name}
</p>
<p className="text-gray-700 dark:text-gray-300">
<strong>Status:</strong> {tag.active ? 'Active' : 'Inactive'}
</p>
{/* "View tasks with this tag" button */}
<button
onClick={handleViewTasks}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
>
View tasks with this tag
</button>
</div>
);
};
export default TagDetails;

View file

@ -0,0 +1,113 @@
// src/components/Tag/TagModal.tsx
import React, { useState, useEffect, useRef } from 'react';
import { Tag } from '../../entities/Tag';
interface TagModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (tag: Tag) => void;
tag?: Tag;
}
const TagModal: React.FC<TagModalProps> = ({ isOpen, onClose, onSave, tag }) => {
const [formData, setFormData] = useState<Tag>(
tag || {
name: '',
}
);
const modalRef = useRef<HTMLDivElement>(null);
// Close modal if clicked outside of it
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
// Update form state when editing a tag
useEffect(() => {
if (tag) {
setFormData(tag);
} else {
setFormData({
name: '',
});
}
}, [tag]);
// Handle form input changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
// Handle form submission
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div ref={modalRef} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-auto overflow-hidden">
<form onSubmit={handleSubmit}>
<fieldset>
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Tag Name */}
<div>
<label htmlFor="tagName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Tag Name
</label>
<input
type="text"
id="tagName"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="Enter tag name"
/>
</div>
</div>
{/* Modal Actions */}
<div className="flex justify-end items-center p-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-3 py-1 text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
className="ml-2 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600"
>
{tag ? 'Update Tag' : 'Create Tag'}
</button>
</div>
</fieldset>
</form>
</div>
</div>
);
};
export default TagModal;

View file

@ -0,0 +1,40 @@
import React from 'react';
interface TaskActionsProps {
taskId: number | undefined;
onDelete: () => void;
onSave: () => void;
onCancel: () => void;
}
const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onCancel }) => {
return (
<div className="flex justify-end items-center mt-4 space-x-2">
{taskId && (
<button
type="button"
onClick={onDelete}
className="flex items-center px-3 py-1.5 text-xs text-white bg-red-500 rounded hover:bg-red-600"
>
<i className="bi bi-trash mr-2"></i> Delete
</button>
)}
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="button"
onClick={onSave}
className="px-3 py-1.5 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
>
Save
</button>
</div>
);
};
export default TaskActions;

View file

@ -0,0 +1,44 @@
import React from 'react';
interface TaskDueDateProps {
dueDate: string;
className?: string;
}
const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
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 'bg-blue-700 text-white';
if (dueDate === tomorrow) return 'bg-blue-700 text-white';
if (dueDate < today) return 'bg-red-700 text-white';
return 'bg-gray-300 text-gray-700';
};
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 'TODAY';
if (dueDate === tomorrow) return 'TOMORROW';
if (dueDate === yesterday) return 'YESTERDAY';
// Format due date into a human-readable format
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 ${getDueDateClass()} ${className}`}>
<i className="bi bi-clock mr-1"></i>
{formatDueDate()}
</div>
);
};
export default TaskDueDate;

View file

@ -0,0 +1,70 @@
import React from "react";
import TaskPriorityIcon from "./TaskPriorityIcon";
import TaskTags from "./TaskTags";
import TaskStatusBadge from "./TaskStatusBadge";
import TaskDueDate from "./TaskDueDate";
import { Project } from "../../entities/Project";
import { Task } from "../../entities/Task";
interface TaskHeaderProps {
task: Task;
project?: Project;
onTaskClick: (e: React.MouseEvent) => void; // For opening the modal
}
const TaskHeader: React.FC<TaskHeaderProps> = ({ task, project, onTaskClick }) => {
return (
<div className="py-2 px-4 cursor-pointer" onClick={onTaskClick}>
{/* Full view (md and larger) */}
<div className="hidden md:flex flex-col md:flex-row md:items-center md:justify-between">
{/* First Line (Task Priority, Name, and Project) */}
<div className="flex items-center space-x-4 mb-2 md:mb-0">
<TaskPriorityIcon priority={task.priority} status={task.status} />
<div className="flex flex-col">
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">
{task.name}
</span>
{project && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{project.name}
</div>
)}
</div>
</div>
{/* Second Line (Tags, Due Date, Status) */}
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-4">
<TaskTags tags={task.tags} />
{task.due_date && <TaskDueDate dueDate={task.due_date} />}
<TaskStatusBadge status={task.status} />
</div>
</div>
{/* Mobile view (below md breakpoint) */}
<div className="block md:hidden">
{/* First Line (Priority Icon and Task Title) */}
<div className="flex items-center mb-2">
<TaskPriorityIcon priority={task.priority} status={task.status} />
<span className="ml-2 font-medium text-sm text-gray-900 dark:text-gray-100">
{task.name}
</span>
</div>
{/* Second Line (Status Icon and Due Date) */}
<div className="flex items-center mb-2 pl-6">
<TaskStatusBadge status={task.status} />
{task.due_date && (
<TaskDueDate dueDate={task.due_date} className="ml-2" />
)}
</div>
{/* Third Line (Tags, indented) */}
<div className="pl-6">
<TaskTags tags={task.tags} />
</div>
</div>
</div>
);
};
export default TaskHeader;

View file

@ -0,0 +1,84 @@
import React, { useState, useEffect } from 'react';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import TaskHeader from './TaskHeader';
import TaskModal from './TaskModal';
interface TaskItemProps {
task: Task;
onTaskUpdate: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
projects: Project[];
}
const TaskItem: React.FC<TaskItemProps> = ({
task,
onTaskUpdate,
onTaskDelete,
projects,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectList, setProjectList] = useState<Project[]>(projects); // Keep track of projects
const handleTaskClick = () => {
setIsModalOpen(true); // Open the modal when task title is clicked
};
const handleSave = (updatedTask: Task) => {
onTaskUpdate(updatedTask); // Save the updated task
setIsModalOpen(false); // Close the modal after saving
};
const handleDelete = () => {
if (task.id) {
onTaskDelete(task.id); // Delete the task
}
};
// Function to create a new project
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 }),
});
if (!response.ok) {
throw new Error('Failed to create project');
}
const newProject = await response.json();
// Update local project list
setProjectList((prevProjects) => [...prevProjects, newProject]);
return newProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
};
// Find the project associated with this task
const project = projectList.find((p) => p.id === task.project_id);
return (
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1">
<TaskHeader task={task} project={project} onTaskClick={handleTaskClick} />
{/* Task Modal for editing */}
<TaskModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
task={task}
onSave={handleSave}
onDelete={onTaskDelete}
projects={projectList} // Pass updated project list to modal
onCreateProject={handleCreateProject} // Pass project creation function
/>
</div>
);
};
export default TaskItem;

View file

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

View file

@ -0,0 +1,330 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Task } from '../../entities/Task';
import TagInput from '../../TagInput';
import TaskActions from './TaskActions';
import PriorityDropdown from '../Shared/PriorityDropdown';
import StatusDropdown from '../Shared/StatusDropdown';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useToast } from '../Shared/ToastContext'; // Import the toast hook
interface Tag {
id?: number;
name: string;
}
interface Project {
id: number;
name: string;
}
interface TaskModalProps {
isOpen: boolean;
onClose: () => void;
task: Task;
onSave: (task: Task) => void;
onDelete: (taskId: number) => void;
projects: Project[];
onCreateProject: (name: string) => Promise<Project>;
}
const TaskModal: React.FC<TaskModalProps> = ({
isOpen,
onClose,
task,
onSave,
onDelete,
projects,
onCreateProject,
}) => {
const [formData, setFormData] = useState<Task>(task);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>(task.tags?.map(tag => tag.name) || []);
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects);
const [newProjectName, setNewProjectName] = useState<string>('');
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false); // State to control confirm dialog
const { showSuccessToast, showErrorToast } = useToast(); // Use toast functions
useEffect(() => {
setFormData(task);
setTags(task.tags?.map(tag => tag.name) || []);
}, [task]);
useEffect(() => {
if (isOpen) {
fetch('/api/tags')
.then((response) => response.json())
.then((data) => setAvailableTags(data.map((tag: Tag) => tag.name)))
.catch((error) => console.error('Failed to fetch tags', error));
}
}, [isOpen]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleTagsChange = useCallback((newTags: string[]) => {
setTags(newTags);
}, []);
const handleProjectSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value.toLowerCase();
setNewProjectName(query);
setDropdownOpen(true);
setFilteredProjects(
projects.filter((project) =>
project.name.toLowerCase().includes(query)
)
);
};
const handleProjectSelection = (project: Project) => {
setFormData({ ...formData, project_id: project.id });
setNewProjectName(project.name);
setDropdownOpen(false);
};
const handleCreateProject = async () => {
if (newProjectName.trim() !== '') {
setIsCreatingProject(true);
try {
const newProject = await onCreateProject(newProjectName);
setFormData({ ...formData, project_id: newProject.id });
setFilteredProjects([...filteredProjects, newProject]);
setNewProjectName(newProject.name);
setDropdownOpen(false);
showSuccessToast('Project created successfully!');
} catch (error) {
showErrorToast('Failed to create project.');
console.error('Error creating project:', error);
} finally {
setIsCreatingProject(false);
}
}
};
const handleSubmit = () => {
onSave({ ...formData, tags: tags.map(tag => ({ name: tag })) });
showSuccessToast('Task updated successfully!');
handleClose();
};
const handleDeleteClick = () => {
setShowConfirmDialog(true); // Show confirmation dialog
};
const handleDeleteConfirm = () => {
if (formData.id) {
onDelete(formData.id);
showSuccessToast('Task deleted successfully!');
setShowConfirmDialog(false);
handleClose();
}
};
const handleClose = () => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
};
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]);
if (!isOpen) return null;
return (
<>
<div
className={`fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
>
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 rounded-lg shadow-2xl w-full max-w-3xl mx-auto overflow-hidden transform transition-transform duration-300 ${
isClosing ? 'scale-95' : 'scale-100'
}`}
style={{ maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
<form>
<fieldset>
<div className="p-4 space-y-3 flex-grow text-sm">
{/* Task Name */}
<div className="py-4">
<input
type="text"
id={`task_name_${task.id}`}
name="name"
value={formData.name}
onChange={handleChange}
required
className="block w-full text-xl font-semibold border-none focus:outline-none shadow-sm py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-200"
placeholder="Add Task Name"
/>
</div>
{/* Tags */}
<div className="pb-3">
<label
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Tags
</label>
<div className="w-full">
<TagInput
onTagsChange={handleTagsChange}
initialTags={formData.tags?.map((tag) => tag.name) || []}
availableTags={availableTags}
/>
</div>
</div>
{/* Project */}
<div className="pb-3">
<label
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3"
>
Project
</label>
<input
type="text"
placeholder="Search or create a project..."
value={newProjectName}
onChange={handleProjectSearch}
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
{dropdownOpen && newProjectName && (
<div className="absolute mt-1 bg-white dark:bg-gray-900 shadow-md rounded-md w-full z-10">
{filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<button
key={project.id}
type="button"
onClick={() => handleProjectSelection(project)}
className="block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
>
{project.name}
</button>
))
) : (
<div className="px-4 py-2 text-gray-500 dark:text-gray-300">
No matching projects
</div>
)}
{newProjectName && (
<button
type="button"
onClick={handleCreateProject}
disabled={isCreatingProject}
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600"
>
{isCreatingProject ? 'Creating...' : `+ Create "${newProjectName}"`}
</button>
)}
</div>
)}
</div>
{/* Status and Priority */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pb-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Status
</label>
<StatusDropdown
value={formData.status}
onChange={(value) => setFormData({ ...formData, status: value })}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Priority
</label>
<PriorityDropdown
value={formData.priority || 'medium'}
onChange={(value) => setFormData({ ...formData, priority: value })}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Due Date
</label>
<input
type="date"
id={`task_due_date_${task.id}`}
name="due_date"
value={formData.due_date || ''}
onChange={handleChange}
className="block w-full focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-900 rounded-md text-gray-900 dark:text-gray-100"
/>
</div>
</div>
{/* Note */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Note
</label>
<textarea
id={`task_note_${task.id}`}
name="note"
rows={3}
value={formData.note || ''}
onChange={handleChange}
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm p-3 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
placeholder="Add any additional notes here"
></textarea>
</div>
</div>
{/* Task Actions */}
<div className="p-3 border-t dark:border-gray-700">
<TaskActions
taskId={task.id}
onDelete={handleDeleteClick}
onSave={handleSubmit}
onCancel={handleClose}
/>
</div>
</fieldset>
</form>
</div>
</div>
{showConfirmDialog && (
<ConfirmDialog
title="Delete Task"
message="Are you sure you want to delete this task? This action cannot be undone."
onConfirm={handleDeleteConfirm}
onCancel={() => setShowConfirmDialog(false)}
/>
)}
</>
);
};
export default TaskModal;

View file

@ -0,0 +1,24 @@
import React from 'react';
interface TaskPriorityIconProps {
priority: string | undefined;
status: string;
}
const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status }) => {
const getPriorityClass = () => {
if (status === 'done') return 'text-green-500';
switch (priority) {
case 'high':
return 'text-red-500';
case 'medium':
return 'text-yellow-500';
default:
return 'text-gray-300';
}
};
return <i className={`bi bi-circle ${getPriorityClass()}`}></i>;
};
export default TaskPriorityIcon;

View file

@ -0,0 +1,49 @@
import React from 'react';
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid';
interface TaskStatusBadgeProps {
status: string;
className?: string; // Allows passing custom classes for spacing
}
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
let statusIcon, statusLabel, badgeClass;
switch (status) {
case 'not_started':
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = 'Not Started';
badgeClass = 'border-gray-400 text-gray-400 dark:text-gray-400 dark:border-gray-700';
break;
case 'in_progress':
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
statusLabel = 'In Progress';
badgeClass = 'border-blue-400 text-blue-400 dark:text-blue-400 dark:border-blue-700';
break;
case 'done':
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
statusLabel = 'Done';
badgeClass = 'border-green-400 text-green-400 dark:text-green-400 dark:border-green-700';
break;
case 'archived':
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
statusLabel = 'Archived';
badgeClass = 'border-gray-400 text-gray-400 dark:text-gray-400 dark:border-gray-700';
break;
default:
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = 'Unknown';
badgeClass = 'border-gray-400 text-gray-400 dark:text-gray-400 dark:border-gray-700';
}
return (
<div
className={`flex items-center justify-center px-2 py-1 rounded-md border ${badgeClass} w-10 ${className}`}
>
{statusIcon}
<span className="text-xs font-medium"></span>
</div>
);
};
export default TaskStatusBadge;

View file

@ -0,0 +1,39 @@
import { TagIcon } from '@heroicons/react/24/solid';
import React from 'react';
import { useNavigate } from 'react-router-dom';
interface Tag {
id: number;
name: string;
}
interface TaskTagsProps {
tags: Tag[];
className?: string; // Allows passing custom classes for spacing
}
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], className }) => {
const navigate = useNavigate();
// Function to handle tag click and navigate to a filtered view
const handleTagClick = (tagName: string) => {
navigate(`/tasks?tag=${tagName}`); // Navigate to tasks filtered by the clicked tag
};
return (
<div className={`flex space-x-2 ${className}`}>
{tags.map((tag, index) => (
<button
key={tag.id || index}
onClick={() => handleTagClick(tag.name)}
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"
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
</button>
))}
</div>
);
};
export default TaskTags;

View file

@ -0,0 +1,31 @@
import { Project } from "../../entities/Project";
export const getDescription = (query: URLSearchParams, projects: Project[]): string => {
const projectId = query.get('project_id');
if (projectId) {
const project = projects.find((p) => p.id.toString() === projectId);
return project
? `You are currently viewing all tasks associated with the "${project.name}" project. You can organize tasks within this project, set their priority, and track their completion. Use this space to focus on the tasks that belong specifically to this project.`
: 'You are viewing tasks for a specific project. Use this space to manage and track tasks associated with this project.';
}
if (query.get('type') === 'today') {
return 'These are the tasks that are due today or tasks youve scheduled for immediate attention. Use this view to focus on what needs to be completed today. Mark tasks as completed, update their status, or adjust their due dates if needed.';
}
if (query.get('type') === 'inbox') {
return 'The inbox is where all uncategorized tasks live. Tasks that havent been assigned to a project or given a due date will show up here. This is your “brain dump” area where you can quickly jot down tasks and organize them later.';
}
if (query.get('type') === 'next') {
return 'This view shows all the tasks that are actionable in the near future. These tasks are ready to be worked on next and dont have long-term deadlines. Its a good place to focus when youre looking to make quick progress on tasks.';
}
if (query.get('type') === 'upcoming') {
return 'This view highlights tasks that are scheduled for the upcoming week. It helps you prepare and stay ahead of deadlines by giving you an overview of the work you need to tackle in the near future. Tasks with due dates within the next 7 days will appear here.';
}
if (query.get('type') === 'someday') {
return 'The “Someday” view is for tasks that arent urgent and dont have a specific due date. These are tasks you may want to get to at some point, but they arent a priority right now. Use this section to keep track of ideas or long-term goals.';
}
if (query.get('status') === 'done') {
return 'Here you can see all the tasks youve completed. Its a great way to review your accomplishments and reflect on the work youve finished. You can also find tasks that may need to be unarchived or referenced in the future.';
}
return 'You are viewing all tasks. This includes tasks from different projects, tasks without specific due dates, and tasks with varying levels of priority. Use this view for an overall look at everything on your to-do list.';
};

View file

@ -0,0 +1,27 @@
export const getTitleAndIcon = (query: URLSearchParams, projects: Project[]) => {
const projectId = query.get('project_id');
if (projectId) {
const project = projects.find((p) => p.id.toString() === projectId);
return { title: project ? project.name : 'Project', icon: 'bi-folder-fill' };
}
if (query.get('type') === 'today') {
return { title: 'Today', icon: 'bi-calendar-day-fill' };
}
if (query.get('type') === 'inbox') {
return { title: 'Inbox', icon: 'bi-inbox-fill' };
}
if (query.get('type') === 'next') {
return { title: 'Next Actions', icon: 'bi-arrow-right-circle-fill' };
}
if (query.get('type') === 'upcoming') {
return { title: 'Upcoming', icon: 'bi-calendar3' };
}
if (query.get('type') === 'someday') {
return { title: 'Someday', icon: 'bi-moon-stars-fill' };
}
if (query.get('status') === 'done') {
return { title: 'Completed', icon: 'bi-check-circle' };
}
return { title: 'All Tasks', icon: 'bi-layers' };
};

View file

@ -0,0 +1,100 @@
// contexts/DataContext.tsx
import React, { createContext, useContext } from 'react';
import useFetchTags from '../hooks/useFetchTags';
import useFetchAreas from '../hooks/useFetchAreas';
import useManageAreas from '../hooks/useManageAreas';
import useManageNotes from '../hooks/useManageNotes';
import useManageProjects from '../hooks/useManageProjects';
import useManageTags from '../hooks/useManageTags';
import useManageTasks from '../hooks/useManageTasks'; // Import the tasks hook
interface DataContextProps {
tasks: any[];
tags: any[];
areas: any[];
notes: any[];
isLoading: boolean;
isError: boolean;
createNote: (noteData: any) => Promise<void>;
updateNote: (noteId: number, noteData: any) => Promise<void>;
deleteNote: (noteId: number) => Promise<void>;
createArea: (areaData: any) => Promise<void>;
updateArea: (areaId: number, areaData: any) => Promise<void>;
deleteArea: (areaId: number) => Promise<void>;
createProject: (projectData: any) => Promise<void>;
updateProject: (projectId: number, projectData: any) => Promise<void>;
deleteProject: (projectId: number) => Promise<void>;
createTag: (tagData: any) => Promise<void>;
updateTag: (tagId: number, tagData: any) => Promise<void>;
deleteTag: (tagId: number) => Promise<void>;
createTask: (taskData: any) => Promise<void>;
updateTask: (taskId: number, taskData: any) => Promise<void>;
deleteTask: (taskId: number) => Promise<void>;
mutateTags: () => void;
mutateAreas: () => void;
mutateNotes: () => void;
}
const DataContext = createContext<DataContextProps | undefined>(undefined);
export const useDataContext = () => {
const context = useContext(DataContext);
if (!context) {
throw new Error('useDataContext must be used within a DataProvider');
}
return context;
};
export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { tags, isLoading: isLoadingTags, isError: isErrorTags, mutate: mutateTags } = useFetchTags();
const { areas, isLoading: isLoadingAreas, isError: isErrorAreas, mutate: mutateAreas } = useFetchAreas();
const { createArea, updateArea, deleteArea } = useManageAreas();
const { createProject, updateProject, deleteProject } = useManageProjects();
const { createTag, updateTag, deleteTag } = useManageTags();
const { tasks, isLoading: isLoadingTasks, isError: isErrorTasks, createTask, updateTask, deleteTask } = useManageTasks();
const {
notes,
isLoading: isLoadingNotes,
isError: isErrorNotes,
createNote,
updateNote,
deleteNote,
mutate: mutateNotes,
} = useManageNotes();
const isLoading = isLoadingTags || isLoadingAreas || isLoadingNotes || isLoadingTasks;
const isError = isErrorTags || isErrorAreas || isErrorNotes || isErrorTasks;
return (
<DataContext.Provider
value={{
tasks,
tags,
areas,
notes,
isLoading,
isError,
createNote,
updateNote,
deleteNote,
createArea,
updateArea,
deleteArea,
createProject,
updateProject,
deleteProject,
createTag,
updateTag,
deleteTag,
createTask, // Added task creation
updateTask, // Added task update
deleteTask, // Added task deletion
mutateTags,
mutateAreas,
mutateNotes,
}}
>
{children}
</DataContext.Provider>
);
};

View file

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

View file

@ -0,0 +1,12 @@
export interface Note {
id?: number;
title: string;
content: string;
created_at?: string;
updated_at?: string; // Make updated_at optional
tags?: { id: number; name: string }[];
project?: {
id: number;
name: string;
};
}

View file

@ -0,0 +1,11 @@
import { Area } from "./Area";
export interface Project {
id?: number;
name: string;
description?: string;
active: boolean;
pin_to_sidebar: boolean;
area?: Area;
area_id?: number | null;
}

View file

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

View file

@ -0,0 +1,12 @@
import { Tag } from "./Tag";
export interface Task {
id?: number;
name: string;
status: 'not_started' | 'in_progress' | 'done' | 'archived';
priority?: 'low' | 'medium' | 'high';
due_date?: string;
note?: string;
tags?: Tag[];
project_id?: number;
}

View file

@ -0,0 +1,55 @@
// src/hooks/useFetch.ts
import { useState, useEffect } from 'react';
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
}
const useFetch = <T,>(url: string, options?: RequestInit): UseFetchResult<T> => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true; // To prevent setting state on unmounted component
const controller = new AbortController(); // To handle component unmounting
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to fetch data.');
}
const result: T = await response.json();
if (isMounted) {
setData(result);
}
} catch (err: any) {
if (isMounted) {
if (err.name !== 'AbortError') {
setError(err.message);
}
}
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
// Cleanup function to abort fetch on unmount
return () => {
isMounted = false;
controller.abort();
};
}, [url, JSON.stringify(options)]); // Note: Be cautious with dependencies
return { data, loading, error };
};
export default useFetch;

View file

@ -0,0 +1,16 @@
import useSWR from 'swr';
import { Area } from '../entities/Area';
import { fetcher } from '../utils/fetcher';
const useFetchAreas = () => {
const { data, error, mutate } = useSWR<Area[]>('/api/areas?active=true', fetcher);
return {
areas: data || [],
isLoading: !error && !data,
isError: !!error,
mutate,
};
};
export default useFetchAreas;

View file

@ -0,0 +1,16 @@
// src/hooks/useFetchNotes.ts
import useFetch from './useFetch';
import { Note } from '../entities/Note';
const useFetchNotes = () => {
const { data, loading, error } = useFetch<Note[]>('/api/notes', {
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
return { notes: data || [], loading, error };
};
export default useFetchNotes;

View file

@ -0,0 +1,33 @@
import useSWR from 'swr';
import { Project } from '../entities/Project';
import { fetcher } from '../utils/fetcher';
interface ProjectsAPIResponse {
projects: Project[];
task_status_counts: Record<number, any>;
}
import { useMemo } from 'react';
const useFetchProjects = (activeFilter: string, areaFilter: string) => {
const url = useMemo(() => {
const queryParams = new URLSearchParams();
if (activeFilter !== 'all') queryParams.append('active', activeFilter);
if (areaFilter) queryParams.append('area_id', areaFilter);
return `/api/projects?${queryParams.toString()}`;
}, [activeFilter, areaFilter]);
const { data, error, mutate } = useSWR<ProjectsAPIResponse>(url, fetcher);
return {
projects: data?.projects || [],
taskStatusCounts: data?.task_status_counts || {},
isLoading: !error && !data,
isError: error,
mutate,
};
};
export default useFetchProjects;

View file

@ -0,0 +1,16 @@
// src/hooks/useFetchTags.ts
import useSWR from 'swr';
import { fetcher } from '../utils/fetcher'; // Adjust the path to the fetcher if needed
const useFetchTags = () => {
const { data, error, mutate } = useSWR('/api/tags', fetcher);
return {
tags: data || [],
isLoading: !data && !error,
isError: !!error,
mutate,
};
};
export default useFetchTags;

View file

View file

@ -0,0 +1,73 @@
// src/hooks/useManageAreas.ts
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
import { Area } from '../entities/Area';
const useManageAreas = () => {
const { mutate } = useSWRConfig();
const createArea = useCallback(async (areaData: Partial<Area>) => {
try {
const response = await fetch('/api/areas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(areaData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create area.');
}
const newArea: Area = await response.json();
mutate('/api/areas?active=true', (current: Area[] = []) => [...current, newArea], false);
} catch (error) {
console.error('Error creating area:', error);
throw error;
}
}, [mutate]);
const updateArea = useCallback(async (areaId: number, areaData: Partial<Area>) => {
try {
const response = await fetch(`/api/areas/${areaId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(areaData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update area.');
}
const updatedArea: Area = await response.json();
mutate('/api/areas?active=true', (current: Area[] = []) =>
current.map(area => (area.id === areaId ? updatedArea : area)),
false
);
} catch (error) {
console.error('Error updating area:', error);
throw error;
}
}, [mutate]);
const deleteArea = useCallback(async (areaId: number) => {
try {
const response = await fetch(`/api/areas/${areaId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete area.');
}
mutate('/api/areas?active=true', (current: Area[] = []) =>
current.filter(area => area.id !== areaId),
false
);
} catch (error) {
console.error('Error deleting area:', error);
throw error;
}
}, [mutate]);
return { createArea, updateArea, deleteArea };
};
export default useManageAreas;

View file

@ -0,0 +1,90 @@
// src/hooks/useManageNotes.ts
import useSWR from 'swr';
import { Note } from '../entities/Note';
import { fetcher } from '../utils/fetcher';
import { useCallback } from 'react';
const useManageNotes = () => {
const { data: notes, error, mutate } = useSWR<Note[]>('/api/notes', fetcher);
const createNote = useCallback(
async (noteData: Partial<Note>) => {
const response = await fetch('/api/note', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(noteData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create note.');
}
const newNote: Note = await response.json();
// Optimistically update the cache
mutate([...notes, newNote], false);
},
[mutate, notes]
);
const updateNote = useCallback(
async (noteId: number, noteData: Partial<Note>) => {
const response = await fetch(`/api/note/${noteId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(noteData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update note.');
}
const updatedNote: Note = await response.json();
// Optimistically update the cache
mutate(notes.map((note) => (note.id === noteId ? updatedNote : note)), false);
},
[mutate, notes]
);
const deleteNote = useCallback(
async (noteId: number) => {
const response = await fetch(`/api/note/${noteId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete note.');
}
// Optimistically update the cache
mutate(notes.filter((note) => note.id !== noteId), false);
},
[mutate, notes]
);
return {
notes: notes || [],
isLoading: !error && !notes,
isError: error,
createNote,
updateNote,
deleteNote,
};
};
export default useManageNotes;

View file

@ -0,0 +1,67 @@
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
import { Project } from '../entities/Project';
const useManageProjects = () => {
const { mutate } = useSWRConfig();
const createProject = useCallback(async (projectData: Partial<Project>) => {
try {
const response = await fetch('/api/project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(projectData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create project.');
}
const newProject: Project = await response.json();
mutate('/api/projects', (current: Project[] = []) => [...current, newProject], false);
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
}, [mutate]);
const updateProject = useCallback(async (projectId: number, projectData: Partial<Project>) => {
try {
const response = await fetch(`/api/project/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(projectData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update project.');
}
const updatedProject: Project = await response.json();
mutate('/api/projects', (current: Project[] = []) =>
current.map((project) => (project.id === projectId ? updatedProject : project)), false);
} catch (error) {
console.error('Error updating project:', error);
throw error;
}
}, [mutate]);
const deleteProject = useCallback(async (projectId: number) => {
try {
const response = await fetch(`/api/project/${projectId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete project.');
}
mutate('/api/projects', (current: Project[] = []) =>
current.filter((project) => project.id !== projectId), false);
} catch (error) {
console.error('Error deleting project:', error);
throw error;
}
}, [mutate]);
return { createProject, updateProject, deleteProject };
};
export default useManageProjects;

View file

@ -0,0 +1,71 @@
// src/hooks/useManageTags.ts
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
import { Tag } from '../entities/Tag'; // Adjust the path if needed
const useManageTags = () => {
const { mutate } = useSWRConfig();
const createTag = useCallback(async (tagData: Partial<Tag>) => {
try {
const response = await fetch('/api/tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create tag.');
}
const newTag: Tag = await response.json();
mutate('/api/tags', (current: Tag[] = []) => [...current, newTag], false);
} catch (error) {
console.error('Error creating tag:', error);
throw error;
}
}, [mutate]);
const updateTag = useCallback(async (tagId: number, tagData: Partial<Tag>) => {
try {
const response = await fetch(`/api/tag/${tagId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tagData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update tag.');
}
const updatedTag: Tag = await response.json();
mutate('/api/tags', (current: Tag[] = []) =>
current.map(tag => (tag.id === tagId ? updatedTag : tag)), false
);
} catch (error) {
console.error('Error updating tag:', error);
throw error;
}
}, [mutate]);
const deleteTag = useCallback(async (tagId: number) => {
try {
const response = await fetch(`/api/tag/${tagId}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete tag.');
}
mutate('/api/tags', (current: Tag[] = []) =>
current.filter(tag => tag.id !== tagId), false
);
} catch (error) {
console.error('Error deleting tag:', error);
throw error;
}
}, [mutate]);
return { createTag, updateTag, deleteTag };
};
export default useManageTags;

View file

@ -0,0 +1,77 @@
// hooks/useManageTasks.ts
import { useState } from 'react';
import { Task } from '../entities/Task';
const useManageTasks = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const fetchTasks = async (query: string = '') => {
setIsLoading(true);
setIsError(false);
try {
const response = await fetch(`/api/tasks${query}`);
if (response.ok) {
const data = await response.json();
setTasks(data);
} else {
throw new Error('Failed to fetch tasks.');
}
} catch (error) {
setIsError(true);
} finally {
setIsLoading(false);
}
};
const createTask = async (taskData: Partial<Task>) => {
try {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (response.ok) {
const newTask = await response.json();
setTasks((prevTasks) => [newTask, ...prevTasks]);
}
} catch (error) {
console.error('Error creating task:', error);
}
};
const updateTask = async (taskId: number, taskData: Partial<Task>) => {
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(taskData),
});
if (response.ok) {
setTasks((prevTasks) =>
prevTasks.map((task) => (task.id === taskId ? { ...task, ...taskData } : task))
);
}
} catch (error) {
console.error('Error updating task:', error);
}
};
const deleteTask = async (taskId: number) => {
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
}
} catch (error) {
console.error('Error deleting task:', error);
}
};
return { tasks, isLoading, isError, fetchTasks, createTask, updateTask, deleteTask };
};
export default useManageTasks;

34
app/frontend/index.tsx Normal file
View file

@ -0,0 +1,34 @@
import React from "react";
import { createRoot } from "react-dom/client"; // Import createRoot from react-dom
import { BrowserRouter } from "react-router-dom"; // Import BrowserRouter
import App from "./App";
import { ToastProvider } from "./components/Shared/ToastContext";
// Determine initial dark mode preference
const storedPreference = localStorage.getItem("isDarkMode");
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDarkMode = storedPreference
? storedPreference === "true"
: prefersDarkMode;
// Add or remove the 'dark' class before rendering the app
if (isDarkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
// Get the root DOM element
const container = document.getElementById("root");
// Ensure the root element exists before creating root
if (container) {
const root = createRoot(container); // Use createRoot to create a root
root.render(
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
);
}

View file

@ -0,0 +1,9 @@
/* ./app/frontend/styles/tailwind.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
input:focus, select:focus, textarea:focus {
outline: none;
box-shadow: none;
}

View file

@ -0,0 +1,21 @@
// src/utils/fetcher.ts
export const fetcher = async (url: string) => {
const response = await fetch(url, {
credentials: 'include',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
const errorData = await response.json();
const error = new Error(errorData.error || 'An error occurred while fetching the data.');
// Attach extra info to the error object.
(error as any).info = errorData;
(error as any).status = response.status;
throw error;
}
return response.json();
};

View file

@ -8,8 +8,16 @@ module AuthenticationHelper
end
def require_login
return if ['/login', '/logout'].include? request.path
# Allow requests to '/login' and '/logout' without checking for login
return if ['/login', '/logout', '/api/current_user'].include? request.path
redirect '/login' unless logged_in?
# If the user is not logged in and the request is not an API request, redirect to login
return if logged_in?
if request.xhr? || request.path.start_with?('/api/')
halt 401, { error: 'You must be logged in' }.to_json # For API calls, return a 401 status
else
redirect '/login' # For non-API calls, redirect to login page
end
end
end

View file

@ -2,5 +2,5 @@ class Area < ActiveRecord::Base
belongs_to :user
has_many :projects, dependent: :destroy
validates :name, presence: true
validates :name, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -4,4 +4,5 @@ class Note < ActiveRecord::Base
has_and_belongs_to_many :tags
validates :content, presence: true
validates :title, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -1,3 +1,4 @@
# models/project.rb
class Project < ActiveRecord::Base
belongs_to :user
belongs_to :area, optional: true
@ -7,7 +8,7 @@ class Project < ActiveRecord::Base
scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }
validates :name, presence: true
validates :name, presence: true, uniqueness: { scope: :user_id }
def task_status_counts
status_counts = tasks.group(:status).count

View file

@ -2,4 +2,6 @@ class Tag < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tasks
has_and_belongs_to_many :notes
validates :name, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -4,10 +4,27 @@ class Task < ActiveRecord::Base
has_and_belongs_to_many :tags
enum priority: { low: 0, medium: 1, high: 2 }
enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3 }
enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3, waiting: 4 }
scope :complete, -> { where(status: statuses[:done]) }
scope :incomplete, -> { where.not(status: statuses[:done]) }
scope :due_today, -> { incomplete.where('due_date <= ?', Date.today.end_of_day) }
scope :upcoming, -> { incomplete.where('due_date BETWEEN ? AND ?', Date.today, Date.today + 7.days) }
scope :someday, -> { incomplete.where(due_date: nil) }
scope :next_actions, -> { incomplete.where(due_date: nil, project_id: nil) }
scope :waiting_for, -> { incomplete.where(status: statuses[:waiting]) }
scope :inbox, -> { incomplete.where('due_date IS NULL OR project_id IS NULL') }
validates :name, presence: true
scope :ordered_by_due_date, lambda { |direction = 'asc'|
order(Arel.sql("CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date #{direction}"))
}
scope :with_tag, lambda { |tag_name|
joins(:tags).where(tags: { name: tag_name })
}
scope :by_status, ->(status) { where(status: statuses[status]) }
scope :by_priority, ->(priority) { where(priority: priorities[priority]) }
validates :name, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -1,11 +1,16 @@
class User < ActiveRecord::Base
has_secure_password
has_many :areas
has_many :projects
has_many :tasks
has_many :tags, dependent: :destroy
has_many :tasks, dependent: :destroy
has_many :projects, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :notes, dependent: :destroy
has_many :tags, dependent: :destroy
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true
validates :appearance, inclusion: { in: %w[light dark] }
validates :language, presence: true
validates :timezone, presence: true
# has_one_attached :avatar_image
end

View file

@ -1,42 +1,84 @@
class Sinatra::Application
post '/areas/create' do
area = current_user.areas.create(name: params[:name])
# app.rb or your main Sinatra application file
if area.persisted?
redirect '/'
require 'sinatra'
require 'json'
# Assuming you have a helper method `current_user` to get the authenticated user
# Create a new Area
post '/api/areas' do
content_type :json
begin
request_body = request.body.read
area_data = JSON.parse(request_body, symbolize_names: true)
# Validate required fields
halt 400, { error: 'Area name is required.' }.to_json unless area_data[:name] && !area_data[:name].strip.empty?
# Create new Area
area = current_user.areas.build(name: area_data[:name], description: area_data[:description])
if area.save
status 201
area.to_json
else
@errors = 'There was a problem creating the area.'
redirect '/'
end
end
patch '/areas/:id' do
area = current_user.areas.find_by(id: params[:id])
if area
area.name = params[:name]
if area.save
redirect request.referrer || '/'
else
@errors = 'There was a problem updating the area.'
erb :some_template
end
else
status 404
"Area not found or doesn't belong to the current user."
end
end
delete '/area/:id' do
area = current_user.areas.find_by(id: params[:id])
if area
area.destroy
redirect request.referrer || '/'
else
status 404
@errors = 'Area not found or not owned by the current user.'
status 400
{ error: 'There was a problem creating the area.', details: area.errors.full_messages }.to_json
end
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON.' }.to_json
end
end
get '/api/areas/:id' do
area = current_user.areas.find_by(id: params[:id])
halt 404, { error: "Area not found or doesn't belong to the current user." }.to_json unless area
area.to_json
end
# Update an existing Area
patch '/api/areas/:id' do
content_type :json
begin
area = current_user.areas.find_by(id: params[:id])
halt 404, { error: 'Area not found.' }.to_json unless area
request_body = request.body.read
area_data = JSON.parse(request_body, symbolize_names: true)
# Update Area attributes
area.name = area_data[:name] if area_data[:name]
area.description = area_data[:description] if area_data[:description]
if area.save
status 200
area.to_json
else
status 400
{ error: 'There was a problem updating the area.', details: area.errors.full_messages }.to_json
end
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON.' }.to_json
end
end
# Delete an Area
delete '/api/areas/:id' do
content_type :json
area = current_user.areas.find_by(id: params[:id])
halt 404, { error: 'Area not found.' }.to_json unless area
if area.destroy
status 204
else
status 400
{ error: 'There was a problem deleting the area.' }.to_json
end
end
# Fetch all Areas
get '/api/areas' do
content_type :json
areas = current_user.areas
areas.to_json
end

View file

@ -1,22 +1,50 @@
class Sinatra::Application
get '/login' do
erb :login
end
require 'json'
post '/login' do
@user = User.find_by(email: params[:email])
if @user&.authenticate(params[:password])
session[:user_id] = @user.id
redirect '/'
class Sinatra::Application
# Serve the login page (if needed for non-React or fallback)
# get '/login' do
# erb :login
# end
# Handle login requests (now accepting JSON)
get '/api/current_user' do
content_type :json
if logged_in?
{ user: { email: current_user.email, id: current_user.id } }.to_json
else
logger.warn "Invalid credentials for user with email #{params[:email]}"
@errors = ['Invalid credentials']
erb :login
{ user: nil }.to_json
end
end
post '/login' do
content_type :json
request_payload = begin
JSON.parse(request.body.read)
rescue StandardError
nil
end
if request_payload
email = request_payload['email']
password = request_payload['password']
else
halt 400, { error: 'Invalid login parameters.' }.to_json
end
user = User.find_by(email: email)
if user&.authenticate(password)
session[:user_id] = user.id
status 200
{ user: { email: user.email, id: user.id } }.to_json
else
halt 401, { errors: ['Invalid credentials'] }.to_json
end
end
# Handle logout
get '/logout' do
session.clear
redirect '/login'
end
end
end

View file

@ -13,7 +13,7 @@ class Sinatra::Application
end
end
get '/notes' do
get '/api/notes' do
order_by = params[:order_by] || 'title:asc'
order_column, order_direction = order_by.split(':')
@ -27,65 +27,96 @@ class Sinatra::Application
@base_url = '/notes?'
@base_url += "#{@base_query}&" unless @base_query.empty?
erb :'notes/index'
@notes.to_json(include: :tags)
end
post '/note/create' do
get '/api/note/:id' do
content_type :json
note = current_user.notes.includes(:tags).find_by(id: params[:id])
halt 404, { error: 'Note not found.' }.to_json unless note
# Return the note and its associated tags as JSON
note.to_json(include: :tags)
end
post '/api/note' do
content_type :json
# Parse the request body to extract the JSON data
request_body = request.body.read
note_data = JSON.parse(request_body, symbolize_names: true)
# Extract the attributes from the parsed JSON data
note_attributes = {
title: params[:title],
content: params[:content],
title: note_data[:title],
content: note_data[:content],
user_id: current_user.id
}
if params[:project_id].empty?
# Check for the presence of a project_id
if note_data[:project_id].to_s.empty?
note = current_user.notes.build(note_attributes)
else
project = current_user.projects.find_by(id: params[:project_id])
halt 400, 'Invalid project.' unless project
project = current_user.projects.find_by(id: note_data[:project_id])
halt 400, { error: 'Invalid project.' }.to_json unless project
note = project.notes.build(note_attributes)
end
# Save the note and update its tags
if note.save
update_note_tags(note, params[:tags])
redirect request.referrer || '/'
update_note_tags(note, note_data[:tags])
status 201
note.to_json(include: :tags)
else
halt 400, 'There was a problem creating the note.'
status 400
{ error: 'There was a problem creating the note.', details: note.errors.full_messages }.to_json
end
end
patch '/note/:id' do
patch '/api/note/:id' do
content_type :json
note = current_user.notes.find_by(id: params[:id])
halt 404, 'Note not found.' unless note
halt 404, { error: 'Note not found.' }.to_json unless note
# Parse the request body to get the content, title, and tags
request_body = request.body.read
request_data = JSON.parse(request_body)
note_attributes = {
title: params[:title],
content: params[:content]
title: request_data['title'],
content: request_data['content']
}
if params[:project_id] && !params[:project_id].empty?
project = current_user.projects.find_by(id: params[:project_id])
halt 400, 'Invalid project.' unless project
# Handle project association if provided
if request_data['project_id'] && !request_data['project_id'].to_s.empty?
project = current_user.projects.find_by(id: request_data['project_id'])
halt 400, { error: 'Invalid project.' }.to_json unless project
note.project = project
else
note.project = nil
end
# Update the note and its tags
if note.update(note_attributes)
update_note_tags(note, params[:tags])
redirect request.referrer || '/'
update_note_tags(note, request_data['tags']) # Process tags correctly
note.to_json(include: :tags) # Return updated note with tags
else
halt 400, 'There was a problem updating the note.'
status 400
{ error: 'There was a problem updating the note.', details: note.errors.full_messages }.to_json
end
end
delete '/note/:id' do
delete '/api/note/:id' do
content_type :json
note = current_user.notes.find_by(id: params[:id])
halt 404, 'Note not found.' unless note
halt 404, { error: 'Note not found.' }.to_json unless note
if note.destroy!
redirect '/notes'
if note.destroy
{ message: 'Note deleted successfully.' }.to_json
else
halt 400, 'There was a problem deleting the note.'
status 400
{ error: 'There was a problem deleting the note.' }.to_json
end
end
end

View file

@ -1,67 +1,142 @@
require 'sinatra/namespace'
class Sinatra::Application
get '/projects' do
@projects_with_tasks = current_user.projects.left_joins(:tasks, :area).distinct.order('projects.name ASC') # , areas.name ASC')
# Include the namespace module
register Sinatra::Namespace
@task_status_counts = @projects_with_tasks.each_with_object({}) do |project, counts|
counts[project.id] = project.task_status_counts
# Namespace your routes under /api
namespace '/api' do
before do
content_type :json
end
@grouped_projects = @projects_with_tasks.group_by(&:area)
# Get all projects and associated tasks with JSON response
# Get all projects and associated tasks with JSON response
get '/projects' do
# Parse query parameters for 'active', 'pin_to_sidebar', and 'area_id'
active_param = params[:active]
is_active = active_param == 'true' unless active_param.nil?
erb :'projects/index'
end
pin_to_sidebar_param = params[:pin_to_sidebar]
is_pinned = pin_to_sidebar_param == 'true' unless pin_to_sidebar_param.nil?
get '/project/:id' do
@project = current_user.projects.includes(:tasks).find_by(id: params[:id])
halt 404, 'Project not found' unless @project
area_id_param = params[:area_id]
erb :'projects/show'
end
# Build the query
projects = current_user.projects
.left_joins(:tasks, :area)
.distinct
.order('projects.name ASC')
post '/project/create' do
project = current_user.projects.new(
name: params[:name],
description: params[:description],
area_id: params[:area_id].presence
)
# Apply 'active' filter if provided
projects = projects.where(active: is_active) unless is_active.nil?
if project.save
redirect request.referrer || '/'
else
@errors = 'There was a problem creating the project.'
redirect '/'
# Apply 'pin_to_sidebar' filter if provided
projects = projects.where(pin_to_sidebar: is_pinned) unless is_pinned.nil?
# Apply 'area_id' filter if provided
projects = projects.where(area_id: area_id_param) if area_id_param
# Count task statuses for each project
task_status_counts = projects.each_with_object({}) do |project, counts|
counts[project.id] = project.task_status_counts
end
# Group projects by area
grouped_projects = projects.group_by(&:area)
# Return projects, task counts, and grouped projects as JSON
{
projects: projects.as_json(include: { tasks: {}, area: { only: :name } }),
task_status_counts: task_status_counts,
grouped_projects: grouped_projects.as_json(include: { area: { only: :name } })
}.to_json
end
end
patch '/project/:id' do
project = current_user.projects.find_by(id: params[:id])
# Get a specific project by ID with JSON response
get '/project/:id' do
# Find the project and include associated tasks
project = current_user.projects.includes(:tasks).find_by(id: params[:id])
if project
project.name = params[:name]
project.description = params[:description]
project.area_id = params[:area_id].presence
halt 404, { error: 'Project not found' }.to_json unless project
# Return the project and associated tasks as JSON
project.as_json(include: { tasks: {}, area: { only: %i[id name] } }).to_json
end
# Create a new project with JSON response
post '/project' do
# Parse the request body as JSON
request_body = request.body.read
project_data = begin
JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
# Build a new project with the provided parameters
project = current_user.projects.new(
name: project_data['name'],
description: project_data['description'] || '',
area_id: project_data['area_id'],
active: true,
pin_to_sidebar: false
)
if project.save
redirect "/project/#{project.id}"
status 201
project.as_json.to_json
else
@errors = 'There was a problem updating the project.'
erb :edit_project
status 400
{ error: 'There was a problem creating the project.', details: project.errors.full_messages }.to_json
end
else
status 404
"Project not found or doesn't belong to the current user."
end
end
delete '/project/:id' do
project = current_user.projects.find_by(id: params[:id])
# Update an existing project by ID with JSON response
patch '/project/:id' do
# Find the project by ID
project = current_user.projects.find_by(id: params[:id])
if project
project.destroy
redirect '/projects'
else
status 404
"Project not found or doesn't belong to the current user."
halt 404, { error: 'Project not found.' }.to_json unless project
# Parse the request body as JSON
request_body = request.body.read
project_data = begin
JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
# Update the project with the provided parameters
project.assign_attributes(
name: project_data['name'],
description: project_data['description'],
area_id: project_data['area_id'],
active: project_data['active'],
pin_to_sidebar: project_data['pin_to_sidebar']
)
if project.save
project.as_json.to_json
else
status 400
{ error: 'There was a problem updating the project.', details: project.errors.full_messages }.to_json
end
end
# Delete an existing project by ID with JSON response
delete '/project/:id' do
# Find the project by ID
project = current_user.projects.find_by(id: params[:id])
halt 404, { error: 'Project not found' }.to_json unless project
if project.destroy
{ message: 'Project successfully deleted' }.to_json
else
status 400
{ error: 'There was a problem deleting the project.' }.to_json
end
end
end
end

92
app/routes/tags_routes.rb Normal file
View file

@ -0,0 +1,92 @@
class Sinatra::Application
# Get all tags with JSON response
get '/api/tags' do
content_type :json
# Fetch all tags for the current user
tags = current_user.tags.order('name ASC')
# Return the tags as JSON
tags.as_json(only: %i[id name]).to_json
end
# Get a specific tag by ID with JSON response
get '/api/tag/:id' do
content_type :json
# Find the tag by ID
tag = current_user.tags.find_by(id: params[:id])
# Return a 404 status if the tag is not found
halt 404, { error: 'Tag not found' }.to_json unless tag
# Return the tag as JSON
tag.as_json(only: %i[id name]).to_json
end
# Create a new tag with JSON response
post '/api/tag' do
content_type :json
# Parse the request body to get the tag name
request_body = JSON.parse(request.body.read)
tag = current_user.tags.new(name: request_body['name'])
# Attempt to save the tag
if tag.save
# Return the newly created tag as JSON
status 201
tag.as_json(only: %i[id name]).to_json
else
# Return an error message with a 400 status if the tag creation fails
status 400
{ error: 'There was a problem creating the tag.' }.to_json
end
end
# Update an existing tag by ID with JSON response
patch '/api/tag/:id' do
content_type :json
# Find the tag by ID
tag = current_user.tags.find_by(id: params[:id])
# Return a 404 status if the tag is not found
halt 404, { error: 'Tag not found' }.to_json unless tag
# Parse the request body to get the updated tag name
request_body = JSON.parse(request.body.read)
tag.name = request_body['name']
# Attempt to save the updated tag
if tag.save
# Return the updated tag as JSON
tag.as_json(only: %i[id name]).to_json
else
# Return an error message with a 400 status if the update fails
status 400
{ error: 'There was a problem updating the tag.' }.to_json
end
end
# Delete an existing tag by ID with JSON response
delete '/api/tag/:id' do
content_type :json
# Find the tag by ID
tag = current_user.tags.find_by(id: params[:id])
# Return a 404 status if the tag is not found
halt 404, { error: 'Tag not found' }.to_json unless tag
# Attempt to delete the tag
if tag.destroy
# Return a success message
{ message: 'Tag successfully deleted' }.to_json
else
# Return an error message with a 400 status if deletion fails
status 400
{ error: 'There was a problem deleting the tag.' }.to_json
end
end
end

View file

@ -1,155 +1,188 @@
module Sinatra
class Application
def update_task_tags(task, tags_json)
return if tags_json.blank?
def update_task_tags(task, tags_data)
return if tags_data.nil?
begin
tag_names = JSON.parse(tags_json).map { |tag| tag['value'] }.uniq
tags = tag_names.map do |name|
current_user.tags.find_or_create_by(name: name)
end
task.tags = tags
rescue JSON::ParserError
puts "Failed to parse JSON for tags: #{tags_json}"
end
# Filter out nil or empty tag names, then ensure uniqueness with `uniq`
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
# Find or create tags based on the tag names
existing_tags = Tag.where(name: tag_names)
new_tags = tag_names - existing_tags.pluck(:name)
created_tags = new_tags.map { |name| Tag.create(name: name) }
# Associate only unique tags with the task
task.tags = (existing_tags + created_tags).uniq
end
get '/tasks' do
# Base query with necessary joins
base_query = current_user.tasks.includes(:project, :tags)
get '/api/tasks' do
content_type :json
# Apply filters based on due_date and status
# Start with a base query for tasks belonging to the current user
@tasks = current_user.tasks.includes(:project, :tags)
# Filter tasks based on the provided `type` parameter
@tasks = case params[:type]
when 'today'
base_query
.where('status = ? OR (status = ? AND due_date <= ?)',
Task.statuses[:in_progress],
Task.statuses[:not_started],
Date.today.end_of_day)
@tasks.due_today
when 'upcoming'
base_query
.where('(status = ? OR status = ?) AND due_date BETWEEN ? AND ?',
Task.statuses[:in_progress],
Task.statuses[:not_started],
Date.today,
Date.today + 7.days)
when 'never'
base_query.incomplete.where(due_date: nil)
@tasks.upcoming
when 'next'
@tasks.next_actions
when 'inbox'
@tasks.inbox
when 'someday'
@tasks.someday
when 'waiting'
@tasks.waiting_for
else
params[:status] == 'done' ? base_query.complete : base_query.incomplete
params[:status] == 'done' ? @tasks.complete : @tasks.incomplete
end
# Apply ordering
# Apply ordering by due_date or other columns
if params[:order_by]
order_column, order_direction = params[:order_by].split(':')
order_direction ||= 'asc'
@tasks = if order_column == 'due_date'
@tasks.order(Arel.sql("CASE WHEN tasks.due_date IS NULL THEN 1 ELSE 0 END, tasks.due_date #{order_direction}"))
@tasks.ordered_by_due_date(order_direction)
else
@tasks.order("tasks.#{order_column} #{order_direction}")
@tasks.order("#{order_column} #{order_direction}")
end
end
# Filter by tag if provided
if params[:tag]
tagged_task_ids = Tag.joins(:tasks).where(name: params[:tag],
tasks: { user_id: current_user.id }).select('tasks.id')
@tasks = @tasks.where(id: tagged_task_ids)
end
@tasks = @tasks.with_tag(params[:tag]) if params[:tag]
@tasks = @tasks.left_joins(:tags).distinct
erb :'tasks/index'
# Return the tasks in JSON format with their tags and project
@tasks.to_json(include: { tags: { only: %i[id name] }, project: { only: :name } })
end
post '/task/create' do
post '/api/task' do
content_type :json
# Parse the request body as JSON
request_body = request.body.read
task_data = begin
JSON.parse(request_body)
rescue JSON::ParserError => e
halt 400, { error: 'Invalid JSON format.' }.to_json
end
# Build task attributes
task_attributes = {
name: params[:name],
priority: params[:priority],
due_date: params[:due_date],
status: params[:status] || Task.statuses[:not_started],
note: params[:note],
name: task_data['name'],
priority: task_data['priority'] || 'medium', # Default priority
due_date: task_data['due_date'],
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],
user_id: current_user.id
}
if params[:project_id].blank?
task = current_user.tasks.build(task_attributes)
else
project = current_user.projects.find_by(id: params[:project_id])
halt 400, 'Invalid project.' unless project
task = project.tasks.build(task_attributes)
end
# Create and assign task to variable
value = task_data['project_id']
task = if value.nil? || value.to_s.strip.empty?
# Assign the built task to the 'task' variable
current_user.tasks.build(task_attributes)
else
project = current_user.projects.find_by(id: value)
halt 400, { error: 'Invalid project.' }.to_json unless project
project.tasks.build(task_attributes)
end
# Save task and respond
if task.save
update_task_tags(task, params[:tags])
redirect request.referrer || '/'
update_task_tags(task, task_data['tags'])
status 201
task.to_json(include: { tags: { only: :name }, project: { only: :name } })
else
halt 400, 'There was a problem creating the task.'
# Collect error messages for better debugging
errors = task.errors.full_messages
halt 400, { error: 'There was a problem creating the task.', details: errors }.to_json
end
end
patch '/task/:id' do
patch '/api/task/:id' do
content_type :json
puts "Request to update task with ID: #{params[:id]}"
puts "Current user: #{current_user&.id}"
# Parse the request body as JSON
request_body = request.body.read
task_data = begin
JSON.parse(request_body)
rescue JSON::ParserError => e
halt 400, { error: 'Invalid JSON format.' }.to_json
end
# Find the task belonging to the current user
task = current_user.tasks.find_by(id: params[:id])
halt 404, 'Task not found.' unless task
halt 404, { error: 'Task not found.' }.to_json unless task
# Build task attributes
task_attributes = {
name: params[:name],
priority: params[:priority],
status: params[:status] || Task.statuses[:not_started],
note: params[:note],
due_date: params[:due_date]
name: task_data['name'], # Get the name from the JSON body
priority: task_data['priority'],
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],
due_date: task_data['due_date']
}
if params[:project_id] && !params[:project_id].empty?
project = current_user.projects.find_by(id: params[:project_id])
halt 400, 'Invalid project.' unless project
# Safely handle project_id
if task_data['project_id'] && !task_data['project_id'].to_s.strip.empty?
project = current_user.projects.find_by(id: task_data['project_id'])
halt 400, { error: 'Invalid project.' }.to_json unless project
task.project = project
else
task.project = nil
end
# Update task attributes
if task.update(task_attributes)
update_task_tags(task, params[:tags])
redirect request.referrer || '/'
update_task_tags(task, task_data['tags'])
task.to_json(include: { tags: { only: :name }, project: { only: :name } })
else
halt 400, 'There was a problem updating the task.'
# Collect error messages for better debugging
errors = task.errors.full_messages
halt 400, { error: 'There was a problem updating the task.', details: errors }.to_json
end
end
patch '/task/:id/toggle_completion' do
patch '/api/task/:id/toggle_completion' do
content_type :json
task = current_user.tasks.find_by(id: params[:id])
halt 404, { error: 'Task not found.' }.to_json unless task
if task
new_status = if task.done?
task.note.present? ? :in_progress : :not_started
else
:done
end
task.status = new_status
new_status = if task.done?
task.note.present? ? :in_progress : :not_started
else
:done
end
task.status = new_status
if task.save
task.to_json
else
status 422
{ error: 'Unable to update task' }.to_json
end
if task.save
task.to_json
else
status 400
{ error: 'Task not found' }.to_json
status 422
{ error: 'Unable to update task' }.to_json
end
end
delete '/task/:id' do
delete '/api/task/:id' do
content_type :json
task = current_user.tasks.find_by(id: params[:id])
halt 404, 'Task not found.' unless task
halt 404, { error: 'Task not found.' }.to_json unless task
if task.destroy
redirect request.referrer || '/'
status 200
{ message: 'Task successfully deleted' }.to_json
else
halt 400, 'There was a problem deleting the task.'
halt 400, { error: 'There was a problem deleting the task.' }.to_json
end
end
end

View file

@ -0,0 +1,59 @@
# app/controllers/user_routes.rb
module Sinatra
class Application
# GET /api/profile - Fetch the current user's profile
get '/api/profile' do
content_type :json
user = current_user
if user
user.to_json(only: %i[id email appearance language timezone avatar_image])
else
halt 404, { error: 'Profile not found.' }.to_json
end
end
# PATCH /api/profile - Update the current user's profile
patch '/api/profile' do
content_type :json
begin
request_payload = JSON.parse(request.body.read)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
user = current_user
halt 404, { error: 'Profile not found.' }.to_json if user.nil?
# Permit only allowed parameters
allowed_params = {}
allowed_params[:appearance] = request_payload['appearance'] if request_payload.key?('appearance')
allowed_params[:language] = request_payload['language'] if request_payload.key?('language')
allowed_params[:timezone] = request_payload['timezone'] if request_payload.key?('timezone')
allowed_params[:avatar_image] = request_payload['avatar_image'] if request_payload.key?('avatar_image')
# Handle avatar image upload if using Active Storage
# Uncomment if using Active Storage
# if request_payload['avatar_image']
# begin
# decoded_image = Base64.decode64(request_payload['avatar_image'].split(',')[1])
# user.avatar_image.attach(io: StringIO.new(decoded_image), filename: "avatar_#{Time.now.to_i}.png", content_type: 'image/png')
# rescue => e
# halt 400, { error: 'Invalid avatar image format.' }.to_json
# end
# end
if user.update(allowed_params)
# If handling tags on user profile, implement here
# For now, we're not associating tags with user profiles
user.to_json(only: %i[id email appearance language timezone avatar_image])
else
status 400
{ error: 'Failed to update profile.', details: user.errors.full_messages }.to_json
end
end
end
end

View file

@ -1,19 +0,0 @@
<div class="modal modal-lg fade" id="editAreaModal<%= area.id %>" tabindex="-1" aria-labelledby="editAreaModalLabel<%= area.id %>" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editAreaModalLabel">Edit Area</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<%= partial :'areas/_form', locals: {
area: area,
form_id: 'editAreaForm',
action_url: "/areas/#{area.id}",
method: 'patch',
button_text: 'Update Area'
} %>
</div>
</div>
</div>
</div>

View file

@ -1,12 +0,0 @@
<form id="<%= form_id %>" action="<%= action_url %>" method="post">
<% unless area.new_record? %>
<input type="hidden" name="_method" value="<%= method %>">
<% end %>
<div class="mb-3">
<label for="areaName" class="form-label">Area Name:</label>
<input type="text" class="form-control" id="areaName" name="name" value="<%= area.name %>" required>
</div>
<button type="submit" class="btn btn-primary"><%= button_text %></button>
</form>

View file

@ -1,19 +0,0 @@
<div class="modal modal-lg fade" id="newAreaModal" tabindex="-1" aria-labelledby="newAreaModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="newAreaModalLabel">New Area</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<%= partial :'areas/_form', locals: {
area: Area.new,
form_id: 'newAreaForm',
action_url: '/areas/create',
method: 'post',
button_text: 'Create Area'
} %>
</div>
</div>
</div>
</div>

View file

@ -1,17 +0,0 @@
<h2 class="mb-5"><i class="bi bi-inbox-fill ms-3 me-2"></i> Inbox</h2>
<% unless params[:status] == 'done' %>
<%= partial :'tasks/_minimal_form', locals: { task: Task.new } %>
<% end %>
<div class="mx-3 mb-2 bg-white task-list">
<% if @tasks %>
<% @tasks.each do |task| %>
<div id="edit_task_form_<%= task.id %>" class="d-none">
<%= partial :'tasks/_form', locals: { task: task } %>
</div>
<%= partial :'tasks/_task', locals: { task: task } %>
<% end %>
<% end %>
</div>
<%= partial :'tasks/_edit_task_modal' %>

10
app/views/index.erb Normal file
View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Sinatra React App</title>
</head>
<body>
<div id="root"></div>
<script src="/js/bundle.js"></script>
</body>
</html>

View file

@ -2,28 +2,18 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>tu|du|di</title>
<title>tududi</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@yaireo/tagify/dist/tagify.css" rel="stylesheet">
<link rel="stylesheet" href="/css/app.css">
<%# <link rel="stylesheet" href="/css/app.css"> %>
</head>
<body class="container">
<div class="row flex-nowrap">
<% if current_user %>
<%= partial :'sidebar' %>
<div class="px-md-4 pt-4 mb-3 main-content col-md-9 col-lg-10">
<%= yield %>
</div>
<% else %>
<div class="px-md-4 pt-4 mb-3 col-md-12">
<%= yield %>
</div>
<% end %>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script> </body>
<body>
<div id="root"></div> <!-- React will render here -->
<script src="/js/bundle.js"></script> <!-- Load the React bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@yaireo/tagify@latest/dist/tagify.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View file

@ -1,27 +0,0 @@
<div class="container mt-5">
<h2 class="mb-4 text-center" style="margin-top: 200px;">tududi Login</h2>
<% if @errors %>
<div class="alert alert-danger w-50 mx-auto" role="alert">
<ul class="mb-0">
<% @errors.each do |error| %>
<li><%= error %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="card mx-auto shadow-sm w-50">
<div class="card-body">
<form action="/login" method="post">
<div class="mb-3">
<label for="email" class="form-label">Email:</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password:</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary mt-4">Login</button>
</form>
</div>
</div>
</div>

View file

@ -1,13 +0,0 @@
<div class="modal modal-lg fade" id="editNoteModal" tabindex="-1" aria-labelledby="editNoteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editNoteModalLabel">Edit Note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="editNoteFormContainer"></div>
</div>
</div>
</div>
</div>

View file

@ -1,62 +0,0 @@
<% form_action = note.new_record? ? '/note/create' : "/note/#{note.id}" %>
<% form_id ||= 'noteForm' %>
<% form_method = note.new_record? ? 'post' : 'patch' %>
<form id="<%= form_id %>" action="<%= form_action %>" method="post">
<% unless note.new_record? %>
<input type="hidden" name="_method" value="<%= form_method %>">
<% end %>
<fieldset>
<div class="row mb-3">
<div class="col-md-12">
<div class="input-group input-group-lg">
<input type="text" id="note_name_<%= note.id || 'new_' + context %>" name="title" value="<%= note.title %>" class="form-control" placeholder="+ Add Title" required>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<label for="note_project" class="form-label">Project (optional):</label>
<select id="note_project_<%= note.id || 'new_' + context %>" name="project_id" class="form-select">
<option value="">No Project</option>
<% current_user.projects.each do |project| %>
<option value="<%= project.id %>" <%= 'selected' if note.project_id == project.id %>><%= project.name %></option>
<% end %>
</select>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<textarea rows="10" id="note_content_<%= note.id || 'new_' + context %>" name="content" class="form-control no-focus-outline" rows="5" placeholder="Note content..." required><%= note.content %></textarea>
</div>
</div>
<div class="row mb-3">
<div class="col-md-12">
<input name="tags" id="note_tags_<%= note.id || 'new_' + context %>" class="form-control" value="<%= note.tags&.map(&:name)&.join(',') %>" placeholder="Add Tags">
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<%= note.new_record? ? 'Create Note' : 'Update Note' %>
</button>
<% unless note.new_record? %>
<button type="submit" class="btn btn-danger" onclick="deleteNote('<%= note.id %>')">
<i class="bi bi-trash"></i>
</button>
<% end %>
</div>
</fieldset>
</form>
<% if !note.new_record? %>
<form id="delete_note_<%= note.id %>" action="/note/<%= note.id %>" method="post" class="d-none">
<input type="hidden" name="_method" value="delete">
</form>
<% end %>
<script>
function deleteNote(noteId) {
if (confirm('Are you sure you want to delete this note?')) {
event.preventDefault();
var form = document.getElementById('delete_note_' + noteId);
form.submit();
}
}
</script>

View file

@ -1,13 +0,0 @@
<div class="modal modal-lg fade" id="newNoteModal" tabindex="-1" aria-labelledby="newNoteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="newNoteModalLabel">New Note</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<%= partial :'notes/_form', locals: { note: Note.new, form_action: '/note/create', form_id: 'newNoteForm', context: context } %>
</div>
</div>
</div>
</div>

View file

@ -1,29 +0,0 @@
<div class="px-3 py-2 d-flex align-items-center note-item" data-note-id="<%= note.id || 'new' %>">
<i class="fs-6 bi-journal-text me-2"></i>
<div class="row flex-grow-1 align-items-center">
<div class="col-md-4">
<div class="">
<a href="#" class="link-dark text-decoration-none">
<%= note.title %>
</a>
<% if note.tags.any? %>
<div class="ms-3 opacity-75 d-inline-block">
<% note.tags.each do |tag| %>
<% tag_url = "#{@base_url}tag=#{tag.name}" %>
<a href="<%= tag_url %>" class="badge bg-primary-subtle link-primary text-decoration-none rounded">
<%= tag.name %>
</a>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="col-md-3">
<% if note.project && params[:id].blank? %>
<a href="/project/<%= note.project.id %>" class="badge border border-secondary text-decoration-none link-dark">
<%= note.project.name %>
</a>
<% end %>
</div>
</div>
</div>

View file

@ -1,47 +0,0 @@
<h2 class="mb-5"><i class="bi bi-journal-text ms-3 me-2"></i> Notes</h2>
<div class="d-flex justify-content-between align-items-center mb-2 px-3">
<h4 class="mt-2 fw-bold">Notes</h4>
<div class="d-flex align-items-center">
<% if params[:tag] %>
<span class="badge bg-primary-subtle text-primary me-2" style="font-size: 13px">
<i class="bi bi-tag-fill me-1 opacity-50"></i><%= params[:tag] %>
<a href="<%= url_without_tag %>" class="text-decoration-none text-dark ms-1">
<i class="bi bi-x text-primary"></i>
</a>
</span>
<% end %>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" id="orderNotesByDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-sort-alpha-down me-2"></i> <%= order_name(params[:order_by]) %>
</button>
<ul class="dropdown-menu" aria-labelledby="orderNotesByDropdown">
<% ['title:asc', 'created_at:desc'].each do |order| %>
<li><a class="dropdown-item small" href="<%= "/notes?#{request.query_string}&order_by=#{order}" %>"><%= order.split(':').first.capitalize.gsub('_', ' ') %></a></li>
<% end %>
</ul>
</div>
</div>
</div>
<div class="rounded py-2 px-3 mx-3 d-flex align-items-center border border-black"
data-bs-toggle="collapse"
data-bs-target="#newNoteForm"
aria-expanded="false"
aria-controls="newNoteForm"
style="cursor: pointer"
data-context="notes">
+ <span class="ms-2">Add note</span>
</div>
<div class="collapse" id="newNoteForm">
<div class="card rounded shadow-sm mt-2 p-4 mx-3">
<%= partial :'notes/_form', locals: {note: Note.new, context: 'notes'} %>
</div>
</div>
<div class="mx-3 my-2 note-list bg-white">
<% @notes.each do |note| %>
<div id="edit_note_form_<%= note.id %>" class="d-none">
<%= partial :'notes/_form', locals: { note: note } %>
</div>
<%= partial :'notes/_note', locals: {note: note} %>
<% end %>
</div>
<%= partial :'notes/_edit_note_modal' %>

View file

@ -1,39 +0,0 @@
<% projects.each do |project| %>
<% counts = @task_status_counts[project.id] %>
<div class="col-md-4 mb-3">
<a class="text-decoration-none project-card" href="/project/<%= project.id %>">
<div class="card shadow-sm" style="min-height: 177px;">
<div class="d-flex flex-column justify-content-between h-100">
<div>
<div class="rounded" style="height: 100px;"></div>
<div class="card-body p-0">
<div class="card-footer p-0">
<div class="progress rounded-0" style="height: 2px;">
<div class="progress-bar" role="progressbar" style="width: <%= project.progress_percentage %>%" aria-valuenow="<%= project.progress_percentage %>" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<h5 class="card-title px-3 pt-3 pb-0 mb-1">
<%= project.name.length > 20 ? project.name.upcase[0,25] + "..." : project.name.upcase %>
</h5>
<div class="card-text px-3 small opacity-50">
<%= counts[:total] %> Tasks
<% if counts[:in_progress] > 0 %>
, <i class="bi bi-circle-fill text-success me-1" style="font-size: 0.5em; position: relative; top: -0.3em;"></i> <%= counts[:in_progress] %> in progress
<% end %>
</div>
</div>
</div>
</div>
</div>
</a>
</div>
<% end %>
<div class="col-md-4 mb-3">
<a class="text-decoration-none project-card" href="#" data-bs-toggle="modal" data-bs-target="#newProjectModal">
<div class="card shadow-sm p-0 opacity-25" style="min-height: 177px;">
<div class="card-body rounded px-0 p-0 text-center">
<i class="bi bi-plus opacity-25" style="font-size: 72px; line-height: 175px;"></i>
</div>
</div>
</a>
</div>

View file

@ -1,13 +0,0 @@
<div class="modal modal-lg fade" id="editProjectModal" tabindex="-1" aria-labelledby="editProjectModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editProjectModalLabel">Edit Project</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<%= partial :'projects/_form', locals: { project: @project, form_action: "/project/#{@project.id}", form_id: 'editProjectForm' } %>
</div>
</div>
</div>
</div>

View file

@ -1,28 +0,0 @@
<% form_action ||= "/projects/#{project.id}" %>
<% form_id ||= 'projectForm' %>
<% form_method = project.new_record? ? 'post' : 'patch' %>
<form id="<%= form_id %>" action="<%= form_action %>" method="post">
<% unless project.new_record? %>
<input type="hidden" name="_method" value="<%= form_method %>">
<% end %>
<div class="mb-3">
<label for="projectName" class="form-label">Project Name:</label>
<input type="text" class="form-control" id="projectName" name="name" value="<%= project.name %>" required>
</div>
<div class="mb-3">
<label for="projectDescription" class="form-label">Description:</label>
<textarea class="form-control" id="projectDescription" name="description" rows="3"><%= project.description %></textarea>
</div>
<div class="mb-3">
<label for="projectArea" class="form-label">Area (optional):</label>
<select class="form-select" id="projectArea" name="area_id">
<option value="">No Area</option>
<% current_user.areas.each do |area| %>
<option value="<%= area.id %>" <%= 'selected' if project.area_id == area.id %>><%= area.name %></option>
<% end %>
</select>
</div>
<div class="">
<button type="submit" class="btn btn-primary"><%= project.new_record? ? 'Create Project' : 'Update Project' %></button>
</div>
</form>

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