├── .babelrc ├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ └── github-pages.yml ├── .gitignore ├── .nojekyll ├── .rubocop.yml ├── CNAME ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app.rb ├── app ├── config │ ├── database.yml │ └── puma.rb ├── frontend │ ├── App.tsx │ ├── Layout.tsx │ ├── components │ │ ├── Area │ │ │ ├── AreaDetails.tsx │ │ │ └── AreaModal.tsx │ │ ├── Areas.tsx │ │ ├── Login.tsx │ │ ├── Navbar.tsx │ │ ├── Note │ │ │ ├── NoteDetails.tsx │ │ │ └── NoteModal.tsx │ │ ├── Notes.tsx │ │ ├── Profile │ │ │ └── ProfileSettings.tsx │ │ ├── Project │ │ │ ├── ProjectDetails.tsx │ │ │ ├── ProjectItem.tsx │ │ │ └── ProjectModal.tsx │ │ ├── Projects.tsx │ │ ├── Shared │ │ │ ├── ConfirmDialog.tsx │ │ │ ├── DarkModeToggle.tsx │ │ │ ├── NotFound.tsx │ │ │ ├── PriorityDropdown.tsx │ │ │ ├── StatusDropdown.tsx │ │ │ ├── Switch.tsx │ │ │ └── ToastContext.tsx │ │ ├── Sidebar.tsx │ │ ├── Sidebar │ │ │ ├── CreateNewDropdownButton.tsx │ │ │ ├── SidebarAreas.tsx │ │ │ ├── SidebarFooter.tsx │ │ │ ├── SidebarHeader.tsx │ │ │ ├── SidebarNav.tsx │ │ │ ├── SidebarNotes.tsx │ │ │ ├── SidebarProjects.tsx │ │ │ └── SidebarTags.tsx │ │ ├── Tag │ │ │ ├── TagDetails.tsx │ │ │ ├── TagInput.tsx │ │ │ └── TagModal.tsx │ │ ├── Tags.tsx │ │ ├── Task │ │ │ ├── NewTask.tsx │ │ │ ├── TaskActions.tsx │ │ │ ├── TaskDueDate.tsx │ │ │ ├── TaskHeader.tsx │ │ │ ├── TaskItem.tsx │ │ │ ├── TaskList.tsx │ │ │ ├── TaskModal.tsx │ │ │ ├── TaskPriorityIcon.tsx │ │ │ ├── TaskStatusBadge.tsx │ │ │ ├── TaskTags.tsx │ │ │ ├── TasksToday.tsx │ │ │ ├── getDescription.ts │ │ │ └── getTitleAndIcon.ts │ │ └── Tasks.tsx │ ├── entities │ │ ├── Area.ts │ │ ├── Metrics.ts │ │ ├── Note.ts │ │ ├── Project.ts │ │ ├── Tag.ts │ │ ├── Task.ts │ │ └── User.ts │ ├── index.tsx │ ├── store │ │ └── useStore.ts │ ├── styles │ │ └── tailwind.css │ └── utils │ │ ├── areasService.ts │ │ ├── fetcher.ts │ │ ├── notesService.ts │ │ ├── projectsService.ts │ │ ├── tagsService.ts │ │ └── tasksService.ts ├── helpers │ └── authentication_helper.rb ├── models │ ├── area.rb │ ├── note.rb │ ├── project.rb │ ├── tag.rb │ ├── task.rb │ └── user.rb ├── routes │ ├── areas_routes.rb │ ├── authentication_routes.rb │ ├── notes_routes.rb │ ├── projects_routes.rb │ ├── tags_routes.rb │ ├── tasks_routes.rb │ └── users_routes.rb └── views │ ├── index.erb │ └── layout.erb ├── babel.config.js ├── config.ru ├── console.rb ├── create_migration.sh ├── db ├── migrate │ ├── 20231107102451_create_users.rb │ ├── 20231107102516_create_areas.rb │ ├── 20231107102609_create_projects.rb │ ├── 20231107102631_create_tasks.rb │ ├── 20231109055429_add_fields_to_tasks.rb │ ├── 20231109055533_add_fields_to_projects.rb │ ├── 20231110163101_add_cascade_delete_to_projects_and_tasks.rb │ ├── 20231114203847_add_tags.rb │ ├── 20231114210336_create_tasks_tags.rb │ ├── 20231115092055_rename_tasks_tags_to_tags_tasks.rb │ ├── 20231116112552_create_notes.rb │ ├── 20231116120633_create_join_table_notes_tags.rb │ ├── 20231117170940_add_title_to_notes.rb │ ├── 20231117174412_add_project_to_notes.rb │ ├── 20231127092131_change_priority_in_tasks.rb │ ├── 20231127094906_add_note_and_status_to_tasks.rb │ ├── 20240326093339_add_active_to_projects.rb │ ├── 20241006225631_add_pin_to_sidebar_to_projects.rb │ ├── 20241007143928_add_profile_fields_to_users.rb │ ├── 20241016105827_create_description_for_area.rb │ ├── 20241121113756_create_projects_tags.rb │ ├── 20241126095028_add_priority_to_projects.rb │ └── 20250224162915_add_due_date_at_to_projects.rb ├── schema.rb └── seeds.rb ├── eslint.config.mjs ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── js │ └── bundle.js ├── run.sh ├── screenshots ├── all-dark.png ├── all-light.png ├── mobile-all-dark.png └── mobile-all-light.png ├── scripts └── deploy_to_docker.sh ├── tailwind.config.js ├── test └── test_app.rb ├── tsconfig.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": ["react-refresh/babel"] 8 | } 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | db/*.sqlite3 2 | *.sqlite3 3 | *.sqlite3-shm 4 | *.sqlite3-wal 5 | certs/ 6 | .DS_Store -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: chrisvel 4 | patreon: ChrisVeleris 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: chrisveleris 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Setup Pages 15 | uses: actions/configure-pages@v3 16 | - name: Build with Jekyll 17 | uses: actions/jekyll-build-pages@v1 18 | with: 19 | source: ./ 20 | destination: ./_site 21 | - name: Upload artifact 22 | uses: actions/upload-pages-artifact@v1 23 | - name: Deploy to GitHub Pages 24 | id: deployment 25 | uses: actions/deploy-pages@v2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | *.sqlite3-shm 3 | *.sqlite3-wal 4 | certs/ 5 | .DS_Store 6 | 7 | .byebug_history 8 | node_modules 9 | .env 10 | 11 | public/js/bundle.js -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisvel/tududi/912cfacb70a0d8fe30fc546840a14c450218d4af/.nojekyll -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Metrics/ClassLength: 2 | Max: 500 3 | Metrics/BlockLength: 4 | Max: 50 5 | Metrics/MethodLength: 6 | Max: 50 7 | Style/Documentation: 8 | Enabled: false 9 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | tududi.com -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the React frontend 2 | FROM node:16 AS frontend-builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy and install frontend dependencies 7 | COPY package*.json ./ 8 | RUN npm install 9 | 10 | # Copy the rest of the frontend code 11 | COPY . . 12 | 13 | # Build the frontend assets 14 | RUN npm run build 15 | 16 | # Stage 2: Build the Sinatra backend 17 | FROM ruby:3.2.2-slim 18 | 19 | # Install necessary packages 20 | RUN apt-get update -qq && apt-get install -y \ 21 | build-essential \ 22 | libsqlite3-dev \ 23 | openssl \ 24 | libffi-dev \ 25 | libpq-dev 26 | 27 | WORKDIR /usr/src/app 28 | 29 | # Copy and install backend dependencies 30 | COPY Gemfile* ./ 31 | RUN bundle config set without 'development test' && bundle install 32 | 33 | # Copy the backend code 34 | COPY . . 35 | 36 | # Remove any existing development databases 37 | RUN rm -f db/development* 38 | 39 | # Copy built frontend assets from the frontend builder stage 40 | COPY --from=frontend-builder /app/public ./public 41 | 42 | # Expose the application port 43 | EXPOSE 9292 44 | 45 | # Set environment variables 46 | ENV RACK_ENV=production 47 | ENV TUDUDI_INTERNAL_SSL_ENABLED=false 48 | 49 | # Generate SSL certificates 50 | RUN mkdir -p certs && \ 51 | openssl req -x509 -newkey rsa:4096 -keyout certs/server.key -out certs/server.crt \ 52 | -days 365 -nodes -subj '/CN=localhost' 53 | 54 | # Run database migrations and start the Puma server 55 | CMD rake db:migrate && puma -C app/config/puma.rb 56 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'puma' 4 | gem 'rake' 5 | gem 'sinatra' 6 | 7 | # DB 8 | gem 'sinatra-activerecord' 9 | gem 'sinatra-cross_origin' 10 | gem 'sinatra-namespace' 11 | gem 'sqlite3' 12 | 13 | # Authentication 14 | gem 'bcrypt' 15 | 16 | # Other 17 | gem 'byebug' 18 | gem 'rerun' 19 | 20 | # Development 21 | gem 'faker' 22 | gem 'rubocop' 23 | 24 | # Testing 25 | gem 'minitest', group: :test 26 | gem 'rack-test', group: :test 27 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activemodel (7.1.1) 5 | activesupport (= 7.1.1) 6 | activerecord (7.1.1) 7 | activemodel (= 7.1.1) 8 | activesupport (= 7.1.1) 9 | timeout (>= 0.4.0) 10 | activesupport (7.1.1) 11 | base64 12 | bigdecimal 13 | concurrent-ruby (~> 1.0, >= 1.0.2) 14 | connection_pool (>= 2.2.5) 15 | drb 16 | i18n (>= 1.6, < 2) 17 | minitest (>= 5.1) 18 | mutex_m 19 | tzinfo (~> 2.0) 20 | ast (2.4.2) 21 | base64 (0.2.0) 22 | bcrypt (3.1.19) 23 | bigdecimal (3.1.4) 24 | byebug (11.1.3) 25 | concurrent-ruby (1.2.2) 26 | connection_pool (2.4.1) 27 | drb (2.2.0) 28 | ruby2_keywords 29 | faker (3.2.2) 30 | i18n (>= 1.8.11, < 2) 31 | ffi (1.16.3) 32 | i18n (1.14.1) 33 | concurrent-ruby (~> 1.0) 34 | json (2.6.3) 35 | language_server-protocol (3.17.0.3) 36 | listen (3.8.0) 37 | rb-fsevent (~> 0.10, >= 0.10.3) 38 | rb-inotify (~> 0.9, >= 0.9.10) 39 | minitest (5.20.0) 40 | multi_json (1.15.0) 41 | mustermann (3.0.0) 42 | ruby2_keywords (~> 0.0.1) 43 | mutex_m (0.2.0) 44 | nio4r (2.5.9) 45 | parallel (1.23.0) 46 | parser (3.2.2.4) 47 | ast (~> 2.4.1) 48 | racc 49 | puma (6.4.0) 50 | nio4r (~> 2.0) 51 | racc (1.7.3) 52 | rack (2.2.8) 53 | rack-protection (3.1.0) 54 | rack (~> 2.2, >= 2.2.4) 55 | rack-test (2.1.0) 56 | rack (>= 1.3) 57 | rainbow (3.1.1) 58 | rake (13.1.0) 59 | rb-fsevent (0.11.2) 60 | rb-inotify (0.10.1) 61 | ffi (~> 1.0) 62 | regexp_parser (2.8.2) 63 | rerun (0.14.0) 64 | listen (~> 3.0) 65 | rexml (3.2.6) 66 | rubocop (1.57.2) 67 | json (~> 2.3) 68 | language_server-protocol (>= 3.17.0) 69 | parallel (~> 1.10) 70 | parser (>= 3.2.2.4) 71 | rainbow (>= 2.2.2, < 4.0) 72 | regexp_parser (>= 1.8, < 3.0) 73 | rexml (>= 3.2.5, < 4.0) 74 | rubocop-ast (>= 1.28.1, < 2.0) 75 | ruby-progressbar (~> 1.7) 76 | unicode-display_width (>= 2.4.0, < 3.0) 77 | rubocop-ast (1.30.0) 78 | parser (>= 3.2.1.0) 79 | ruby-progressbar (1.13.0) 80 | ruby2_keywords (0.0.5) 81 | sinatra (3.1.0) 82 | mustermann (~> 3.0) 83 | rack (~> 2.2, >= 2.2.4) 84 | rack-protection (= 3.1.0) 85 | tilt (~> 2.0) 86 | sinatra-activerecord (2.0.27) 87 | activerecord (>= 4.1) 88 | sinatra (>= 1.0) 89 | sinatra-contrib (3.1.0) 90 | multi_json 91 | mustermann (~> 3.0) 92 | rack-protection (= 3.1.0) 93 | sinatra (= 3.1.0) 94 | tilt (~> 2.0) 95 | sinatra-cross_origin (0.4.0) 96 | sinatra-namespace (1.0) 97 | sinatra-contrib 98 | sqlite3 (1.6.8-arm64-darwin) 99 | sqlite3 (1.6.8-x86_64-linux) 100 | tilt (2.3.0) 101 | timeout (0.4.1) 102 | tzinfo (2.0.6) 103 | concurrent-ruby (~> 1.0) 104 | unicode-display_width (2.5.0) 105 | 106 | PLATFORMS 107 | arm64-darwin-22 108 | x86_64-linux 109 | 110 | DEPENDENCIES 111 | bcrypt 112 | byebug 113 | faker 114 | minitest 115 | puma 116 | rack-test 117 | rake 118 | rerun 119 | rubocop 120 | sinatra 121 | sinatra-activerecord 122 | sinatra-cross_origin 123 | sinatra-namespace 124 | sqlite3 125 | 126 | BUNDLED WITH 127 | 2.4.21 128 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 [Chris Veleris] 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to use, 5 | copy, modify, merge, publish, and distribute copies of the Software, subject to 6 | the following conditions: 7 | 8 | 1. **Non-Commercial Use**: The Software is provided for personal and internal 9 | business use only. Commercial use, including but not limited to selling, 10 | reselling, or redistributing the Software for profit, is prohibited without 11 | prior written permission from the copyright holder. 12 | 13 | 2. **Attribution**: The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions of the Software. 15 | 16 | 3. **No Warranty**: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY 17 | KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 19 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 20 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 22 | DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📝 tududi 2 | 3 | `tududi` is a task and project management web application that allows users to efficiently manage their tasks and projects, categorize them into different areas, and track due dates. It is designed to be intuitive and easy to use, providing a seamless experience for personal productivity. 4 | 5 | ![Light Mode Screenshot](screenshots/all-light.png) 6 | 7 | ![Dark Mode Screenshot](screenshots/all-dark.png) 8 | 9 | ![Light Mobile Screenshot](screenshots/mobile-all-light.png) 10 | 11 | ![Dark Mobile Screenshot](screenshots/mobile-all-dark.png) 12 | 13 | ## 🚀 How It Works 14 | 15 | This app allows users to manage their tasks, projects, areas, notes, and tags in an organized way. Users can create tasks, projects, areas (to group projects), notes, and tags. Each task can be associated with a project, and both tasks and notes can be tagged for better organization. Projects can belong to areas and can also have multiple notes and tags. This structure helps users categorize and track their work efficiently, whether they’re managing individual tasks, larger projects, or keeping detailed notes. 16 | 17 | ## ✨ Features 18 | 19 | - **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. 20 | - **Quick Notes**: Create, update, delete, or assign text notes to projects. 21 | - **Tags**: Create tags for tasks and notes to enhance organization. 22 | - **Project Tracking**: Organize tasks into projects. Each project can contain multiple tasks and/or multiple notes. 23 | - **Area Categorization**: Group projects into areas for better organization and focus. 24 | - **Due Date Tracking**: Set due dates for tasks and view them based on due date categories. 25 | - **Responsive Design**: Accessible from various devices, ensuring a consistent experience across desktops, tablets, and mobile phones. 26 | 27 | ## 🗺️ Roadmap 28 | 29 | Check out our [GitHub Project](https://github.com/users/chrisvel/projects/2) for planned features and progress. 30 | 31 | ## 🛠️ Getting Started 32 | 33 | **One simple command**, that's all it takes to run tududi with _docker_. 34 | 35 | ### 🐋 Docker 36 | 37 | First pull the latest image: 38 | 39 | ```bash 40 | docker pull chrisvel/tududi:latest 41 | ``` 42 | 43 | Then set up the necessary environment variables: 44 | 45 | - `TUDUDI_USER_EMAIL` 46 | - `TUDUDI_USER_PASSWORD` 47 | - `TUDUDI_SESSION_SECRET` 48 | - `TUDUDI_INTERNAL_SSL_ENABLED` 49 | 50 | 1. (Optional) Create a random session secret: 51 | ```bash 52 | openssl rand -hex 64 53 | ``` 54 | 55 | 2. Run the Docker container: 56 | ```bash 57 | docker run \ 58 | -e TUDUDI_USER_EMAIL=myemail@example.com \ 59 | -e TUDUDI_USER_PASSWORD=mysecurepassword \ 60 | -e TUDUDI_SESSION_SECRET=your_generated_hash_here \ 61 | -e TUDUDI_INTERNAL_SSL_ENABLED=false \ 62 | -v ~/tududi_db:/usr/src/app/tududi_db \ 63 | -p 9292:9292 \ 64 | -d chrisvel/tududi:latest 65 | ``` 66 | 67 | 3. Navigate to [https://localhost:9292](https://localhost:9292) and login with your credentials. 68 | 69 | ## 🚧 Development 70 | 71 | ### Prerequisites 72 | 73 | Before you begin, ensure you have the following installed: 74 | - Ruby (version 3.2.2 or higher) 75 | - Sinatra 76 | - SQLite3 77 | - Puma 78 | - ReactJS 79 | 80 | ### 🏗 Installation 81 | 82 | To install `tududi`, follow these steps: 83 | 84 | 1. Clone the repository: 85 | ```bash 86 | git clone https://github.com/chrisvel/tududi.git 87 | ``` 88 | 2. Navigate to the project directory: 89 | ```bash 90 | cd tududi 91 | ``` 92 | 3. Install the required gems: 93 | ```bash 94 | bundle install 95 | ``` 96 | 97 | ### 🔒 SSL Setup 98 | 99 | 1. Create and enter the directory: 100 | ```bash 101 | mkdir certs 102 | cd certs 103 | ``` 104 | 2. Create the key and cert: 105 | ```bash 106 | openssl genrsa -out server.key 2048 107 | openssl req -new -x509 -key server.key -out server.crt -days 365 108 | ``` 109 | 110 | ### 📂 Database Setup 111 | 112 | Execute the migrations: 113 | 114 | ```bash 115 | rake db:migrate 116 | ``` 117 | 118 | ### 👤 Create Your User 119 | 120 | 1. Open the console: 121 | ```bash 122 | rake console 123 | ``` 124 | 2. Add the user: 125 | ```ruby 126 | User.create(email: "myemail@somewhere.com", password: "awes0meHax0Rp4ssword") 127 | ``` 128 | 129 | ### 🚀 Usage 130 | 131 | To start the application, run: 132 | 133 | ```bash 134 | puma -C app/config/puma.rb 135 | ``` 136 | 137 | ### 🔍 Testing 138 | 139 | To run tests, execute: 140 | 141 | ```bash 142 | bundle exec ruby -Itest test/test_app.rb 143 | ``` 144 | 145 | ## 🤝 Contributing 146 | 147 | Contributions to `tududi` are welcome. To contribute: 148 | 149 | 1. Fork the repository. 150 | 2. Create a new branch (\`git checkout -b feature/AmazingFeature\`). 151 | 3. Make your changes. 152 | 4. Commit your changes (\`git commit -m 'Add some AmazingFeature'\`). 153 | 5. Push to the branch (\`git push origin feature/AmazingFeature\`). 154 | 6. Open a pull request. 155 | 156 | ## 📜 License 157 | 158 | This project is licensed for free personal use, with consent required for commercial use. Refer to the LICENSE for further details. 159 | 160 | ## 📬 Contact 161 | 162 | For questions or comments, please [open an issue](https://github.com/chrisvel/tududi/issues) or contact the developer directly. 163 | 164 | --- 165 | 166 | README created by [Chris Veleris](https://github.com/chrisvel) for `tududi`. 167 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'irb' 2 | 3 | require 'sinatra/activerecord' 4 | require 'sinatra/activerecord/rake' 5 | 6 | require './app' 7 | 8 | desc 'Start an interactive console' 9 | task :console do 10 | ARGV.clear 11 | IRB.start 12 | end -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra' 2 | require 'sinatra/activerecord' 3 | require 'securerandom' 4 | require 'byebug' 5 | 6 | require './app/models/user' 7 | require './app/models/area' 8 | require './app/models/project' 9 | require './app/models/task' 10 | require './app/models/tag' 11 | require './app/models/note' 12 | 13 | require './app/helpers/authentication_helper' 14 | 15 | require './app/routes/authentication_routes' 16 | require './app/routes/tasks_routes' 17 | require './app/routes/projects_routes' 18 | require './app/routes/areas_routes' 19 | require './app/routes/notes_routes' 20 | require './app/routes/tags_routes' 21 | require './app/routes/users_routes' 22 | 23 | require 'sinatra/cross_origin' 24 | 25 | helpers AuthenticationHelper 26 | 27 | use Rack::MethodOverride 28 | 29 | set :database_file, './app/config/database.yml' 30 | set :views, proc { File.join(root, 'app/views') } 31 | set :public_folder, 'public' 32 | 33 | configure do 34 | enable :sessions 35 | secure_flag = production? && ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true' 36 | set :sessions, httponly: true, 37 | secure: secure_flag, 38 | expire_after: 2_592_000, 39 | same_site: secure_flag ? :none : :lax 40 | set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) } 41 | 42 | # Auto-create user if not exists 43 | if ENV['TUDUDI_USER_EMAIL'] && ENV['TUDUDI_USER_PASSWORD'] && ActiveRecord::Base.connection.table_exists?('users') 44 | user = User.find_or_initialize_by(email: ENV['TUDUDI_USER_EMAIL']) 45 | if user.new_record? 46 | user.password = ENV['TUDUDI_USER_PASSWORD'] 47 | user.save 48 | end 49 | end 50 | end 51 | 52 | use Rack::Protection 53 | 54 | before do 55 | require_login 56 | end 57 | 58 | configure do 59 | enable :cross_origin 60 | end 61 | 62 | before do 63 | response.headers['Access-Control-Allow-Origin'] = 'http://localhost:8080' 64 | response.headers['Access-Control-Allow-Credentials'] = 'true' 65 | end 66 | 67 | options '*' do 68 | response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' 69 | response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept' 70 | 200 71 | end 72 | 73 | helpers do 74 | def current_path 75 | request.path_info 76 | end 77 | 78 | def partial(page, options = {}) 79 | erb page, options.merge!(layout: false) 80 | end 81 | 82 | def nav_link_active?(path, query_params = {}, project_id = nil) 83 | current_uri = request.path_info 84 | current_query = request.query_string 85 | current_params = Rack::Utils.parse_nested_query(current_query) 86 | is_project_page = current_uri.include?('/project/') && path.include?('/project/') 87 | 88 | if is_project_page 89 | current_uri == path && (!project_id || current_uri.end_with?("/#{project_id}")) 90 | elsif !query_params.empty? 91 | current_uri == path && query_params.all? { |k, v| current_params[k] == v } 92 | else 93 | current_uri == path && current_params.empty? 94 | end 95 | end 96 | 97 | def nav_link(path, query_params = {}, project_id = nil) 98 | is_active = nav_link_active?(path, query_params, project_id) 99 | 100 | classes = 'nav-link py-1 px-3' 101 | classes += ' active-link' if is_active 102 | 103 | classes 104 | end 105 | 106 | def update_query_params(key, value) 107 | uri = URI(request.url) 108 | params = Rack::Utils.parse_nested_query(uri.query) 109 | params[key] = value 110 | Rack::Utils.build_query(params) 111 | end 112 | 113 | def url_without_tag 114 | uri = URI(request.url) 115 | params = Rack::Utils.parse_nested_query(uri.query) 116 | params.delete('tag') # Remove the 'tag' parameter 117 | uri.query = Rack::Utils.build_query(params) 118 | uri.to_s 119 | end 120 | end 121 | 122 | get '/*' do 123 | erb :index 124 | end 125 | 126 | not_found do 127 | content_type :json 128 | status 404 129 | { error: 'Not Found', message: 'The requested resource could not be found.' }.to_json 130 | end 131 | -------------------------------------------------------------------------------- /app/config/database.yml: -------------------------------------------------------------------------------- 1 | # config/database.yml 2 | default: &default 3 | adapter: sqlite3 4 | pool: 5 5 | timeout: 5000 6 | 7 | development: 8 | <<: *default 9 | database: db/development.sqlite3 10 | 11 | test: 12 | <<: *default 13 | database: db/test.sqlite3 14 | 15 | production: 16 | <<: *default 17 | database: tududi_db/production.sqlite3 18 | -------------------------------------------------------------------------------- /app/config/puma.rb: -------------------------------------------------------------------------------- 1 | if ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true' 2 | ssl_bind '0.0.0.0', '9292', { 3 | key: 'certs/server.key', 4 | cert: 'certs/server.crt', 5 | verify_mode: 'none' 6 | } 7 | else 8 | bind 'tcp://0.0.0.0:9292' 9 | end 10 | -------------------------------------------------------------------------------- /app/frontend/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | Routes, 4 | Route, 5 | useNavigate, 6 | Navigate, 7 | useLocation, 8 | } from "react-router-dom"; 9 | import Login from "./components/Login"; 10 | import Tasks from "./components/Tasks"; 11 | import NotFound from "./components/Shared/NotFound"; 12 | import ProjectDetails from "./components/Project/ProjectDetails"; 13 | import Projects from "./components/Projects"; 14 | import AreaDetails from "./components/Area/AreaDetails"; 15 | import Areas from "./components/Areas"; 16 | import TagDetails from "./components/Tag/TagDetails"; 17 | import Tags from "./components/Tags"; 18 | import Notes from "./components/Notes"; 19 | import NoteDetails from "./components/Note/NoteDetails"; 20 | import ProfileSettings from "./components/Profile/ProfileSettings"; 21 | import Layout from "./Layout"; 22 | import { User } from "./entities/User"; 23 | import TasksToday from "./components/Task/TasksToday"; 24 | 25 | const App: React.FC = () => { 26 | const [currentUser, setCurrentUser] = useState(null); 27 | const [loading, setLoading] = useState(true); 28 | const navigate = useNavigate(); 29 | const location = useLocation(); 30 | 31 | useEffect(() => { 32 | const fetchCurrentUser = async () => { 33 | try { 34 | const response = await fetch("/api/current_user", { 35 | credentials: "include", 36 | headers: { 37 | Accept: "application/json", 38 | }, 39 | }); 40 | const data = await response.json(); 41 | if (data.user) { 42 | setCurrentUser(data.user); 43 | } else { 44 | navigate("/login"); 45 | } 46 | } catch (err) { 47 | console.error("Failed to fetch current user:", err); 48 | navigate("/login"); 49 | } finally { 50 | setLoading(false); 51 | } 52 | }; 53 | 54 | fetchCurrentUser(); 55 | }, [navigate]); 56 | 57 | const toggleDarkMode = () => { 58 | const newValue = !isDarkMode; 59 | setIsDarkMode(newValue); 60 | localStorage.setItem("isDarkMode", JSON.stringify(newValue)); 61 | }; 62 | 63 | const [isDarkMode, setIsDarkMode] = useState(() => { 64 | const storedPreference = localStorage.getItem("isDarkMode"); 65 | return storedPreference !== null 66 | ? storedPreference === "true" 67 | : window.matchMedia("(prefers-color-scheme: dark)").matches; 68 | }); 69 | 70 | useEffect(() => { 71 | const updateTheme = () => { 72 | document.documentElement.classList.toggle("dark", isDarkMode); 73 | }; 74 | updateTheme(); 75 | 76 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); 77 | const mediaListener = (e: MediaQueryListEvent) => { 78 | if (!localStorage.getItem("isDarkMode")) { 79 | setIsDarkMode(e.matches); 80 | } 81 | }; 82 | mediaQuery.addEventListener("change", mediaListener); 83 | return () => mediaQuery.removeEventListener("change", mediaListener); 84 | }, [isDarkMode]); 85 | 86 | useEffect(() => { 87 | if (currentUser && location.pathname === "/") { 88 | navigate("/today", { replace: true }); 89 | } 90 | }, [currentUser, location.pathname, navigate]); 91 | 92 | if (loading) { 93 | return ( 94 |
95 |
96 | Loading... 97 |
98 |
99 | ); 100 | } 101 | 102 | return ( 103 | <> 104 | {currentUser ? ( 105 | 111 | 112 | } /> 113 | } /> 114 | } /> 115 | } /> 116 | } /> 117 | } /> 118 | } /> 119 | } /> 120 | } /> 121 | } /> 122 | } /> 123 | } 126 | /> 127 | } /> 128 | 129 | 130 | ) : ( 131 | 132 | )} 133 | 134 | ); 135 | }; 136 | 137 | export default App; 138 | -------------------------------------------------------------------------------- /app/frontend/components/Area/AreaDetails.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useParams, Link } from 'react-router-dom'; 3 | import { useStore } from '../../store/useStore'; 4 | import { Area } from '../../entities/Area'; 5 | 6 | const AreaDetails: React.FC = () => { 7 | const { id } = useParams<{ id: string }>(); 8 | const { areas } = useStore((state) => state.areasStore); 9 | const [area, setArea] = useState(null); 10 | const [isLoading, setIsLoading] = useState(true); 11 | const [isError, setIsError] = useState(false); 12 | 13 | useEffect(() => { 14 | if (!areas.length) setIsLoading(true); 15 | const foundArea = areas.find((a: Area) => a.id === Number(id)); 16 | setArea(foundArea || null); 17 | if (!foundArea) { 18 | setIsError(true); 19 | } 20 | setIsLoading(false); 21 | }, [id, areas]); 22 | 23 | if (isLoading) { 24 | return ( 25 |
26 |
27 | Loading area details... 28 |
29 |
30 | ); 31 | } 32 | 33 | if (isError || !area) { 34 | return ( 35 |
36 |
37 | {isError ? 'Error loading area details.' : 'Area not found.'} 38 |
39 |
40 | ); 41 | } 42 | 43 | return ( 44 |
45 |
46 |

47 | Area: {area?.name} 48 |

49 |

{area?.description}

50 | 54 | View Projects in {area?.name} 55 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default AreaDetails; -------------------------------------------------------------------------------- /app/frontend/components/Area/AreaModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Area } from '../../entities/Area'; 3 | import { useToast } from '../Shared/ToastContext'; 4 | 5 | interface AreaModalProps { 6 | isOpen: boolean; 7 | onClose: () => void; 8 | onSave: (areaData: Partial) => Promise; 9 | area?: Area | null; 10 | } 11 | 12 | const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) => { 13 | const [formData, setFormData] = useState({ 14 | id: area?.id || 0, 15 | name: area?.name || '', 16 | description: area?.description || '', 17 | }); 18 | 19 | const [error, setError] = useState(null); 20 | const modalRef = useRef(null); 21 | const [isSubmitting, setIsSubmitting] = useState(false); 22 | const [isClosing, setIsClosing] = useState(false); 23 | 24 | const { showSuccessToast, showErrorToast } = useToast(); 25 | 26 | useEffect(() => { 27 | if (isOpen) { 28 | setFormData({ 29 | id: area?.id || 0, 30 | name: area?.name || '', 31 | description: area?.description || '', 32 | }); 33 | setError(null); 34 | } 35 | }, [isOpen, area]); 36 | 37 | useEffect(() => { 38 | const handleClickOutside = (event: MouseEvent) => { 39 | if ( 40 | modalRef.current && 41 | !modalRef.current.contains(event.target as Node) 42 | ) { 43 | handleClose(); 44 | } 45 | }; 46 | 47 | if (isOpen) { 48 | document.addEventListener('mousedown', handleClickOutside); 49 | } 50 | return () => { 51 | document.removeEventListener('mousedown', handleClickOutside); 52 | }; 53 | }, [isOpen]); 54 | 55 | useEffect(() => { 56 | const handleKeyDown = (event: KeyboardEvent) => { 57 | if (event.key === 'Escape') { 58 | handleClose(); 59 | } 60 | }; 61 | 62 | if (isOpen) { 63 | document.addEventListener('keydown', handleKeyDown); 64 | } 65 | return () => { 66 | document.removeEventListener('keydown', handleKeyDown); 67 | }; 68 | }, [isOpen]); 69 | 70 | const handleChange = ( 71 | e: React.ChangeEvent 72 | ) => { 73 | const { name, value } = e.target; 74 | setFormData((prev) => ({ 75 | ...prev, 76 | [name]: value, 77 | })); 78 | }; 79 | 80 | const handleSubmit = async () => { 81 | if (!formData.name.trim()) { 82 | setError('Area name is required.'); 83 | return; 84 | } 85 | 86 | setIsSubmitting(true); 87 | setError(null); 88 | 89 | try { 90 | await onSave(formData); 91 | showSuccessToast(`Area ${formData.id ? 'updated' : 'created'} successfully!`); 92 | handleClose(); 93 | } catch (err) { 94 | setError((err as Error).message); 95 | showErrorToast('Failed to save area.'); 96 | } finally { 97 | setIsSubmitting(false); 98 | } 99 | }; 100 | 101 | const handleClose = () => { 102 | setIsClosing(true); 103 | setTimeout(() => { 104 | onClose(); 105 | setIsClosing(false); 106 | }, 300); 107 | }; 108 | 109 | if (!isOpen) return null; 110 | 111 | return ( 112 |
115 |
122 |
123 |
124 |
125 | {/* Area Name */} 126 |
127 | 137 |
138 | 139 | {/* Area Description */} 140 |
141 | 144 |