Merge pull request #23 from chrisvel/feature-UI-upgrade
Upgrade to React.js - New layout
This commit is contained in:
commit
aacf7deb37
129 changed files with 21047 additions and 1278 deletions
8
.babelrc
Normal file
8
.babelrc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"presets": [
|
||||
"@babel/preset-env",
|
||||
"@babel/preset-react",
|
||||
"@babel/preset-typescript"
|
||||
],
|
||||
"plugins": ["react-refresh/babel"]
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -4,4 +4,6 @@
|
|||
certs/
|
||||
.DS_Store
|
||||
|
||||
.byebug_history
|
||||
.byebug_history
|
||||
node_modules
|
||||
.env
|
||||
40
Dockerfile
40
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
8
Gemfile
8
Gemfile
|
|
@ -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
|
||||
12
Gemfile.lock
12
Gemfile.lock
|
|
@ -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
116
README.md
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
## 🚀 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 they’re 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
46
app.rb
|
|
@ -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
133
app/frontend/App.tsx
Normal 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
337
app/frontend/Layout.tsx
Normal 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;
|
||||
53
app/frontend/components/Area/AreaDetails.tsx
Normal file
53
app/frontend/components/Area/AreaDetails.tsx
Normal 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;
|
||||
171
app/frontend/components/Area/AreaModal.tsx
Normal file
171
app/frontend/components/Area/AreaModal.tsx
Normal 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;
|
||||
177
app/frontend/components/Areas.tsx
Normal file
177
app/frontend/components/Areas.tsx
Normal 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;
|
||||
96
app/frontend/components/Login.tsx
Normal file
96
app/frontend/components/Login.tsx
Normal 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;
|
||||
128
app/frontend/components/Navbar.tsx
Normal file
128
app/frontend/components/Navbar.tsx
Normal 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;
|
||||
175
app/frontend/components/Note/NoteDetails.tsx
Normal file
175
app/frontend/components/Note/NoteDetails.tsx
Normal 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;
|
||||
193
app/frontend/components/Note/NoteModal.tsx
Normal file
193
app/frontend/components/Note/NoteModal.tsx
Normal 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;
|
||||
164
app/frontend/components/Notes.tsx
Normal file
164
app/frontend/components/Notes.tsx
Normal 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;
|
||||
212
app/frontend/components/Profile/ProfileSettings.tsx
Normal file
212
app/frontend/components/Profile/ProfileSettings.tsx
Normal 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;
|
||||
254
app/frontend/components/Project/ProjectDetails.tsx
Normal file
254
app/frontend/components/Project/ProjectDetails.tsx
Normal 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;
|
||||
192
app/frontend/components/Project/ProjectModal.tsx
Normal file
192
app/frontend/components/Project/ProjectModal.tsx
Normal 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;
|
||||
336
app/frontend/components/Projects.tsx
Normal file
336
app/frontend/components/Projects.tsx
Normal 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;
|
||||
|
||||
35
app/frontend/components/Shared/ConfirmDialog.tsx
Normal file
35
app/frontend/components/Shared/ConfirmDialog.tsx
Normal 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;
|
||||
20
app/frontend/components/Shared/DarkModeToggle.tsx
Normal file
20
app/frontend/components/Shared/DarkModeToggle.tsx
Normal 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;
|
||||
12
app/frontend/components/Shared/NotFound.tsx
Normal file
12
app/frontend/components/Shared/NotFound.tsx
Normal 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;
|
||||
82
app/frontend/components/Shared/PriorityDropdown.tsx
Normal file
82
app/frontend/components/Shared/PriorityDropdown.tsx
Normal 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;
|
||||
83
app/frontend/components/Shared/StatusDropdown.tsx
Normal file
83
app/frontend/components/Shared/StatusDropdown.tsx
Normal 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;
|
||||
55
app/frontend/components/Shared/ToastContext.tsx
Normal file
55
app/frontend/components/Shared/ToastContext.tsx
Normal 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">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
119
app/frontend/components/Sidebar.tsx
Normal file
119
app/frontend/components/Sidebar.tsx
Normal 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;
|
||||
62
app/frontend/components/Sidebar/SidebarAreas.tsx
Normal file
62
app/frontend/components/Sidebar/SidebarAreas.tsx
Normal 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;
|
||||
44
app/frontend/components/Sidebar/SidebarFooter.tsx
Normal file
44
app/frontend/components/Sidebar/SidebarFooter.tsx
Normal 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;
|
||||
16
app/frontend/components/Sidebar/SidebarHeader.tsx
Normal file
16
app/frontend/components/Sidebar/SidebarHeader.tsx
Normal 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;
|
||||
60
app/frontend/components/Sidebar/SidebarNav.tsx
Normal file
60
app/frontend/components/Sidebar/SidebarNav.tsx
Normal 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;
|
||||
55
app/frontend/components/Sidebar/SidebarNotes.tsx
Normal file
55
app/frontend/components/Sidebar/SidebarNotes.tsx
Normal 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;
|
||||
55
app/frontend/components/Sidebar/SidebarProjects.tsx
Normal file
55
app/frontend/components/Sidebar/SidebarProjects.tsx
Normal 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;
|
||||
56
app/frontend/components/Sidebar/SidebarTags.tsx
Normal file
56
app/frontend/components/Sidebar/SidebarTags.tsx
Normal 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;
|
||||
75
app/frontend/components/Tag/TagDetails.tsx
Normal file
75
app/frontend/components/Tag/TagDetails.tsx
Normal 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;
|
||||
64
app/frontend/components/Tag/TagInput.tsx
Normal file
64
app/frontend/components/Tag/TagInput.tsx
Normal 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;
|
||||
109
app/frontend/components/Tag/TagModal.tsx
Normal file
109
app/frontend/components/Tag/TagModal.tsx
Normal 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;
|
||||
170
app/frontend/components/Tags.tsx
Normal file
170
app/frontend/components/Tags.tsx
Normal 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;
|
||||
46
app/frontend/components/Task/NewTask.tsx
Normal file
46
app/frontend/components/Task/NewTask.tsx
Normal 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;
|
||||
40
app/frontend/components/Task/TaskActions.tsx
Normal file
40
app/frontend/components/Task/TaskActions.tsx
Normal 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;
|
||||
42
app/frontend/components/Task/TaskDueDate.tsx
Normal file
42
app/frontend/components/Task/TaskDueDate.tsx
Normal 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;
|
||||
84
app/frontend/components/Task/TaskHeader.tsx
Normal file
84
app/frontend/components/Task/TaskHeader.tsx
Normal 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;
|
||||
80
app/frontend/components/Task/TaskItem.tsx
Normal file
80
app/frontend/components/Task/TaskItem.tsx
Normal 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;
|
||||
42
app/frontend/components/Task/TaskList.tsx
Normal file
42
app/frontend/components/Task/TaskList.tsx
Normal 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;
|
||||
366
app/frontend/components/Task/TaskModal.tsx
Normal file
366
app/frontend/components/Task/TaskModal.tsx
Normal 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;
|
||||
24
app/frontend/components/Task/TaskPriorityIcon.tsx
Normal file
24
app/frontend/components/Task/TaskPriorityIcon.tsx
Normal 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;
|
||||
42
app/frontend/components/Task/TaskStatusBadge.tsx
Normal file
42
app/frontend/components/Task/TaskStatusBadge.tsx
Normal 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;
|
||||
50
app/frontend/components/Task/TaskTags.tsx
Normal file
50
app/frontend/components/Task/TaskTags.tsx
Normal 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;
|
||||
31
app/frontend/components/Task/getDescription.ts
Normal file
31
app/frontend/components/Task/getDescription.ts
Normal 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 you’ve 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 haven’t 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 don’t have long-term deadlines. It’s a good place to focus when you’re 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 aren’t urgent and don’t have a specific due date. These are tasks you may want to get to at some point, but they aren’t 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 you’ve completed. It’s a great way to review your accomplishments and reflect on the work you’ve 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.';
|
||||
};
|
||||
29
app/frontend/components/Task/getTitleAndIcon.ts
Normal file
29
app/frontend/components/Task/getTitleAndIcon.ts
Normal 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' };
|
||||
};
|
||||
292
app/frontend/components/Tasks.tsx
Normal file
292
app/frontend/components/Tasks.tsx
Normal 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;
|
||||
|
||||
99
app/frontend/contexts/DataContext.tsx
Normal file
99
app/frontend/contexts/DataContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
5
app/frontend/entities/Area.ts
Normal file
5
app/frontend/entities/Area.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface Area {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
12
app/frontend/entities/Note.ts
Normal file
12
app/frontend/entities/Note.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
11
app/frontend/entities/Project.ts
Normal file
11
app/frontend/entities/Project.ts
Normal 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;
|
||||
}
|
||||
4
app/frontend/entities/Tag.ts
Normal file
4
app/frontend/entities/Tag.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface Tag {
|
||||
id?: number | undefined;
|
||||
name: string;
|
||||
}
|
||||
15
app/frontend/entities/Task.ts
Normal file
15
app/frontend/entities/Task.ts
Normal 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';
|
||||
5
app/frontend/entities/User.ts
Normal file
5
app/frontend/entities/User.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
53
app/frontend/hooks/useFetch.ts
Normal file
53
app/frontend/hooks/useFetch.ts
Normal 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;
|
||||
16
app/frontend/hooks/useFetchAreas.ts
Normal file
16
app/frontend/hooks/useFetchAreas.ts
Normal 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;
|
||||
15
app/frontend/hooks/useFetchNotes.ts
Normal file
15
app/frontend/hooks/useFetchNotes.ts
Normal 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;
|
||||
33
app/frontend/hooks/useFetchProjects.ts
Normal file
33
app/frontend/hooks/useFetchProjects.ts
Normal 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;
|
||||
15
app/frontend/hooks/useFetchTags.ts
Normal file
15
app/frontend/hooks/useFetchTags.ts
Normal 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;
|
||||
0
app/frontend/hooks/useFetchTasks.ts
Normal file
0
app/frontend/hooks/useFetchTasks.ts
Normal file
71
app/frontend/hooks/useManageAreas.ts
Normal file
71
app/frontend/hooks/useManageAreas.ts
Normal 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;
|
||||
86
app/frontend/hooks/useManageNotes.ts
Normal file
86
app/frontend/hooks/useManageNotes.ts
Normal 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;
|
||||
67
app/frontend/hooks/useManageProjects.ts
Normal file
67
app/frontend/hooks/useManageProjects.ts
Normal 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;
|
||||
69
app/frontend/hooks/useManageTags.ts
Normal file
69
app/frontend/hooks/useManageTags.ts
Normal 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;
|
||||
76
app/frontend/hooks/useManageTasks.ts
Normal file
76
app/frontend/hooks/useManageTasks.ts
Normal 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
30
app/frontend/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
app/frontend/styles/tailwind.css
Normal file
10
app/frontend/styles/tailwind.css
Normal 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;
|
||||
}
|
||||
|
||||
18
app/frontend/utils/fetcher.ts
Normal file
18
app/frontend/utils/fetcher.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
66
app/routes/tags_routes.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
41
app/routes/users_routes.rb
Normal file
41
app/routes/users_routes.rb
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
10
app/views/index.erb
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue