Move to React
Add .gitignore Removed node_modules from previous commit Fix task modes Fix task modes Fix task modes Remove node_modules Update basic task modal Add notes functionality Improve UI Setup views Add scopes Fix projects layout Restructure Fix rest of the UI issues Cleanup old views Add .env to .gitignore
This commit is contained in:
parent
d06e124e5b
commit
dfcb97a355
125 changed files with 18516 additions and 1134 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
|
||||
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
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -5,6 +5,10 @@
|
|||

|
||||

|
||||
|
||||
## How It Works
|
||||
|
||||
This app allows users to manage their tasks, projects, areas, notes, and tags in an organized way. A user can create tasks, projects, areas (to group projects), notes, and tags. Each task can be associated with a project, and both tasks and notes can be tagged for better organization. Projects can belong to areas, and can also have multiple notes and tags. This structure helps users categorize and track their work efficiently, whether 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.
|
||||
|
|
@ -13,6 +17,8 @@
|
|||
- **Project Tracking**: Organize tasks into projects. Each project can contain multiple tasks and/or multiple notes.
|
||||
- **Area Categorization**: Group projects into areas for better organization and focus.
|
||||
- **Due Date Tracking**: Set due dates for tasks and view them based on due date categories.
|
||||
|
||||
## Roadmap (planned or in progress)
|
||||
- **Responsive Design (in progress)**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones.
|
||||
|
||||
## Getting Started
|
||||
|
|
@ -94,7 +100,7 @@ Pull the latest image:
|
|||
docker pull chrisvel/tududi:0.20
|
||||
```
|
||||
|
||||
In order to start the docker container you need 3 enviromental variables:
|
||||
In order to start the docker container you need 4 enviromental variables:
|
||||
|
||||
```bash
|
||||
TUDUDI_USER_EMAIL
|
||||
|
|
@ -130,7 +136,7 @@ TUDUDI_INTERNAL_SSL_ENABLED
|
|||
|
||||
To run tests:
|
||||
|
||||
```bash
|
||||
```bash
|
||||
bundle exec ruby -Itest test/test_app.rb
|
||||
```
|
||||
|
||||
|
|
|
|||
51
app.rb
51
app.rb
|
|
@ -18,6 +18,10 @@ require './app/routes/tasks_routes'
|
|||
require './app/routes/projects_routes'
|
||||
require './app/routes/areas_routes'
|
||||
require './app/routes/notes_routes'
|
||||
require './app/routes/tags_routes'
|
||||
require './app/routes/users_routes'
|
||||
|
||||
require 'sinatra/cross_origin'
|
||||
|
||||
helpers AuthenticationHelper
|
||||
|
||||
|
|
@ -30,7 +34,8 @@ set :public_folder, 'public'
|
|||
configure do
|
||||
enable :sessions
|
||||
set :sessions, httponly: true, secure: (production? && ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true'),
|
||||
expire_after: 2_592_000
|
||||
expire_after: 2_592_000,
|
||||
same_site: production? ? :none : :lax
|
||||
set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) }
|
||||
|
||||
# Auto-create user if not exists
|
||||
|
|
@ -49,6 +54,21 @@ before do
|
|||
require_login
|
||||
end
|
||||
|
||||
configure do
|
||||
enable :cross_origin
|
||||
end
|
||||
|
||||
before do
|
||||
response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8080' # Adjust based on frontend URL
|
||||
response.headers['Access-Control-Allow-Credentials'] = 'true' # Important for sending session cookies
|
||||
end
|
||||
|
||||
options '*' do
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept'
|
||||
200
|
||||
end
|
||||
|
||||
helpers TaskHelper
|
||||
|
||||
helpers do
|
||||
|
|
@ -100,17 +120,24 @@ helpers do
|
|||
end
|
||||
end
|
||||
|
||||
get '/' do
|
||||
redirect '/tasks?due_date=today'
|
||||
get '/*' do
|
||||
# redirect '/tasks?due_date=today'
|
||||
erb :index
|
||||
end
|
||||
|
||||
get '/inbox' do
|
||||
@tasks = current_user.tasks
|
||||
.incomplete
|
||||
.left_joins(:tags)
|
||||
.where(project_id: nil, due_date: nil)
|
||||
.where(tags: { id: nil }) # Filter tasks with no tags
|
||||
.order('tasks.created_at DESC')
|
||||
|
||||
erb :inbox
|
||||
not_found do
|
||||
content_type :json
|
||||
status 404
|
||||
{ error: 'Not Found', message: 'The requested resource could not be found.' }.to_json
|
||||
end
|
||||
|
||||
# get '/inbox' do
|
||||
# @tasks = current_user.tasks
|
||||
# .incomplete
|
||||
# .left_joins(:tags)
|
||||
# .where(project_id: nil, due_date: nil)
|
||||
# .where(tags: { id: nil }) # Filter tasks with no tags
|
||||
# .order('tasks.created_at DESC')
|
||||
|
||||
# erb :inbox
|
||||
# end
|
||||
|
|
|
|||
146
app/frontend/App.tsx
Normal file
146
app/frontend/App.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Routes, Route, useNavigate, Navigate } from "react-router-dom";
|
||||
import Login from "./Login";
|
||||
import Tasks from "./Tasks";
|
||||
import NotFound from "./NotFound";
|
||||
import ProjectDetails from "./components/Project/ProjectDetails";
|
||||
import Projects from "./Projects";
|
||||
import AreaDetails from "./components/Area/AreaDetails";
|
||||
import Areas from "./Areas";
|
||||
import TagDetails from "./components/Tag/TagDetails";
|
||||
import Tags from "./Tags";
|
||||
import Notes from "./Notes";
|
||||
import NoteDetails from "./components/Note/NoteDetails";
|
||||
import ProfileSettings from "./components/Profile/ProfileSettings";
|
||||
import Layout from "./Layout";
|
||||
import { DataProvider } from './contexts/DataContext'; // Import the DataProvider
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface User {
|
||||
email: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
if (storedPreference !== null) {
|
||||
return storedPreference === "true";
|
||||
} else {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/current_user", {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.user) {
|
||||
setCurrentUser(data.user);
|
||||
} else {
|
||||
navigate("/login");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch current user:", err);
|
||||
navigate("/login");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
}, [navigate]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDarkMode) {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.remove("dark");
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
if (localStorage.getItem("isDarkMode") === null) {
|
||||
setIsDarkMode(e.matches);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser && location.pathname === "/") {
|
||||
const options = { path: '/tasks?type=today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" /> }
|
||||
navigate(options.path, { state: { title: options.title, icon: options.icon }, replace: true });
|
||||
}
|
||||
}, [currentUser, location.pathname, navigate]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newValue = !isDarkMode;
|
||||
setIsDarkMode(newValue);
|
||||
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataProvider>
|
||||
{currentUser ? (
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to="/tasks" replace />}
|
||||
/>
|
||||
<Route path="/tasks" element={<Tasks />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/areas" element={<Areas />} />
|
||||
<Route path="/area/:id" element={<AreaDetails />} />
|
||||
<Route path="/tags" element={<Tags />} />
|
||||
<Route path="/tag/:id" element={<TagDetails />} />
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</DataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
177
app/frontend/Areas.tsx
Normal file
177
app/frontend/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 './components/Shared/ConfirmDialog';
|
||||
import AreaModal from './components/Area/AreaModal';
|
||||
import { useDataContext } from './contexts/DataContext';
|
||||
import { Area } from './entities/Area';
|
||||
|
||||
const Areas: React.FC = () => {
|
||||
const { areas, isLoading, isError, createArea, updateArea, deleteArea } = useDataContext();
|
||||
const [isAreaModalOpen, setIsAreaModalOpen] = useState<boolean>(false);
|
||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [areaToDelete, setAreaToDelete] = useState<Area | null>(null);
|
||||
|
||||
const handleSaveArea = async (areaData: Area) => {
|
||||
try {
|
||||
if (areaData.id) {
|
||||
await updateArea(areaData.id, {
|
||||
name: areaData.name,
|
||||
description: areaData.description,
|
||||
});
|
||||
} else {
|
||||
await createArea({
|
||||
name: areaData.name,
|
||||
description: areaData.description,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving area:', error);
|
||||
} finally {
|
||||
setIsAreaModalOpen(false);
|
||||
setSelectedArea(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditArea = (area: Area) => {
|
||||
setSelectedArea(area);
|
||||
setIsAreaModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateArea = () => {
|
||||
setSelectedArea(null);
|
||||
setIsAreaModalOpen(true);
|
||||
};
|
||||
|
||||
const openConfirmDialog = (area: Area) => {
|
||||
setAreaToDelete(area);
|
||||
setIsConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteArea = async () => {
|
||||
if (!areaToDelete) return;
|
||||
|
||||
try {
|
||||
await deleteArea(areaToDelete.id);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setAreaToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeConfirmDialog = () => {
|
||||
setIsConfirmDialogOpen(false);
|
||||
setAreaToDelete(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading areas...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-red-500 p-4">
|
||||
An error occurred while fetching areas.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
{/* Areas Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<Squares2X2Icon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
|
||||
Areas
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Areas List */}
|
||||
{areas.length === 0 ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">No areas found.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{areas.map((area) => (
|
||||
<li
|
||||
key={area.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
||||
>
|
||||
{/* Area Content */}
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<Link
|
||||
to={`/projects?area_id=${area.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
|
||||
>
|
||||
{area.name}
|
||||
</Link>
|
||||
{area.description && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
|
||||
{area.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditArea(area)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${area.name}`}
|
||||
title={`Edit ${area.name}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfirmDialog(area)}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${area.name}`}
|
||||
title={`Delete ${area.name}`}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* AreaModal */}
|
||||
{isAreaModalOpen && (
|
||||
<AreaModal
|
||||
isOpen={isAreaModalOpen}
|
||||
onClose={() => setIsAreaModalOpen(false)}
|
||||
onSave={handleSaveArea}
|
||||
area={selectedArea}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && areaToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Area"
|
||||
message={`Are you sure you want to delete the area "${areaToDelete.name}"?`}
|
||||
onConfirm={handleDeleteArea}
|
||||
onCancel={closeConfirmDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Areas;
|
||||
20
app/frontend/DarkModeToggle.tsx
Normal file
20
app/frontend/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;
|
||||
272
app/frontend/Layout.tsx
Normal file
272
app/frontend/Layout.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import React, { useState } from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import './styles/tailwind.css';
|
||||
import ProjectModal from './components/Project/ProjectModal';
|
||||
import NoteModal from './components/Note/NoteModal';
|
||||
import AreaModal from './components/Area/AreaModal';
|
||||
import TagModal from './components/Tag/TagModal';
|
||||
import { Note } from './entities/Note';
|
||||
import { Area } from './entities/Area';
|
||||
import { Tag } from './entities/Tag';
|
||||
import { useDataContext } from './contexts/DataContext'; // Import the data context
|
||||
|
||||
interface LayoutProps {
|
||||
currentUser: {
|
||||
email: string;
|
||||
};
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
currentUser,
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
children,
|
||||
}) => {
|
||||
// State for modals
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isAreaModalOpen, setIsAreaModalOpen] = useState(false);
|
||||
const [isTagModalOpen, setIsTagModalOpen] = useState(false);
|
||||
|
||||
// State for selected entities
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
|
||||
// Use context to fetch data
|
||||
const {
|
||||
tags,
|
||||
areas,
|
||||
notes,
|
||||
isLoading,
|
||||
isError,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
createArea,
|
||||
updateArea,
|
||||
deleteArea,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject
|
||||
} = useDataContext(); // Now includes project management functions
|
||||
|
||||
// Handler functions for modals
|
||||
const openNoteModal = (note: Note | null = null) => {
|
||||
setSelectedNote(note);
|
||||
setIsNoteModalOpen(true);
|
||||
};
|
||||
|
||||
const closeNoteModal = () => {
|
||||
setIsNoteModalOpen(false);
|
||||
setSelectedNote(null);
|
||||
};
|
||||
|
||||
const openProjectModal = () => {
|
||||
setIsProjectModalOpen(true);
|
||||
};
|
||||
|
||||
const closeProjectModal = () => {
|
||||
setIsProjectModalOpen(false);
|
||||
};
|
||||
|
||||
const openAreaModal = (area: Area | null = null) => {
|
||||
setSelectedArea(area);
|
||||
setIsAreaModalOpen(true);
|
||||
};
|
||||
|
||||
const closeAreaModal = () => {
|
||||
setIsAreaModalOpen(false);
|
||||
setSelectedArea(null);
|
||||
};
|
||||
|
||||
const openTagModal = (tag: Tag | null = null) => {
|
||||
setSelectedTag(tag);
|
||||
setIsTagModalOpen(true);
|
||||
};
|
||||
|
||||
const closeTagModal = () => {
|
||||
setIsTagModalOpen(false);
|
||||
setSelectedTag(null);
|
||||
};
|
||||
|
||||
// Handler for saving notes
|
||||
const handleSaveNote = async (noteData: Note) => {
|
||||
try {
|
||||
if (noteData.id) {
|
||||
// Update existing note
|
||||
await updateNote(noteData.id, {
|
||||
title: noteData.title,
|
||||
content: noteData.content,
|
||||
tags: noteData.tags?.map((tag) => tag.name),
|
||||
project_id: noteData.project?.id,
|
||||
});
|
||||
} else {
|
||||
// Create new note
|
||||
await createNote({
|
||||
title: noteData.title,
|
||||
content: noteData.content,
|
||||
tags: noteData.tags?.map((tag) => tag.name),
|
||||
project_id: noteData.project?.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving note:', error);
|
||||
}
|
||||
closeNoteModal();
|
||||
};
|
||||
|
||||
// Handler for saving projects
|
||||
const handleSaveProject = async (projectData: Project) => {
|
||||
try {
|
||||
if (projectData.id) {
|
||||
await updateProject(projectData.id, projectData);
|
||||
} else {
|
||||
await createProject(projectData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving project:', error);
|
||||
}
|
||||
closeProjectModal();
|
||||
};
|
||||
|
||||
// Handler for saving areas
|
||||
const handleSaveArea = async (areaData: Area) => {
|
||||
try {
|
||||
if (areaData.id) {
|
||||
await updateArea(areaData.id, areaData);
|
||||
} else {
|
||||
await createArea(areaData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving area:', error);
|
||||
}
|
||||
closeAreaModal();
|
||||
};
|
||||
|
||||
// Handler for saving tags
|
||||
const handleSaveTag = async (tagData: Tag) => {
|
||||
try {
|
||||
if (tagData.id) {
|
||||
await updateTag(tagData.id, tagData);
|
||||
} else {
|
||||
await createTag(tagData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving tag:', error);
|
||||
}
|
||||
closeTagModal();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`min-h-screen flex ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
currentUser={currentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
openProjectModal={openProjectModal}
|
||||
openNoteModal={openNoteModal}
|
||||
openAreaModal={openAreaModal}
|
||||
openTagModal={openTagModal}
|
||||
notes={notes}
|
||||
areas={areas}
|
||||
tags={tags}
|
||||
/>
|
||||
<div className="flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800">
|
||||
<div className="text-xl text-gray-700 dark:text-gray-200">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className={`min-h-screen flex ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
currentUser={currentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
openProjectModal={openProjectModal}
|
||||
openNoteModal={openNoteModal}
|
||||
openAreaModal={openAreaModal}
|
||||
openTagModal={openTagModal}
|
||||
notes={notes}
|
||||
areas={areas}
|
||||
tags={tags}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800">
|
||||
<div className="text-xl text-red-500">Error fetching data.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex ${isDarkMode ? 'dark' : ''}`}>
|
||||
<Sidebar
|
||||
currentUser={currentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
openProjectModal={openProjectModal}
|
||||
openNoteModal={openNoteModal}
|
||||
openAreaModal={openAreaModal}
|
||||
openTagModal={openTagModal}
|
||||
notes={notes}
|
||||
areas={areas}
|
||||
tags={tags}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 overflow-y-auto h-screen">
|
||||
<div className="flex-grow p-6 pt-20 overflow-y-auto">
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProjectModalOpen && (
|
||||
<ProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={closeProjectModal}
|
||||
onSave={handleSaveProject}
|
||||
areas={areas}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNoteModalOpen && (
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
onClose={closeNoteModal}
|
||||
onSave={handleSaveNote}
|
||||
note={selectedNote}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAreaModalOpen && (
|
||||
<AreaModal
|
||||
isOpen={isAreaModalOpen}
|
||||
onClose={closeAreaModal}
|
||||
onSave={handleSaveArea}
|
||||
area={selectedArea}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTagModalOpen && (
|
||||
<TagModal
|
||||
isOpen={isTagModalOpen}
|
||||
onClose={closeTagModal}
|
||||
onSave={handleSaveTag}
|
||||
tag={selectedTag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
101
app/frontend/Login.tsx
Normal file
101
app/frontend/Login.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// src/Login.tsx
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Login successful:', data);
|
||||
navigate('/tasks?type=today&order_by=due_date%3Aasc');
|
||||
} else {
|
||||
setError(data.errors[0] || 'Login failed. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
console.error('Error during login:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 flex items-center justify-center min-h-screen px-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center text-gray-700">
|
||||
Login
|
||||
</h2>
|
||||
{error && (
|
||||
<div className="mb-4 text-center text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-gray-600 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-gray-600 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-500 text-white py-2 rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-6 text-center">
|
||||
<a href="#" className="text-blue-500 hover:underline">
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
46
app/frontend/NewTask.tsx
Normal file
46
app/frontend/NewTask.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useToast } from './components/Shared/ToastContext'; // Adjust the import path accordingly
|
||||
|
||||
interface NewTaskProps {
|
||||
onTaskCreate: (taskName: string) => void;
|
||||
}
|
||||
|
||||
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
||||
const [taskName, setTaskName] = useState<string>('');
|
||||
const { showSuccessToast, showErrorToast } = useToast(); // Use the toast functions
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskName(event.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && taskName.trim()) {
|
||||
try {
|
||||
await onTaskCreate(taskName.trim());
|
||||
setTaskName('');
|
||||
showSuccessToast('Task created successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
showErrorToast('Failed to create task.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-4 mb-2 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
|
||||
<span className="text-xl text-gray-500 dark:text-gray-400 mr-4">
|
||||
<i className="bi bi-plus-circle"></i>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={taskName}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-medium text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none"
|
||||
placeholder="Add New Task"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTask;
|
||||
12
app/frontend/NotFound.tsx
Normal file
12
app/frontend/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;
|
||||
164
app/frontend/Notes.tsx
Normal file
164
app/frontend/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 './components/Note/NoteModal';
|
||||
import ConfirmDialog from './components/Shared/ConfirmDialog';
|
||||
import { useDataContext } from './contexts/DataContext';
|
||||
import { Note } from './entities/Note';
|
||||
|
||||
const Notes: React.FC = () => {
|
||||
const { notes, createNote, updateNote, deleteNote, isLoading, isError } = useDataContext();
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const handleDeleteNote = async () => {
|
||||
if (!noteToDelete) return;
|
||||
try {
|
||||
await deleteNote(noteToDelete.id);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setNoteToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Error deleting note:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditNote = (note: Note) => {
|
||||
setSelectedNote(note);
|
||||
setIsNoteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveNote = async (noteData: { id: number; }) => {
|
||||
try {
|
||||
if (noteData.id) {
|
||||
await updateNote(noteData.id, noteData);
|
||||
} else {
|
||||
await createNote(noteData);
|
||||
}
|
||||
setIsNoteModalOpen(false);
|
||||
setSelectedNote(null);
|
||||
} catch (err) {
|
||||
console.error('Error saving note:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredNotes = notes.filter(
|
||||
(note) =>
|
||||
note.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
note.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading notes...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">Error loading notes.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
{/* Notes Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">Notes</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar with Icon */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes List */}
|
||||
{filteredNotes.length === 0 ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">No notes found.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{filteredNotes.map((note) => (
|
||||
<li key={note.id} className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center">
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
|
||||
>
|
||||
{note.title}
|
||||
</Link>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 truncate">
|
||||
{note.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditNote(note)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${note.title}`}
|
||||
title={`Edit ${note.title}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNoteToDelete(note);
|
||||
setIsConfirmDialogOpen(true);
|
||||
}}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${note.title}`}
|
||||
title={`Delete ${note.title}`}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* NoteModal */}
|
||||
{isNoteModalOpen && (
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
onClose={() => setIsNoteModalOpen(false)}
|
||||
onSave={handleSaveNote}
|
||||
note={selectedNote}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && noteToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Note"
|
||||
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
|
||||
onConfirm={handleDeleteNote}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notes;
|
||||
313
app/frontend/Projects.tsx
Normal file
313
app/frontend/Projects.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Project } from "./entities/Project";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import ConfirmDialog from "./components/Shared/ConfirmDialog";
|
||||
import ProjectModal from "./components/Project/ProjectModal";
|
||||
import { useDataContext } from "./contexts/DataContext";
|
||||
import useFetchProjects from "./hooks/useFetchProjects";
|
||||
|
||||
// Utility function to generate initials
|
||||
const getProjectInitials = (name: string) => {
|
||||
const words = name.trim().split(' ').filter(word => word.length > 0); // Filter out any empty strings
|
||||
if (words.length === 1) {
|
||||
return name.toUpperCase();
|
||||
}
|
||||
return words.map(word => word[0].toUpperCase()).join('');
|
||||
};
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const { areas, createProject, updateProject, deleteProject } = useDataContext();
|
||||
|
||||
const [taskStatusCounts, setTaskStatusCounts] = useState<Record<number, any>>({});
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
|
||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); // To track which dropdown is active
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Get filters from URL query parameters
|
||||
const activeFilter = searchParams.get("active");
|
||||
const areaFilter = searchParams.get("area_id") || "";
|
||||
|
||||
// Fetch projects with current filters
|
||||
const {
|
||||
projects,
|
||||
taskStatusCounts: fetchedTaskStatusCounts,
|
||||
isLoading,
|
||||
isError,
|
||||
mutate,
|
||||
} = useFetchProjects(activeFilter, areaFilter);
|
||||
|
||||
// Update local task status counts when fetched data changes
|
||||
useEffect(() => {
|
||||
setTaskStatusCounts(fetchedTaskStatusCounts);
|
||||
}, [fetchedTaskStatusCounts]);
|
||||
|
||||
// Calculate the completion percentage for the project
|
||||
const getCompletionPercentage = (projectId: number) => {
|
||||
const taskStatus = taskStatusCounts[projectId] || {};
|
||||
const totalTasks =
|
||||
(taskStatus.done || 0) +
|
||||
(taskStatus.not_started || 0) +
|
||||
(taskStatus.in_progress || 0);
|
||||
|
||||
if (totalTasks === 0) return 0;
|
||||
|
||||
return Math.round((taskStatus.done / totalTasks) * 100);
|
||||
};
|
||||
|
||||
// Handle project save (either create or update)
|
||||
const handleSaveProject = async (project: Project) => {
|
||||
if (project.id) {
|
||||
await updateProject(project.id, project);
|
||||
} else {
|
||||
await createProject(project);
|
||||
}
|
||||
setIsProjectModalOpen(false);
|
||||
mutate(); // Refetch projects after save
|
||||
};
|
||||
|
||||
// Open edit modal and populate form data
|
||||
const handleEditProject = (project: Project) => {
|
||||
setProjectToEdit(project);
|
||||
setIsProjectModalOpen(true);
|
||||
};
|
||||
|
||||
// Handle delete project
|
||||
const handleDeleteProject = async () => {
|
||||
if (!projectToDelete) return;
|
||||
await deleteProject(projectToDelete.id);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setProjectToDelete(null);
|
||||
mutate();
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const handleActiveFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newActiveFilter = e.target.value;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
if (newActiveFilter === "all") {
|
||||
params.delete("active"); // Remove 'active' filter when "All" is selected
|
||||
} else {
|
||||
params.set("active", newActiveFilter); // Set 'active' filter to 'true' or 'false'
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
const handleAreaFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newAreaFilter = e.target.value;
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (newAreaFilter) {
|
||||
params.set("area_id", newAreaFilter);
|
||||
} else {
|
||||
params.delete("area_id");
|
||||
}
|
||||
setSearchParams(params);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading projects...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">Error loading projects.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group projects by area
|
||||
const groupedProjects = projects.reduce<Record<string, Project[]>>(
|
||||
(acc, project) => {
|
||||
const areaName = project.area ? project.area.name : "Uncategorized";
|
||||
if (!acc[areaName]) acc[areaName] = [];
|
||||
acc[areaName].push(project);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-6xl">
|
||||
<div className="flex items-center mb-8">
|
||||
<i className="bi bi-folder-fill text-xl mr-2"></i>
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
|
||||
Projects
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Filters for Active Status and Area */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:space-x-4 mb-6">
|
||||
<div className="mb-4 md:mb-0 w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="activeFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="activeFilter"
|
||||
value={activeFilter || "all"} // Use "all" when no active filter is selected
|
||||
onChange={handleActiveFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-1/3">
|
||||
<label
|
||||
htmlFor="areaFilter"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Area
|
||||
</label>
|
||||
<select
|
||||
id="areaFilter"
|
||||
value={areaFilter}
|
||||
onChange={handleAreaFilterChange}
|
||||
className="block w-full p-2 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All Areas</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id.toString()}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.keys(groupedProjects).length === 0 ? (
|
||||
<div className="text-gray-700 dark:text-gray-300">
|
||||
No projects found.
|
||||
</div>
|
||||
) : (
|
||||
Object.keys(groupedProjects).map((areaName) => (
|
||||
<React.Fragment key={areaName}>
|
||||
<h3 className="col-span-full text-md uppercase font-light text-gray-800 dark:text-gray-200 mb-4">
|
||||
{areaName}
|
||||
</h3>
|
||||
|
||||
{groupedProjects[areaName].map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative"
|
||||
style={{ minHeight: "280px", maxHeight: "280px" }} // Increased card height for image space
|
||||
>
|
||||
<div className="bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden rounded-t-lg" style={{ height: "160px" }}>
|
||||
<span className="text-2xl font-extrabold text-gray-500 dark:text-gray-400 opacity-20">
|
||||
{getProjectInitials(project.name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start p-4">
|
||||
<Link
|
||||
to={`/project/${project.id}`}
|
||||
className="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline line-clamp-2"
|
||||
style={{ minHeight: "3.3rem", maxHeight: "3.3rem" }} // Fixed title height
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-400 focus:outline-none"
|
||||
onClick={() => setActiveDropdown(activeDropdown === project.id ? null : project.id)}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{activeDropdown === project.id && (
|
||||
<div className="absolute right-0 mt-2 w-28 bg-white dark:bg-gray-700 shadow-lg rounded-md z-10">
|
||||
<button
|
||||
onClick={() => handleEditProject(project)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setProjectToDelete(project);
|
||||
setIsConfirmDialogOpen(true);
|
||||
setActiveDropdown(null);
|
||||
}}
|
||||
className="block px-4 py-2 text-sm text-red-500 dark:text-red-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-0 right-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{
|
||||
width: `${getCompletionPercentage(project.id)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getCompletionPercentage(project.id)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Modal */}
|
||||
{isProjectModalOpen && (
|
||||
<ProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => {
|
||||
setIsProjectModalOpen(false);
|
||||
setProjectToEdit(null);
|
||||
}}
|
||||
onSave={handleSaveProject}
|
||||
project={projectToEdit || undefined}
|
||||
areas={areas}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
message={`Are you sure you want to delete the project "${projectToDelete?.name}"?`}
|
||||
onConfirm={handleDeleteProject}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
124
app/frontend/Sidebar.tsx
Normal file
124
app/frontend/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
// src/components/Sidebar.tsx
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import SidebarAreas from './components/Sidebar/SidebarAreas';
|
||||
import SidebarFooter from './components/Sidebar/SidebarFooter';
|
||||
import SidebarHeader from './components/Sidebar/SidebarHeader';
|
||||
import SidebarNav from './components/Sidebar/SidebarNav';
|
||||
import SidebarProjects from './components/Sidebar/SidebarProjects';
|
||||
import SidebarTags from './components/Sidebar/SidebarTags';
|
||||
import SidebarNotes from './components/Sidebar/SidebarNotes';
|
||||
import { Note } from './entities/Note';
|
||||
import { Area } from './entities/Area';
|
||||
import { Tag } from './entities/Tag';
|
||||
|
||||
interface SidebarProps {
|
||||
currentUser: { email: string };
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
openProjectModal: () => void;
|
||||
openNoteModal: (note: Note | null) => void;
|
||||
openAreaModal: (area: Area | null) => void;
|
||||
openTagModal: (tag: Tag | null) => void;
|
||||
notes: Note[];
|
||||
areas: Area[];
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
currentUser,
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
openProjectModal,
|
||||
openNoteModal,
|
||||
openAreaModal,
|
||||
openTagModal,
|
||||
notes,
|
||||
areas,
|
||||
tags,
|
||||
}) => {
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const handleNavClick = (path: string, title: string, icon: string) => {
|
||||
navigate(path, { state: { title, icon } });
|
||||
setIsSidebarOpen(false);
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden p-4 bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
<button
|
||||
className="flex items-center focus:outline-none"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`fixed lg:static z-50 h-screen w-72 bg-white dark:bg-gray-900 text-gray-900 dark:text-white lg:translate-x-0 transform transition-transform duration-300 ease-in-out ${
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
} lg:flex lg:flex-col flex-shrink-0`}
|
||||
>
|
||||
<div className="flex flex-col h-full overflow-y-auto p-3">
|
||||
<SidebarHeader />
|
||||
<SidebarNav
|
||||
handleNavClick={handleNavClick}
|
||||
location={location}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<SidebarProjects
|
||||
handleNavClick={handleNavClick}
|
||||
location={location}
|
||||
isDarkMode={isDarkMode}
|
||||
openProjectModal={openProjectModal}
|
||||
/>
|
||||
<SidebarNotes
|
||||
handleNavClick={handleNavClick}
|
||||
openNoteModal={openNoteModal}
|
||||
notes={notes}
|
||||
location={location}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<SidebarAreas
|
||||
handleNavClick={handleNavClick}
|
||||
areas={areas}
|
||||
location={location}
|
||||
isDarkMode={isDarkMode}
|
||||
openAreaModal={openAreaModal}
|
||||
/>
|
||||
<SidebarTags
|
||||
handleNavClick={handleNavClick}
|
||||
location={location}
|
||||
isDarkMode={isDarkMode}
|
||||
openTagModal={openTagModal}
|
||||
tags={tags}
|
||||
/>
|
||||
<SidebarFooter
|
||||
currentUser={currentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
isDropdownOpen={isDropdownOpen}
|
||||
toggleDropdown={toggleDropdown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
67
app/frontend/TagInput.tsx
Normal file
67
app/frontend/TagInput.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { TagIcon } from '@heroicons/react/24/solid';
|
||||
import React, { useState } from 'react';
|
||||
import TaskTags from './components/Task/TaskTags'; // Import TaskTags
|
||||
|
||||
interface TagInputProps {
|
||||
initialTags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
availableTags: string[]; // Available tags to preload
|
||||
}
|
||||
|
||||
const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availableTags = [] }) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [tags, setTags] = useState<string[]>(initialTags || []);
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
};
|
||||
|
||||
// Handle key press (Enter) to add the tag
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && inputValue.trim()) {
|
||||
event.preventDefault(); // Prevent form submission
|
||||
const trimmedValue = inputValue.trim();
|
||||
if (!tags.includes(trimmedValue)) {
|
||||
const updatedTags = [...tags, trimmedValue];
|
||||
setTags(updatedTags); // Update internal state
|
||||
onTagsChange(updatedTags); // Notify parent
|
||||
}
|
||||
setInputValue(''); // Clear the input
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing a tag
|
||||
const removeTag = (tagToRemoveId: number) => {
|
||||
const updatedTags = tags.filter((_, index) => index !== tagToRemoveId);
|
||||
setTags(updatedTags); // Update internal state
|
||||
onTagsChange(updatedTags); // Notify parent
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<TaskTags
|
||||
tags={tags.map((tag, index) => ({ id: index, name: tag }))}
|
||||
onTagRemove={removeTag}
|
||||
className="flex flex-wrap gap-2"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
list="available-tags"
|
||||
placeholder="Type to select an existing tag or add a new one"
|
||||
className="w-full px-2 border border-gray-300 dark:border-gray-900 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 py-2 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<datalist id="available-tags">
|
||||
{availableTags.map((tag, index) => (
|
||||
<option key={index} value={tag} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagInput;
|
||||
174
app/frontend/Tags.tsx
Normal file
174
app/frontend/Tags.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilSquareIcon, TrashIcon, PlusCircleIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||
import ConfirmDialog from './components/Shared/ConfirmDialog';
|
||||
import TagModal from './components/Tag/TagModal';
|
||||
import { useDataContext } from './contexts/DataContext';
|
||||
|
||||
const Tags: React.FC = () => {
|
||||
const { tags, createTag, updateTag, deleteTag, isLoading, isError } = useDataContext();
|
||||
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>(''); // State for search input
|
||||
|
||||
const handleDeleteTag = async () => {
|
||||
if (!tagToDelete) return;
|
||||
try {
|
||||
await deleteTag(tagToDelete.id);
|
||||
setIsConfirmDialogOpen(false);
|
||||
setTagToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tag:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setSelectedTag(tag);
|
||||
setIsTagModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateTag = () => {
|
||||
setSelectedTag(null);
|
||||
setIsTagModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTag = async (tagData: Tag) => {
|
||||
try {
|
||||
if (tagData.id) {
|
||||
await updateTag(tagData.id, tagData);
|
||||
} else {
|
||||
await createTag(tagData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save tag:', err);
|
||||
}
|
||||
|
||||
setIsTagModalOpen(false);
|
||||
setSelectedTag(null);
|
||||
};
|
||||
|
||||
const openConfirmDialog = (tag: Tag) => {
|
||||
setTagToDelete(tag);
|
||||
setIsConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeConfirmDialog = () => {
|
||||
setIsConfirmDialogOpen(false);
|
||||
setTagToDelete(null);
|
||||
};
|
||||
|
||||
const filteredTags = tags.filter(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading tags...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className="text-red-500 p-4">Error loading tags</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
{/* Tags Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<TagIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">Tags</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar with Icon */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags List */}
|
||||
{filteredTags.length === 0 ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">No tags found.</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredTags.map((tag) => (
|
||||
<li
|
||||
key={tag.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
||||
>
|
||||
{/* Tag Content */}
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<Link
|
||||
to={`/tag/${tag.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${tag.name}`}
|
||||
title={`Edit ${tag.name}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfirmDialog(tag)}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${tag.name}`}
|
||||
title={`Delete ${tag.name}`}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* TagModal */}
|
||||
{isTagModalOpen && (
|
||||
<TagModal
|
||||
isOpen={isTagModalOpen}
|
||||
onClose={() => setIsTagModalOpen(false)}
|
||||
onSave={handleSaveTag}
|
||||
tag={selectedTag}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && tagToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Tag"
|
||||
message={`Are you sure you want to delete the tag "${tagToDelete.name}"?`}
|
||||
onConfirm={handleDeleteTag}
|
||||
onCancel={closeConfirmDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tags;
|
||||
321
app/frontend/Tasks.tsx
Normal file
321
app/frontend/Tasks.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import TaskList from "./components/Task/TaskList";
|
||||
import NewTask from "./NewTask";
|
||||
import { Task } from "./entities/Task";
|
||||
import { Project } from "./entities/Project";
|
||||
import { getTitleAndIcon } from "./components/Task/getTitleAndIcon";
|
||||
import { getDescription } from "./components/Task/getDescription";
|
||||
import { TagIcon, XMarkIcon } from "@heroicons/react/24/solid"; // Import X icon for removing tag
|
||||
|
||||
// Helper function to capitalize the first letter of a string
|
||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
const Tasks: React.FC = () => {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
|
||||
const [orderBy, setOrderBy] = useState<string>("due_date:asc"); // State for sorting
|
||||
const dropdownRef = useRef<HTMLDivElement>(null); // Reference to the dropdown
|
||||
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const query = new URLSearchParams(location.search);
|
||||
const { title: stateTitle, icon: stateIcon } = location.state || {};
|
||||
|
||||
const { title, icon } =
|
||||
stateTitle && stateIcon
|
||||
? { title: stateTitle, icon: stateIcon }
|
||||
: getTitleAndIcon(query, projects);
|
||||
|
||||
// Extract tag from query params
|
||||
const tag = query.get("tag");
|
||||
|
||||
// Load orderBy from localStorage or use default
|
||||
useEffect(() => {
|
||||
const savedOrderBy = localStorage.getItem("order_by") || "due_date:asc";
|
||||
setOrderBy(savedOrderBy);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (!params.get("order_by")) {
|
||||
params.set("order_by", savedOrderBy); // Set the default to URL if not present
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
});
|
||||
}
|
||||
}, [location, navigate]);
|
||||
|
||||
// Close dropdown if clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
// Fetch data when location changes
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Fetch tasks with the selected tag if present
|
||||
const tagId = query.get("tag");
|
||||
const [tasksResponse, projectsResponse] = await Promise.all([
|
||||
fetch(`/api/tasks${location.search}${tagId ? `&tag=${tagId}` : ""}`),
|
||||
fetch("/api/projects"),
|
||||
]);
|
||||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
setTasks(tasksData || []);
|
||||
} else {
|
||||
throw new Error("Failed to fetch tasks.");
|
||||
}
|
||||
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
setProjects(projectsData?.projects || []);
|
||||
} else {
|
||||
throw new Error("Failed to fetch projects.");
|
||||
}
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [location]);
|
||||
|
||||
// Function to remove the tag from the URL
|
||||
const handleRemoveTag = () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("tag"); // Remove tag from query params
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`, // Update the URL without the tag parameter
|
||||
});
|
||||
};
|
||||
|
||||
// Function to create a new task
|
||||
const handleTaskCreate = async (taskData: Partial<Task>) => {
|
||||
try {
|
||||
const response = await fetch("/api/task", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(taskData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newTask = await response.json();
|
||||
setTasks((prevTasks) => [newTask, ...prevTasks]);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error("Failed to create task:", errorData.error);
|
||||
setError("Failed to create task.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating task:", error);
|
||||
setError("Error creating task.");
|
||||
}
|
||||
};
|
||||
|
||||
// Function to update an existing task
|
||||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${updatedTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updatedTask),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task.id === updatedTask.id ? updatedTask : task
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
setError("Error updating task.");
|
||||
}
|
||||
};
|
||||
|
||||
// Function to delete a task
|
||||
const handleTaskDelete = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${taskId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error("Failed to delete task:", errorData.error);
|
||||
setError("Failed to delete task.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
setError("Error deleting task.");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sorting changes
|
||||
const handleSortChange = (order: string) => {
|
||||
setOrderBy(order);
|
||||
localStorage.setItem("order_by", order); // Save the selected order to localStorage
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("order_by", order); // Update or add the order_by param
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
});
|
||||
setDropdownOpen(false); // Close dropdown on selection
|
||||
};
|
||||
|
||||
// Get the description for the current task view
|
||||
const description = getDescription(query, projects);
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
{" "}
|
||||
{/* Center the content with padding */}
|
||||
<div className="w-full max-w-4xl">
|
||||
{" "}
|
||||
{/* Limit the width to 3xl (48rem) */}
|
||||
{/* Title and Icon */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<i className={`bi ${icon} text-xl mr-2`}></i>
|
||||
<h2 className="text-2xl font-light">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* If tag exists, display it as a styled button with an X to remove */}
|
||||
{tag && (
|
||||
<div className="ml-4 flex items-center space-x-2">
|
||||
<button
|
||||
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
onClick={handleRemoveTag}
|
||||
>
|
||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{capitalize(tag)}</span>
|
||||
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-300 hover:text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
<div className="relative inline-block text-left" ref={dropdownRef}>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-700 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
id="menu-button"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
<i className="bi bi-sort-alpha-down me-2"></i>{" "}
|
||||
{capitalize(orderBy.split(":")[0].replace("_", " "))}
|
||||
<svg
|
||||
className="-mr-1 ml-2 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.292 7.707a1 1 0 011.414 0L10 11.414l3.293-3.707a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
className="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
>
|
||||
<div className="py-1" role="none">
|
||||
{[
|
||||
"due_date:asc",
|
||||
"name:asc",
|
||||
"priority:desc",
|
||||
"status:desc",
|
||||
"created_at:desc",
|
||||
].map((order) => (
|
||||
<button
|
||||
key={order}
|
||||
onClick={() => handleSortChange(order)}
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 w-full text-left"
|
||||
>
|
||||
{capitalize(order.split(":")[0].replace("_", " "))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
{loading ? (
|
||||
<p>Loading...</p>
|
||||
) : error ? (
|
||||
<p className="text-red-500">{error}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* New Task Form */}
|
||||
<NewTask
|
||||
onTaskCreate={(taskName: string) =>
|
||||
handleTaskCreate({ name: taskName, status: "not_started" })
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Task List */}
|
||||
{tasks.length > 0 ? (
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
onTaskCreate={handleTaskCreate}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center mt-4">
|
||||
No tasks available.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
||||
54
app/frontend/components/Area/AreaDetails.tsx
Normal file
54
app/frontend/components/Area/AreaDetails.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useDataContext } from '../../contexts/DataContext'; // Import the DataContext
|
||||
|
||||
const AreaDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { areas, isLoading, isError } = useDataContext(); // Get areas and loading/error state from DataContext
|
||||
const [area, setArea] = useState<any | null>(null); // Allow flexibility in the type for now
|
||||
|
||||
useEffect(() => {
|
||||
// Find the area with the matching ID from the DataContext
|
||||
const foundArea = areas.find((a) => a.id === Number(id));
|
||||
setArea(foundArea || null);
|
||||
}, [id, areas]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading area details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !area) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="textpro-red-500 text-lg">
|
||||
{isError ? 'Error loading area details.' : 'Area not found.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-4xl mx-auto bg-white dark:bg-gray-800 shadow-lg rounded-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Area: {area?.name}
|
||||
</h2>
|
||||
<p className="text-md text-gray-700 dark:text-gray-300">{area?.description}</p>
|
||||
<Link
|
||||
to={`/projects?area_id=${area?.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline mt-4 block"
|
||||
>
|
||||
View Projects in {area?.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaDetails;
|
||||
175
app/frontend/components/Area/AreaModal.tsx
Normal file
175
app/frontend/components/Area/AreaModal.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useDataContext } from '../../contexts/DataContext'; // Import DataContext
|
||||
|
||||
interface AreaModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
area?: Area | null;
|
||||
}
|
||||
|
||||
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area }) => {
|
||||
const { createArea, updateArea } = useDataContext(); // Use create and update methods from DataContext
|
||||
const [formData, setFormData] = useState<Area>({
|
||||
id: area?.id || 0,
|
||||
name: area?.name || '',
|
||||
description: area?.description || '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
// Synchronize formData with the area prop when the modal opens or area changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
id: area?.id || 0,
|
||||
name: area?.name || '',
|
||||
description: area?.description || '',
|
||||
});
|
||||
setError(null); // Reset error when modal opens
|
||||
}
|
||||
}, [isOpen, area]);
|
||||
|
||||
// Close modal when clicking outside of it
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle input changes
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Basic validation
|
||||
if (!formData.name.trim()) {
|
||||
setError('Area name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (formData.id && formData.id !== 0) {
|
||||
await updateArea(formData.id, formData); // Call updateArea from DataContext
|
||||
} else {
|
||||
await createArea(formData); // Call createArea from DataContext
|
||||
}
|
||||
onClose(); // Close modal after success
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-50 z-50">
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-auto overflow-hidden"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 id="modal-title" className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{formData.id && formData.id !== 0 ? 'Edit Area' : 'Create Area'}
|
||||
</h3>
|
||||
|
||||
{/* Area Name */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="areaName"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Area Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="areaName"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter area name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Area Description */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="areaDescription"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="areaDescription"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter area description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end items-center p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 mr-2 text-xs py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`px-4 text-xs py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none ${
|
||||
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : formData.id && formData.id !== 0 ? 'Update Area' : 'Create Area'}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AreaModal;
|
||||
176
app/frontend/components/Note/NoteDetails.tsx
Normal file
176
app/frontend/components/Note/NoteDetails.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { PencilSquareIcon, TrashIcon, TagIcon } from '@heroicons/react/24/solid';
|
||||
import { useDataContext } from '../../contexts/DataContext'; // Import the DataContext
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import NoteModal from './NoteModal';
|
||||
import { Note } from '../../entities/Note'; // Adjust path as necessary
|
||||
|
||||
const NoteDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { notes, deleteNote, isLoading, isError } = useDataContext(); // Get notes and deleteNote from context
|
||||
const [note, setNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); // State for the modal
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Find the note with the matching ID from the context
|
||||
const foundNote = notes.find((n) => n.id === Number(id));
|
||||
setNote(foundNote || null);
|
||||
}, [id, notes]);
|
||||
|
||||
const handleDeleteNote = async () => {
|
||||
if (!noteToDelete) return;
|
||||
try {
|
||||
await deleteNote(noteToDelete.id);
|
||||
navigate('/notes'); // Navigate back to the notes list after deletion
|
||||
} catch (err) {
|
||||
console.error('Error deleting note:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNote = (updatedNote: Note) => {
|
||||
setNote(updatedNote); // Update the note after saving
|
||||
setIsNoteModalOpen(false); // Close modal after saving
|
||||
};
|
||||
|
||||
const handleEditNote = () => {
|
||||
setIsNoteModalOpen(true); // Open the modal when editing
|
||||
};
|
||||
|
||||
const handleOpenConfirmDialog = (note: Note) => {
|
||||
setNoteToDelete(note);
|
||||
setIsConfirmDialogOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading note details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !note) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">
|
||||
{isError ? 'Error loading note details.' : 'Note not found.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
{/* Header Section with Title and Action Buttons */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<i className="bi bi-journal-text text-xl mr-2"></i>
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
|
||||
{note.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleEditNote}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${note.title}`}
|
||||
title={`Edit ${note.title}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenConfirmDialog(note)}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${note.title}`}
|
||||
title={`Delete ${note.title}`}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card with Tags and Metadata */}
|
||||
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
|
||||
{/* Note Tags */}
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mt-2 flex flex-wrap space-x-2">
|
||||
{note.tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => navigate(`/tasks?tag=${tag.name}`)}
|
||||
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Metadata */}
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
|
||||
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
{/* Note Project */}
|
||||
{note.project && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Project</h3>
|
||||
<Link
|
||||
to={`/project/${note.project.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{note.project.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note Content */}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||
{note.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* NoteModal for editing */}
|
||||
{isNoteModalOpen && (
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen}
|
||||
onClose={() => setIsNoteModalOpen(false)}
|
||||
onSave={handleSaveNote}
|
||||
note={note} // Pass the current note to the modal for editing
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ConfirmDialog */}
|
||||
{isConfirmDialogOpen && noteToDelete && (
|
||||
<ConfirmDialog
|
||||
title="Delete Note"
|
||||
message={`Are you sure you want to delete the note "${noteToDelete.title}"?`}
|
||||
onConfirm={handleDeleteNote}
|
||||
onCancel={() => {
|
||||
setIsConfirmDialogOpen(false);
|
||||
setNoteToDelete(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteDetails;
|
||||
198
app/frontend/components/Note/NoteModal.tsx
Normal file
198
app/frontend/components/Note/NoteModal.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import TagInput from '../../TagInput'; // Adjust the import path
|
||||
import { Note } from '../../entities/Note'; // Import the centralized Note type
|
||||
import { useDataContext } from '../../contexts/DataContext'; // Use DataContext
|
||||
|
||||
interface NoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
note?: Note | null; // If null, it's for new note creation
|
||||
onSave?: (note: Note) => void; // Optional callback for saving
|
||||
}
|
||||
|
||||
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note }) => {
|
||||
const { createNote, updateNote } = useDataContext(); // Use create and update methods from DataContext
|
||||
const [formData, setFormData] = useState<Note>(
|
||||
note || {
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
}
|
||||
);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch available tags when the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tags', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAvailableTags(data.map((tag: { name: string }) => tag.name));
|
||||
} else {
|
||||
console.error('Failed to fetch available tags');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching available tags:', err);
|
||||
}
|
||||
};
|
||||
fetchAvailableTags();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close modal if clicked outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle input change
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle tags change
|
||||
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: newTags.map((tagName) => ({ id: null, name: tagName })),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Handle form submit
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.title.trim() || !formData.content.trim()) {
|
||||
setError('Title and Content are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (note?.id) {
|
||||
await updateNote(note.id, formData); // Call updateNote if editing
|
||||
} else {
|
||||
await createNote(formData); // Call createNote if creating
|
||||
}
|
||||
onClose(); // Close modal after saving
|
||||
} catch (err) {
|
||||
console.error('Error saving note:', err);
|
||||
setError('Failed to save note.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-2xl mx-auto overflow-hidden"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Note Title */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="noteTitle"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Note Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="noteTitle"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter note title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Note Content */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="noteContent"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={5}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter note content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Tags
|
||||
</label>
|
||||
<TagInput
|
||||
initialTags={formData?.tags?.map((tag) => tag.name) || []}
|
||||
onTagsChange={handleTagsChange}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && <div className="text-red-500 mb-4">{error}</div>}
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end items-center p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 mr-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{note?.id ? 'Update Note' : 'Create Note'}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteModal;
|
||||
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-4xl mx-auto p-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Profile Settings
|
||||
</h2>
|
||||
|
||||
{success && <div className="mb-4 text-green-500">{success}</div>}
|
||||
{error && <div className="mb-4 text-red-500">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Appearance Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Appearance
|
||||
</label>
|
||||
<select
|
||||
name="appearance"
|
||||
value={formData.appearance}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Language Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
name="language"
|
||||
value={formData.language}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
{/* Add more languages if necessary */}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Timezone Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Timezone
|
||||
</label>
|
||||
<select
|
||||
name="timezone"
|
||||
value={formData.timezone}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Avatar Image Upload */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Avatar Image
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="mt-1 block w-full text-sm text-gray-500 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-200 dark:hover:file:bg-gray-600"
|
||||
/>
|
||||
{formData.avatar_image && (
|
||||
<img
|
||||
src={formData.avatar_image}
|
||||
alt="Avatar Preview"
|
||||
className="mt-2 h-24 w-24 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettings;
|
||||
205
app/frontend/components/Project/ProjectDetails.tsx
Normal file
205
app/frontend/components/Project/ProjectDetails.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useLocation, useNavigate, Link } from 'react-router-dom';
|
||||
import { PencilSquareIcon, TrashIcon, Squares2X2Icon } from '@heroicons/react/24/solid';
|
||||
import NewTask from '../../NewTask';
|
||||
import TaskList from '../Task/TaskList';
|
||||
import ProjectModal from '../Project/ProjectModal';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useDataContext } from '../../contexts/DataContext';
|
||||
|
||||
const ProjectDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { areas, createArea, updateArea, deleteArea } = useDataContext();
|
||||
const { projects, updateProject, deleteProject } = useDataContext();
|
||||
|
||||
const [project, setProject] = useState<any>(null);
|
||||
const [tasks, setTasks] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
|
||||
const { title: stateTitle, icon: stateIcon } = location.state || {};
|
||||
const projectTitle = stateTitle || project?.name || 'Project';
|
||||
const projectIcon = stateIcon || 'bi-folder-fill';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/project/${id}`, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setProject(data);
|
||||
setTasks(data.tasks || []);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to fetch project.');
|
||||
}
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProject();
|
||||
}, [id]);
|
||||
|
||||
const handleTaskCreate = async (taskData: Partial<any>) => {
|
||||
if (!project?.id) {
|
||||
console.error('Project ID is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the project_id to the taskData payload
|
||||
const taskPayload = {
|
||||
...taskData,
|
||||
project_id: project.id,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/task`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(taskPayload),
|
||||
});
|
||||
|
||||
const newTask = await response.json();
|
||||
if (response.ok) {
|
||||
setTasks([...tasks, newTask]);
|
||||
} else {
|
||||
throw new Error(newTask.error || 'Failed to create task');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating task:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskUpdate = async (updatedTask: any) => {
|
||||
// Simulated function for task update
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number) => {
|
||||
// Simulated function for task deletion
|
||||
};
|
||||
|
||||
const handleEditProject = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveProject = async (updatedProject: any) => {
|
||||
try {
|
||||
await updateProject(updatedProject.id, updatedProject);
|
||||
setProject(updatedProject);
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Error saving project:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
await deleteProject(project.id);
|
||||
navigate('/projects');
|
||||
} catch (err) {
|
||||
console.error('Error deleting project:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Loading project details...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<i className={`bi ${projectIcon} text-xl mr-2`}></i>
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">{projectTitle}</h2>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleEditProject}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsConfirmDialogOpen(true)}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{project?.area && (
|
||||
<div className="flex items-center mb-4">
|
||||
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
|
||||
<Link
|
||||
to={`/projects/?area_id=${project?.area.id}`}
|
||||
className="text-gray-600 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{project.area.name.toUpperCase()}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project?.description && (
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* Create a new task for this project */}
|
||||
<NewTask onTaskCreate={(taskName: string) => handleTaskCreate({ name: taskName, status: 'not_started', project: project })} />
|
||||
|
||||
<TaskList tasks={tasks} onTaskCreate={handleTaskCreate} onTaskUpdate={handleTaskUpdate} onTaskDelete={handleTaskDelete} projects={[project]} />
|
||||
|
||||
<ProjectModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveProject}
|
||||
project={project || undefined}
|
||||
areas={areas}
|
||||
/>
|
||||
|
||||
{isConfirmDialogOpen && (
|
||||
<ConfirmDialog
|
||||
title="Delete Project"
|
||||
message={`Are you sure you want to delete the project "${project?.name}"?`}
|
||||
onConfirm={handleDeleteProject}
|
||||
onCancel={() => setIsConfirmDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetails;
|
||||
191
app/frontend/components/Project/ProjectModal.tsx
Normal file
191
app/frontend/components/Project/ProjectModal.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
interface ProjectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (project: Project) => void;
|
||||
onDelete?: () => void;
|
||||
project?: Project;
|
||||
areas: Area[];
|
||||
}
|
||||
|
||||
const ProjectModal: React.FC<ProjectModalProps> = ({ isOpen, onClose, onSave, onDelete, project, areas }) => {
|
||||
const [formData, setFormData] = useState<Project>(
|
||||
project || {
|
||||
name: '',
|
||||
description: '',
|
||||
area_id: null,
|
||||
active: true,
|
||||
pin_to_sidebar: false,
|
||||
}
|
||||
);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project) {
|
||||
setFormData(project);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div ref={modalRef} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-lg mx-auto overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* Project Name */}
|
||||
<div>
|
||||
<label htmlFor="projectName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projectName"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="projectDescription" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="projectDescription"
|
||||
name="description"
|
||||
rows={3}
|
||||
value={formData.description || ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter project description (optional)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{/* Area */}
|
||||
<div>
|
||||
<label htmlFor="projectArea" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Area (optional)
|
||||
</label>
|
||||
<select
|
||||
id="projectArea"
|
||||
name="area_id"
|
||||
value={formData.area_id || ''}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">No Area</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Active Checkbox */}
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="active"
|
||||
name="active"
|
||||
checked={formData.active}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 appearance-none border border-gray-300 rounded-md bg-white dark:bg-gray-700 checked:bg-blue-600 checked:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="active" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Custom Pin to Sidebar Checkbox */}
|
||||
{/* <div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="pin_to_sidebar"
|
||||
name="pin_to_sidebar"
|
||||
checked={formData.pin_to_sidebar}
|
||||
onChange={handleChange}
|
||||
className="h-5 w-5 appearance-none border border-gray-300 rounded-md bg-white dark:bg-gray-700 checked:bg-blue-600 checked:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="pin_to_sidebar" className="ml-2 block text-sm text-gray-700 dark:text-gray-300">
|
||||
Pin to Sidebar
|
||||
</label>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-between items-center p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{project && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<div className={`flex space-x-2 ${!project ? 'ml-auto' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1 text-sm bg-blue-600 dark:bg-blue-500 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600"
|
||||
>
|
||||
{project ? 'Update Project' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectModal;
|
||||
37
app/frontend/components/Shared/ConfirmDialog.tsx
Normal file
37
app/frontend/components/Shared/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// src/components/ConfirmDialog.tsx
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ title, message, onConfirm, onCancel }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded shadow-lg">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">{title}</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6">{message}</p>
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 focus:outline-none"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDialog;
|
||||
81
app/frontend/components/Shared/PriorityDropdown.tsx
Normal file
81
app/frontend/components/Shared/PriorityDropdown.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDownIcon, ArrowDownIcon, ArrowUpIcon, FireIcon } from '@heroicons/react/24/outline'; // Import the icons
|
||||
|
||||
interface PriorityDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Low', icon: <ArrowDownIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'medium', label: 'Medium', icon: <ArrowUpIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'high', label: 'High', icon: <FireIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
|
||||
];
|
||||
|
||||
const PriorityDropdown: React.FC<PriorityDropdownProps> = ({ value, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (priority: string) => {
|
||||
onChange(priority);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedPriority = priorities.find(p => p.value === value);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative inline-block text-left w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{selectedPriority ? selectedPriority.icon : ''}
|
||||
<span>{selectedPriority ? selectedPriority.label : 'Select Priority'}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md">
|
||||
{priorities.map((priority) => (
|
||||
<button
|
||||
key={priority.value}
|
||||
onClick={() => handleSelect(priority.value)}
|
||||
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{priority.icon} <span>{priority.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityDropdown;
|
||||
82
app/frontend/components/Shared/StatusDropdown.tsx
Normal file
82
app/frontend/components/Shared/StatusDropdown.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDownIcon, MinusIcon, ClockIcon, CheckCircleIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline'; // Import Heroicons
|
||||
|
||||
interface StatusDropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const statuses = [
|
||||
{ value: 'not_started', label: 'Not Started', icon: <MinusIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'in_progress', label: 'In Progress', icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'done', label: 'Done', icon: <CheckCircleIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'archived', label: 'Archived', icon: <ArchiveBoxIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
];
|
||||
|
||||
const StatusDropdown: React.FC<StatusDropdownProps> = ({ value, onChange }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (status: string) => {
|
||||
onChange(status);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedStatus = statuses.find(s => s.value === value);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative inline-block text-left w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="flex items-center space-x-2"> {/* Added space-x-3 here */}
|
||||
{selectedStatus ? selectedStatus.icon : ''}
|
||||
<span>{selectedStatus ? selectedStatus.label : 'Select Status'}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md">
|
||||
{statuses.map((status) => (
|
||||
<button
|
||||
key={status.value}
|
||||
onClick={() => handleSelect(status.value)}
|
||||
className="flex items-center justify-between space-x-2 px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{status.icon} <span>{status.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusDropdown;
|
||||
62
app/frontend/components/Shared/ToastContext.tsx
Normal file
62
app/frontend/components/Shared/ToastContext.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
// Define the shape of the context
|
||||
interface ToastContextProps {
|
||||
showSuccessToast: (message: string) => void;
|
||||
showErrorToast: (message: string) => void;
|
||||
}
|
||||
|
||||
// Create a context with default values
|
||||
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
|
||||
|
||||
// Toast provider component
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||
|
||||
// Show success toast
|
||||
const showSuccessToast = useCallback((message: string) => {
|
||||
setToastMessage(message);
|
||||
setToastType('success');
|
||||
setTimeout(() => setToastMessage(null), 3000); // Auto-hide after 3 seconds
|
||||
}, []);
|
||||
|
||||
// Show error toast
|
||||
const showErrorToast = useCallback((message: string) => {
|
||||
setToastMessage(message);
|
||||
setToastType('error');
|
||||
setTimeout(() => setToastMessage(null), 3000); // Auto-hide after 3 seconds
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showSuccessToast, showErrorToast }}>
|
||||
{children}
|
||||
{toastMessage && <Toast message={toastMessage} type={toastType} onClose={() => setToastMessage(null)} />}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook to use the ToastContext
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Toast component
|
||||
const Toast: React.FC<{ message: string; type: 'success' | 'error'; onClose: () => void }> = ({ message, type, onClose }) => {
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button onClick={onClose} className="ml-4">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
app/frontend/components/Sidebar/SidebarAreas.tsx
Normal file
57
app/frontend/components/Sidebar/SidebarAreas.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// src/components/Sidebar/SidebarAreas.tsx
|
||||
|
||||
import React from 'react';
|
||||
import { Squares2X2Icon, PlusCircleIcon } from '@heroicons/react/24/solid'; // Using solid style
|
||||
import { Area } from '../../entities/Area'; // Adjust the import path
|
||||
|
||||
interface SidebarAreasProps {
|
||||
handleNavClick: (path: string, title: string, icon: string) => void;
|
||||
location: Location;
|
||||
isDarkMode: boolean;
|
||||
openAreaModal: () => void; // Modify to not require an area parameter for creation
|
||||
}
|
||||
|
||||
const SidebarAreas: React.FC<SidebarAreasProps> = ({
|
||||
handleNavClick,
|
||||
location,
|
||||
isDarkMode,
|
||||
openAreaModal,
|
||||
}) => {
|
||||
const isActiveArea = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="flex flex-col space-y-1">
|
||||
{/* "AREAS" Title with Add Button */}
|
||||
<li
|
||||
className={`flex justify-between items-center px-4 py-2 rounded-md uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveArea(
|
||||
'/areas'
|
||||
)}`}
|
||||
onClick={() => handleNavClick('/areas', 'Areas', 'squares2x2')}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Squares2X2Icon className="h-5 w-5 mr-2" />
|
||||
AREAS
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering the parent onClick
|
||||
openAreaModal(); // Open modal for creating a new area
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
aria-label="Add Area"
|
||||
title="Add Area"
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarAreas;
|
||||
68
app/frontend/components/Sidebar/SidebarFooter.tsx
Normal file
68
app/frontend/components/Sidebar/SidebarFooter.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface SidebarFooterProps {
|
||||
currentUser: { email: string };
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
isDropdownOpen: boolean;
|
||||
toggleDropdown: () => void;
|
||||
}
|
||||
|
||||
const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||
currentUser,
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
isDropdownOpen,
|
||||
toggleDropdown,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-auto">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<div className="relative">
|
||||
<button
|
||||
className="flex justify-center items-center text-gray-700 dark:text-gray-300 w-full focus:outline-none"
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
<strong>{currentUser.email}</strong>
|
||||
<i className={`ml-2 bi ${isDropdownOpen ? 'bi-caret-up-fill' : 'bi-caret-down-fill'}`}></i>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isDropdownOpen && (
|
||||
<ul className="absolute bottom-full mb-2 w-full bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-lg shadow-lg z-50">
|
||||
<li className="hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block w-full px-4 py-2 text-left"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</li>
|
||||
<li className="border-t border-gray-200 dark:border-gray-700"></li>
|
||||
<li className="hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">
|
||||
<a
|
||||
href="/logout"
|
||||
className="block w-full px-4 py-2 text-left text-red-500"
|
||||
>
|
||||
Sign out
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
id="darkModeToggle"
|
||||
className="mt-3 w-full flex items-center justify-center text-gray-700 dark:text-gray-300"
|
||||
onClick={toggleDarkMode}
|
||||
>
|
||||
<i className={`bi ${isDarkMode ? 'bi-sun' : 'bi-moon'} text-lg`}></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarFooter;
|
||||
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) => void;
|
||||
location: Location;
|
||||
isDarkMode: boolean;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/tasks?type=today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/tasks?type=upcoming', title: 'Upcoming', icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks?type=next', title: 'Next Actions', icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
|
||||
{ path: '/tasks?type=inbox', title: 'Inbox', icon: <InboxIcon className="h-5 w-5" />, query: 'type=inbox' },
|
||||
{ path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
|
||||
{ path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
|
||||
{ path: '/tasks?status=done', title: 'Completed', icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
|
||||
{ path: '/tasks', title: 'All Tasks', icon: <ListBulletIcon className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location, isDarkMode }) => {
|
||||
const isActive = (path: string, query?: string) => {
|
||||
const isPathMatch = location.pathname === '/tasks';
|
||||
const isQueryMatch = query ? location.search.includes(query) : location.search === '';
|
||||
return isPathMatch && isQueryMatch
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col space-y-1">
|
||||
{navLinks.map((link) => (
|
||||
<li key={link.path}>
|
||||
<button
|
||||
onClick={() => handleNavClick(link.path, link.title)}
|
||||
className={`w-full text-left px-4 py-1 flex items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
|
||||
link.path,
|
||||
link.query
|
||||
)}`}
|
||||
>
|
||||
{link.icon}
|
||||
<span className="ml-2">{link.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarNav;
|
||||
59
app/frontend/components/Sidebar/SidebarNotes.tsx
Normal file
59
app/frontend/components/Sidebar/SidebarNotes.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// src/components/Sidebar/SidebarNotes.tsx
|
||||
|
||||
import React from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { BookOpenIcon, PlusCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
||||
interface SidebarNotesProps {
|
||||
handleNavClick: (path: string, title: string, icon: string) => void;
|
||||
location: Location;
|
||||
isDarkMode: boolean;
|
||||
openNoteModal: (note: Note | null) => void;
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
const SidebarNotes: React.FC<SidebarNotesProps> = ({
|
||||
handleNavClick,
|
||||
location,
|
||||
isDarkMode,
|
||||
openNoteModal,
|
||||
notes,
|
||||
}) => {
|
||||
const isActiveNote = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="flex flex-col space-y-1">
|
||||
<li
|
||||
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveNote(
|
||||
'/notes'
|
||||
)}`}
|
||||
onClick={() => handleNavClick('/notes', 'Notes', 'book')}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<BookOpenIcon className="h-5 w-5 mr-2" />
|
||||
NOTES
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openNoteModal(null);
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
aria-label="Add Note"
|
||||
title="Add Note"
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarNotes;
|
||||
77
app/frontend/components/Sidebar/SidebarProjects.tsx
Normal file
77
app/frontend/components/Sidebar/SidebarProjects.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// src/components/Sidebar/SidebarProjects.tsx
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { FolderIcon, PlusCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
interface SidebarProjectsProps {
|
||||
handleNavClick: (path: string, title: string, icon: string) => void;
|
||||
location: Location;
|
||||
isDarkMode: boolean;
|
||||
openProjectModal: () => void; // Add this prop
|
||||
}
|
||||
|
||||
const SidebarProjects: React.FC<SidebarProjectsProps> = ({
|
||||
handleNavClick,
|
||||
location,
|
||||
isDarkMode,
|
||||
openProjectModal,
|
||||
}) => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects?pin_to_sidebar=true'); // Fetch only pinned projects
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setProjects(data.projects || []);
|
||||
} else {
|
||||
console.error('Failed to fetch projects:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error);
|
||||
}
|
||||
};
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const isActiveProject = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="flex flex-col space-y-1 mt-4">
|
||||
{/* "PROJECTS" Title with Add Button */}
|
||||
<li
|
||||
className={`flex justify-between items-center px-4 py-2 uppercase rounded-md text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveProject(
|
||||
'/projects'
|
||||
)}`}
|
||||
onClick={() => handleNavClick('/projects', 'Projects', 'folder')}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<FolderIcon className="h-5 w-5 mr-2" />
|
||||
PROJECTS
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering the parent onClick
|
||||
openProjectModal(); // Open the modal
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
aria-label="Add Project"
|
||||
title="Add Project"
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarProjects;
|
||||
58
app/frontend/components/Sidebar/SidebarTags.tsx
Normal file
58
app/frontend/components/Sidebar/SidebarTags.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { TagIcon, PlusCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
|
||||
interface SidebarTagsProps {
|
||||
handleNavClick: (path: string, title: string, icon: string) => void;
|
||||
location: Location;
|
||||
isDarkMode: boolean;
|
||||
openTagModal: (tag: Tag | null) => void;
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
const SidebarTags: React.FC<SidebarTagsProps> = ({
|
||||
handleNavClick,
|
||||
location,
|
||||
isDarkMode,
|
||||
openTagModal,
|
||||
tags,
|
||||
}) => {
|
||||
const isActiveTag = (path: string) => {
|
||||
return location.pathname === path
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="flex flex-col space-y-1">
|
||||
{/* "TAGS" Title with Add Button */}
|
||||
<li
|
||||
className={`flex justify-between items-center rounded-md px-4 py-2 uppercase text-xs tracking-wider cursor-pointer hover:text-black dark:hover:text-white ${isActiveTag(
|
||||
'/tags'
|
||||
)}`}
|
||||
onClick={() => handleNavClick('/tags', 'Tags', 'tag')}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<TagIcon className="h-5 w-5 mr-2" />
|
||||
TAGS
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent triggering the parent onClick
|
||||
openTagModal(null); // Open the modal for creating a new tag
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
aria-label="Add Tag"
|
||||
title="Add Tag"
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarTags;
|
||||
76
app/frontend/components/Tag/TagDetails.tsx
Normal file
76
app/frontend/components/Tag/TagDetails.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const TagDetails: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [tag, setTag] = useState<Tag | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate(); // Use the `useNavigate` hook for navigation
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTag = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/tag/${id}`);
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setTag(data);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch tag.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error fetching tag.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTag();
|
||||
}, [id]);
|
||||
|
||||
// Function to handle the redirection to tasks with the tag
|
||||
const handleViewTasks = () => {
|
||||
if (tag) {
|
||||
navigate(`/tasks?tag=${encodeURIComponent(tag.name)}`); // Redirect to the tasks page with the tag as a query param
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-700 dark:text-gray-300">Loading tag details...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
return <div className="text-gray-700 dark:text-gray-300">Tag not found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">Tag Details</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Name:</strong> {tag.name}
|
||||
</p>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<strong>Status:</strong> {tag.active ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
|
||||
{/* "View tasks with this tag" button */}
|
||||
<button
|
||||
onClick={handleViewTasks}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
View tasks with this tag
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagDetails;
|
||||
113
app/frontend/components/Tag/TagModal.tsx
Normal file
113
app/frontend/components/Tag/TagModal.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// src/components/Tag/TagModal.tsx
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
|
||||
interface TagModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (tag: Tag) => void;
|
||||
tag?: Tag;
|
||||
}
|
||||
|
||||
const TagModal: React.FC<TagModalProps> = ({ isOpen, onClose, onSave, tag }) => {
|
||||
const [formData, setFormData] = useState<Tag>(
|
||||
tag || {
|
||||
name: '',
|
||||
}
|
||||
);
|
||||
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close modal if clicked outside of it
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Update form state when editing a tag
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
setFormData(tag);
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
});
|
||||
}
|
||||
}, [tag]);
|
||||
|
||||
// Handle form input changes
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
|
||||
<div ref={modalRef} className="bg-white dark:bg-gray-800 rounded-lg shadow-lg w-full max-w-md mx-auto overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* Tag Name */}
|
||||
<div>
|
||||
<label htmlFor="tagName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Tag Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tagName"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Enter tag name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex justify-end items-center p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1 text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-2 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-600"
|
||||
>
|
||||
{tag ? 'Update Tag' : 'Create Tag'}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagModal;
|
||||
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-xs text-white bg-red-500 rounded hover:bg-red-600"
|
||||
>
|
||||
<i className="bi bi-trash mr-2"></i> Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-3 py-1.5 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskActions;
|
||||
44
app/frontend/components/Task/TaskDueDate.tsx
Normal file
44
app/frontend/components/Task/TaskDueDate.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
interface TaskDueDateProps {
|
||||
dueDate: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TaskDueDate: React.FC<TaskDueDateProps> = ({ dueDate, className }) => {
|
||||
const getDueDateClass = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
if (dueDate === today) return 'bg-blue-700 text-white';
|
||||
if (dueDate === tomorrow) return 'bg-blue-700 text-white';
|
||||
if (dueDate < today) return 'bg-red-700 text-white';
|
||||
return 'bg-gray-300 text-gray-700';
|
||||
};
|
||||
|
||||
const formatDueDate = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
if (dueDate === today) return 'TODAY';
|
||||
if (dueDate === tomorrow) return 'TOMORROW';
|
||||
if (dueDate === yesterday) return 'YESTERDAY';
|
||||
|
||||
// Format due date into a human-readable format
|
||||
return new Date(dueDate).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center text-xs py-1 px-2 rounded-md ${getDueDateClass()} ${className}`}>
|
||||
<i className="bi bi-clock mr-1"></i>
|
||||
{formatDueDate()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDueDate;
|
||||
70
app/frontend/components/Task/TaskHeader.tsx
Normal file
70
app/frontend/components/Task/TaskHeader.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React from "react";
|
||||
import TaskPriorityIcon from "./TaskPriorityIcon";
|
||||
import TaskTags from "./TaskTags";
|
||||
import TaskStatusBadge from "./TaskStatusBadge";
|
||||
import TaskDueDate from "./TaskDueDate";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { Task } from "../../entities/Task";
|
||||
|
||||
interface TaskHeaderProps {
|
||||
task: Task;
|
||||
project?: Project;
|
||||
onTaskClick: (e: React.MouseEvent) => void; // For opening the modal
|
||||
}
|
||||
|
||||
const TaskHeader: React.FC<TaskHeaderProps> = ({ task, project, onTaskClick }) => {
|
||||
return (
|
||||
<div className="py-2 px-4 cursor-pointer" onClick={onTaskClick}>
|
||||
{/* Full view (md and larger) */}
|
||||
<div className="hidden md:flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
{/* First Line (Task Priority, Name, and Project) */}
|
||||
<div className="flex items-center space-x-4 mb-2 md:mb-0">
|
||||
<TaskPriorityIcon priority={task.priority} status={task.status} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{task.name}
|
||||
</span>
|
||||
{project && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{project.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Second Line (Tags, Due Date, Status) */}
|
||||
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-4">
|
||||
<TaskTags tags={task.tags} />
|
||||
{task.due_date && <TaskDueDate dueDate={task.due_date} />}
|
||||
<TaskStatusBadge status={task.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile view (below md breakpoint) */}
|
||||
<div className="block md:hidden">
|
||||
{/* First Line (Priority Icon and Task Title) */}
|
||||
<div className="flex items-center mb-2">
|
||||
<TaskPriorityIcon priority={task.priority} status={task.status} />
|
||||
<span className="ml-2 font-medium text-sm text-gray-900 dark:text-gray-100">
|
||||
{task.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second Line (Status Icon and Due Date) */}
|
||||
<div className="flex items-center mb-2 pl-6">
|
||||
<TaskStatusBadge status={task.status} />
|
||||
{task.due_date && (
|
||||
<TaskDueDate dueDate={task.due_date} className="ml-2" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Third Line (Tags, indented) */}
|
||||
<div className="pl-6">
|
||||
<TaskTags tags={task.tags} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskHeader;
|
||||
84
app/frontend/components/Task/TaskItem.tsx
Normal file
84
app/frontend/components/Task/TaskItem.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import TaskHeader from './TaskHeader';
|
||||
import TaskModal from './TaskModal';
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
onTaskUpdate: (task: Task) => void;
|
||||
onTaskDelete: (taskId: number) => void;
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
const TaskItem: React.FC<TaskItemProps> = ({
|
||||
task,
|
||||
onTaskUpdate,
|
||||
onTaskDelete,
|
||||
projects,
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [projectList, setProjectList] = useState<Project[]>(projects); // Keep track of projects
|
||||
|
||||
const handleTaskClick = () => {
|
||||
setIsModalOpen(true); // Open the modal when task title is clicked
|
||||
};
|
||||
|
||||
const handleSave = (updatedTask: Task) => {
|
||||
onTaskUpdate(updatedTask); // Save the updated task
|
||||
setIsModalOpen(false); // Close the modal after saving
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (task.id) {
|
||||
onTaskDelete(task.id); // Delete the task
|
||||
}
|
||||
};
|
||||
|
||||
// Function to create a new project
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const response = await fetch('/api/project', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, active: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create project');
|
||||
}
|
||||
|
||||
const newProject = await response.json();
|
||||
// Update local project list
|
||||
setProjectList((prevProjects) => [...prevProjects, newProject]);
|
||||
return newProject;
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Find the project associated with this task
|
||||
const project = projectList.find((p) => p.id === task.project_id);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1">
|
||||
<TaskHeader task={task} project={project} onTaskClick={handleTaskClick} />
|
||||
|
||||
{/* Task Modal for editing */}
|
||||
<TaskModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
task={task}
|
||||
onSave={handleSave}
|
||||
onDelete={onTaskDelete}
|
||||
projects={projectList} // Pass updated project list to modal
|
||||
onCreateProject={handleCreateProject} // Pass project creation function
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskItem;
|
||||
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;
|
||||
0
app/frontend/components/Task/TaskMeta.tsx
Normal file
0
app/frontend/components/Task/TaskMeta.tsx
Normal file
330
app/frontend/components/Task/TaskModal.tsx
Normal file
330
app/frontend/components/Task/TaskModal.tsx
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Task } from '../../entities/Task';
|
||||
import TagInput from '../../TagInput';
|
||||
import TaskActions from './TaskActions';
|
||||
import PriorityDropdown from '../Shared/PriorityDropdown';
|
||||
import StatusDropdown from '../Shared/StatusDropdown';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import { useToast } from '../Shared/ToastContext'; // Import the toast hook
|
||||
|
||||
interface Tag {
|
||||
id?: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
task: Task;
|
||||
onSave: (task: Task) => void;
|
||||
onDelete: (taskId: number) => void;
|
||||
projects: Project[];
|
||||
onCreateProject: (name: string) => Promise<Project>;
|
||||
}
|
||||
|
||||
const TaskModal: React.FC<TaskModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
task,
|
||||
onSave,
|
||||
onDelete,
|
||||
projects,
|
||||
onCreateProject,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Task>(task);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [tags, setTags] = useState<string[]>(task.tags?.map(tag => tag.name) || []);
|
||||
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects);
|
||||
const [newProjectName, setNewProjectName] = useState<string>('');
|
||||
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false); // State to control confirm dialog
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast(); // Use toast functions
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(task);
|
||||
setTags(task.tags?.map(tag => tag.name) || []);
|
||||
}, [task]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetch('/api/tags')
|
||||
.then((response) => response.json())
|
||||
.then((data) => setAvailableTags(data.map((tag: Tag) => tag.name)))
|
||||
.catch((error) => console.error('Failed to fetch tags', error));
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagsChange = useCallback((newTags: string[]) => {
|
||||
setTags(newTags);
|
||||
}, []);
|
||||
|
||||
const handleProjectSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const query = e.target.value.toLowerCase();
|
||||
setNewProjectName(query);
|
||||
setDropdownOpen(true);
|
||||
setFilteredProjects(
|
||||
projects.filter((project) =>
|
||||
project.name.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleProjectSelection = (project: Project) => {
|
||||
setFormData({ ...formData, project_id: project.id });
|
||||
setNewProjectName(project.name);
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (newProjectName.trim() !== '') {
|
||||
setIsCreatingProject(true);
|
||||
try {
|
||||
const newProject = await onCreateProject(newProjectName);
|
||||
setFormData({ ...formData, project_id: newProject.id });
|
||||
setFilteredProjects([...filteredProjects, newProject]);
|
||||
setNewProjectName(newProject.name);
|
||||
setDropdownOpen(false);
|
||||
showSuccessToast('Project created successfully!');
|
||||
} catch (error) {
|
||||
showErrorToast('Failed to create project.');
|
||||
console.error('Error creating project:', error);
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave({ ...formData, tags: tags.map(tag => ({ name: tag })) });
|
||||
showSuccessToast('Task updated successfully!');
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowConfirmDialog(true); // Show confirmation dialog
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (formData.id) {
|
||||
onDelete(formData.id);
|
||||
showSuccessToast('Task deleted successfully!');
|
||||
setShowConfirmDialog(false);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed inset-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50 transition-opacity duration-300 ${
|
||||
isClosing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 rounded-lg shadow-2xl w-full max-w-3xl mx-auto overflow-hidden transform transition-transform duration-300 ${
|
||||
isClosing ? 'scale-95' : 'scale-100'
|
||||
}`}
|
||||
style={{ maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<form>
|
||||
<fieldset>
|
||||
<div className="p-4 space-y-3 flex-grow text-sm">
|
||||
{/* Task Name */}
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
id={`task_name_${task.id}`}
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold border-none focus:outline-none shadow-sm py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-200"
|
||||
placeholder="Add Task Name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="pb-3">
|
||||
<label
|
||||
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Tags
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
initialTags={formData.tags?.map((tag) => tag.name) || []}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project */}
|
||||
<div className="pb-3">
|
||||
<label
|
||||
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search or create a project..."
|
||||
value={newProjectName}
|
||||
onChange={handleProjectSearch}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
{dropdownOpen && newProjectName && (
|
||||
<div className="absolute mt-1 bg-white dark:bg-gray-900 shadow-md rounded-md w-full z-10">
|
||||
{filteredProjects.length > 0 ? (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => handleProjectSelection(project)}
|
||||
className="block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-gray-500 dark:text-gray-300">
|
||||
No matching projects
|
||||
</div>
|
||||
)}
|
||||
{newProjectName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateProject}
|
||||
disabled={isCreatingProject}
|
||||
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
{isCreatingProject ? 'Creating...' : `+ Create "${newProjectName}"`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and Priority */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pb-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Status
|
||||
</label>
|
||||
<StatusDropdown
|
||||
value={formData.status}
|
||||
onChange={(value) => setFormData({ ...formData, status: value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Priority
|
||||
</label>
|
||||
<PriorityDropdown
|
||||
value={formData.priority || 'medium'}
|
||||
onChange={(value) => setFormData({ ...formData, priority: value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id={`task_due_date_${task.id}`}
|
||||
name="due_date"
|
||||
value={formData.due_date || ''}
|
||||
onChange={handleChange}
|
||||
className="block w-full focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-900 rounded-md text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Note
|
||||
</label>
|
||||
<textarea
|
||||
id={`task_note_${task.id}`}
|
||||
name="note"
|
||||
rows={3}
|
||||
value={formData.note || ''}
|
||||
onChange={handleChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm p-3 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
placeholder="Add any additional notes here"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Actions */}
|
||||
<div className="p-3 border-t dark:border-gray-700">
|
||||
<TaskActions
|
||||
taskId={task.id}
|
||||
onDelete={handleDeleteClick}
|
||||
onSave={handleSubmit}
|
||||
onCancel={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmDialog && (
|
||||
<ConfirmDialog
|
||||
title="Delete Task"
|
||||
message="Are you sure you want to delete this task? This action cannot be undone."
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setShowConfirmDialog(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskModal;
|
||||
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;
|
||||
49
app/frontend/components/Task/TaskStatusBadge.tsx
Normal file
49
app/frontend/components/Task/TaskStatusBadge.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
import { MinusIcon, CheckCircleIcon, ArchiveBoxIcon, ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
interface TaskStatusBadgeProps {
|
||||
status: string;
|
||||
className?: string; // Allows passing custom classes for spacing
|
||||
}
|
||||
|
||||
const TaskStatusBadge: React.FC<TaskStatusBadgeProps> = ({ status, className }) => {
|
||||
let statusIcon, statusLabel, badgeClass;
|
||||
|
||||
switch (status) {
|
||||
case 'not_started':
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = 'Not Started';
|
||||
badgeClass = 'border-gray-400 text-gray-400 dark:text-gray-400 dark:border-gray-700';
|
||||
break;
|
||||
case 'in_progress':
|
||||
statusIcon = <ArrowPathIcon className="h-4 w-4 text-blue-400" />;
|
||||
statusLabel = 'In Progress';
|
||||
badgeClass = 'border-blue-400 text-blue-400 dark:text-blue-400 dark:border-blue-700';
|
||||
break;
|
||||
case 'done':
|
||||
statusIcon = <CheckCircleIcon className="h-4 w-4 text-green-400" />;
|
||||
statusLabel = 'Done';
|
||||
badgeClass = 'border-green-400 text-green-400 dark:text-green-400 dark:border-green-700';
|
||||
break;
|
||||
case 'archived':
|
||||
statusIcon = <ArchiveBoxIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = 'Archived';
|
||||
badgeClass = 'border-gray-400 text-gray-400 dark:text-gray-400 dark:border-gray-700';
|
||||
break;
|
||||
default:
|
||||
statusIcon = <MinusIcon className="h-4 w-4 text-gray-400" />;
|
||||
statusLabel = 'Unknown';
|
||||
badgeClass = 'border-gray-400 text-gray-400 dark:text-gray-400 dark:border-gray-700';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center px-2 py-1 rounded-md border ${badgeClass} w-10 ${className}`}
|
||||
>
|
||||
{statusIcon}
|
||||
<span className="text-xs font-medium"></span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskStatusBadge;
|
||||
39
app/frontend/components/Task/TaskTags.tsx
Normal file
39
app/frontend/components/Task/TaskTags.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { TagIcon } from '@heroicons/react/24/solid';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TaskTagsProps {
|
||||
tags: Tag[];
|
||||
className?: string; // Allows passing custom classes for spacing
|
||||
}
|
||||
|
||||
const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], className }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Function to handle tag click and navigate to a filtered view
|
||||
const handleTagClick = (tagName: string) => {
|
||||
navigate(`/tasks?tag=${tagName}`); // Navigate to tasks filtered by the clicked tag
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex space-x-2 ${className}`}>
|
||||
{tags.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id || index}
|
||||
onClick={() => handleTagClick(tag.name)}
|
||||
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTags;
|
||||
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.';
|
||||
};
|
||||
27
app/frontend/components/Task/getTitleAndIcon.ts
Normal file
27
app/frontend/components/Task/getTitleAndIcon.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export const getTitleAndIcon = (query: URLSearchParams, projects: Project[]) => {
|
||||
const projectId = query.get('project_id');
|
||||
if (projectId) {
|
||||
const project = projects.find((p) => p.id.toString() === projectId);
|
||||
return { title: project ? project.name : 'Project', icon: 'bi-folder-fill' };
|
||||
}
|
||||
|
||||
if (query.get('type') === 'today') {
|
||||
return { title: 'Today', icon: 'bi-calendar-day-fill' };
|
||||
}
|
||||
if (query.get('type') === 'inbox') {
|
||||
return { title: 'Inbox', icon: 'bi-inbox-fill' };
|
||||
}
|
||||
if (query.get('type') === 'next') {
|
||||
return { title: 'Next Actions', icon: 'bi-arrow-right-circle-fill' };
|
||||
}
|
||||
if (query.get('type') === 'upcoming') {
|
||||
return { title: 'Upcoming', icon: 'bi-calendar3' };
|
||||
}
|
||||
if (query.get('type') === 'someday') {
|
||||
return { title: 'Someday', icon: 'bi-moon-stars-fill' };
|
||||
}
|
||||
if (query.get('status') === 'done') {
|
||||
return { title: 'Completed', icon: 'bi-check-circle' };
|
||||
}
|
||||
return { title: 'All Tasks', icon: 'bi-layers' };
|
||||
};
|
||||
100
app/frontend/contexts/DataContext.tsx
Normal file
100
app/frontend/contexts/DataContext.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// contexts/DataContext.tsx
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import useFetchTags from '../hooks/useFetchTags';
|
||||
import useFetchAreas from '../hooks/useFetchAreas';
|
||||
import useManageAreas from '../hooks/useManageAreas';
|
||||
import useManageNotes from '../hooks/useManageNotes';
|
||||
import useManageProjects from '../hooks/useManageProjects';
|
||||
import useManageTags from '../hooks/useManageTags';
|
||||
import useManageTasks from '../hooks/useManageTasks'; // Import the tasks hook
|
||||
|
||||
interface DataContextProps {
|
||||
tasks: any[];
|
||||
tags: any[];
|
||||
areas: any[];
|
||||
notes: any[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
createNote: (noteData: any) => Promise<void>;
|
||||
updateNote: (noteId: number, noteData: any) => Promise<void>;
|
||||
deleteNote: (noteId: number) => Promise<void>;
|
||||
createArea: (areaData: any) => Promise<void>;
|
||||
updateArea: (areaId: number, areaData: any) => Promise<void>;
|
||||
deleteArea: (areaId: number) => Promise<void>;
|
||||
createProject: (projectData: any) => Promise<void>;
|
||||
updateProject: (projectId: number, projectData: any) => Promise<void>;
|
||||
deleteProject: (projectId: number) => Promise<void>;
|
||||
createTag: (tagData: any) => Promise<void>;
|
||||
updateTag: (tagId: number, tagData: any) => Promise<void>;
|
||||
deleteTag: (tagId: number) => Promise<void>;
|
||||
createTask: (taskData: any) => Promise<void>;
|
||||
updateTask: (taskId: number, taskData: any) => Promise<void>;
|
||||
deleteTask: (taskId: number) => Promise<void>;
|
||||
mutateTags: () => void;
|
||||
mutateAreas: () => void;
|
||||
mutateNotes: () => void;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextProps | undefined>(undefined);
|
||||
|
||||
export const useDataContext = () => {
|
||||
const context = useContext(DataContext);
|
||||
if (!context) {
|
||||
throw new Error('useDataContext must be used within a DataProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { tags, isLoading: isLoadingTags, isError: isErrorTags, mutate: mutateTags } = useFetchTags();
|
||||
const { areas, isLoading: isLoadingAreas, isError: isErrorAreas, mutate: mutateAreas } = useFetchAreas();
|
||||
const { createArea, updateArea, deleteArea } = useManageAreas();
|
||||
const { createProject, updateProject, deleteProject } = useManageProjects();
|
||||
const { createTag, updateTag, deleteTag } = useManageTags();
|
||||
const { tasks, isLoading: isLoadingTasks, isError: isErrorTasks, createTask, updateTask, deleteTask } = useManageTasks();
|
||||
const {
|
||||
notes,
|
||||
isLoading: isLoadingNotes,
|
||||
isError: isErrorNotes,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
mutate: mutateNotes,
|
||||
} = useManageNotes();
|
||||
|
||||
const isLoading = isLoadingTags || isLoadingAreas || isLoadingNotes || isLoadingTasks;
|
||||
const isError = isErrorTags || isErrorAreas || isErrorNotes || isErrorTasks;
|
||||
|
||||
return (
|
||||
<DataContext.Provider
|
||||
value={{
|
||||
tasks,
|
||||
tags,
|
||||
areas,
|
||||
notes,
|
||||
isLoading,
|
||||
isError,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
createArea,
|
||||
updateArea,
|
||||
deleteArea,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
createTag,
|
||||
updateTag,
|
||||
deleteTag,
|
||||
createTask, // Added task creation
|
||||
updateTask, // Added task update
|
||||
deleteTask, // Added task deletion
|
||||
mutateTags,
|
||||
mutateAreas,
|
||||
mutateNotes,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
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; // Make updated_at optional
|
||||
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;
|
||||
name: string;
|
||||
}
|
||||
12
app/frontend/entities/Task.ts
Normal file
12
app/frontend/entities/Task.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Tag } from "./Tag";
|
||||
|
||||
export interface Task {
|
||||
id?: number;
|
||||
name: string;
|
||||
status: 'not_started' | 'in_progress' | 'done' | 'archived';
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
due_date?: string;
|
||||
note?: string;
|
||||
tags?: Tag[];
|
||||
project_id?: number;
|
||||
}
|
||||
55
app/frontend/hooks/useFetch.ts
Normal file
55
app/frontend/hooks/useFetch.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// src/hooks/useFetch.ts
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface UseFetchResult<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const useFetch = <T,>(url: string, options?: RequestInit): UseFetchResult<T> => {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true; // To prevent setting state on unmounted component
|
||||
const controller = new AbortController(); // To handle component unmounting
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to fetch data.');
|
||||
}
|
||||
const result: T = await response.json();
|
||||
if (isMounted) {
|
||||
setData(result);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isMounted) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// Cleanup function to abort fetch on unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [url, JSON.stringify(options)]); // Note: Be cautious with dependencies
|
||||
|
||||
return { data, loading, error };
|
||||
};
|
||||
|
||||
export default useFetch;
|
||||
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;
|
||||
16
app/frontend/hooks/useFetchNotes.ts
Normal file
16
app/frontend/hooks/useFetchNotes.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// src/hooks/useFetchNotes.ts
|
||||
import useFetch from './useFetch';
|
||||
import { Note } from '../entities/Note';
|
||||
|
||||
const useFetchNotes = () => {
|
||||
const { data, loading, error } = useFetch<Note[]>('/api/notes', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return { notes: data || [], loading, error };
|
||||
};
|
||||
|
||||
export default useFetchNotes;
|
||||
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;
|
||||
16
app/frontend/hooks/useFetchTags.ts
Normal file
16
app/frontend/hooks/useFetchTags.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// src/hooks/useFetchTags.ts
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '../utils/fetcher'; // Adjust the path to the fetcher if needed
|
||||
|
||||
const useFetchTags = () => {
|
||||
const { data, error, mutate } = useSWR('/api/tags', fetcher);
|
||||
|
||||
return {
|
||||
tags: data || [],
|
||||
isLoading: !data && !error,
|
||||
isError: !!error,
|
||||
mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFetchTags;
|
||||
0
app/frontend/hooks/useFetchTasks.ts
Normal file
0
app/frontend/hooks/useFetchTasks.ts
Normal file
73
app/frontend/hooks/useManageAreas.ts
Normal file
73
app/frontend/hooks/useManageAreas.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// src/hooks/useManageAreas.ts
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { Area } from '../entities/Area';
|
||||
|
||||
const useManageAreas = () => {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const createArea = useCallback(async (areaData: Partial<Area>) => {
|
||||
try {
|
||||
const response = await fetch('/api/areas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create area.');
|
||||
}
|
||||
const newArea: Area = await response.json();
|
||||
mutate('/api/areas?active=true', (current: Area[] = []) => [...current, newArea], false);
|
||||
} catch (error) {
|
||||
console.error('Error creating area:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
const updateArea = useCallback(async (areaId: number, areaData: Partial<Area>) => {
|
||||
try {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update area.');
|
||||
}
|
||||
const updatedArea: Area = await response.json();
|
||||
mutate('/api/areas?active=true', (current: Area[] = []) =>
|
||||
current.map(area => (area.id === areaId ? updatedArea : area)),
|
||||
false
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating area:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
const deleteArea = useCallback(async (areaId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete area.');
|
||||
}
|
||||
mutate('/api/areas?active=true', (current: Area[] = []) =>
|
||||
current.filter(area => area.id !== areaId),
|
||||
false
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
return { createArea, updateArea, deleteArea };
|
||||
};
|
||||
|
||||
export default useManageAreas;
|
||||
90
app/frontend/hooks/useManageNotes.ts
Normal file
90
app/frontend/hooks/useManageNotes.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// src/hooks/useManageNotes.ts
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { Note } from '../entities/Note';
|
||||
import { fetcher } from '../utils/fetcher';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const useManageNotes = () => {
|
||||
const { data: notes, error, mutate } = useSWR<Note[]>('/api/notes', fetcher);
|
||||
|
||||
const createNote = useCallback(
|
||||
async (noteData: Partial<Note>) => {
|
||||
const response = await fetch('/api/note', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create note.');
|
||||
}
|
||||
|
||||
const newNote: Note = await response.json();
|
||||
|
||||
// Optimistically update the cache
|
||||
mutate([...notes, newNote], false);
|
||||
},
|
||||
[mutate, notes]
|
||||
);
|
||||
|
||||
const updateNote = useCallback(
|
||||
async (noteId: number, noteData: Partial<Note>) => {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update note.');
|
||||
}
|
||||
|
||||
const updatedNote: Note = await response.json();
|
||||
|
||||
// Optimistically update the cache
|
||||
mutate(notes.map((note) => (note.id === noteId ? updatedNote : note)), false);
|
||||
},
|
||||
[mutate, notes]
|
||||
);
|
||||
|
||||
const deleteNote = useCallback(
|
||||
async (noteId: number) => {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete note.');
|
||||
}
|
||||
|
||||
// Optimistically update the cache
|
||||
mutate(notes.filter((note) => note.id !== noteId), false);
|
||||
},
|
||||
[mutate, notes]
|
||||
);
|
||||
|
||||
return {
|
||||
notes: notes || [],
|
||||
isLoading: !error && !notes,
|
||||
isError: error,
|
||||
createNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
};
|
||||
};
|
||||
|
||||
export default useManageNotes;
|
||||
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;
|
||||
71
app/frontend/hooks/useManageTags.ts
Normal file
71
app/frontend/hooks/useManageTags.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// src/hooks/useManageTags.ts
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { Tag } from '../entities/Tag'; // Adjust the path if needed
|
||||
|
||||
const useManageTags = () => {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const createTag = useCallback(async (tagData: Partial<Tag>) => {
|
||||
try {
|
||||
const response = await fetch('/api/tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tagData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to create tag.');
|
||||
}
|
||||
const newTag: Tag = await response.json();
|
||||
mutate('/api/tags', (current: Tag[] = []) => [...current, newTag], false);
|
||||
} catch (error) {
|
||||
console.error('Error creating tag:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
const updateTag = useCallback(async (tagId: number, tagData: Partial<Tag>) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tag/${tagId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(tagData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to update tag.');
|
||||
}
|
||||
const updatedTag: Tag = await response.json();
|
||||
mutate('/api/tags', (current: Tag[] = []) =>
|
||||
current.map(tag => (tag.id === tagId ? updatedTag : tag)), false
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating tag:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
const deleteTag = useCallback(async (tagId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tag/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to delete tag.');
|
||||
}
|
||||
mutate('/api/tags', (current: Tag[] = []) =>
|
||||
current.filter(tag => tag.id !== tagId), false
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting tag:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [mutate]);
|
||||
|
||||
return { createTag, updateTag, deleteTag };
|
||||
};
|
||||
|
||||
export default useManageTags;
|
||||
77
app/frontend/hooks/useManageTasks.ts
Normal file
77
app/frontend/hooks/useManageTasks.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// hooks/useManageTasks.ts
|
||||
import { useState } from 'react';
|
||||
import { Task } from '../entities/Task';
|
||||
|
||||
const useManageTasks = () => {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
|
||||
const fetchTasks = async (query: string = '') => {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
try {
|
||||
const response = await fetch(`/api/tasks${query}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTasks(data);
|
||||
} else {
|
||||
throw new Error('Failed to fetch tasks.');
|
||||
}
|
||||
} catch (error) {
|
||||
setIsError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createTask = async (taskData: Partial<Task>) => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData),
|
||||
});
|
||||
if (response.ok) {
|
||||
const newTask = await response.json();
|
||||
setTasks((prevTasks) => [newTask, ...prevTasks]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateTask = async (taskId: number, taskData: Partial<Task>) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData),
|
||||
});
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) => (task.id === taskId ? { ...task, ...taskData } : task))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return { tasks, isLoading, isError, fetchTasks, createTask, updateTask, deleteTask };
|
||||
};
|
||||
|
||||
export default useManageTasks;
|
||||
34
app/frontend/index.tsx
Normal file
34
app/frontend/index.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client"; // Import createRoot from react-dom
|
||||
import { BrowserRouter } from "react-router-dom"; // Import BrowserRouter
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./components/Shared/ToastContext";
|
||||
|
||||
// Determine initial dark mode preference
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
const prefersDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const isDarkMode = storedPreference
|
||||
? storedPreference === "true"
|
||||
: prefersDarkMode;
|
||||
|
||||
// Add or remove the 'dark' class before rendering the app
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
|
||||
// Get the root DOM element
|
||||
const container = document.getElementById("root");
|
||||
|
||||
// Ensure the root element exists before creating root
|
||||
if (container) {
|
||||
const root = createRoot(container); // Use createRoot to create a root
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
9
app/frontend/styles/tailwind.css
Normal file
9
app/frontend/styles/tailwind.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/* ./app/frontend/styles/tailwind.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
21
app/frontend/utils/fetcher.ts
Normal file
21
app/frontend/utils/fetcher.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// src/utils/fetcher.ts
|
||||
|
||||
export const fetcher = async (url: string) => {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const error = new Error(errorData.error || 'An error occurred while fetching the data.');
|
||||
// Attach extra info to the error object.
|
||||
(error as any).info = errorData;
|
||||
(error as any).status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
|
@ -8,8 +8,16 @@ module AuthenticationHelper
|
|||
end
|
||||
|
||||
def require_login
|
||||
return if ['/login', '/logout'].include? request.path
|
||||
# Allow requests to '/login' and '/logout' without checking for login
|
||||
return if ['/login', '/logout', '/api/current_user'].include? request.path
|
||||
|
||||
redirect '/login' unless logged_in?
|
||||
# If the user is not logged in and the request is not an API request, redirect to login
|
||||
return if logged_in?
|
||||
|
||||
if request.xhr? || request.path.start_with?('/api/')
|
||||
halt 401, { error: 'You must be logged in' }.to_json # For API calls, return a 401 status
|
||||
else
|
||||
redirect '/login' # For non-API calls, redirect to login page
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# models/project.rb
|
||||
class Project < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :area, optional: true
|
||||
|
|
@ -7,7 +8,7 @@ class Project < ActiveRecord::Base
|
|||
scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
|
||||
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }
|
||||
|
||||
validates :name, presence: true
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
|
||||
def task_status_counts
|
||||
status_counts = tasks.group(:status).count
|
||||
|
|
|
|||
|
|
@ -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,84 @@
|
|||
class Sinatra::Application
|
||||
post '/areas/create' do
|
||||
area = current_user.areas.create(name: params[:name])
|
||||
# app.rb or your main Sinatra application file
|
||||
|
||||
if area.persisted?
|
||||
redirect '/'
|
||||
require 'sinatra'
|
||||
require 'json'
|
||||
|
||||
# Assuming you have a helper method `current_user` to get the authenticated user
|
||||
|
||||
# Create a new Area
|
||||
post '/api/areas' do
|
||||
content_type :json
|
||||
begin
|
||||
request_body = request.body.read
|
||||
area_data = JSON.parse(request_body, symbolize_names: true)
|
||||
|
||||
# Validate required fields
|
||||
halt 400, { error: 'Area name is required.' }.to_json unless area_data[:name] && !area_data[:name].strip.empty?
|
||||
|
||||
# Create new Area
|
||||
area = current_user.areas.build(name: area_data[:name], description: area_data[:description])
|
||||
|
||||
if area.save
|
||||
status 201
|
||||
area.to_json
|
||||
else
|
||||
@errors = 'There was a problem creating the area.'
|
||||
redirect '/'
|
||||
end
|
||||
end
|
||||
|
||||
patch '/areas/:id' do
|
||||
area = current_user.areas.find_by(id: params[:id])
|
||||
|
||||
if area
|
||||
area.name = params[:name]
|
||||
|
||||
if area.save
|
||||
redirect request.referrer || '/'
|
||||
else
|
||||
@errors = 'There was a problem updating the area.'
|
||||
erb :some_template
|
||||
end
|
||||
else
|
||||
status 404
|
||||
"Area not found or doesn't belong to the current user."
|
||||
end
|
||||
end
|
||||
|
||||
delete '/area/:id' do
|
||||
area = current_user.areas.find_by(id: params[:id])
|
||||
|
||||
if area
|
||||
area.destroy
|
||||
redirect request.referrer || '/'
|
||||
else
|
||||
status 404
|
||||
@errors = 'Area not found or not owned by the current user.'
|
||||
status 400
|
||||
{ error: 'There was a problem creating the area.', details: area.errors.full_messages }.to_json
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
get '/api/areas/:id' do
|
||||
area = current_user.areas.find_by(id: params[:id])
|
||||
halt 404, { error: "Area not found or doesn't belong to the current user." }.to_json unless area
|
||||
area.to_json
|
||||
end
|
||||
|
||||
# Update an existing Area
|
||||
patch '/api/areas/:id' do
|
||||
content_type :json
|
||||
begin
|
||||
area = current_user.areas.find_by(id: params[:id])
|
||||
halt 404, { error: 'Area not found.' }.to_json unless area
|
||||
|
||||
request_body = request.body.read
|
||||
area_data = JSON.parse(request_body, symbolize_names: true)
|
||||
|
||||
# Update Area attributes
|
||||
area.name = area_data[:name] if area_data[:name]
|
||||
area.description = area_data[:description] if area_data[:description]
|
||||
|
||||
if area.save
|
||||
status 200
|
||||
area.to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem updating the area.', details: area.errors.full_messages }.to_json
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Delete an Area
|
||||
delete '/api/areas/:id' do
|
||||
content_type :json
|
||||
area = current_user.areas.find_by(id: params[:id])
|
||||
halt 404, { error: 'Area not found.' }.to_json unless area
|
||||
|
||||
if area.destroy
|
||||
status 204
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem deleting the area.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch all Areas
|
||||
get '/api/areas' do
|
||||
content_type :json
|
||||
areas = current_user.areas
|
||||
areas.to_json
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,22 +1,50 @@
|
|||
class Sinatra::Application
|
||||
get '/login' do
|
||||
erb :login
|
||||
end
|
||||
require 'json'
|
||||
|
||||
post '/login' do
|
||||
@user = User.find_by(email: params[:email])
|
||||
if @user&.authenticate(params[:password])
|
||||
session[:user_id] = @user.id
|
||||
redirect '/'
|
||||
class Sinatra::Application
|
||||
# Serve the login page (if needed for non-React or fallback)
|
||||
# get '/login' do
|
||||
# erb :login
|
||||
# end
|
||||
|
||||
# Handle login requests (now accepting JSON)
|
||||
get '/api/current_user' do
|
||||
content_type :json
|
||||
|
||||
if logged_in?
|
||||
{ user: { email: current_user.email, id: current_user.id } }.to_json
|
||||
else
|
||||
logger.warn "Invalid credentials for user with email #{params[:email]}"
|
||||
@errors = ['Invalid credentials']
|
||||
erb :login
|
||||
{ user: nil }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post '/login' do
|
||||
content_type :json
|
||||
request_payload = begin
|
||||
JSON.parse(request.body.read)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
if request_payload
|
||||
email = request_payload['email']
|
||||
password = request_payload['password']
|
||||
else
|
||||
halt 400, { error: 'Invalid login parameters.' }.to_json
|
||||
end
|
||||
|
||||
user = User.find_by(email: email)
|
||||
if user&.authenticate(password)
|
||||
session[:user_id] = user.id
|
||||
status 200
|
||||
{ user: { email: user.email, id: user.id } }.to_json
|
||||
else
|
||||
halt 401, { errors: ['Invalid credentials'] }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Handle logout
|
||||
get '/logout' do
|
||||
session.clear
|
||||
redirect '/login'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Sinatra::Application
|
|||
end
|
||||
end
|
||||
|
||||
get '/notes' do
|
||||
get '/api/notes' do
|
||||
order_by = params[:order_by] || 'title:asc'
|
||||
order_column, order_direction = order_by.split(':')
|
||||
|
||||
|
|
@ -27,65 +27,96 @@ class Sinatra::Application
|
|||
@base_url = '/notes?'
|
||||
@base_url += "#{@base_query}&" unless @base_query.empty?
|
||||
|
||||
erb :'notes/index'
|
||||
@notes.to_json(include: :tags)
|
||||
end
|
||||
|
||||
post '/note/create' do
|
||||
get '/api/note/:id' do
|
||||
content_type :json
|
||||
note = current_user.notes.includes(:tags).find_by(id: params[:id])
|
||||
|
||||
halt 404, { error: 'Note not found.' }.to_json unless note
|
||||
|
||||
# Return the note and its associated tags as JSON
|
||||
note.to_json(include: :tags)
|
||||
end
|
||||
|
||||
post '/api/note' do
|
||||
content_type :json
|
||||
|
||||
# Parse the request body to extract the JSON data
|
||||
request_body = request.body.read
|
||||
note_data = JSON.parse(request_body, symbolize_names: true)
|
||||
|
||||
# Extract the attributes from the parsed JSON data
|
||||
note_attributes = {
|
||||
title: params[:title],
|
||||
content: params[:content],
|
||||
title: note_data[:title],
|
||||
content: note_data[:content],
|
||||
user_id: current_user.id
|
||||
}
|
||||
|
||||
if params[:project_id].empty?
|
||||
# Check for the presence of a project_id
|
||||
if note_data[:project_id].to_s.empty?
|
||||
note = current_user.notes.build(note_attributes)
|
||||
else
|
||||
project = current_user.projects.find_by(id: params[:project_id])
|
||||
halt 400, 'Invalid project.' unless project
|
||||
project = current_user.projects.find_by(id: note_data[:project_id])
|
||||
halt 400, { error: 'Invalid project.' }.to_json unless project
|
||||
note = project.notes.build(note_attributes)
|
||||
end
|
||||
|
||||
# Save the note and update its tags
|
||||
if note.save
|
||||
update_note_tags(note, params[:tags])
|
||||
redirect request.referrer || '/'
|
||||
update_note_tags(note, note_data[:tags])
|
||||
status 201
|
||||
note.to_json(include: :tags)
|
||||
else
|
||||
halt 400, 'There was a problem creating the note.'
|
||||
status 400
|
||||
{ error: 'There was a problem creating the note.', details: note.errors.full_messages }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
patch '/note/:id' do
|
||||
patch '/api/note/:id' do
|
||||
content_type :json
|
||||
note = current_user.notes.find_by(id: params[:id])
|
||||
halt 404, 'Note not found.' unless note
|
||||
halt 404, { error: 'Note not found.' }.to_json unless note
|
||||
|
||||
# Parse the request body to get the content, title, and tags
|
||||
request_body = request.body.read
|
||||
request_data = JSON.parse(request_body)
|
||||
|
||||
note_attributes = {
|
||||
title: params[:title],
|
||||
content: params[:content]
|
||||
title: request_data['title'],
|
||||
content: request_data['content']
|
||||
}
|
||||
|
||||
if params[:project_id] && !params[:project_id].empty?
|
||||
project = current_user.projects.find_by(id: params[:project_id])
|
||||
halt 400, 'Invalid project.' unless project
|
||||
# Handle project association if provided
|
||||
if request_data['project_id'] && !request_data['project_id'].to_s.empty?
|
||||
project = current_user.projects.find_by(id: request_data['project_id'])
|
||||
halt 400, { error: 'Invalid project.' }.to_json unless project
|
||||
note.project = project
|
||||
else
|
||||
note.project = nil
|
||||
end
|
||||
|
||||
# Update the note and its tags
|
||||
if note.update(note_attributes)
|
||||
update_note_tags(note, params[:tags])
|
||||
redirect request.referrer || '/'
|
||||
update_note_tags(note, request_data['tags']) # Process tags correctly
|
||||
note.to_json(include: :tags) # Return updated note with tags
|
||||
else
|
||||
halt 400, 'There was a problem updating the note.'
|
||||
status 400
|
||||
{ error: 'There was a problem updating the note.', details: note.errors.full_messages }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
delete '/note/:id' do
|
||||
delete '/api/note/:id' do
|
||||
content_type :json
|
||||
note = current_user.notes.find_by(id: params[:id])
|
||||
halt 404, 'Note not found.' unless note
|
||||
halt 404, { error: 'Note not found.' }.to_json unless note
|
||||
|
||||
if note.destroy!
|
||||
redirect '/notes'
|
||||
if note.destroy
|
||||
{ message: 'Note deleted successfully.' }.to_json
|
||||
else
|
||||
halt 400, 'There was a problem deleting the note.'
|
||||
status 400
|
||||
{ error: 'There was a problem deleting the note.' }.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,67 +1,142 @@
|
|||
require 'sinatra/namespace'
|
||||
|
||||
class Sinatra::Application
|
||||
get '/projects' do
|
||||
@projects_with_tasks = current_user.projects.left_joins(:tasks, :area).distinct.order('projects.name ASC') # , areas.name ASC')
|
||||
# Include the namespace module
|
||||
register Sinatra::Namespace
|
||||
|
||||
@task_status_counts = @projects_with_tasks.each_with_object({}) do |project, counts|
|
||||
counts[project.id] = project.task_status_counts
|
||||
# Namespace your routes under /api
|
||||
namespace '/api' do
|
||||
before do
|
||||
content_type :json
|
||||
end
|
||||
|
||||
@grouped_projects = @projects_with_tasks.group_by(&:area)
|
||||
# Get all projects and associated tasks with JSON response
|
||||
# Get all projects and associated tasks with JSON response
|
||||
get '/projects' do
|
||||
# Parse query parameters for 'active', 'pin_to_sidebar', and 'area_id'
|
||||
active_param = params[:active]
|
||||
is_active = active_param == 'true' unless active_param.nil?
|
||||
|
||||
erb :'projects/index'
|
||||
end
|
||||
pin_to_sidebar_param = params[:pin_to_sidebar]
|
||||
is_pinned = pin_to_sidebar_param == 'true' unless pin_to_sidebar_param.nil?
|
||||
|
||||
get '/project/:id' do
|
||||
@project = current_user.projects.includes(:tasks).find_by(id: params[:id])
|
||||
halt 404, 'Project not found' unless @project
|
||||
area_id_param = params[:area_id]
|
||||
|
||||
erb :'projects/show'
|
||||
end
|
||||
# Build the query
|
||||
projects = current_user.projects
|
||||
.left_joins(:tasks, :area)
|
||||
.distinct
|
||||
.order('projects.name ASC')
|
||||
|
||||
post '/project/create' do
|
||||
project = current_user.projects.new(
|
||||
name: params[:name],
|
||||
description: params[:description],
|
||||
area_id: params[:area_id].presence
|
||||
)
|
||||
# Apply 'active' filter if provided
|
||||
projects = projects.where(active: is_active) unless is_active.nil?
|
||||
|
||||
if project.save
|
||||
redirect request.referrer || '/'
|
||||
else
|
||||
@errors = 'There was a problem creating the project.'
|
||||
redirect '/'
|
||||
# Apply 'pin_to_sidebar' filter if provided
|
||||
projects = projects.where(pin_to_sidebar: is_pinned) unless is_pinned.nil?
|
||||
|
||||
# Apply 'area_id' filter if provided
|
||||
projects = projects.where(area_id: area_id_param) if area_id_param
|
||||
|
||||
# Count task statuses for each project
|
||||
task_status_counts = projects.each_with_object({}) do |project, counts|
|
||||
counts[project.id] = project.task_status_counts
|
||||
end
|
||||
|
||||
# Group projects by area
|
||||
grouped_projects = projects.group_by(&:area)
|
||||
|
||||
# Return projects, task counts, and grouped projects as JSON
|
||||
{
|
||||
projects: projects.as_json(include: { tasks: {}, area: { only: :name } }),
|
||||
task_status_counts: task_status_counts,
|
||||
grouped_projects: grouped_projects.as_json(include: { area: { only: :name } })
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
patch '/project/:id' do
|
||||
project = current_user.projects.find_by(id: params[:id])
|
||||
# Get a specific project by ID with JSON response
|
||||
get '/project/:id' do
|
||||
# Find the project and include associated tasks
|
||||
project = current_user.projects.includes(:tasks).find_by(id: params[:id])
|
||||
|
||||
if project
|
||||
project.name = params[:name]
|
||||
project.description = params[:description]
|
||||
project.area_id = params[:area_id].presence
|
||||
halt 404, { error: 'Project not found' }.to_json unless project
|
||||
|
||||
# Return the project and associated tasks as JSON
|
||||
project.as_json(include: { tasks: {}, area: { only: %i[id name] } }).to_json
|
||||
end
|
||||
|
||||
# Create a new project with JSON response
|
||||
post '/project' do
|
||||
# Parse the request body as JSON
|
||||
request_body = request.body.read
|
||||
project_data = begin
|
||||
JSON.parse(request_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
# Build a new project with the provided parameters
|
||||
project = current_user.projects.new(
|
||||
name: project_data['name'],
|
||||
description: project_data['description'] || '',
|
||||
area_id: project_data['area_id'],
|
||||
active: true,
|
||||
pin_to_sidebar: false
|
||||
)
|
||||
|
||||
if project.save
|
||||
redirect "/project/#{project.id}"
|
||||
status 201
|
||||
project.as_json.to_json
|
||||
else
|
||||
@errors = 'There was a problem updating the project.'
|
||||
erb :edit_project
|
||||
status 400
|
||||
{ error: 'There was a problem creating the project.', details: project.errors.full_messages }.to_json
|
||||
end
|
||||
else
|
||||
status 404
|
||||
"Project not found or doesn't belong to the current user."
|
||||
end
|
||||
end
|
||||
|
||||
delete '/project/:id' do
|
||||
project = current_user.projects.find_by(id: params[:id])
|
||||
# Update an existing project by ID with JSON response
|
||||
patch '/project/:id' do
|
||||
# Find the project by ID
|
||||
project = current_user.projects.find_by(id: params[:id])
|
||||
|
||||
if project
|
||||
project.destroy
|
||||
redirect '/projects'
|
||||
else
|
||||
status 404
|
||||
"Project not found or doesn't belong to the current user."
|
||||
halt 404, { error: 'Project not found.' }.to_json unless project
|
||||
|
||||
# Parse the request body as JSON
|
||||
request_body = request.body.read
|
||||
project_data = begin
|
||||
JSON.parse(request_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
# Update the project with the provided parameters
|
||||
project.assign_attributes(
|
||||
name: project_data['name'],
|
||||
description: project_data['description'],
|
||||
area_id: project_data['area_id'],
|
||||
active: project_data['active'],
|
||||
pin_to_sidebar: project_data['pin_to_sidebar']
|
||||
)
|
||||
|
||||
if project.save
|
||||
project.as_json.to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem updating the project.', details: project.errors.full_messages }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Delete an existing project by ID with JSON response
|
||||
delete '/project/:id' do
|
||||
# Find the project by ID
|
||||
project = current_user.projects.find_by(id: params[:id])
|
||||
|
||||
halt 404, { error: 'Project not found' }.to_json unless project
|
||||
|
||||
if project.destroy
|
||||
{ message: 'Project successfully deleted' }.to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'There was a problem deleting the project.' }.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
92
app/routes/tags_routes.rb
Normal file
92
app/routes/tags_routes.rb
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
class Sinatra::Application
|
||||
# Get all tags with JSON response
|
||||
get '/api/tags' do
|
||||
content_type :json
|
||||
|
||||
# Fetch all tags for the current user
|
||||
tags = current_user.tags.order('name ASC')
|
||||
|
||||
# Return the tags as JSON
|
||||
tags.as_json(only: %i[id name]).to_json
|
||||
end
|
||||
|
||||
# Get a specific tag by ID with JSON response
|
||||
get '/api/tag/:id' do
|
||||
content_type :json
|
||||
|
||||
# Find the tag by ID
|
||||
tag = current_user.tags.find_by(id: params[:id])
|
||||
|
||||
# Return a 404 status if the tag is not found
|
||||
halt 404, { error: 'Tag not found' }.to_json unless tag
|
||||
|
||||
# Return the tag as JSON
|
||||
tag.as_json(only: %i[id name]).to_json
|
||||
end
|
||||
|
||||
# Create a new tag with JSON response
|
||||
post '/api/tag' do
|
||||
content_type :json
|
||||
|
||||
# Parse the request body to get the tag name
|
||||
request_body = JSON.parse(request.body.read)
|
||||
tag = current_user.tags.new(name: request_body['name'])
|
||||
|
||||
# Attempt to save the tag
|
||||
if tag.save
|
||||
# Return the newly created tag as JSON
|
||||
status 201
|
||||
tag.as_json(only: %i[id name]).to_json
|
||||
else
|
||||
# Return an error message with a 400 status if the tag creation fails
|
||||
status 400
|
||||
{ error: 'There was a problem creating the tag.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Update an existing tag by ID with JSON response
|
||||
patch '/api/tag/:id' do
|
||||
content_type :json
|
||||
|
||||
# Find the tag by ID
|
||||
tag = current_user.tags.find_by(id: params[:id])
|
||||
|
||||
# Return a 404 status if the tag is not found
|
||||
halt 404, { error: 'Tag not found' }.to_json unless tag
|
||||
|
||||
# Parse the request body to get the updated tag name
|
||||
request_body = JSON.parse(request.body.read)
|
||||
tag.name = request_body['name']
|
||||
|
||||
# Attempt to save the updated tag
|
||||
if tag.save
|
||||
# Return the updated tag as JSON
|
||||
tag.as_json(only: %i[id name]).to_json
|
||||
else
|
||||
# Return an error message with a 400 status if the update fails
|
||||
status 400
|
||||
{ error: 'There was a problem updating the tag.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# Delete an existing tag by ID with JSON response
|
||||
delete '/api/tag/:id' do
|
||||
content_type :json
|
||||
|
||||
# Find the tag by ID
|
||||
tag = current_user.tags.find_by(id: params[:id])
|
||||
|
||||
# Return a 404 status if the tag is not found
|
||||
halt 404, { error: 'Tag not found' }.to_json unless tag
|
||||
|
||||
# Attempt to delete the tag
|
||||
if tag.destroy
|
||||
# Return a success message
|
||||
{ message: 'Tag successfully deleted' }.to_json
|
||||
else
|
||||
# Return an error message with a 400 status if deletion fails
|
||||
status 400
|
||||
{ error: 'There was a problem deleting the tag.' }.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,155 +1,188 @@
|
|||
module Sinatra
|
||||
class Application
|
||||
def update_task_tags(task, tags_json)
|
||||
return if tags_json.blank?
|
||||
def update_task_tags(task, tags_data)
|
||||
return if tags_data.nil?
|
||||
|
||||
begin
|
||||
tag_names = JSON.parse(tags_json).map { |tag| tag['value'] }.uniq
|
||||
tags = tag_names.map do |name|
|
||||
current_user.tags.find_or_create_by(name: name)
|
||||
end
|
||||
task.tags = tags
|
||||
rescue JSON::ParserError
|
||||
puts "Failed to parse JSON for tags: #{tags_json}"
|
||||
end
|
||||
# Filter out nil or empty tag names, then ensure uniqueness with `uniq`
|
||||
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
|
||||
|
||||
# Find or create tags based on the tag names
|
||||
existing_tags = Tag.where(name: tag_names)
|
||||
new_tags = tag_names - existing_tags.pluck(:name)
|
||||
created_tags = new_tags.map { |name| Tag.create(name: name) }
|
||||
|
||||
# Associate only unique tags with the task
|
||||
task.tags = (existing_tags + created_tags).uniq
|
||||
end
|
||||
|
||||
get '/tasks' do
|
||||
# Base query with necessary joins
|
||||
base_query = current_user.tasks.includes(:project, :tags)
|
||||
get '/api/tasks' do
|
||||
content_type :json
|
||||
|
||||
# Apply filters based on due_date and status
|
||||
# Start with a base query for tasks belonging to the current user
|
||||
@tasks = current_user.tasks.includes(:project, :tags)
|
||||
|
||||
# Filter tasks based on the provided `type` parameter
|
||||
@tasks = case params[:type]
|
||||
when 'today'
|
||||
base_query
|
||||
.where('status = ? OR (status = ? AND due_date <= ?)',
|
||||
Task.statuses[:in_progress],
|
||||
Task.statuses[:not_started],
|
||||
Date.today.end_of_day)
|
||||
@tasks.due_today
|
||||
when 'upcoming'
|
||||
base_query
|
||||
.where('(status = ? OR status = ?) AND due_date BETWEEN ? AND ?',
|
||||
Task.statuses[:in_progress],
|
||||
Task.statuses[:not_started],
|
||||
Date.today,
|
||||
Date.today + 7.days)
|
||||
when 'never'
|
||||
base_query.incomplete.where(due_date: nil)
|
||||
@tasks.upcoming
|
||||
when 'next'
|
||||
@tasks.next_actions
|
||||
when 'inbox'
|
||||
@tasks.inbox
|
||||
when 'someday'
|
||||
@tasks.someday
|
||||
when 'waiting'
|
||||
@tasks.waiting_for
|
||||
else
|
||||
params[:status] == 'done' ? base_query.complete : base_query.incomplete
|
||||
params[:status] == 'done' ? @tasks.complete : @tasks.incomplete
|
||||
end
|
||||
|
||||
# Apply ordering
|
||||
# Apply ordering by due_date or other columns
|
||||
if params[:order_by]
|
||||
order_column, order_direction = params[:order_by].split(':')
|
||||
order_direction ||= 'asc'
|
||||
|
||||
@tasks = if order_column == 'due_date'
|
||||
@tasks.order(Arel.sql("CASE WHEN tasks.due_date IS NULL THEN 1 ELSE 0 END, tasks.due_date #{order_direction}"))
|
||||
@tasks.ordered_by_due_date(order_direction)
|
||||
else
|
||||
@tasks.order("tasks.#{order_column} #{order_direction}")
|
||||
@tasks.order("#{order_column} #{order_direction}")
|
||||
end
|
||||
end
|
||||
|
||||
# Filter by tag if provided
|
||||
if params[:tag]
|
||||
tagged_task_ids = Tag.joins(:tasks).where(name: params[:tag],
|
||||
tasks: { user_id: current_user.id }).select('tasks.id')
|
||||
@tasks = @tasks.where(id: tagged_task_ids)
|
||||
end
|
||||
@tasks = @tasks.with_tag(params[:tag]) if params[:tag]
|
||||
|
||||
@tasks = @tasks.left_joins(:tags).distinct
|
||||
|
||||
erb :'tasks/index'
|
||||
# Return the tasks in JSON format with their tags and project
|
||||
@tasks.to_json(include: { tags: { only: %i[id name] }, project: { only: :name } })
|
||||
end
|
||||
|
||||
post '/task/create' do
|
||||
post '/api/task' do
|
||||
content_type :json
|
||||
|
||||
# Parse the request body as JSON
|
||||
request_body = request.body.read
|
||||
task_data = begin
|
||||
JSON.parse(request_body)
|
||||
rescue JSON::ParserError => e
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
# Build task attributes
|
||||
task_attributes = {
|
||||
name: params[:name],
|
||||
priority: params[:priority],
|
||||
due_date: params[:due_date],
|
||||
status: params[:status] || Task.statuses[:not_started],
|
||||
note: params[:note],
|
||||
name: task_data['name'],
|
||||
priority: task_data['priority'] || 'medium', # Default priority
|
||||
due_date: task_data['due_date'],
|
||||
status: task_data['status'] || Task.statuses[:not_started],
|
||||
note: task_data['note'],
|
||||
user_id: current_user.id
|
||||
}
|
||||
|
||||
if params[:project_id].blank?
|
||||
task = current_user.tasks.build(task_attributes)
|
||||
else
|
||||
project = current_user.projects.find_by(id: params[:project_id])
|
||||
halt 400, 'Invalid project.' unless project
|
||||
task = project.tasks.build(task_attributes)
|
||||
end
|
||||
# Create and assign task to variable
|
||||
value = task_data['project_id']
|
||||
task = if value.nil? || value.to_s.strip.empty?
|
||||
# Assign the built task to the 'task' variable
|
||||
current_user.tasks.build(task_attributes)
|
||||
else
|
||||
project = current_user.projects.find_by(id: value)
|
||||
halt 400, { error: 'Invalid project.' }.to_json unless project
|
||||
project.tasks.build(task_attributes)
|
||||
end
|
||||
|
||||
# Save task and respond
|
||||
if task.save
|
||||
update_task_tags(task, params[:tags])
|
||||
redirect request.referrer || '/'
|
||||
update_task_tags(task, task_data['tags'])
|
||||
status 201
|
||||
task.to_json(include: { tags: { only: :name }, project: { only: :name } })
|
||||
else
|
||||
halt 400, 'There was a problem creating the task.'
|
||||
# Collect error messages for better debugging
|
||||
errors = task.errors.full_messages
|
||||
halt 400, { error: 'There was a problem creating the task.', details: errors }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
patch '/task/:id' do
|
||||
patch '/api/task/:id' do
|
||||
content_type :json
|
||||
puts "Request to update task with ID: #{params[:id]}"
|
||||
puts "Current user: #{current_user&.id}"
|
||||
|
||||
# Parse the request body as JSON
|
||||
request_body = request.body.read
|
||||
task_data = begin
|
||||
JSON.parse(request_body)
|
||||
rescue JSON::ParserError => e
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
# Find the task belonging to the current user
|
||||
task = current_user.tasks.find_by(id: params[:id])
|
||||
|
||||
halt 404, 'Task not found.' unless task
|
||||
halt 404, { error: 'Task not found.' }.to_json unless task
|
||||
|
||||
# Build task attributes
|
||||
task_attributes = {
|
||||
name: params[:name],
|
||||
priority: params[:priority],
|
||||
status: params[:status] || Task.statuses[:not_started],
|
||||
note: params[:note],
|
||||
due_date: params[:due_date]
|
||||
name: task_data['name'], # Get the name from the JSON body
|
||||
priority: task_data['priority'],
|
||||
status: task_data['status'] || Task.statuses[:not_started],
|
||||
note: task_data['note'],
|
||||
due_date: task_data['due_date']
|
||||
}
|
||||
|
||||
if params[:project_id] && !params[:project_id].empty?
|
||||
project = current_user.projects.find_by(id: params[:project_id])
|
||||
halt 400, 'Invalid project.' unless project
|
||||
# Safely handle project_id
|
||||
if task_data['project_id'] && !task_data['project_id'].to_s.strip.empty?
|
||||
project = current_user.projects.find_by(id: task_data['project_id'])
|
||||
halt 400, { error: 'Invalid project.' }.to_json unless project
|
||||
task.project = project
|
||||
else
|
||||
task.project = nil
|
||||
end
|
||||
|
||||
# Update task attributes
|
||||
if task.update(task_attributes)
|
||||
update_task_tags(task, params[:tags])
|
||||
redirect request.referrer || '/'
|
||||
update_task_tags(task, task_data['tags'])
|
||||
task.to_json(include: { tags: { only: :name }, project: { only: :name } })
|
||||
else
|
||||
halt 400, 'There was a problem updating the task.'
|
||||
# Collect error messages for better debugging
|
||||
errors = task.errors.full_messages
|
||||
halt 400, { error: 'There was a problem updating the task.', details: errors }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
patch '/task/:id/toggle_completion' do
|
||||
patch '/api/task/:id/toggle_completion' do
|
||||
content_type :json
|
||||
|
||||
task = current_user.tasks.find_by(id: params[:id])
|
||||
halt 404, { error: 'Task not found.' }.to_json unless task
|
||||
|
||||
if task
|
||||
new_status = if task.done?
|
||||
task.note.present? ? :in_progress : :not_started
|
||||
else
|
||||
:done
|
||||
end
|
||||
task.status = new_status
|
||||
new_status = if task.done?
|
||||
task.note.present? ? :in_progress : :not_started
|
||||
else
|
||||
:done
|
||||
end
|
||||
task.status = new_status
|
||||
|
||||
if task.save
|
||||
task.to_json
|
||||
else
|
||||
status 422
|
||||
{ error: 'Unable to update task' }.to_json
|
||||
end
|
||||
if task.save
|
||||
task.to_json
|
||||
else
|
||||
status 400
|
||||
{ error: 'Task not found' }.to_json
|
||||
status 422
|
||||
{ error: 'Unable to update task' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
delete '/task/:id' do
|
||||
delete '/api/task/:id' do
|
||||
content_type :json
|
||||
|
||||
task = current_user.tasks.find_by(id: params[:id])
|
||||
halt 404, 'Task not found.' unless task
|
||||
halt 404, { error: 'Task not found.' }.to_json unless task
|
||||
|
||||
if task.destroy
|
||||
redirect request.referrer || '/'
|
||||
status 200
|
||||
{ message: 'Task successfully deleted' }.to_json
|
||||
else
|
||||
halt 400, 'There was a problem deleting the task.'
|
||||
halt 400, { error: 'There was a problem deleting the task.' }.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
59
app/routes/users_routes.rb
Normal file
59
app/routes/users_routes.rb
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# app/controllers/user_routes.rb
|
||||
|
||||
module Sinatra
|
||||
class Application
|
||||
# GET /api/profile - Fetch the current user's profile
|
||||
get '/api/profile' do
|
||||
content_type :json
|
||||
user = current_user
|
||||
|
||||
if user
|
||||
user.to_json(only: %i[id email appearance language timezone avatar_image])
|
||||
else
|
||||
halt 404, { error: 'Profile not found.' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH /api/profile - Update the current user's profile
|
||||
patch '/api/profile' do
|
||||
content_type :json
|
||||
|
||||
begin
|
||||
request_payload = JSON.parse(request.body.read)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: 'Invalid JSON format.' }.to_json
|
||||
end
|
||||
|
||||
user = current_user
|
||||
|
||||
halt 404, { error: 'Profile not found.' }.to_json if user.nil?
|
||||
|
||||
# Permit only allowed parameters
|
||||
allowed_params = {}
|
||||
allowed_params[:appearance] = request_payload['appearance'] if request_payload.key?('appearance')
|
||||
allowed_params[:language] = request_payload['language'] if request_payload.key?('language')
|
||||
allowed_params[:timezone] = request_payload['timezone'] if request_payload.key?('timezone')
|
||||
allowed_params[:avatar_image] = request_payload['avatar_image'] if request_payload.key?('avatar_image')
|
||||
|
||||
# Handle avatar image upload if using Active Storage
|
||||
# Uncomment if using Active Storage
|
||||
# if request_payload['avatar_image']
|
||||
# begin
|
||||
# decoded_image = Base64.decode64(request_payload['avatar_image'].split(',')[1])
|
||||
# user.avatar_image.attach(io: StringIO.new(decoded_image), filename: "avatar_#{Time.now.to_i}.png", content_type: 'image/png')
|
||||
# rescue => e
|
||||
# halt 400, { error: 'Invalid avatar image format.' }.to_json
|
||||
# end
|
||||
# end
|
||||
|
||||
if user.update(allowed_params)
|
||||
# If handling tags on user profile, implement here
|
||||
# For now, we're not associating tags with user profiles
|
||||
user.to_json(only: %i[id email appearance language timezone avatar_image])
|
||||
else
|
||||
status 400
|
||||
{ error: 'Failed to update profile.', details: user.errors.full_messages }.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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' %>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<% projects.each do |project| %>
|
||||
<% counts = @task_status_counts[project.id] %>
|
||||
<div class="col-md-4 mb-3">
|
||||
<a class="text-decoration-none project-card" href="/project/<%= project.id %>">
|
||||
<div class="card shadow-sm" style="min-height: 177px;">
|
||||
<div class="d-flex flex-column justify-content-between h-100">
|
||||
<div>
|
||||
<div class="rounded" style="height: 100px;"></div>
|
||||
<div class="card-body p-0">
|
||||
<div class="card-footer p-0">
|
||||
<div class="progress rounded-0" style="height: 2px;">
|
||||
<div class="progress-bar" role="progressbar" style="width: <%= project.progress_percentage %>%" aria-valuenow="<%= project.progress_percentage %>" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="card-title px-3 pt-3 pb-0 mb-1">
|
||||
<%= project.name.length > 20 ? project.name.upcase[0,25] + "..." : project.name.upcase %>
|
||||
</h5>
|
||||
<div class="card-text px-3 small opacity-50">
|
||||
<%= counts[:total] %> Tasks
|
||||
<% if counts[:in_progress] > 0 %>
|
||||
, <i class="bi bi-circle-fill text-success me-1" style="font-size: 0.5em; position: relative; top: -0.3em;"></i> <%= counts[:in_progress] %> in progress
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="col-md-4 mb-3">
|
||||
<a class="text-decoration-none project-card" href="#" data-bs-toggle="modal" data-bs-target="#newProjectModal">
|
||||
<div class="card shadow-sm p-0 opacity-25" style="min-height: 177px;">
|
||||
<div class="card-body rounded px-0 p-0 text-center">
|
||||
<i class="bi bi-plus opacity-25" style="font-size: 72px; line-height: 175px;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<div class="modal modal-lg fade" id="editProjectModal" tabindex="-1" aria-labelledby="editProjectModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editProjectModalLabel">Edit Project</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<%= partial :'projects/_form', locals: { project: @project, form_action: "/project/#{@project.id}", form_id: 'editProjectForm' } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<% form_action ||= "/projects/#{project.id}" %>
|
||||
<% form_id ||= 'projectForm' %>
|
||||
<% form_method = project.new_record? ? 'post' : 'patch' %>
|
||||
<form id="<%= form_id %>" action="<%= form_action %>" method="post">
|
||||
<% unless project.new_record? %>
|
||||
<input type="hidden" name="_method" value="<%= form_method %>">
|
||||
<% end %>
|
||||
<div class="mb-3">
|
||||
<label for="projectName" class="form-label">Project Name:</label>
|
||||
<input type="text" class="form-control" id="projectName" name="name" value="<%= project.name %>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="projectDescription" class="form-label">Description:</label>
|
||||
<textarea class="form-control" id="projectDescription" name="description" rows="3"><%= project.description %></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="projectArea" class="form-label">Area (optional):</label>
|
||||
<select class="form-select" id="projectArea" name="area_id">
|
||||
<option value="">No Area</option>
|
||||
<% current_user.areas.each do |area| %>
|
||||
<option value="<%= area.id %>" <%= 'selected' if project.area_id == area.id %>><%= area.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="">
|
||||
<button type="submit" class="btn btn-primary"><%= project.new_record? ? 'Create Project' : 'Update Project' %></button>
|
||||
</div>
|
||||
</form>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue