Merge pull request #23 from chrisvel/feature-UI-upgrade

Upgrade to React.js - New layout
This commit is contained in:
Chris 2024-11-06 09:22:42 +02:00 committed by GitHub
commit aacf7deb37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 21047 additions and 1278 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,23 +1,55 @@
# Stage 1: Build the React frontend
FROM node:16 AS frontend-builder
WORKDIR /app
# Copy and install frontend dependencies
COPY package*.json ./
RUN npm install
# Copy the rest of the frontend code
COPY . .
# Build the frontend assets
RUN npm run build
# Stage 2: Build the Sinatra backend
FROM ruby:3.2.2-slim
RUN apt-get update -qq && apt-get install -y build-essential libsqlite3-dev openssl libffi-dev libpq-dev
# Install necessary packages
RUN apt-get update -qq && apt-get install -y \
build-essential \
libsqlite3-dev \
openssl \
libffi-dev \
libpq-dev
WORKDIR /usr/src/app
# Copy and install backend dependencies
COPY Gemfile* ./
RUN bundle config set without 'development test' && bundle install
# Copy the backend code
COPY . .
# Remove any existing development databases
RUN rm -f db/development*
# Copy built frontend assets from the frontend builder stage
COPY --from=frontend-builder /app/public ./public
# Expose the application port
EXPOSE 9292
# Set environment variables
ENV RACK_ENV=production
ENV TUDUDI_INTERNAL_SSL_ENABLED=false
# Generate SSL certificates
RUN mkdir -p certs && \
openssl req -x509 -newkey rsa:4096 -keyout certs/server.key -out certs/server.crt -days 365 -nodes -subj '/CN=localhost'
openssl req -x509 -newkey rsa:4096 -keyout certs/server.key -out certs/server.crt \
-days 365 -nodes -subj '/CN=localhost'
CMD rake db:migrate; puma -C app/config/puma.rb
# Run database migrations and start the Puma server
CMD rake db:migrate && puma -C app/config/puma.rb

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

116
README.md
View file

@ -1,31 +1,41 @@
# tududi
# 📝 tududi
`tududi` is a task and project management web application built with Sinatra. It allows users to efficiently manage their tasks and projects, categorize them into different areas, and track due dates. `tududi` is designed to be intuitive and easy to use, providing a seamless experience for personal productivity.
`tududi` is a task and project management web application that allows users to efficiently manage their tasks and projects, categorize them into different areas, and track due dates. It is designed to be intuitive and easy to use, providing a seamless experience for personal productivity.
![image](screenshots/all-light.png)
![image](screenshots/all-dark.png)
![Light Mode Screenshot](screenshots/all-light.png)
## Features
![Dark Mode Screenshot](screenshots/all-dark.png)
- **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.
- **Quick Notes**: Create, update, delete or assign text notes to projects.
- **Tags**: Create tags for tasks and notes.
## 🚀 How It Works
This app allows users to manage their tasks, projects, areas, notes, and tags in an organized way. Users 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.
- **Quick Notes**: Create, update, delete, or assign text notes to projects.
- **Tags**: Create tags for tasks and notes to enhance organization.
- **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.
- **Responsive Design (in progress)**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones.
- **Responsive Design**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones.
## Getting Started
## 🗺️ Roadmap
Check out our [GitHub Project](https://github.com/users/chrisvel/projects/2) for planned features and progress.
## 🛠️ Getting Started
### Prerequisites
Before you begin, ensure you have met the following requirements:
Before you begin, ensure you have the following installed:
- Ruby (version 3.2.2 or higher)
- Sinatra
- SQLite3
- Puma
- ReactJS
### Installation
### 🏗 Installation
To install `tududi`, follow these steps:
@ -42,51 +52,47 @@ To install `tududi`, follow these steps:
bundle install
```
#### SSL setup
### 🔒 SSL Setup
1. Create and enter the directory:
```bash
mkdir certs
```
2. Navigate to the certs directory:
```bash
cd certs
```
2. Create the key and cert:
```bash
openssl genrsa -out server.key 2048
openssl req -new -x509 -key server.key -out server.crt -days 365
```
### DB setup
1. Execute the migrations
### 📂 Database Setup
```bash
rake db:migrate
```
Execute the migrations:
### Create your user
1. Open console
```bash
rake db:migrate
```
### 👤 Create Your User
1. Open the console:
```bash
rake console
```
2. Add the user
2. Add the user:
```ruby
User.create(email: "myemail@somewhere.com", password: "awes0meHax0Rp4ssword")
```
### Usage
### 🚀 Usage
To start the application, run the following command in your terminal:
To start the application, run:
```bash
puma -C app/config/puma.rb
```
### Docker
### 🐋 Docker
Pull the latest image:
@ -94,66 +100,58 @@ Pull the latest image:
docker pull chrisvel/tududi:0.20
```
In order to start the docker container you need 3 enviromental variables:
Set up the necessary environment variables:
```bash
TUDUDI_USER_EMAIL
TUDUDI_USER_PASSWORD
TUDUDI_SESSION_SECRET
TUDUDI_INTERNAL_SSL_ENABLED
```
- `TUDUDI_USER_EMAIL`
- `TUDUDI_USER_PASSWORD`
- `TUDUDI_SESSION_SECRET`
- `TUDUDI_INTERNAL_SSL_ENABLED`
**PLEASE NOTE:** I am generating a new SSL certificate inside the Dockerfile. There will be an option to create and link an externally generated one in the future - at this stage I am doing this for simplicity.
1. (optional - only If you want to use the pre-generated SSL Certificate) Create a random session secret and copy the hash to use it as a `TUDUDI_SESSION_SECRET`:
1. (Optional) Create a random session secret:
```bash
openssl rand -hex 64
```
You will also have to set `TUDUDI_INTERNAL_SSL_ENABLED=true` in the docker command below.
2. Run the docker command with your produced hash at the previous step:
2. Run the Docker container:
```bash
docker run \
-e TUDUDI_USER_EMAIL=myemail@example.com \
-e TUDUDI_USER_PASSWORD=mysecurepassword \
-e TUDUDI_SESSION_SECRET=3337c138d17ac7acefa412e5db0d7ef6540905b198cc28c5bf0d11e48807a71bdfe48d82ed0a0a6eb667c937cbdd1db3e1e6073b3148bff37f73cc6398a39671 \
-e TUDUDI_SESSION_SECRET=your_generated_hash_here \
-e TUDUDI_INTERNAL_SSL_ENABLED=false \
-v ~/tududi_db:/usr/src/app/tududi_db \
-p 9292:9292 \
-d chrisvel/tududi:0.20
-d chrisvel/tududi:0.30
```
3. Navigate to https://localhost:9292 and fill in your email and password.
4. Enjoy
3. Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials.
### Testing
### 🔍 Testing
To run tests:
To run tests, execute:
```bash
```bash
bundle exec ruby -Itest test/test_app.rb
```
Open your browser and navigate to `http://localhost:9292` to access the application and login with the email and the password you created.
## Contributing
## 🤝 Contributing
Contributions to `tududi` are welcome. To contribute:
1. Fork the repository.
2. Create a new branch (`git checkout -b feature/AmazingFeature`).
2. Create a new branch (\`git checkout -b feature/AmazingFeature\`).
3. Make your changes.
4. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
5. Push to the branch (`git push origin feature/AmazingFeature`).
4. Commit your changes (\`git commit -m 'Add some AmazingFeature'\`).
5. Push to the branch (\`git push origin feature/AmazingFeature\`).
6. Open a pull request.
## License
## 📜 License
This project is licensed under the [MIT License](LICENSE).
This project is licensed for free personal use, with consent required for commercial use. Refer to the LICENSE for further details.
## Contact
## 📬 Contact
If you have any questions or comments about `tududi`, please feel free to [open an issue](https://github.com/chrisvel/tududi/issues) or contact the developer directly.
For questions or comments, please [open an issue](https://github.com/chrisvel/tududi/issues) or contact the developer directly.
---

46
app.rb
View file

@ -11,13 +11,16 @@ require './app/models/tag'
require './app/models/note'
require './app/helpers/authentication_helper'
require './app/helpers/task_helper'
require './app/routes/authentication_routes'
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
@ -29,8 +32,11 @@ 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
secure_flag = production? && ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true'
set :sessions, httponly: true,
secure: secure_flag,
expire_after: 2_592_000,
same_site: secure_flag ? :none : :lax
set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) }
# Auto-create user if not exists
@ -49,7 +55,20 @@ before do
require_login
end
helpers TaskHelper
configure do
enable :cross_origin
end
before do
response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8080'
response.headers['Access-Control-Allow-Credentials'] = 'true'
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 do
def current_path
@ -87,7 +106,7 @@ helpers do
def update_query_params(key, value)
uri = URI(request.url)
params = Rack::Utils.parse_nested_query(uri.query)
params[key] = value # Update or add the key-value pair
params[key] = value
Rack::Utils.build_query(params)
end
@ -100,17 +119,12 @@ helpers do
end
end
get '/' do
redirect '/tasks?due_date=today'
get '/*' do
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

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

@ -0,0 +1,133 @@
import React, { useEffect, useState } from "react";
import {
Routes,
Route,
useNavigate,
Navigate,
useLocation,
} from "react-router-dom";
import Login from "./components/Login";
import Tasks from "./components/Tasks";
import NotFound from "./components/Shared/NotFound";
import ProjectDetails from "./components/Project/ProjectDetails";
import Projects from "./components/Projects";
import AreaDetails from "./components/Area/AreaDetails";
import Areas from "./components/Areas";
import TagDetails from "./components/Tag/TagDetails";
import Tags from "./components/Tags";
import Notes from "./components/Notes";
import NoteDetails from "./components/Note/NoteDetails";
import ProfileSettings from "./components/Profile/ProfileSettings";
import Layout from "./Layout";
import { DataProvider } from "./contexts/DataContext";
import { User } from "./entities/User";
const App: React.FC = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const location = useLocation();
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]);
const toggleDarkMode = () => {
const newValue = !isDarkMode;
setIsDarkMode(newValue);
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
};
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
const storedPreference = localStorage.getItem("isDarkMode");
return storedPreference !== null
? storedPreference === "true"
: window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
const updateTheme = () => {
document.documentElement.classList.toggle("dark", isDarkMode);
};
updateTheme();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const mediaListener = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("isDarkMode")) {
setIsDarkMode(e.matches);
}
};
mediaQuery.addEventListener("change", mediaListener);
return () => mediaQuery.removeEventListener("change", mediaListener);
}, [isDarkMode]);
useEffect(() => {
if (currentUser && location.pathname === "/") {
navigate("/tasks?type=today", { replace: true });
}
}, [currentUser, location.pathname, navigate]);
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}
setCurrentUser={setCurrentUser} // Make sure to pass this down
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;

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

@ -0,0 +1,337 @@
import React, { useState, useEffect } from "react";
import Navbar from "./components/Navbar";
import Sidebar from "./components/Sidebar";
import "./styles/tailwind.css";
import ProjectModal from "./components/Project/ProjectModal";
import NoteModal from "./components/Note/NoteModal";
import AreaModal from "./components/Area/AreaModal";
import TagModal from "./components/Tag/TagModal";
import { Note } from "./entities/Note";
import { Area } from "./entities/Area";
import { Tag } from "./entities/Tag";
import { Project } from "./entities/Project";
import { useDataContext } from "./contexts/DataContext";
import { User } from "./entities/User";
interface LayoutProps {
currentUser: User;
isDarkMode: boolean;
setCurrentUser: React.Dispatch<React.SetStateAction<User|null>>;
toggleDarkMode: () => void;
children: React.ReactNode;
}
const Layout: React.FC<LayoutProps> = ({
currentUser,
setCurrentUser,
isDarkMode,
toggleDarkMode,
children,
}) => {
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
const [isTagModalOpen, setIsTagModalOpen] = useState(false);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const {
tags,
areas,
notes,
isLoading,
isError,
createNote,
updateNote,
deleteNote,
createArea,
updateArea,
deleteArea,
createTag,
updateTag,
deleteTag,
createProject,
updateProject,
deleteProject,
} = useDataContext();
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(
window.innerWidth >= 1024
);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1024) {
setIsSidebarOpen(true);
} else {
setIsSidebarOpen(false);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
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);
};
const handleSaveNote = async (noteData: Note) => {
try {
if (noteData.id) {
await updateNote(noteData.id, {
title: noteData.title,
content: noteData.content,
tags: noteData.tags?.map((tag) => tag.name),
project_id: noteData.project?.id,
});
} else {
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();
};
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();
};
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();
};
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();
};
const mainContentMarginLeft = isSidebarOpen ? "ml-64" : "ml-0";
if (isLoading) {
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
{/* Navbar */}
<Navbar
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
currentUser={currentUser}
setCurrentUser={setCurrentUser}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
/>
{/* Sidebar */}
<Sidebar
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
notes={notes}
areas={areas}
tags={tags}
/>
{/* Main Content */}
<div
className={`flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
>
<div className="text-xl text-gray-700 dark:text-gray-200">
Loading...
</div>
</div>
</div>
);
}
if (isError) {
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
{/* Navbar */}
<Navbar
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
currentUser={currentUser}
setCurrentUser={setCurrentUser}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
/>
{/* Sidebar */}
<Sidebar
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
notes={notes}
areas={areas}
tags={tags}
/>
{/* Main Content */}
<div
className={`flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
>
<div className="text-xl text-red-500">Error fetching data.</div>
</div>
</div>
);
}
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
{/* Navbar */}
<Navbar
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
currentUser={currentUser}
setCurrentUser={setCurrentUser}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
/>
{/* Sidebar */}
<Sidebar
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
openProjectModal={openProjectModal}
openNoteModal={openNoteModal}
openAreaModal={openAreaModal}
openTagModal={openTagModal}
notes={notes}
areas={areas}
tags={tags}
/>
{/* Main Content */}
<div
className={`transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
>
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
<div className="flex-grow p-6 pt-24">
<div className="w-full max-w-5xl mx-auto">{children}</div>
</div>
</div>
</div>
{/* Modals */}
{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;

View file

@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useDataContext } from '../../contexts/DataContext';
const AreaDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { areas, isLoading, isError } = useDataContext();
const [area, setArea] = useState<any | null>(null);
useEffect(() => {
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-5xl mx-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
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,171 @@
import React, { useState, useEffect, useRef } from 'react';
import { Area } from '../../entities/Area';
import { useDataContext } from '../../contexts/DataContext';
interface AreaModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (areaData: Area) => void;
area?: Area | null;
}
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area }) => {
const { createArea, updateArea } = useDataContext();
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);
useEffect(() => {
if (isOpen) {
setFormData({
id: area?.id || 0,
name: area?.name || '',
description: area?.description || '',
});
setError(null);
}
}, [isOpen, area]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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);
} else {
await createArea(formData);
}
onClose();
} 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,177 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import {
PencilSquareIcon,
TrashIcon,
Squares2X2Icon,
} from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog';
import AreaModal from './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 lg:px-2 ">
<div className="w-full max-w-5xl">
{/* 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,96 @@
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 flex-col items-center justify-center min-h-screen px-4">
{/* Logo with engraved effect */}
<h1 className="text-5xl font-bold text-gray-300 mb-6">
tududi
</h1>
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
{error && (
<div className="mb-4 text-center text-red-500">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label
htmlFor="email"
className="block text-gray-600 mb-1"
>
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>
</div>
);
};
export default Login;

View file

@ -0,0 +1,128 @@
import React, { useState, useRef, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { UserIcon, Bars3Icon } from "@heroicons/react/24/solid";
interface NavbarProps {
isDarkMode: boolean;
toggleDarkMode: () => void;
currentUser: {
email: string;
avatarUrl?: string;
};
setCurrentUser: React.Dispatch<React.SetStateAction<any>>;
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
const Navbar: React.FC<NavbarProps> = ({
isDarkMode,
toggleDarkMode,
currentUser,
setCurrentUser,
isSidebarOpen,
setIsSidebarOpen,
}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const handleLogout = async () => {
try {
const response = await fetch("/logout", {
method: "GET",
credentials: "include",
});
if (response.ok) {
setCurrentUser(null); // Update the application state
navigate("/login"); // Redirect to the login page
} else {
console.error("Failed to log out");
}
} catch (error) {
console.error("Error logging out:", error);
}
};
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white dark:bg-gray-900 text-gray-900 dark:text-white shadow-md h-16">
<div className="px-4 sm:px-6 lg:px-8 h-full flex items-center justify-between">
<div className="flex items-center">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="flex items-center focus:outline-none text-gray-500 dark:text-gray-500"
aria-label={isSidebarOpen ? "Collapse Sidebar" : "Expand Sidebar"}
>
<Bars3Icon className="h-6 mt-1 w-6 mr-2" />
</button>
<Link
to="/"
className="flex items-center no-underline text-gray-900 dark:text-white"
>
<span className="text-2xl font-bold">tududi</span>
</Link>
</div>
<div className="flex items-center space-x-4">
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center focus:outline-none"
aria-label="User Menu"
>
{currentUser?.avatarUrl ? (
<img
src={currentUser.avatarUrl}
alt="User Avatar"
className="h-8 w-8 rounded-full object-cover border-2 border-green-500"
/>
) : (
<div className="h-8 w-8 rounded-full border-2 border-green-500 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<UserIcon className="h-6 w-6 text-gray-500 dark:text-gray-300" />
</div>
)}
</button>
{isDropdownOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 z-50">
<Link
to="/profile"
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Profile
</Link>
<button
onClick={handleLogout}
className="w-full text-left block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Logout
</button>
</div>
)}
</div>
</div>
</div>
</nav>
);
};
export default Navbar;

View file

@ -0,0 +1,175 @@
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 ConfirmDialog from '../Shared/ConfirmDialog';
import NoteModal from './NoteModal';
import { Note } from '../../entities/Note';
const NoteDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const { notes, deleteNote, isLoading, isError } = useDataContext();
const [note, setNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const navigate = useNavigate();
useEffect(() => {
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');
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleSaveNote = (updatedNote: Note) => {
setNote(updatedNote);
setIsNoteModalOpen(false);
};
const handleEditNote = () => {
setIsNoteModalOpen(true);
};
const handleOpenConfirmDialog = (note: Note) => {
setNoteToDelete(note);
setIsConfirmDialogOpen(true);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading note details...
</div>
</div>
);
}
if (isError || !note) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-red-500 text-lg">
{isError ? 'Error loading note details.' : 'Note not found.'}
</div>
</div>
);
}
return (
<div className="flex justify-center px-4 lg:px-2 ">
<div className="w-full max-w-5xl">
{/* Header Section with Title and Action Buttons */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<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}
/>
)}
{/* 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,193 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import TagInput from '../Tag/TagInput';
import { Note } from '../../entities/Note';
import { useDataContext } from '../../contexts/DataContext';
interface NoteModalProps {
isOpen: boolean;
onClose: () => void;
note?: Note | null;
onSave?: (note: Note) => void;
}
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note }) => {
const { createNote, updateNote } = useDataContext();
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);
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]);
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]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleTagsChange = useCallback((newTags: string[]) => {
setFormData((prev) => ({
...prev,
tags: newTags.map((tagName) => ({ id: -1, name: tagName })),
}));
}, []);
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);
} else {
await createNote(formData);
}
onClose();
} 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,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 './Note/NoteModal';
import ConfirmDialog from './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: Note) => {
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 lg:px-2 ">
<div className="w-full max-w-5xl">
{/* Notes Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">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;

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-5xl 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,254 @@
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 TaskList from "../Task/TaskList";
import ProjectModal from "../Project/ProjectModal";
import ConfirmDialog from "../Shared/ConfirmDialog";
import { useDataContext } from "../../contexts/DataContext";
import NewTask from "../Task/NewTask";
import { Project } from "../../entities/Project";
import { Task } from "../../entities/Task";
import useManageTasks from "../../hooks/useManageTasks";
const ProjectDetails: React.FC = () => {
const { updateTask, deleteTask } = useManageTasks();
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { areas } = useDataContext();
const { updateProject, deleteProject } = useDataContext();
const [project, setProject] = useState<Project>();
const [tasks, setTasks] = useState<Task[]>([]);
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;
}
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: Task) => {
if (updatedTask.id === undefined) {
console.error("Cannot update task: Task ID is missing");
return;
}
try {
await updateTask(updatedTask.id, updatedTask);
setTasks((prevTasks) =>
prevTasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
)
);
} catch (err) {
console.error("Error updating task:", err);
}
};
const handleTaskDelete = async (taskId: number | undefined) => {
if (taskId === undefined) {
console.error("Cannot delete task: Task ID is missing");
return;
}
try {
await deleteTask(taskId);
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
} catch (err) {
console.error("Error deleting task:", err);
}
};
const handleEditProject = () => {
setIsModalOpen(true);
};
const handleSaveProject = async (updatedProject: Project) => {
if (!updatedProject) return;
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 lg:px-2 ">
<div className="w-full max-w-5xl">
<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 ? [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,192 @@
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 } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).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-2xl 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,336 @@
import React, { useState, useEffect } from "react";
import { Project } from "../entities/Project";
import { Link, useSearchParams } from "react-router-dom";
import { EllipsisVerticalIcon, MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import ConfirmDialog from "./Shared/ConfirmDialog";
import ProjectModal from "./Project/ProjectModal";
import { useDataContext } from "../contexts/DataContext";
import useFetchProjects from "../hooks/useFetchProjects";
const getProjectInitials = (name: string) => {
const words = name
.trim()
.split(" ")
.filter((word) => word.length > 0);
if (words.length === 1) {
return name.toUpperCase();
}
return words.map((word) => word[0].toUpperCase()).join("");
};
const 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);
const [searchQuery, setSearchQuery] = useState<string>('');
const [searchParams, setSearchParams] = useSearchParams();
// Set default URL parameter ?active=true if not provided
useEffect(() => {
if (!searchParams.has("active")) {
searchParams.set("active", "true");
setSearchParams(searchParams);
}
}, [searchParams, setSearchParams]);
const activeFilter = searchParams.get("active") ?? "active";
const areaFilter = searchParams.get("area_id") ?? "";
const {
projects,
taskStatusCounts: fetchedTaskStatusCounts,
isLoading,
isError,
mutate,
} = useFetchProjects(activeFilter, areaFilter);
useEffect(() => {
setTaskStatusCounts(fetchedTaskStatusCounts);
}, [fetchedTaskStatusCounts]);
const getCompletionPercentage = (projectId: number | undefined) => {
if (!projectId) return 0;
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);
};
const handleSaveProject = async (project: Project) => {
if (project.id) {
await updateProject(project.id, project);
} else {
await createProject(project);
}
setIsProjectModalOpen(false);
mutate();
};
const handleEditProject = (project: Project) => {
setProjectToEdit(project);
setIsProjectModalOpen(true);
};
const handleDeleteProject = async () => {
if (!projectToDelete) return;
await deleteProject(projectToDelete.id!);
setIsConfirmDialogOpen(false);
setProjectToDelete(null);
mutate();
};
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newActiveFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newActiveFilter === "all") {
params.delete("active");
} else {
params.set("active", newActiveFilter);
}
setSearchParams(params);
};
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newAreaFilter = e.target.value;
const params = new URLSearchParams(searchParams);
if (newAreaFilter) {
params.set("area_id", newAreaFilter);
} else {
params.delete("area_id");
}
setSearchParams(params);
};
const filteredProjects = projects.filter((project) =>
project.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 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>
);
}
const groupedProjects = filteredProjects.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 lg:px-2">
<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"}
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>
{/* 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 projects..."
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>
{/* 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-2 mt-6">
{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" }} // Consistent card height
>
<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 ?? null
)
}
>
<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;

View file

@ -0,0 +1,35 @@
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,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;

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;

View file

@ -0,0 +1,82 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline'; // Import the icons
import { PriorityType } from '../../entities/Task';
interface PriorityDropdownProps {
value: PriorityType;
onChange: (value: PriorityType) => 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: PriorityType) => {
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 as PriorityType)}
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
>
<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,83 @@
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
import { StatusType } from '../../entities/Task';
interface StatusDropdownProps {
value: StatusType;
onChange: (value: StatusType) => 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: StatusType) => {
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">
{selectedStatus ? selectedStatus.icon : ''}
<span>{selectedStatus ? selectedStatus.label : 'Select Status'}</span>
</span>
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</button>
{isOpen && (
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md">
{statuses.map((status) => (
<button
key={status.value}
onClick={() => handleSelect(status.value as StatusType)}
className="flex items-center justify-between space-x-2 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
>
<span className="flex items-center space-x-2">
{status.icon} <span>{status.label}</span>
</span>
</button>
))}
</div>
)}
</div>
);
};
export default StatusDropdown;

View file

@ -0,0 +1,55 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
interface ToastContextProps {
showSuccessToast: (message: string) => void;
showErrorToast: (message: string) => void;
}
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toastMessage, setToastMessage] = useState<string | null>(null);
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const showSuccessToast = useCallback((message: string) => {
setToastMessage(message);
setToastType('success');
setTimeout(() => setToastMessage(null), 3000);
}, []);
const showErrorToast = useCallback((message: string) => {
setToastMessage(message);
setToastType('error');
setTimeout(() => setToastMessage(null), 3000);
}, []);
return (
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
{children}
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />}
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
const 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,119 @@
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Area } from '../entities/Area';
import { Note } from '../entities/Note';
import { Tag } from '../entities/Tag';
import SidebarAreas from './Sidebar/SidebarAreas';
import SidebarFooter from './Sidebar/SidebarFooter';
import SidebarNav from './Sidebar/SidebarNav';
import SidebarNotes from './Sidebar/SidebarNotes';
import SidebarProjects from './Sidebar/SidebarProjects';
import SidebarTags from './Sidebar/SidebarTags';
interface SidebarProps {
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
openProjectModal: () => void;
openNoteModal: (note: Note | null) => void;
openAreaModal: (area: Area | null) => void;
openTagModal: (tag: Tag | null) => void;
notes: Note[];
areas: Area[];
tags: Tag[];
}
const Sidebar: React.FC<SidebarProps> = ({
isSidebarOpen,
setIsSidebarOpen,
currentUser,
isDarkMode,
toggleDarkMode,
openProjectModal,
openNoteModal,
openAreaModal,
openTagModal,
notes,
areas,
tags,
}) => {
const navigate = useNavigate();
const location = useLocation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
const handleNavClick = (path: string, title: string, icon: JSX.Element) => {
navigate(path, { state: { title } });
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
}
};
return (
<div
className={`fixed top-16 left-0 ${isSidebarOpen ? 'w-full sm:w-64' : 'w-0'} h-[calc(100vh-4rem)] bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-width duration-300 ease-in-out z-50`}
style={{
visibility: isSidebarOpen ? 'visible' : 'hidden',
overflow: 'hidden',
}}
>
{isSidebarOpen && (
<div className="flex flex-col h-full overflow-y-auto">
<div className="px-3 pb-3 pt-6">
{/* Sidebar Contents */}
<SidebarNav
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
/>
<SidebarProjects
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
openProjectModal={openProjectModal}
/>
<SidebarNotes
handleNavClick={handleNavClick}
openNoteModal={openNoteModal}
notes={notes}
location={location}
isDarkMode={isDarkMode}
/>
<SidebarAreas
handleNavClick={handleNavClick}
areas={areas}
location={location}
isDarkMode={isDarkMode}
openAreaModal={openAreaModal}
/>
<SidebarTags
handleNavClick={handleNavClick}
location={location}
isDarkMode={isDarkMode}
openTagModal={openTagModal}
tags={tags}
/>
</div>
<SidebarFooter
currentUser={currentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
isDropdownOpen={isDropdownOpen}
toggleDropdown={toggleDropdown}
/>
</div>
)}
</div>
);
};
export default Sidebar;

View file

@ -0,0 +1,62 @@
import React from "react";
import { Squares2X2Icon, PlusCircleIcon } from "@heroicons/react/24/outline";
import { Location } from "react-router-dom";
import { Area } from "../../entities/Area";
interface SidebarAreasProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openAreaModal: (area: Area | null) => void;
areas: Area[];
}
const SidebarAreas: React.FC<SidebarAreasProps> = ({
handleNavClick,
location,
openAreaModal,
}) => {
const isActiveArea = (path: string) => {
return location.pathname === path
? "bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white"
: "text-gray-700 dark:text-gray-300";
};
return (
<>
<ul className="flex flex-col space-y-1">
{/* "AREAS" Title with Add Button */}
<li
className={`flex justify-between items-center px-4 py-2 rounded-md uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveArea(
"/areas"
)}`}
onClick={() =>
handleNavClick(
"/areas",
"Areas",
<Squares2X2Icon className="h-5 w-5 mr-2" />
)
}
>
<span className="flex items-center">
<Squares2X2Icon className="h-5 w-5 mr-2" />
AREAS
</span>
<button
onClick={(e) => {
e.stopPropagation();
openAreaModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Area"
title="Add Area"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarAreas;

View file

@ -0,0 +1,44 @@
import React from 'react';
import { SunIcon, MoonIcon } from '@heroicons/react/24/outline';
interface SidebarFooterProps {
currentUser: { email: string };
isDarkMode: boolean;
toggleDarkMode: () => void;
isSidebarOpen: boolean;
setIsSidebarOpen: React.Dispatch<React.SetStateAction<boolean>>;
isDropdownOpen: boolean;
toggleDropdown: () => void;
}
const SidebarFooter: React.FC<SidebarFooterProps> = ({
isDarkMode,
toggleDarkMode,
isSidebarOpen,
setIsSidebarOpen,
}) => {
return (
<div className="mt-auto p-3">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<div className={`flex items-center justify-center`}>
{/* Dark Mode Toggle */}
{isSidebarOpen && (
<button
onClick={toggleDarkMode}
className="focus:outline-none text-gray-700 dark:text-gray-300"
aria-label="Toggle Dark Mode"
>
{isDarkMode ? (
<SunIcon className="h-6 w-6 text-yellow-500" />
) : (
<MoonIcon className="h-6 w-6 text-gray-500" />
)}
</button>
)}
</div>
</div>
</div>
);
};
export default SidebarFooter;

View file

@ -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, icon: JSX.Element) => 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 }) => {
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, link.icon)}
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,55 @@
import React from 'react';
import { Location } from 'react-router-dom';
import { BookOpenIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
import { Note } from '../../entities/Note';
interface SidebarNotesProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openNoteModal: (note: Note | null) => void;
notes: Note[];
}
const SidebarNotes: React.FC<SidebarNotesProps> = ({
handleNavClick,
location,
openNoteModal,
}) => {
const isActiveNote = (path: string) => {
return location.pathname === path
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
return (
<>
<ul className="flex flex-col space-y-1">
<li
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveNote(
'/notes'
)}`}
onClick={() => handleNavClick('/notes', 'Notes', <BookOpenIcon className="h-5 w-5 mr-2" />)}
>
<span className="flex items-center">
<BookOpenIcon className="h-5 w-5 mr-2" />
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,55 @@
import React, { useState, useEffect } from 'react';
import { Location } from 'react-router-dom';
import { FolderIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
import { Project } from '../../entities/Project';
interface SidebarProjectsProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openProjectModal: () => void;
}
const SidebarProjects: React.FC<SidebarProjectsProps> = ({
handleNavClick,
location,
openProjectModal,
}) => {
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', <FolderIcon className="h-5 w-5 mr-2" />)}
>
<span className="flex items-center">
<FolderIcon className="h-5 w-5 mr-2" />
PROJECTS
</span>
<button
onClick={(e) => {
e.stopPropagation();
openProjectModal();
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Project"
title="Add Project"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarProjects;

View file

@ -0,0 +1,56 @@
import React from 'react';
import { Location } from 'react-router-dom';
import { TagIcon, PlusCircleIcon } from '@heroicons/react/24/outline';
import { Tag } from '../../entities/Tag';
interface SidebarTagsProps {
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
location: Location;
isDarkMode: boolean;
openTagModal: (tag: Tag | null) => void;
tags: Tag[];
}
const SidebarTags: React.FC<SidebarTagsProps> = ({
handleNavClick,
location,
openTagModal,
}) => {
const isActiveTag = (path: string) => {
return location.pathname === path
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-300';
};
return (
<>
<ul className="flex flex-col space-y-1">
{/* "TAGS" Title with Add Button */}
<li
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveTag(
'/tags'
)}`}
onClick={() => handleNavClick('/tags', 'Tags', <TagIcon className="h-5 w-5 mr-2" />)}
>
<span className="flex items-center">
<TagIcon className="h-5 w-5 mr-2" />
TAGS
</span>
<button
onClick={(e) => {
e.stopPropagation();
openTagModal(null);
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
aria-label="Add Tag"
title="Add Tag"
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</li>
</ul>
</>
);
};
export default SidebarTags;

View file

@ -0,0 +1,75 @@
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();
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]);
const handleViewTasks = () => {
if (tag) {
navigate(`/tasks?tag=${encodeURIComponent(tag.name)}`);
}
};
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,64 @@
import React, { useState } from 'react';
import TaskTags from '../Task/TaskTags';
interface TagInputProps {
initialTags: string[];
onTagsChange: (tags: string[]) => void;
availableTags: string[];
}
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags }) => {
const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(initialTags || []);
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
};
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if ((event.key === 'Enter' || event.key === ',') && inputValue.trim()) {
event.preventDefault();
const trimmedValue = inputValue.trim();
if (!tags.includes(trimmedValue)) {
const updatedTags = [...tags, trimmedValue];
setTags(updatedTags);
onTagsChange(updatedTags);
}
setInputValue('');
}
};
const removeTag = (tagToRemoveId: number | string | undefined) => {
if (tagToRemoveId === undefined) return; // Handle undefined case
const updatedTags = tags.filter((_, index) => index !== Number(tagToRemoveId));
setTags(updatedTags);
onTagsChange(updatedTags);
};
return (
<div className="space-y-2">
<TaskTags
tags={tags.map((tag, index) => ({ id: index, name: tag }))}
onTagRemove={removeTag}
className="flex flex-wrap gap-1"
/>
<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;

View file

@ -0,0 +1,109 @@
// 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 | null;
}
const TagModal: React.FC<TagModalProps> = ({ isOpen, onClose, onSave, tag }) => {
const [formData, setFormData] = useState<Tag>(
tag || {
name: '',
}
);
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 (tag) {
setFormData(tag);
} else {
setFormData({
name: '',
});
}
}, [tag]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
};
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,170 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog';
import TagModal from './Tag/TagModal';
import { useDataContext } from '../contexts/DataContext';
import { Tag } from '../entities/Tag';
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>('');
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 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 lg:px-2 ">
<div className="w-full max-w-5xl">
{/* Tags Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<TagIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">Tags</h2>
</div>
</div>
{/* Search Bar with Icon */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder="Search tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{/* Tags List */}
{filteredTags.length === 0 ? (
<p className="text-gray-700 dark:text-gray-300">No tags found.</p>
) : (
<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;

View file

@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { useToast } from '../../components/Shared/ToastContext';
interface NewTaskProps {
onTaskCreate: (taskName: string) => void;
}
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
const [taskName, setTaskName] = useState<string>('');
const { showSuccessToast, showErrorToast } = useToast();
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;

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-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 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 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Save
</button>
</div>
);
};
export default TaskActions;

View file

@ -0,0 +1,42 @@
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';
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}`}>
{formatDueDate()}
</div>
);
};
export default TaskDueDate;

View file

@ -0,0 +1,84 @@
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;
}
const TaskHeader: React.FC<TaskHeaderProps> = ({ task, project, onTaskClick }) => {
const capitalizeFirstLetter = (string: string | undefined) => {
if (!string) {
return '';
}
return string.charAt(0).toUpperCase() + string.slice(1);
};
return (
<div className="py-4 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">
<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>
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-1">
{/* Tags without onTagRemove prop */}
<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"> {/* Add bottom margin */}
<div className="font-medium text-lg text-gray-900 dark:text-gray-100 mb-4">
{/* Increase text size from text-sm to text-base */}
{task.name}
</div>
<div className="flex items-center mb-2">
<TaskPriorityIcon priority={task.priority} status={task.status} />
<span className="ml-2 text-sm">{capitalizeFirstLetter(task.priority)}</span> {/* Increase text size */}
</div>
<div className="flex items-center mb-2">
<TaskStatusBadge status={task.status} />
<span className="ml-2 text-sm"></span> {/* Increase text size */}
</div>
{task.due_date && (
<div className="flex items-center mb-2">
<i className="bi bi-clock mr-2"></i>
<TaskDueDate dueDate={task.due_date} />
</div>
)}
{/* Tags without onTagRemove prop */}
<div className="flex items-center">
<i className="bi bi-tag mr-2"></i>
<div className="flex-1 flex-wrap overflow-hidden">
<TaskTags tags={task.tags || []} />
</div>
</div>
</div>
</div>
);
};
export default TaskHeader;

View file

@ -0,0 +1,80 @@
import React, { useState } 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);
const handleTaskClick = () => {
setIsModalOpen(true);
};
const handleSave = (updatedTask: Task) => {
onTaskUpdate(updatedTask);
setIsModalOpen(false);
};
const handleDelete = () => {
if (task.id) {
onTaskDelete(task.id);
}
};
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();
setProjectList((prevProjects) => [...prevProjects, newProject]);
return newProject;
} catch (error) {
console.error('Error creating project:', error);
throw error;
}
};
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} />
<TaskModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
task={task}
onSave={handleSave}
onDelete={handleDelete}
projects={projectList}
onCreateProject={handleCreateProject}
/>
</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,366 @@
// src/components/Task/TaskModal.tsx
import React, { useState, useEffect, useRef, useCallback } from "react";
import { PriorityType, StatusType, Task } from "../../entities/Task";
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 TagInput from "../Tag/TagInput";
import { Project } from "../../entities/Project";
import { Tag } from "../../entities/Tag";
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);
setFormData((prev) => ({
...prev,
tags: newTags.map((name) => ({ name })),
}));
}, []);
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);
};
// Handler to remove tag
const handleTagRemove = (tagId: string | number | undefined) => {
if (tagId === undefined) return;
const tagIndex = Number(tagId);
if (tagIndex >= 0 && tagIndex < tags.length) {
const updatedTags = tags.filter((_, index) => index !== tagIndex);
setTags(updatedTags);
setFormData((prev) => ({
...prev,
tags: updatedTags.map((name) => ({ name })),
}));
showSuccessToast("Tag removed successfully!");
}
};
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]);
// Handle Escape key to close modal
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
handleClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
if (!isOpen) return null;
return (
<>
<div
className={`fixed inset-0 flex items-start sm: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 sm:max-w-3xl overflow-hidden transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
} h-screen sm:h-auto flex flex-col`}
style={{
maxHeight: "100vh",
}}
>
<form className="flex flex-col flex-1">
<fieldset className="flex flex-col flex-1 justify-between">
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
{/* 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 relative">
<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-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
<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: StatusType) =>
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: PriorityType) =>
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 flex-shrink-0">
<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,42 @@
import React from 'react';
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid';
interface TaskStatusBadgeProps {
status: string;
className?: string;
}
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
let statusIcon, statusLabel;
switch (status) {
case 'not_started':
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = 'Not Started';
break;
case 'in_progress':
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
statusLabel = 'In Progress';
break;
case 'done':
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
statusLabel = 'Done';
break;
case 'archived':
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
statusLabel = 'Archived';
break;
default:
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
statusLabel = 'Unknown';
}
return (
<div className={`flex items-center md:px-2 ${className}`}>
{statusIcon}
<span className="ml-2 text-xs font-medium inline md:hidden">{statusLabel}</span>
</div>
);
};
export default TaskStatusBadge;

View file

@ -0,0 +1,50 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Tag } from '../../entities/Tag';
import { TagIcon, XMarkIcon } from '@heroicons/react/24/solid';
interface TaskTagsProps {
tags: Tag[];
onTagRemove?: (tagId: string | number | undefined) => void;
className?: string;
}
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }) => {
const navigate = useNavigate();
const handleTagClick = (tagName: string) => {
navigate(`/tasks?tag=${tagName}`);
};
return (
<div className={`flex flex-wrap gap-1 ${className}`}>
{tags.map((tag, index) => (
<div
key={tag.id || index}
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium mr-2 px-2.5 py-1 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
>
<button
type="button"
onClick={() => handleTagClick(tag.name)}
className="flex items-center"
>
<TagIcon className="hidden md:block h-4 w-4 text-gray-500 dark:text-gray-300 mr-2" />
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
</button>
{onTagRemove && (
<button
type="button"
onClick={() => onTagRemove(tag.id)}
className="ml-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 focus:outline-none"
aria-label={`Remove tag ${tag.name}`}
>
<XMarkIcon className="h-4 w-4" />
</button>
)}
</div>
))}
</div>
);
};
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,29 @@
import { Project } from "../../entities/Project";
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,292 @@
import React, { useEffect, useState, useRef } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import TaskList from "./Task/TaskList";
import NewTask from "./Task/NewTask";
import { Task } from "../entities/Task";
import { Project } from "../entities/Project";
import { getTitleAndIcon } from "./Task/getTitleAndIcon";
import { getDescription } from "./Task/getDescription";
import { TagIcon, XMarkIcon, ChevronDownIcon } from "@heroicons/react/24/solid";
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");
const dropdownRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const navigate = useNavigate();
const query = new URLSearchParams(location.search);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const { title, icon } =
stateTitle && stateIcon
? { title: stateTitle, icon: stateIcon }
: getTitleAndIcon(query, projects);
const tag = query.get("tag");
useEffect(() => {
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc";
setOrderBy(savedOrderBy);
const params = new URLSearchParams(location.search);
if (!params.get("order_by")) {
params.set("order_by", savedOrderBy);
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
}
}, [location, navigate]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const tagId = query.get("tag");
const [tasksResponse, projectsResponse] = await Promise.all([
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`),
fetch("/api/projects"),
]);
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData || []);
} 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]);
const handleRemoveTag = () => {
const params = new URLSearchParams(location.search);
params.delete("tag");
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
};
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const 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.");
}
};
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.");
}
};
const handleTaskDelete = async (taskId: number) => {
try {
const response = await fetch(`/api/task/${taskId}`, {
method: "DELETE",
});
if (response.ok) {
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
} else {
const errorData = await response.json();
console.error("Failed to delete task:", errorData.error);
setError("Failed to delete task.");
}
} catch (error) {
console.error("Error deleting task:", error);
setError("Error deleting task.");
}
};
const handleSortChange = (order: string) => {
setOrderBy(order);
localStorage.setItem("order_by", order);
const params = new URLSearchParams(location.search);
params.set("order_by", order);
navigate({
pathname: location.pathname,
search: `?${params.toString()}`,
});
setDropdownOpen(false);
};
const description = getDescription(query, projects);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Title and Icon */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between mb-4">
<div className="flex items-center mb-2 sm:mb-0">
<i className={`bi ${icon} text-xl mr-2`}></i>
<h2 className="text-2xl font-light">{title}</h2>
{tag && (
<div className="ml-4 flex items-center space-x-2">
<button
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={handleRemoveTag}
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">
{capitalize(tag)}
</span>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
</button>
</div>
)}
</div>
{/* Sort Dropdown */}
<div className="relative inline-block text-left" ref={dropdownRef}>
<button
type="button"
className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
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("_", " "))}
<ChevronDownIcon className="h-5 w-5 ml-2 text-gray-500 dark:text-gray-300" />
</button>
{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,99 @@
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';
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,
updateTask,
deleteTask,
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;
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 | undefined;
name: string;
}

View file

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

View file

@ -0,0 +1,5 @@
export interface User {
id: number;
email: string;
avatarUrl?: string;
}

View file

@ -0,0 +1,53 @@
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;
const controller = new AbortController();
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();
return () => {
isMounted = false;
controller.abort();
};
}, [url, JSON.stringify(options)]);
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,15 @@
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,15 @@
import useSWR from 'swr';
import { fetcher } from '../utils/fetcher';
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,71 @@
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,86 @@
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();
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();
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.');
}
mutate((notes || []).filter((note) => note.id !== noteId), false);
},
[mutate, notes]
);
return {
notes: notes || [],
isLoading: !error && !notes,
isError: error,
createNote,
updateNote,
deleteNote,
mutate
};
};
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,69 @@
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
import { Tag } from '../entities/Tag';
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,76 @@
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;

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

@ -0,0 +1,30 @@
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";
const storedPreference = localStorage.getItem("isDarkMode");
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
const isDarkMode = storedPreference
? storedPreference === "true"
: prefersDarkMode;
if (isDarkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
const container = document.getElementById("root");
if (container) {
const root = createRoot(container);
root.render(
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
);
}

View file

@ -0,0 +1,10 @@
/* ./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,18 @@
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.');
(error as any).info = errorData;
(error as any).status = response.status;
throw error;
}
return response.json();
};

View file

@ -8,8 +8,14 @@ module AuthenticationHelper
end
def require_login
return if ['/login', '/logout'].include? request.path
return if ['/login', '/logout', '/api/current_user'].include? request.path
redirect '/login' unless logged_in?
return if logged_in?
if request.xhr? || request.path.start_with?('/api/')
halt 401, { error: 'You must be logged in' }.to_json
else
redirect '/login'
end
end
end

View file

@ -1,77 +0,0 @@
module TaskHelper
def priority_class(task)
return 'text-success' if task.done?
case task.priority
when 'medium'
'text-warning'
when 'high'
'text-danger'
else
'text-secondary'
end
end
def due_date_badge_class(due_date)
return 'bg-light text-dark' unless due_date
case due_date.to_date
when Date.today
'bg-primary'
when Date.tomorrow
'bg-info'
else
if due_date.to_date < Date.today
'bg-danger'
else
'bg-light text-dark'
end
end
end
def format_due_date(due_date)
return '' unless due_date
case due_date.to_date
when Date.today
'TODAY'
when Date.tomorrow
'TOMORROW'
when Date.yesterday
'YESTERDAY'
else
due_date.strftime('%Y-%m-%d')
end
end
def status_badge_class(status)
case status
when 'not_started'
'bg-warning-subtle text-warning'
when 'in_progress'
'bg-primary-subtle text-primary'
when 'done'
'bg-success-subtle text-success'
else
'bg-secondary-subtle text-secondary'
end
end
def order_name(order_by)
return 'Select' unless order_by
field, direction = order_by.split(':')
name = case field
when 'due_date' then 'Due Date'
when 'name' then 'Name'
when 'priority' then 'Priority'
when 'status' then 'Status'
when 'created_at' then 'Created At'
else 'Select'
end
direction_icon = direction == 'asc' ? '<i class="bi bi-arrow-up"></i>' : '<i class="bi bi-arrow-down"></i>'
"#{name} #{direction_icon}"
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

@ -7,7 +7,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,74 @@
class Sinatra::Application
post '/areas/create' do
area = current_user.areas.create(name: params[:name])
require 'sinatra'
require 'json'
if area.persisted?
redirect '/'
post '/api/areas' do
content_type :json
begin
request_body = request.body.read
area_data = JSON.parse(request_body, symbolize_names: true)
halt 400, { error: 'Area name is required.' }.to_json unless area_data[:name] && !area_data[:name].strip.empty?
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
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 '/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
get '/api/areas' do
content_type :json
areas = current_user.areas
areas.to_json
end

View file

@ -1,17 +1,38 @@
require 'json'
class Sinatra::Application
get '/login' do
erb :login
get '/api/current_user' do
content_type :json
if logged_in?
{ user: { email: current_user.email, id: current_user.id } }.to_json
else
{ user: nil }.to_json
end
end
post '/login' do
@user = User.find_by(email: params[:email])
if @user&.authenticate(params[:password])
session[:user_id] = @user.id
redirect '/'
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
logger.warn "Invalid credentials for user with email #{params[:email]}"
@errors = ['Invalid credentials']
erb :login
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
@ -19,4 +40,4 @@ class Sinatra::Application
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,88 @@ 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
note.to_json(include: :tags)
end
post '/api/note' do
content_type :json
request_body = request.body.read
note_data = JSON.parse(request_body, symbolize_names: true)
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?
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
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
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
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
if note.update(note_attributes)
update_note_tags(note, params[:tags])
redirect request.referrer || '/'
update_note_tags(note, request_data['tags'])
note.to_json(include: :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,115 @@
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')
register Sinatra::Namespace
@task_status_counts = @projects_with_tasks.each_with_object({}) do |project, counts|
counts[project.id] = project.task_status_counts
namespace '/api' do
before do
content_type :json
end
@grouped_projects = @projects_with_tasks.group_by(&:area)
get '/projects' do
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
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
)
projects = projects.where(active: is_active) unless is_active.nil?
projects = projects.where(pin_to_sidebar: is_pinned) unless is_pinned.nil?
projects = projects.where(area_id: area_id_param) if area_id_param
task_status_counts = projects.each_with_object({}) do |project, counts|
counts[project.id] = project.task_status_counts
end
if project.save
redirect request.referrer || '/'
else
@errors = 'There was a problem creating the project.'
redirect '/'
grouped_projects = projects.group_by(&:area)
{
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 '/project/:id' do
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
project.as_json(include: { tasks: {}, area: { only: %i[id name] } }).to_json
end
post '/project' do
request_body = request.body.read
project_data = begin
JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
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])
patch '/project/:id' do
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
request_body = request.body.read
project_data = begin
JSON.parse(request_body)
rescue JSON::ParserError
halt 400, { error: 'Invalid JSON format.' }.to_json
end
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 '/project/:id' do
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

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

@ -0,0 +1,66 @@
class Sinatra::Application
get '/api/tags' do
content_type :json
tags = current_user.tags.order('name ASC')
tags.as_json(only: %i[id name]).to_json
end
get '/api/tag/:id' do
content_type :json
tag = current_user.tags.find_by(id: params[:id])
halt 404, { error: 'Tag not found' }.to_json unless tag
tag.as_json(only: %i[id name]).to_json
end
post '/api/tag' do
content_type :json
request_body = JSON.parse(request.body.read)
tag = current_user.tags.new(name: request_body['name'])
if tag.save
status 201
tag.as_json(only: %i[id name]).to_json
else
status 400
{ error: 'There was a problem creating the tag.' }.to_json
end
end
patch '/api/tag/:id' do
content_type :json
tag = current_user.tags.find_by(id: params[:id])
halt 404, { error: 'Tag not found' }.to_json unless tag
request_body = JSON.parse(request.body.read)
tag.name = request_body['name']
if tag.save
tag.as_json(only: %i[id name]).to_json
status 400
{ error: 'There was a problem updating the tag.' }.to_json
end
end
delete '/api/tag/:id' do
content_type :json
tag = current_user.tags.find_by(id: params[:id])
halt 404, { error: 'Tag not found' }.to_json unless tag
if tag.destroy
{ message: 'Tag successfully deleted' }.to_json
else
status 400
{ error: 'There was a problem deleting the tag.' }.to_json
end
end
end

View file

@ -1,155 +1,174 @@
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
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
existing_tags = Tag.where(user: current_user, name: tag_names)
new_tags = tag_names - existing_tags.pluck(:name)
created_tags = new_tags.map { |name| Tag.create(name: name, user: current_user) }
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
@tasks = current_user.tasks.includes(:project, :tags)
# Apply filters based on due_date and status
@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
@tasks = @tasks.with_tag(params[:tag]) if params[:tag]
if params[:order_by]
order_column, order_direction = params[:order_by].split(':')
order_direction ||= 'asc'
order_direction = order_direction.downcase == 'desc' ? :desc : :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}"))
else
@tasks.order("tasks.#{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)
allowed_columns = %w[created_at updated_at name priority status due_date]
if allowed_columns.include?(order_column)
@tasks = if order_column == 'due_date'
@tasks.ordered_by_due_date(order_direction)
else
@tasks.order("tasks.#{order_column} #{order_direction}")
end
else
halt 400, { error: 'Invalid order column specified.' }.to_json
end
end
@tasks = @tasks.left_joins(:tags).distinct
erb :'tasks/index'
@tasks.to_json(include: { tags: { only: %i[id name] }, project: { only: :name } })
end
post '/task/create' do
post '/api/task' do
content_type :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
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',
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
value = task_data['project_id']
task = if value.nil? || value.to_s.strip.empty?
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
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.'
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}"
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
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
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
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
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.'
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,41 @@
module Sinatra
class Application
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' 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?
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')
if user.update(allowed_params)
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' %>

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