Initial commit
This commit is contained in:
commit
3bd7628013
47 changed files with 1542 additions and 0 deletions
BIN
.DS_Store
vendored
Normal file
BIN
.DS_Store
vendored
Normal file
Binary file not shown.
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
certs/
|
||||
20
Gemfile
Normal file
20
Gemfile
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
source 'https://rubygems.org'
|
||||
|
||||
gem 'sinatra'
|
||||
gem 'puma'
|
||||
gem 'rake'
|
||||
|
||||
# DB
|
||||
gem 'sinatra-activerecord'
|
||||
gem 'sqlite3'
|
||||
|
||||
# Authentication
|
||||
gem 'bcrypt'
|
||||
|
||||
# Other
|
||||
gem 'rerun'
|
||||
gem 'byebug'
|
||||
|
||||
# Development
|
||||
gem 'faker'
|
||||
gem 'rubocop'
|
||||
109
Gemfile.lock
Normal file
109
Gemfile.lock
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
activemodel (7.1.1)
|
||||
activesupport (= 7.1.1)
|
||||
activerecord (7.1.1)
|
||||
activemodel (= 7.1.1)
|
||||
activesupport (= 7.1.1)
|
||||
timeout (>= 0.4.0)
|
||||
activesupport (7.1.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
mutex_m
|
||||
tzinfo (~> 2.0)
|
||||
ast (2.4.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.19)
|
||||
bigdecimal (3.1.4)
|
||||
byebug (11.1.3)
|
||||
concurrent-ruby (1.2.2)
|
||||
connection_pool (2.4.1)
|
||||
drb (2.2.0)
|
||||
ruby2_keywords
|
||||
faker (3.2.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
ffi (1.16.3)
|
||||
i18n (1.14.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
json (2.6.3)
|
||||
language_server-protocol (3.17.0.3)
|
||||
listen (3.8.0)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
minitest (5.20.0)
|
||||
mustermann (3.0.0)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
mutex_m (0.2.0)
|
||||
nio4r (2.5.9)
|
||||
parallel (1.23.0)
|
||||
parser (3.2.2.4)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
puma (6.4.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8)
|
||||
rack-protection (3.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rainbow (3.1.1)
|
||||
rake (13.1.0)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
regexp_parser (2.8.2)
|
||||
rerun (0.14.0)
|
||||
listen (~> 3.0)
|
||||
rexml (3.2.6)
|
||||
rubocop (1.57.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.2.2.4)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.28.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.30.0)
|
||||
parser (>= 3.2.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
sinatra (3.1.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-protection (= 3.1.0)
|
||||
tilt (~> 2.0)
|
||||
sinatra-activerecord (2.0.27)
|
||||
activerecord (>= 4.1)
|
||||
sinatra (>= 1.0)
|
||||
sqlite3 (1.6.8-arm64-darwin)
|
||||
tilt (2.3.0)
|
||||
timeout (0.4.1)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-22
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt
|
||||
byebug
|
||||
faker
|
||||
puma
|
||||
rake
|
||||
rerun
|
||||
rubocop
|
||||
sinatra
|
||||
sinatra-activerecord
|
||||
sqlite3
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.21
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) [2023] [Chris Veleris]
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
89
README.md
Normal file
89
README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# tu | du | di
|
||||
|
||||
`tu|du|di` is a task and project management web application built with Sinatra. It allows users to efficiently manage their tasks and projects, categorize them into different areas, and track due dates. `tu|du|di` is designed to be intuitive and easy to use, providing a seamless experience for personal productivity.
|
||||
|
||||
## Features
|
||||
|
||||
- **Task Management**: Create, update, and delete tasks. Mark tasks as completed and view them by different filters (Today, Upcoming, Someday).
|
||||
- **Project Tracking**: Organize tasks into projects. Each project can contain multiple tasks.
|
||||
- **Area Categorization**: Group projects into areas for better organization and focus.
|
||||
- **Due Date Tracking**: Set due dates for tasks and view them based on due date categories.
|
||||
- **Responsive Design (in progress) **: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before you begin, ensure you have met the following requirements:
|
||||
- Ruby (version 3.2.2 or higher)
|
||||
- Sinatra
|
||||
- SQLite3
|
||||
- Puma
|
||||
|
||||
### Installation
|
||||
|
||||
To install `tu|du|di`, follow these steps:
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/chrisvel/tu-du-di.git
|
||||
```
|
||||
2. Navigate to the project directory:
|
||||
```bash
|
||||
cd tu-du-di
|
||||
```
|
||||
3. Install the required gems:
|
||||
```bash
|
||||
bundle install
|
||||
```
|
||||
|
||||
#### SSL setup
|
||||
|
||||
1. Create and enter the directory:
|
||||
```bash
|
||||
mkdir certs
|
||||
```
|
||||
|
||||
2. Navigate to the certs directory:
|
||||
```bash
|
||||
cd certs
|
||||
```
|
||||
|
||||
2. Create the key and cert:
|
||||
```bash
|
||||
openssl genrsa -out server.key 2048
|
||||
openssl req -new -x509 -key server.key -out server.crt -days 365
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
To start the application, run the following command in your terminal:
|
||||
|
||||
```bash
|
||||
puma -C app/config/puma.rb
|
||||
```
|
||||
|
||||
Open your browser and navigate to `http://localhost:9292` to access the application.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to `tu|du|di` are welcome. To contribute:
|
||||
|
||||
1. Fork the repository.
|
||||
2. Create a new branch (`git checkout -b feature/AmazingFeature`).
|
||||
3. Make your changes.
|
||||
4. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
|
||||
5. Push to the branch (`git push origin feature/AmazingFeature`).
|
||||
6. Open a pull request.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions or comments about `tu|du|di`, please feel free to [open an issue](https://github.com/chrisvel/tu-du-di/issues) or contact the developer directly.
|
||||
|
||||
---
|
||||
|
||||
README created by [Chris Veleris](https://github.com/chrisvel) for `tu|du|di`.
|
||||
12
Rakefile
Normal file
12
Rakefile
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
require 'irb'
|
||||
|
||||
require 'sinatra/activerecord'
|
||||
require 'sinatra/activerecord/rake'
|
||||
|
||||
require './app'
|
||||
|
||||
desc 'Start an interactive console'
|
||||
task :console do
|
||||
ARGV.clear
|
||||
IRB.start
|
||||
end
|
||||
90
app.rb
Normal file
90
app.rb
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
require 'sinatra'
|
||||
require 'sinatra/activerecord'
|
||||
require 'securerandom'
|
||||
|
||||
require './app/models/user'
|
||||
require './app/models/area'
|
||||
require './app/models/project'
|
||||
require './app/models/task'
|
||||
|
||||
require './app/helpers/authentication_helper'
|
||||
|
||||
require './app/routes/authentication_routes'
|
||||
require './app/routes/tasks_routes'
|
||||
require './app/routes/projects_routes'
|
||||
require './app/routes/areas_routes'
|
||||
|
||||
helpers AuthenticationHelper
|
||||
|
||||
use Rack::MethodOverride
|
||||
|
||||
set :database_file, './app/config/database.yml'
|
||||
set :views, proc { File.join(root, 'app/views') }
|
||||
set :public_folder, 'public'
|
||||
|
||||
configure do
|
||||
enable :sessions
|
||||
set :sessions, httponly: true, secure: production?, expire_after: 2_592_000
|
||||
set :session_secret, ENV.fetch('SESSION_SECRET') { SecureRandom.hex(64) }
|
||||
set :session_secret,
|
||||
'740cca863278d6cbacb64dbdd41cfdb1598e8208ce9b9d29b0a1e7c1e1367ca1241d8048849ee88784731d43879c94f5b9f0a639135828d590a447acb2d98e1c'
|
||||
end
|
||||
|
||||
use Rack::Protection
|
||||
|
||||
before do
|
||||
require_login
|
||||
end
|
||||
|
||||
helpers do
|
||||
def current_path
|
||||
request.path_info
|
||||
end
|
||||
|
||||
def partial(page, options = {})
|
||||
erb page, options.merge!(layout: false)
|
||||
end
|
||||
|
||||
def priority_class(task)
|
||||
return 'text-success' if task.completed
|
||||
|
||||
case task.priority
|
||||
when 'Medium' then 'text-warning'
|
||||
when 'High' then 'text-danger'
|
||||
else 'text-secondary'
|
||||
end
|
||||
end
|
||||
|
||||
def nav_link(path, query_params = {}, project_id = nil)
|
||||
current_uri = request.path_info
|
||||
current_query = request.query_string
|
||||
|
||||
current_params = Rack::Utils.parse_nested_query(current_query)
|
||||
|
||||
is_project_page = current_uri.include?('/project/') && path.include?('/project/')
|
||||
|
||||
is_active = if is_project_page
|
||||
current_uri == path && (!project_id || current_uri.end_with?("/#{project_id}"))
|
||||
elsif !query_params.empty?
|
||||
current_uri == path && query_params.all? { |k, v| current_params[k] == v }
|
||||
else
|
||||
current_uri == path && current_params.empty?
|
||||
end
|
||||
|
||||
classes = 'nav-link py-1 px-3'
|
||||
classes += ' active bg-dark' if is_active
|
||||
classes += ' link-dark' unless is_active
|
||||
|
||||
classes
|
||||
end
|
||||
end
|
||||
|
||||
get '/' do
|
||||
redirect '/inbox' if logged_in?
|
||||
|
||||
erb :inbox
|
||||
end
|
||||
|
||||
get '/inbox' do
|
||||
erb :inbox
|
||||
end
|
||||
17
app/config/database.yml
Normal file
17
app/config/database.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# config/database.yml
|
||||
default: &default
|
||||
adapter: sqlite3
|
||||
pool: 5
|
||||
timeout: 5000
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
database: db/development.sqlite3
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
database: db/test.sqlite3
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
database: db/production.sqlite3
|
||||
5
app/config/puma.rb
Normal file
5
app/config/puma.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ssl_bind '0.0.0.0', '9292', {
|
||||
key: 'certs/server.key',
|
||||
cert: 'certs/server.crt',
|
||||
verify_mode: 'none'
|
||||
}
|
||||
15
app/helpers/authentication_helper.rb
Normal file
15
app/helpers/authentication_helper.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module AuthenticationHelper
|
||||
def logged_in?
|
||||
!!session[:user_id]
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find(session[:user_id]) if session[:user_id]
|
||||
end
|
||||
|
||||
def require_login
|
||||
return if ['/login', '/logout', '/signup'].include? request.path
|
||||
|
||||
redirect '/login' unless logged_in?
|
||||
end
|
||||
end
|
||||
4
app/models/area.rb
Normal file
4
app/models/area.rb
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class Area < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
has_many :projects, dependent: :destroy
|
||||
end
|
||||
5
app/models/project.rb
Normal file
5
app/models/project.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class Project < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :area, optional: true
|
||||
has_many :tasks, dependent: :destroy
|
||||
end
|
||||
6
app/models/task.rb
Normal file
6
app/models/task.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class Task < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :project, optional: true
|
||||
|
||||
default_scope { where(completed: false) }
|
||||
end
|
||||
9
app/models/user.rb
Normal file
9
app/models/user.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
class User < ActiveRecord::Base
|
||||
has_secure_password
|
||||
|
||||
has_many :areas
|
||||
has_many :projects
|
||||
has_many :tasks
|
||||
|
||||
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, uniqueness: true
|
||||
end
|
||||
42
app/routes/areas_routes.rb
Normal file
42
app/routes/areas_routes.rb
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
class Sinatra::Application
|
||||
post '/areas/create' do
|
||||
area = current_user.areas.create(name: params[:name])
|
||||
|
||||
if area.persisted?
|
||||
redirect '/'
|
||||
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 '/'
|
||||
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 '/'
|
||||
else
|
||||
status 404
|
||||
@errors = 'Area not found or not owned by the current user.'
|
||||
end
|
||||
end
|
||||
end
|
||||
22
app/routes/authentication_routes.rb
Normal file
22
app/routes/authentication_routes.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
class Sinatra::Application
|
||||
get '/login' do
|
||||
erb :login
|
||||
end
|
||||
|
||||
post '/login' do
|
||||
@user = User.find_by(email: params[:email])
|
||||
if @user&.authenticate(params[:password])
|
||||
session[:user_id] = @user.id
|
||||
redirect '/'
|
||||
else
|
||||
logger.warn "Invalid credentials for user with email #{params[:email]}"
|
||||
@errors = ['Invalid credentials']
|
||||
erb :login
|
||||
end
|
||||
end
|
||||
|
||||
get '/logout' do
|
||||
session.clear
|
||||
redirect '/login'
|
||||
end
|
||||
end
|
||||
57
app/routes/projects_routes.rb
Normal file
57
app/routes/projects_routes.rb
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
class Sinatra::Application
|
||||
get '/projects' do
|
||||
@projects_with_tasks = current_user.projects.includes(:tasks).order(:name)
|
||||
|
||||
erb :'projects/index'
|
||||
end
|
||||
|
||||
get '/project/:id' do
|
||||
@project = current_user.projects.includes(:tasks).find_by(id: params[:id])
|
||||
halt 404, 'Project not found' unless @project
|
||||
|
||||
erb :'projects/show'
|
||||
end
|
||||
|
||||
post '/project/create' do
|
||||
project = current_user.projects.new(name: params[:name], area_id: params[:area_id].presence)
|
||||
|
||||
if project.save
|
||||
redirect '/'
|
||||
else
|
||||
@errors = 'There was a problem creating the project.'
|
||||
redirect '/'
|
||||
end
|
||||
end
|
||||
|
||||
patch '/project/:id' do
|
||||
project = current_user.projects.find_by(id: params[:id])
|
||||
|
||||
if project
|
||||
project.name = params[:name]
|
||||
project.description = params[:description]
|
||||
project.area_id = params[:area_id].presence
|
||||
|
||||
if project.save
|
||||
redirect "/project/#{project.id}"
|
||||
else
|
||||
@errors = 'There was a problem updating the project.'
|
||||
erb :edit_project
|
||||
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])
|
||||
|
||||
if project
|
||||
project.destroy
|
||||
redirect '/projects'
|
||||
else
|
||||
status 404
|
||||
"Project not found or doesn't belong to the current user."
|
||||
end
|
||||
end
|
||||
end
|
||||
108
app/routes/tasks_routes.rb
Normal file
108
app/routes/tasks_routes.rb
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
class Sinatra::Application
|
||||
get '/tasks' do
|
||||
case params[:due_date]
|
||||
when 'today'
|
||||
today = Date.today
|
||||
@tasks = current_user.tasks.where(due_date: today.beginning_of_day..today.end_of_day, project: nil)
|
||||
@projects_with_tasks = current_user.projects
|
||||
.joins(:tasks)
|
||||
.where(tasks: { due_date: today.beginning_of_day..today.end_of_day })
|
||||
.distinct.order('projects.name ASC')
|
||||
|
||||
when 'upcoming'
|
||||
one_week_from_today = Date.today + 7.days
|
||||
@tasks = current_user.tasks.where(due_date: Date.today..one_week_from_today, project: nil)
|
||||
@projects_with_tasks = current_user.projects.includes(:tasks).where(tasks: { due_date: Date.today..one_week_from_today }).order('projects.name ASC')
|
||||
|
||||
when 'never'
|
||||
@tasks = current_user.tasks.where(due_date: nil, project: nil)
|
||||
@projects_with_tasks = current_user.projects.includes(:tasks).where(tasks: { due_date: nil }).order('projects.name ASC')
|
||||
|
||||
else
|
||||
@tasks = current_user.tasks.where(project: nil)
|
||||
@projects_with_tasks = current_user.projects.includes(:tasks).order('projects.name ASC')
|
||||
end
|
||||
|
||||
@tasks ||= []
|
||||
@projects_with_tasks ||= []
|
||||
|
||||
erb :'tasks/index'
|
||||
end
|
||||
|
||||
post '/task/create' do
|
||||
task_attributes = {
|
||||
name: params[:name],
|
||||
priority: params[:priority],
|
||||
due_date: params[:due_date],
|
||||
user_id: current_user.id
|
||||
}
|
||||
|
||||
if params[:project_id].empty?
|
||||
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
|
||||
|
||||
if task.save
|
||||
redirect request.referrer || '/'
|
||||
else
|
||||
halt 400, 'There was a problem creating the task.'
|
||||
end
|
||||
end
|
||||
|
||||
patch '/task/:id' do
|
||||
task = current_user.tasks.find_by(id: params[:id])
|
||||
|
||||
halt 404, 'Task not found.' unless task
|
||||
|
||||
task_attributes = {
|
||||
name: params[:name],
|
||||
priority: params[:priority],
|
||||
due_date: params[: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
|
||||
task.project = project
|
||||
else
|
||||
task.project = nil
|
||||
end
|
||||
|
||||
if task.update(task_attributes)
|
||||
redirect '/'
|
||||
else
|
||||
halt 400, 'There was a problem updating the task.'
|
||||
end
|
||||
end
|
||||
|
||||
patch '/task/:id/toggle_completion' do
|
||||
content_type :json
|
||||
task = current_user.tasks.find(params[:id])
|
||||
if task
|
||||
task.completed = !task.completed
|
||||
if task.save
|
||||
task.to_json
|
||||
else
|
||||
status 422
|
||||
{ error: 'Unable to update task' }.to_json
|
||||
end
|
||||
else
|
||||
status 400
|
||||
{ error: 'Task not found' }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
delete '/task/:id' do
|
||||
task = current_user.tasks.find_by(id: params[:id])
|
||||
halt 404, 'Task not found.' unless task
|
||||
|
||||
if task.destroy
|
||||
redirect '/'
|
||||
else
|
||||
halt 400, 'There was a problem deleting the task.'
|
||||
end
|
||||
end
|
||||
end
|
||||
19
app/views/areas/_edit_area_modal.erb
Normal file
19
app/views/areas/_edit_area_modal.erb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<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>
|
||||
12
app/views/areas/_form.erb
Normal file
12
app/views/areas/_form.erb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<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>
|
||||
19
app/views/areas/_new_area_modal.erb
Normal file
19
app/views/areas/_new_area_modal.erb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<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>
|
||||
21
app/views/inbox.erb
Normal file
21
app/views/inbox.erb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<h2 class="mb-5"><i class="bi bi-inbox-fill ms-3 me-2"></i> Inbox</h2>
|
||||
<div class="bg-light py-2 px-3 mx-3 d-flex align-items-center border" data-bs-toggle="collapse" data-bs-target="#newTaskForm" aria-expanded="false" aria-controls="newTaskForm" style="cursor: pointer;">
|
||||
<i class="fs-4 bi bi-plus-circle-fill text-primary me-2"></i> <span class="fs-6">New task</span>
|
||||
</div>
|
||||
<div class="collapse" id="newTaskForm">
|
||||
<div class="card border-0 bg-white shadow-sm mt-2 p-4 mx-3">
|
||||
<%= partial :'tasks/_form', locals: { task: Task.new } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-3 my-2">
|
||||
<% standalone_tasks = current_user.tasks.where(project_id: nil) %>
|
||||
<% if standalone_tasks.any? %>
|
||||
<% standalone_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' %>
|
||||
24
app/views/layout.erb
Normal file
24
app/views/layout.erb
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>tu|du|di</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 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 rel="stylesheet" href="/css/app.css">
|
||||
</head>
|
||||
<body class="container-fluid bg-light p-0">
|
||||
<% if current_user %>
|
||||
<div class="row flex-nowrap">
|
||||
<%= partial :'sidebar' %>
|
||||
<div class="col-md-9 col-lg-10 px-md-4 pt-4 mb-3 main-content" style="background: #fbfbfb">
|
||||
<%= yield %>
|
||||
<%= partial :'projects/_new_project_modal' %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<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>
|
||||
<script src="/js/app.js"></script>
|
||||
</html>
|
||||
27
app/views/login.erb
Normal file
27
app/views/login.erb
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<div class="container mt-5">
|
||||
<h2 class="mb-4 text-center" style="margin-top: 200px;">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 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>
|
||||
13
app/views/projects/_edit_project_modal.erb
Normal file
13
app/views/projects/_edit_project_modal.erb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<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>
|
||||
28
app/views/projects/_form.erb
Normal file
28
app/views/projects/_form.erb
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<% 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>
|
||||
13
app/views/projects/_new_project_modal.erb
Normal file
13
app/views/projects/_new_project_modal.erb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<div class="modal modal-lg fade" id="newProjectModal" tabindex="-1" aria-labelledby="newProjectModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="newProjectModalLabel">New 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.new, form_action: '/project/create', form_id: 'newProjectForm' } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
29
app/views/projects/index.erb
Normal file
29
app/views/projects/index.erb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<h2 class="mb-5"><i class="bi bi-hexagon ms-3 me-2"></i>Areas & Projects</h2>
|
||||
<div class="bg-light py-2 px-3 mx-3 d-flex align-items-center border" data-bs-toggle="collapse" data-bs-target="#newTaskForm" aria-expanded="false" aria-controls="newTaskForm" style="cursor: pointer;">
|
||||
<i class="fs-4 bi bi-plus-circle-fill text-primary me-2"></i> <span class="fs-6">New task</span>
|
||||
</div>
|
||||
<div class="collapse" id="newTaskForm">
|
||||
<div class="card bg-white shadow-sm border-0 p-4">
|
||||
<%= partial :'tasks/_form', locals: { task: Task.new } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-3 my-2">
|
||||
<% if @projects_with_tasks.any? %>
|
||||
<% @projects_with_tasks.each do |project| %>
|
||||
<% if project.tasks.any? %>
|
||||
<h5 class="mt-4 ms-4 mb-2">
|
||||
<div class="px-2 py-1 mb-1 border border-dark d-inline-block"><%= project.name.upcase %></div>
|
||||
</h5>
|
||||
<% project.tasks.each do |task| %>
|
||||
<div id="edit_task_form_<%= task.id %>" class="d-none">
|
||||
<%= partial :'tasks/_form', locals: { task: task } %>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<%= partial :'tasks/_task', locals: { task: task } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= partial :'tasks/_edit_task_modal' %>
|
||||
61
app/views/projects/show.erb
Normal file
61
app/views/projects/show.erb
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<% if current_user %>
|
||||
<div class="container-fluid">
|
||||
<div class="row flex-nowrap">
|
||||
<%= partial :'sidebar' %>
|
||||
<div class="col-md-9 col-lg-10 px-md-4 pt-4 mb-3" style="background: #fbfbfb">
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<h2 class="mb-0"><i class="bi bi-hexagon ms-3 me-2"></i><%= @project.name.upcase %></h2>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-link text-secondary" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical fs-6"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#editProjectModal" data-project-id="<%= @project.id %>">
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<% if @project %>
|
||||
<form id="delete_project_<%= @project.id %>" action="/project/<%= @project.id %>" method="post" class="d-none">
|
||||
<input type="hidden" name="_method" value="delete">
|
||||
</form>
|
||||
<% end %>
|
||||
<a class="dropdown-item" href="#" onclick="deleteProject('<%= @project.id %>')">Delete</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% unless @project.description.blank? %>
|
||||
<div class="bg-secondary-subtle px-4 py-3 mb-4 mx-3 rounded">
|
||||
<%= @project.description %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="bg-light py-2 px-3 mx-3 d-flex align-items-center border" data-bs-toggle="collapse" data-bs-target="#newTaskForm" aria-expanded="false" aria-controls="newTaskForm" style="cursor: pointer;">
|
||||
<i class="fs-4 bi bi-plus-circle-fill text-primary me-2"></i> <span class="fs-6">New task</span>
|
||||
</div>
|
||||
<div class="collapse" id="newTaskForm">
|
||||
<div class="card border-0 bg-white shadow-sm mt-2 p-4 mx-3">
|
||||
<%= partial :'tasks/_form', locals: { task: Task.new(project: @project) } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-3 my-2">
|
||||
<% if @project.tasks.any? %>
|
||||
<% @project.tasks.each do |task| %>
|
||||
<div id="edit_task_form_<%= task.id %>" class="d-none">
|
||||
<%= partial :'tasks/_form', locals: { task: task } %>
|
||||
</div>
|
||||
<div>
|
||||
<%= partial :'tasks/_task', locals: { task: task } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= partial :'projects/_edit_project_modal', locals: { project: @project } %>
|
||||
<%= partial :'tasks/_edit_task_modal' %>
|
||||
<% else %>
|
||||
<p>Please log in to view your dashboard.</p>
|
||||
<% end %>
|
||||
103
app/views/sidebar.erb
Normal file
103
app/views/sidebar.erb
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<div class="d-flex flex-column flex-shrink-0 p-3 bg-light vh-100 fixed-sidebar" style="width: 300px;"> <a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
|
||||
<span class="fs-4">tu|du|di</span>
|
||||
</a>
|
||||
<hr>
|
||||
<ul class="nav nav-pills flex-column">
|
||||
<li class="nav-item">
|
||||
<a href="/inbox" class="<%= nav_link('/inbox') %>" aria-current="page">
|
||||
<i class="bi bi-inbox-fill me-1"></i> Inbox
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/tasks?due_date=today" class="<%= nav_link('/tasks', 'due_date' => 'today') %>">
|
||||
<i class="bi bi-calendar-day-fill me-1"></i> Today
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/tasks?due_date=upcoming" class="<%= nav_link('/tasks', 'due_date' => 'upcoming') %>">
|
||||
<i class="bi bi-calendar3 me-1"></i> Upcoming
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/projects" class="<%= nav_link('/projects') %>">
|
||||
<i class="bi bi-hexagon-fill me-1"></i> Areas & Projects
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/tasks?due_date=anytime" class="<%= nav_link('/tasks', 'due_date' => 'anytime') %>">
|
||||
<i class="bi bi-sun-fill me-1"></i> Anytime
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/tasks?due_date=never" class="<%= nav_link('/tasks', 'due_date' => 'never') %>">
|
||||
<i class="bi bi-moon-stars-fill me-1"></i> Someday
|
||||
</a>
|
||||
</li>
|
||||
<li class="border-top my-3"></li>
|
||||
</ul>
|
||||
<div class="px-3 mb-auto">
|
||||
<% current_user.areas.includes(:projects).order('name ASC').each do |area| %>
|
||||
<div class="area-item d-flex justify-content-between align-items-center">
|
||||
<a href="#area_<%= area.id %>_projects" class="nav-link link-dark flex-grow-1" data-bs-toggle="collapse" aria-expanded="true">
|
||||
<i class="bi bi-folder me-1 fs-6"></i>
|
||||
<i class="bi bi-folder2-open me-1 fs-6 d-none"></i>
|
||||
<%= area.name %>
|
||||
</a>
|
||||
<div class="dropdown area-options">
|
||||
<button class="btn btn-link text-secondary" type="button" id="dropdownMenuButton<%= area.id %>" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i>
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton<%= area.id %>">
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#editAreaModal<%= area.id %>">Edit</a></li>
|
||||
<li>
|
||||
<form action="/area/<%= area.id %>" method="post" onsubmit="return confirm('Are you sure you want to delete this area?');">
|
||||
<input type="hidden" name="_method" value="delete">
|
||||
<button type="submit" class="dropdown-item">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<% if area.projects.any? %>
|
||||
<div class="collapse show" id="area_<%= area.id %>_projects">
|
||||
<ul class="nav nav-pills flex-column">
|
||||
<% area.projects.each do |project| %>
|
||||
<li class="nav-item">
|
||||
<a href="/project/<%= project.id %>" class="<%= nav_link("/project/#{project.id}") %>">
|
||||
<i class="bi bi-hexagon me-1"></i> <%= project.name.length > 22 ? project.name[0...22] + '...' : project.name %>
|
||||
</a>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-dark dropdown-toggle w-100" type="button" id="dropdownMenuButton1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
New Section
|
||||
</button>
|
||||
<ul class="dropdown-menu w-100" aria-labelledby="dropdownMenuButton1">
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#newProjectModal">Project</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#newAreaModal">Area</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="dropdown">
|
||||
<a href="#" class="d-flex justify-content-center align-items-center link-dark text-decoration-none dropdown-toggle" id="dropdownUser1" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<strong><%= current_user.email %></strong>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-dark small text-center" aria-labelledby="dropdownUser1">
|
||||
<li><a class="dropdown-item" href="#">Profile</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="/logout">Sign out</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<%= partial :'projects/_new_project_modal' %>
|
||||
<%= partial :'areas/_new_area_modal' %>
|
||||
<% current_user.areas.each do |area| %>
|
||||
<%= partial :'areas/_edit_area_modal', locals: { area: area } %>
|
||||
<% end %>
|
||||
13
app/views/tasks/_edit_task_modal.erb
Normal file
13
app/views/tasks/_edit_task_modal.erb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<div class="modal modal-lg fade" id="editTaskModal" tabindex="-1" aria-labelledby="editTaskModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="editTaskModalLabel">Edit Task</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="editTaskFormContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
64
app/views/tasks/_form.erb
Normal file
64
app/views/tasks/_form.erb
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<% action_url = task.new_record? ? '/task/create' : "/task/#{task.id}" %>
|
||||
<% method = task.new_record? ? 'post' : 'patch' %>
|
||||
|
||||
<form action="<%= action_url %>" method="post" class="">
|
||||
<% unless task.new_record? %>
|
||||
<input type="hidden" name="_method" value="<%= method %>">
|
||||
<% end %>
|
||||
<fieldset>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<label for="task_name" class="form-label">Task:</label>
|
||||
<input type="text" id="task_name" name="name" value="<%= task.name %>" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<label for="task_project" class="form-label">Project (optional):</label>
|
||||
<select id="task_project" name="project_id" class="form-select">
|
||||
<option value="">No Project</option>
|
||||
<% current_user.projects.each do |project| %>
|
||||
<option value="<%= project.id %>" <%= 'selected' if task.project_id == project.id %>><%= project.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="task_priority" class="form-label">Priority:</label>
|
||||
<select id="task_priority" name="priority" class="form-select">
|
||||
<option value="Low" <%= 'selected' if task.priority == 'Low' %>>Low</option>
|
||||
<option value="Medium" <%= 'selected' if task.priority == 'Medium' %>>Medium</option>
|
||||
<option value="High" <%= 'selected' if task.priority == 'High' %>>High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="task_due_date" class="form-label">Due Date:</label>
|
||||
<input type="date" id="task_due_date" name="due_date" value="<%= task.due_date.strftime('%Y-%m-%d') if task.due_date %>" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<%= task.new_record? ? 'Create Task' : 'Update Task' %>
|
||||
</button>
|
||||
<% unless task.new_record? %>
|
||||
<button type="button" class="btn btn-outline-danger" onclick="deleteTask('<%= task.id %>')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<% if !task.new_record? %>
|
||||
<form id="delete_task_<%= task.id %>" action="/task/<%= task.id %>" method="post" class="d-none">
|
||||
<input type="hidden" name="_method" value="delete">
|
||||
</form>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
function deleteTask(taskId) {
|
||||
if (confirm('Are you sure you want to delete this task?')) {
|
||||
var form = document.getElementById('delete_task_' + taskId);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
9
app/views/tasks/_header.erb
Normal file
9
app/views/tasks/_header.erb
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<% if params[:due_date] == 'today' %>
|
||||
<h2 class="mb-5"><i class="bi bi-calendar-fill ms-3 me-2"></i> Today</h2>
|
||||
<% elsif params[:due_date] == 'upcoming' %>
|
||||
<h2 class="mb-5"><i class="bi bi-calendar3 ms-3 me-2"></i> Upcoming</h2>
|
||||
<% elsif params[:due_date] == 'never' %>
|
||||
<h2 class="mb-5"><i class="bi bi-moon-stars-fill ms-3 me-2"></i> Someday</h2>
|
||||
<% elsif params[:due_date] == 'anytime' %>
|
||||
<h2 class="mb-5"><i class="bi bi-sun-fill ms-3 me-2"></i> Anytime</h2>
|
||||
<% end %>
|
||||
25
app/views/tasks/_task.erb
Normal file
25
app/views/tasks/_task.erb
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<div class="border-0 bg-white shadow-sm px-3 py-2 mb-1 d-flex align-items-center task-item <%= 'opacity-50' if task.completed %>" data-task-id="<%= task.id %>">
|
||||
<div class="flex-grow-1 d-flex align-items-center">
|
||||
<span onclick="toggleTaskCompletion(event, <%= task.id %>)" class="toggle-completion">
|
||||
<i class="fs-6 bi <%= task.completed ? 'bi-check-circle-fill' : 'bi-circle' %> <%= priority_class task %> me-2"></i>
|
||||
</span>
|
||||
<%= task.name %>
|
||||
</div>
|
||||
<div class="ms-3 d-flex align-items-center">
|
||||
<% if task.due_date %>
|
||||
<% if task.due_date.to_date == Date.today %>
|
||||
<span class="badge bg-success rounded-pill ms-2">
|
||||
<i class="bi bi-calendar me-2"></i> TODAY
|
||||
</span>
|
||||
<% elsif task.due_date && task.due_date.to_date < Date.today %>
|
||||
<span class="badge bg-danger rounded-pill ms-2">
|
||||
<i class="bi bi-calendar me-2"></i> <%= task.due_date.strftime("%Y-%m-%d") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="badge bg-info rounded-pill ms-2">
|
||||
<i class="bi bi-clock me-2"></i> <%= task.due_date.strftime("%Y-%m-%d") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
40
app/views/tasks/index.erb
Normal file
40
app/views/tasks/index.erb
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<%= partial :'tasks/_header' %>
|
||||
|
||||
<div class="bg-light py-2 px-3 mx-3 d-flex align-items-center border" data-bs-toggle="collapse" data-bs-target="#newTaskForm" aria-expanded="false" aria-controls="newTaskForm" style="cursor: pointer;">
|
||||
<i class="fs-4 bi bi-plus-circle-fill text-primary me-2"></i> <span class="fs-6">New task</span>
|
||||
</div>
|
||||
<div class="collapse" id="newTaskForm">
|
||||
<div class="card border-0 bg-white shadow-sm mt-2 p-4 mx-3">
|
||||
<%= partial :'tasks/_form', locals: { task: Task.new } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-3 my-2">
|
||||
<% if @tasks.any? %>
|
||||
<% @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>
|
||||
<div class="mx-3 my-2">
|
||||
<% if @projects_with_tasks.any? %>
|
||||
<% @projects_with_tasks.each do |project| %>
|
||||
<% if project.tasks.any? %>
|
||||
<h5 class="mt-4 ms-4 mb-2">
|
||||
<div class="px-2 py-1 mb-1 border border-dark d-inline-block"><%= project.name.upcase %></div>
|
||||
</h5>
|
||||
<% project.tasks.each do |task| %>
|
||||
<div id="edit_task_form_<%= task.id %>" class="d-none">
|
||||
<%= partial :'tasks/_form', locals: { task: task } %>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<%= partial :'tasks/_task', locals: { task: task } %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= partial :'tasks/_edit_task_modal' %>
|
||||
2
config.ru
Normal file
2
config.ru
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
require './app'
|
||||
run Sinatra::Application
|
||||
11
db/migrate/20231107102451_create_users.rb
Normal file
11
db/migrate/20231107102451_create_users.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class CreateUsers < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :users do |t|
|
||||
t.string :name
|
||||
t.string :email
|
||||
t.string :password_digest
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
10
db/migrate/20231107102516_create_areas.rb
Normal file
10
db/migrate/20231107102516_create_areas.rb
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
class CreateAreas < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :areas do |t|
|
||||
t.string :name
|
||||
t.references :user, null: false, foreign_key: true
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
11
db/migrate/20231107102609_create_projects.rb
Normal file
11
db/migrate/20231107102609_create_projects.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
class CreateProjects < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :projects do |t|
|
||||
t.string :name
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :area, foreign_key: true
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
13
db/migrate/20231107102631_create_tasks.rb
Normal file
13
db/migrate/20231107102631_create_tasks.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
class CreateTasks < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :tasks do |t|
|
||||
t.string :name
|
||||
t.string :priority
|
||||
t.datetime :due_date
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.references :project, foreign_key: true
|
||||
|
||||
t.timestamps null: false
|
||||
end
|
||||
end
|
||||
end
|
||||
7
db/migrate/20231109055429_add_fields_to_tasks.rb
Normal file
7
db/migrate/20231109055429_add_fields_to_tasks.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
class AddFieldsToTasks < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :tasks, :today, :boolean, default: false
|
||||
add_column :tasks, :description, :text
|
||||
add_column :tasks, :completed, :boolean, default: false
|
||||
end
|
||||
end
|
||||
5
db/migrate/20231109055533_add_fields_to_projects.rb
Normal file
5
db/migrate/20231109055533_add_fields_to_projects.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class AddFieldsToProjects < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :projects, :description, :text
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
class AddCascadeDeleteToProjectsAndTasks < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
# Remove the existing foreign key from projects to areas
|
||||
remove_foreign_key :projects, :areas
|
||||
# Add the new foreign key with on_delete: :cascade
|
||||
add_foreign_key :projects, :areas, on_delete: :cascade
|
||||
|
||||
# Remove the existing foreign key from tasks to projects
|
||||
remove_foreign_key :tasks, :projects
|
||||
# Add the new foreign key with on_delete: :cascade
|
||||
add_foreign_key :tasks, :projects, on_delete: :cascade
|
||||
end
|
||||
end
|
||||
61
db/schema.rb
Normal file
61
db/schema.rb
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2023_11_10_163101) do
|
||||
create_table "areas", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table "projects", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.integer "user_id", null: false
|
||||
t.integer "area_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.text "description"
|
||||
t.index ["area_id"], name: "index_projects_on_area_id"
|
||||
t.index ["user_id"], name: "index_projects_on_user_id"
|
||||
end
|
||||
|
||||
create_table "tasks", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.string "priority"
|
||||
t.datetime "due_date"
|
||||
t.integer "user_id", null: false
|
||||
t.integer "project_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "today", default: false
|
||||
t.text "description"
|
||||
t.boolean "completed", default: false
|
||||
t.index ["project_id"], name: "index_tasks_on_project_id"
|
||||
t.index ["user_id"], name: "index_tasks_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.string "email"
|
||||
t.string "password_digest"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
add_foreign_key "areas", "users"
|
||||
add_foreign_key "projects", "areas", on_delete: :cascade
|
||||
add_foreign_key "projects", "users"
|
||||
add_foreign_key "tasks", "projects", on_delete: :cascade
|
||||
add_foreign_key "tasks", "users"
|
||||
end
|
||||
76
public/css/app.css
Normal file
76
public/css/app.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
body {
|
||||
font-family: 'Poppins', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.dropdown-menu,
|
||||
.btn,
|
||||
.form-control,
|
||||
.form-select {
|
||||
font-size: 0.85rem;
|
||||
padding: .375rem .75rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 0.95rem;
|
||||
padding: .5rem 1rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* sidebar */
|
||||
.area-item .area-options {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
}
|
||||
|
||||
.area-item:hover .area-options {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fixed-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* main */
|
||||
|
||||
.main-content {
|
||||
margin-left: 300px;
|
||||
}
|
||||
178
public/js/app.js
Normal file
178
public/js/app.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
document.addEventListener("DOMContentLoaded", function () {
|
||||
attachEventListeners();
|
||||
});
|
||||
|
||||
function attachEventListeners() {
|
||||
attachCollapseListeners();
|
||||
attachTaskClickListeners();
|
||||
attachProjectModalListeners();
|
||||
attachAreaModalListeners();
|
||||
}
|
||||
|
||||
function attachCollapseListeners() {
|
||||
document.querySelectorAll('.collapse').forEach(collapseElement => {
|
||||
collapseElement.addEventListener('show.bs.collapse', () => toggleFolderIcon(collapseElement, true));
|
||||
collapseElement.addEventListener('hide.bs.collapse', () => toggleFolderIcon(collapseElement, false));
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFolderIcon(collapseElement, isOpening) {
|
||||
const closedFolderIcon = collapseElement.previousElementSibling.querySelector('.bi-folder');
|
||||
const openFolderIcon = collapseElement.previousElementSibling.querySelector('.bi-folder2-open');
|
||||
closedFolderIcon.classList.toggle('d-none', isOpening);
|
||||
openFolderIcon.classList.toggle('d-none', !isOpening);
|
||||
}
|
||||
|
||||
function attachTaskClickListeners() {
|
||||
document.querySelectorAll('.task-item').forEach(taskElement => {
|
||||
taskElement.addEventListener('click', event => {
|
||||
if (!event.target.closest('.toggle-completion')) {
|
||||
openEditTaskModal(taskElement.dataset.taskId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openEditTaskModal(taskId) {
|
||||
const formContainer = document.getElementById('edit_task_form_' + taskId);
|
||||
if (!formContainer) {
|
||||
console.error('Edit form not found for task: ' + taskId);
|
||||
return;
|
||||
}
|
||||
const formHtml = formContainer.innerHTML;
|
||||
document.getElementById('editTaskFormContainer').innerHTML = formHtml;
|
||||
new bootstrap.Modal(document.getElementById('editTaskModal')).show();
|
||||
}
|
||||
|
||||
function attachProjectModalListeners() {
|
||||
document.querySelectorAll('[data-bs-toggle="modal"][data-project-id]').forEach(button => {
|
||||
button.addEventListener('click', () => openProjectModalForEdit(button.getAttribute('data-project-id')));
|
||||
});
|
||||
}
|
||||
|
||||
function openProjectModalForEdit(projectId) {
|
||||
fetch('/project/' + projectId)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok.');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(projectData => {
|
||||
document.getElementById('projectName').value = projectData.name;
|
||||
document.getElementById('projectDescription').value = projectData.description || '';
|
||||
var projectForm = document.getElementById('projectForm');
|
||||
if (projectForm) {
|
||||
projectForm.action = '/projects/' + projectId;
|
||||
projectForm.method = 'patch';
|
||||
}
|
||||
|
||||
var modal = new bootstrap.Modal(document.getElementById('editProjectModal'));
|
||||
modal.show();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching project data:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function attachAreaModalListeners() {
|
||||
document.querySelectorAll('.open-new-area-modal').forEach(button => {
|
||||
button.addEventListener('click', () => new bootstrap.Modal(document.getElementById('newAreaModal')).show());
|
||||
});
|
||||
|
||||
document.querySelectorAll('.open-edit-area-modal').forEach(button => {
|
||||
button.addEventListener('click', () => openEditAreaModal(button.dataset.areaId));
|
||||
});
|
||||
}
|
||||
|
||||
function openNewProjectModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('newProjectModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function deleteProject(projectId) {
|
||||
if (confirm('Are you sure you want to delete this project?')) {
|
||||
const form = document.getElementById('delete_project_' + projectId);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
function openNewAreaModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('newAreaModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function deleteArea(areaId) {
|
||||
if (confirm('Are you sure you want to delete this area?')) {
|
||||
const form = document.getElementById('delete_area_' + areaId);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
|
||||
function openEditAreaModal(areaId) {
|
||||
fetchAreaDataAndPopulateModal(areaId);
|
||||
const modal = new bootstrap.Modal(document.getElementById('editAreaModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function fetchAreaDataAndPopulateModal(areaId) {
|
||||
fetch('/areas/' + areaId + '/data')
|
||||
.then(response => response.json())
|
||||
.then(areaData => {
|
||||
populateAreaEditForm(areaData);
|
||||
})
|
||||
.catch(error => console.error('Error fetching area data:', error));
|
||||
}
|
||||
|
||||
function populateAreaEditForm(areaData) {
|
||||
document.getElementById('editAreaName').value = areaData.name;
|
||||
}
|
||||
|
||||
function toggleTaskCompletion(event, taskId) {
|
||||
event.stopPropagation();
|
||||
fetch('/task/' + taskId + '/toggle_completion', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({_method: 'patch'})
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => updateTaskCompletionStatus(taskId, data))
|
||||
.catch(error => console.error('There has been a problem with your fetch operation:', error));
|
||||
}
|
||||
|
||||
function updateTaskCompletionStatus(taskId, data) {
|
||||
const iconSpan = document.querySelector('.task-item[data-task-id="' + taskId + '"] .toggle-completion');
|
||||
const taskIcon = iconSpan.querySelector('.bi');
|
||||
const taskDiv = iconSpan.closest('.task-item');
|
||||
|
||||
if (data.completed) {
|
||||
taskIcon.classList.remove('bi-circle', 'text-warning', 'text-danger');
|
||||
taskIcon.classList.add('bi-check-circle-fill', 'text-success');
|
||||
taskDiv.classList.add('opacity-50');
|
||||
} else {
|
||||
taskIcon.classList.remove('bi-check-circle-fill', 'text-success');
|
||||
taskIcon.classList.add('bi-circle');
|
||||
taskDiv.classList.remove('opacity-50');
|
||||
applyPriorityColor(taskIcon, data.priority);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPriorityColor(taskIcon, priority) {
|
||||
taskIcon.classList.remove('text-warning', 'text-danger', 'text-secondary');
|
||||
switch (priority) {
|
||||
case 'Medium':
|
||||
taskIcon.classList.add('text-warning');
|
||||
break;
|
||||
case 'High':
|
||||
taskIcon.classList.add('text-danger');
|
||||
break;
|
||||
default:
|
||||
taskIcon.classList.add('text-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue