├── .dockerignore ├── .gitattributes ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── docker-compose.yml ├── image.png ├── models.py ├── requirements.txt ├── static ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── browserconfig.xml ├── css │ └── style.css ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.png ├── js │ ├── Sortable.min.js │ └── main.js ├── manifest.json ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png └── ms-icon-70x70.png └── templates ├── base.html ├── index.html ├── list_index.html ├── login.html └── task_list.html /.dockerignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | .git 4 | .gitignore 5 | .env 6 | .vscode/ 7 | .idea/ 8 | .DS_Store 9 | image.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build and Push 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: 7 | - 'v*.*.*' 8 | 9 | jobs: 10 | build-and-push: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v2 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v2 20 | 21 | - name: Docker meta 22 | id: meta 23 | uses: docker/metadata-action@v3 24 | with: 25 | images: need4swede/sidequests 26 | tags: | 27 | type=semver,pattern={{version}} 28 | type=semver,pattern={{major}}.{{minor}} 29 | type=raw,value=latest,enable={{is_default_branch}} 30 | 31 | - name: Login to DockerHub 32 | uses: docker/login-action@v1 33 | with: 34 | username: ${{ secrets.DOCKERHUB_USERNAME }} 35 | password: ${{ secrets.DOCKERHUB_TOKEN }} 36 | 37 | - name: Build and push Docker image 38 | uses: docker/build-push-action@v2 39 | with: 40 | context: . 41 | platforms: linux/amd64,linux/arm64 42 | push: true 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .DS_Store 162 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SideQuests 2 | 3 | ## Development Setup 4 | 5 | ### Local Env 6 | 7 | It is recommended to run the app locally for development. Please follow these steps to get setup: 8 | 9 | 1. Clone the repository and navigate to the directory. 10 | 11 | ```sh 12 | git clone https://github.com/need4swede/SideQuests.git 13 | cd SideQuests 14 | ``` 15 | 16 | 2. Create a python virtual environment 17 | 18 | ```sh 19 | $ python -m venv .venv 20 | $ source .venv/Scripts/activate 21 | (.venv) $ 22 | ``` 23 | 24 | 3. Install dependencies 25 | 26 | ```sh 27 | (.venv) $ pip install -r requirements.txt 28 | Collecting.... 29 | ... 30 | ``` 31 | 32 | 4. Start the app 33 | 34 | ```sh 35 | (.venv) $ ADMIN_USERNAME=admin ADMIN_PASSWORD=password python app.py 36 | * Serving Flask app 'app' 37 | * Debug mode: on 38 | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. 39 | * Running on all addresses (0.0.0.0) 40 | * Running on http://127.0.0.1:8080 41 | Press CTRL+C to quit 42 | ``` 43 | 44 | 5. Open your browser to `http://localhost:8080` and login with user `admin` and password `password` 45 | 46 | ### Testing 47 | 48 | TBD -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | WORKDIR /app 4 | 5 | COPY requirements.txt . 6 | 7 | RUN pip install --no-cache-dir -r requirements.txt 8 | 9 | COPY . . 10 | 11 | ENV PORT=8080 12 | 13 | EXPOSE 8080 14 | 15 | # Use Gunicorn to run the app 16 | CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Need4Swede 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # SideQuests - Objective Tracker 4 | 5 |  6 |  7 |  8 | 9 | SideQuests provides an intuitive and mobile friendly web-interface for managing tasks and objectives. 10 | 11 | ## 🐳 Setup 12 | 13 | ### Docker Run 14 | 15 | ```bash 16 | docker run -d \ 17 | -p 8080:8080 \ 18 | -e PORT=8080 \ 19 | -e ADMIN_USERNAME=your_admin_username \ 20 | -e ADMIN_PASSWORD=your_admin_password \ 21 | -e SECRET_KEY=your_secret_key \ 22 | need4swede/sidequests:latest 23 | ``` 24 | 25 | ### Docker Compose 26 | 27 | ```yaml 28 | services: 29 | SideQuests: 30 | image: need4swede/sidequests:latest 31 | container_name: SideQuests 32 | ports: 33 | - "8080:8080" 34 | volumes: 35 | - ./instance:/app/instance 36 | environment: 37 | - PORT=8080 38 | - ADMIN_USERNAME=your_admin_username 39 | - ADMIN_PASSWORD=your_admin_password 40 | - SECRET_KEY=your_secret_key 41 | 42 | ``` 43 | 44 | ## ✨ Core Functionality 45 | 46 | **Quests** 47 | - Quests contain individual Objectives. Leaving the title blank automatically assigns today's date as the title. 48 | 49 | **Objectives** 50 | - Objectives are individual tasks within Quests. You need to complete every Objectives to finish a Quest. 51 | 52 | ## 🎨 UI Goodies 53 | 54 | **Simple and Straightforward** 55 | - Nothing fancy here! Just enough to keep you on track and focused! 56 | 57 | **Designed for Mobile** 58 | - Fully responsive pages and elements makes for a great PWA experience. 59 | 60 | **Block Level Design** 61 | - Drag and drop elements to easily organize your Quests & Objectives. 62 | 63 | **Dark Mode** 64 | - No brainer. 65 | 66 | ## 🛠️ Technical Stack 67 | 68 | - 🐍 **Backend**: Flask (Python) 69 | - 💾 **Database**: SQLAlchemy with SQLite 70 | - 🌐 **Frontend**: HTML, CSS, JavaScript -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | # ============================ 2 | # 1. Standard Library Imports 3 | # ============================ 4 | 5 | import os # Interact with the operating system and handle environment variables 6 | from functools import wraps # Create decorator functions 7 | from datetime import datetime # Work with dates and times 8 | from urllib.parse import urlparse, urljoin # Parse and manipulate URLs 9 | 10 | # ============================ 11 | # 2. Third-Party Library Imports 12 | # ============================ 13 | 14 | from flask import ( 15 | Flask, # The core Flask class to create the application 16 | render_template, # Render HTML templates 17 | request, # Access incoming request data 18 | redirect, # Redirect responses to different routes 19 | url_for, # Build URLs for specific functions 20 | jsonify, # Return JSON responses 21 | session, # Manage user sessions 22 | flash # Display flashed messages to users 23 | ) 24 | from flask_sqlalchemy import SQLAlchemy # ORM for database interactions 25 | from flask_migrate import Migrate # Handle database migrations 26 | from dotenv import load_dotenv # Load environment variables from a .env file 27 | 28 | # ============================ 29 | # 3. Application Setup 30 | # ============================ 31 | 32 | # Load environment variables from .env file 33 | # Uncomment the following line if you have a .env file to load environment variables 34 | # load_dotenv() 35 | 36 | # Initialize the Flask application 37 | app = Flask(__name__) 38 | 39 | # ============================ 40 | # 4. Application Configuration 41 | # ============================ 42 | 43 | # Configure the SQLite database URI 44 | app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///sidequests.db' 45 | 46 | # Disable modification tracking to save resources 47 | app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False 48 | 49 | # Secret Key fetched from environment variables for security purposes 50 | app.secret_key = os.getenv('SECRET_KEY') 51 | 52 | # Initialize SQLAlchemy with the Flask app for ORM capabilities 53 | db = SQLAlchemy(app) 54 | 55 | # Initialize Flask-Migrate for handling database migrations 56 | migrate = Migrate(app, db) 57 | 58 | # ============================ 59 | # 5. Database Models 60 | # ============================ 61 | 62 | class Quest(db.Model): 63 | """ 64 | Represents a Quest in the application. 65 | 66 | Attributes: 67 | id (int): Primary key identifier for the quest. 68 | name (str): The name of the quest. 69 | order (int): The display order of the quest. 70 | objectives (List[Objective]): A list of associated objectives. 71 | """ 72 | id = db.Column(db.Integer, primary_key=True) 73 | name = db.Column(db.String(100), nullable=False) 74 | order = db.Column(db.Integer, default=0) 75 | # Establish a one-to-many relationship with Objective 76 | objectives = db.relationship('Objective', backref='quest', lazy=True) 77 | 78 | class Objective(db.Model): 79 | """ 80 | Represents an Objective within a Quest. 81 | 82 | Attributes: 83 | id (int): Primary key identifier for the objective. 84 | title (str): The title or description of the objective. 85 | completed (bool): Status indicating if the objective is completed. 86 | order (int): The display order of the objective within its quest. 87 | list_id (int): Foreign key linking to the associated Quest. 88 | """ 89 | id = db.Column(db.Integer, primary_key=True) 90 | title = db.Column(db.String(200), nullable=False) 91 | completed = db.Column(db.Boolean, default=False) 92 | order = db.Column(db.Integer, default=0) 93 | # Foreign key linking to the Quest model 94 | list_id = db.Column(db.Integer, db.ForeignKey('quest.id'), nullable=False) 95 | 96 | # ============================ 97 | # 6. Database Initialization 98 | # ============================ 99 | 100 | # Ensure all database tables are created 101 | with app.app_context(): 102 | db.create_all() 103 | 104 | # ============================ 105 | # 7. Helper Functions 106 | # ============================ 107 | 108 | def is_safe_url(target): 109 | """ 110 | Validates whether the target URL is safe for redirects. 111 | 112 | Args: 113 | target (str): The target URL to validate. 114 | 115 | Returns: 116 | bool: True if the URL is safe, False otherwise. 117 | """ 118 | ref_url = urlparse(request.host_url) 119 | test_url = urlparse(urljoin(request.host_url, target)) 120 | # Check if the netloc (network location) is the same as the host 121 | return test_url.netloc in [ref_url.netloc, request.host] 122 | 123 | def login_required(f): 124 | """ 125 | Decorator to ensure that routes require user authentication. 126 | 127 | Args: 128 | f (function): The route function to wrap. 129 | 130 | Returns: 131 | function: The wrapped function that includes authentication check. 132 | """ 133 | @wraps(f) 134 | def decorated_function(*args, **kwargs): 135 | # Check if the user is logged in by verifying the session 136 | if 'logged_in' not in session: 137 | # Store the next URL to redirect after successful login 138 | session['next_url'] = request.url 139 | return redirect(url_for('login')) 140 | return f(*args, **kwargs) 141 | return decorated_function 142 | 143 | # ============================ 144 | # 8. Route Definitions 145 | # ============================ 146 | 147 | @app.route('/login', methods=['GET', 'POST']) 148 | def login(): 149 | """ 150 | Handles user login by verifying credentials. 151 | 152 | GET: Renders the login page. 153 | POST: Processes login credentials and authenticates the user. 154 | 155 | Returns: 156 | Response: Redirects to the main page upon successful login or re-renders the login page with errors. 157 | """ 158 | # If the user is already logged in, redirect to the main page 159 | if 'logged_in' in session: 160 | return redirect(url_for('root')) 161 | 162 | if request.method == 'POST': 163 | # Retrieve username and password from the submitted form 164 | username = request.form.get('username') 165 | password = request.form.get('password') 166 | 167 | # Fetch admin credentials from environment variables 168 | admin_username = os.getenv('ADMIN_USERNAME') 169 | admin_password = os.getenv('ADMIN_PASSWORD') 170 | 171 | # Validate credentials 172 | if username == admin_username and password == admin_password: 173 | # Set the session to indicate the user is logged in 174 | session['logged_in'] = True 175 | flash('You were successfully logged in.', 'success') 176 | # Retrieve the next URL to redirect to after login 177 | next_url = session.pop('next_url', None) 178 | return redirect(next_url or url_for('root')) 179 | else: 180 | # Flash an error message for invalid credentials 181 | flash('Invalid username or password.', 'danger') 182 | 183 | # Render the login template for GET requests or failed POST attempts 184 | return render_template('login.html') 185 | 186 | @app.route('/') 187 | @login_required 188 | def root(): 189 | """ 190 | Renders the main page displaying all quests. 191 | 192 | Requires: 193 | User to be authenticated. 194 | 195 | Returns: 196 | Response: The rendered 'list_index.html' template with all quests. 197 | """ 198 | # Query all quests ordered by their 'order' attribute 199 | quests = Quest.query.order_by(Quest.order).all() 200 | # Render the template with the list of quests 201 | return render_template('list_index.html', lists=quests) 202 | 203 | @app.route('/list/', methods=['GET']) 204 | @login_required 205 | def view_list(list_id): 206 | """ 207 | Displays the details of a specific quest, including its objectives. 208 | 209 | Args: 210 | list_id (int): The ID of the quest to view. 211 | 212 | Returns: 213 | Response: The rendered 'task_list.html' template with quest details and objectives. 214 | """ 215 | # Retrieve the quest by ID or return a 404 error if not found 216 | quest = Quest.query.get_or_404(list_id) 217 | # Retrieve all objectives associated with the quest, ordered by their 'order' 218 | objectives = Objective.query.filter_by(list_id=list_id).order_by(Objective.order).all() 219 | # Render the template with the quest and its objectives 220 | return render_template('task_list.html', tasks=objectives, list=quest) 221 | 222 | @app.route('/add_list', methods=['POST']) 223 | @login_required 224 | def add_list(): 225 | """ 226 | Adds a new quest to the database. 227 | 228 | Expects: 229 | Form data with the 'name' of the quest. 230 | 231 | Returns: 232 | JSON: The newly created quest's ID and name upon success. 233 | JSON: An error message with status 400 if the quest name is missing. 234 | """ 235 | # Retrieve the 'name' from the submitted form 236 | name = request.form.get('name') 237 | # If name is not provided or is empty, generate a default name based on the current date 238 | if not name or name.strip() == '': 239 | today = datetime.now() 240 | name = today.strftime('%A, %m/%d/%y').lstrip('0').replace('/0', '/') 241 | if name: 242 | # Determine the maximum current order to place the new quest at the end 243 | max_order = db.session.query(db.func.max(Quest.order)).scalar() or 0 244 | # Create a new Quest instance 245 | new_quest = Quest(name=name, order=max_order + 1) 246 | # Add and commit the new quest to the database 247 | db.session.add(new_quest) 248 | db.session.commit() 249 | # Return the new quest's details as JSON 250 | return jsonify({'id': new_quest.id, 'name': new_quest.name}) 251 | else: 252 | # Return an error if the quest name is still invalid 253 | return jsonify({'error': 'Quest name is required.'}), 400 254 | 255 | @app.route('/delete_list/', methods=['DELETE']) 256 | @login_required 257 | def delete_list(list_id): 258 | """ 259 | Deletes a specific quest and all its associated objectives. 260 | 261 | Args: 262 | list_id (int): The ID of the quest to delete. 263 | 264 | Returns: 265 | JSON: Success message upon successful deletion. 266 | JSON: An error message with status 404 if the quest is not found. 267 | """ 268 | # Retrieve the quest by ID or return a 404 error if not found 269 | quest = Quest.query.get_or_404(list_id) 270 | if quest: 271 | # Delete all objectives associated with the quest 272 | Objective.query.filter_by(list_id=list_id).delete() 273 | # Delete the quest itself 274 | db.session.delete(quest) 275 | db.session.commit() 276 | # Return a success response 277 | return jsonify({'success': True}) 278 | else: 279 | # Return an error if the quest does not exist 280 | return jsonify({'error': 'Quest not found.'}), 404 281 | 282 | 283 | @app.route('/list//add_task', methods=['POST']) 284 | @login_required 285 | def add_task(list_id): 286 | """ 287 | Adds a new objective to a specific quest. 288 | 289 | Args: 290 | list_id (int): The ID of the quest to add the objective to. 291 | 292 | Expects: 293 | Form data with the 'title' of the objective. 294 | 295 | Returns: 296 | JSON: The newly created objective's details upon success. 297 | JSON: An error message with status 400 if the objective title is missing. 298 | """ 299 | # Retrieve the 'title' from the submitted form 300 | title = request.form.get('title') 301 | if title: 302 | # Determine the maximum current order to place the new objective at the end 303 | max_order = db.session.query(db.func.max(Objective.order)).filter_by(list_id=list_id).scalar() or 0 304 | # Create a new Objective instance 305 | new_objective = Objective(title=title, list_id=list_id, order=max_order + 1) 306 | # Add and commit the new objective to the database 307 | db.session.add(new_objective) 308 | db.session.commit() 309 | # Return the new objective's details as JSON 310 | return jsonify({ 311 | 'id': new_objective.id, 312 | 'title': new_objective.title, 313 | 'completed': new_objective.completed 314 | }) 315 | else: 316 | # Return an error if the objective title is missing 317 | return jsonify({'error': 'Objective title is required.'}), 400 318 | 319 | 320 | @app.route('/list//complete/', methods=['POST']) 321 | @login_required 322 | def complete_task(list_id, task_id): 323 | """ 324 | Toggles the completion status of a specific objective. 325 | 326 | Args: 327 | list_id (int): The ID of the quest containing the objective. 328 | task_id (int): The ID of the objective to toggle. 329 | 330 | Returns: 331 | JSON: Success message and the new completion status upon success. 332 | JSON: An error message with status 404 if the objective is not found or does not belong to the quest. 333 | """ 334 | # Retrieve the objective by ID or return a 404 error if not found 335 | objective = Objective.query.get_or_404(task_id) 336 | # Verify that the objective belongs to the specified quest 337 | if objective and objective.list_id == list_id: 338 | # Toggle the 'completed' status 339 | objective.completed = not objective.completed 340 | db.session.commit() 341 | # Return the updated status as JSON 342 | return jsonify({'success': True, 'completed': objective.completed}) 343 | else: 344 | # Return an error if the objective does not belong to the quest 345 | return jsonify({'error': 'Objective not found or does not belong to this quest.'}), 404 346 | 347 | 348 | @app.route('/list//delete/', methods=['DELETE']) 349 | @login_required 350 | def delete_task(list_id, task_id): 351 | """ 352 | Deletes a specific objective from a quest. 353 | 354 | Args: 355 | list_id (int): The ID of the quest containing the objective. 356 | task_id (int): The ID of the objective to delete. 357 | 358 | Returns: 359 | JSON: Success message upon successful deletion. 360 | JSON: An error message with status 404 if the objective is not found or does not belong to the quest. 361 | """ 362 | # Retrieve the objective by ID or return a 404 error if not found 363 | objective = Objective.query.get_or_404(task_id) 364 | # Verify that the objective belongs to the specified quest 365 | if objective and objective.list_id == list_id: 366 | # Delete the objective from the database 367 | db.session.delete(objective) 368 | db.session.commit() 369 | # Return a success response 370 | return jsonify({'success': True}) 371 | else: 372 | # Return an error if the objective does not belong to the quest 373 | return jsonify({'error': 'Objective not found or does not belong to this quest.'}), 404 374 | 375 | 376 | @app.route('/update_list/', methods=['PUT']) 377 | @login_required 378 | def update_list(list_id): 379 | """ 380 | Updates the name of a specific quest. 381 | 382 | Args: 383 | list_id (int): The ID of the quest to update. 384 | 385 | Expects: 386 | JSON data with the new 'name' for the quest. 387 | 388 | Returns: 389 | JSON: Success message upon successful update. 390 | JSON: An error message with status 400 if the new name is empty. 391 | """ 392 | # Parse JSON data from the request 393 | data = request.get_json() 394 | new_name = data.get('name', '').strip() 395 | # Validate that the new name is not empty 396 | if new_name == '': 397 | return jsonify({'error': 'Quest name cannot be empty.'}), 400 398 | 399 | # Retrieve the quest by ID or return a 404 error if not found 400 | quest = Quest.query.get_or_404(list_id) 401 | # Update the quest's name 402 | quest.name = new_name 403 | db.session.commit() 404 | # Return a success response 405 | return jsonify({'success': True}) 406 | 407 | 408 | @app.route('/update_task//', methods=['PUT']) 409 | @login_required 410 | def update_task(list_id, task_id): 411 | """ 412 | Updates the title of a specific objective. 413 | 414 | Args: 415 | list_id (int): The ID of the quest containing the objective. 416 | task_id (int): The ID of the objective to update. 417 | 418 | Expects: 419 | JSON data with the new 'title' for the objective. 420 | 421 | Returns: 422 | JSON: Success message upon successful update. 423 | JSON: An error message with status 400 if the new title is empty or the objective does not belong to the quest. 424 | """ 425 | # Parse JSON data from the request 426 | data = request.get_json() 427 | new_title = data.get('title', '').strip() 428 | # Validate that the new title is not empty 429 | if new_title == '': 430 | return jsonify({'error': 'Objective title cannot be empty.'}), 400 431 | 432 | # Retrieve the objective by ID or return a 404 error if not found 433 | objective = Objective.query.get_or_404(task_id) 434 | # Verify that the objective belongs to the specified quest 435 | if objective.list_id != list_id: 436 | return jsonify({'error': 'Objective does not belong to the specified quest.'}), 400 437 | 438 | # Update the objective's title 439 | objective.title = new_title 440 | db.session.commit() 441 | # Return a success response 442 | return jsonify({'success': True}) 443 | 444 | 445 | @app.route('/update_quest_order', methods=['POST']) 446 | @login_required 447 | def update_quest_order(): 448 | """ 449 | Updates the display order of quests based on a provided list of ordered IDs. 450 | 451 | Expects: 452 | JSON data with 'ordered_ids', a list of quest IDs in the desired order. 453 | 454 | Returns: 455 | JSON: Success message upon successful update. 456 | JSON: An error message with status 400 if an exception occurs during the update. 457 | """ 458 | # Parse JSON data from the request 459 | data = request.get_json() 460 | ordered_ids = data.get('ordered_ids', []) 461 | 462 | try: 463 | # Iterate over the ordered IDs and update each quest's 'order' attribute 464 | for index, quest_id in enumerate(ordered_ids): 465 | quest = Quest.query.get(int(quest_id)) 466 | if quest: 467 | quest.order = index 468 | # Commit all changes to the database 469 | db.session.commit() 470 | # Return a success response 471 | return jsonify({'success': True}) 472 | except Exception as e: 473 | # Rollback the session in case of an error 474 | db.session.rollback() 475 | # Return the error message as JSON with status 400 476 | return jsonify({'error': str(e)}), 400 477 | 478 | 479 | @app.route('/update_objective_order/', methods=['POST']) 480 | @login_required 481 | def update_objective_order(list_id): 482 | """ 483 | Updates the display order of objectives within a specific quest based on a provided list of ordered IDs. 484 | 485 | Args: 486 | list_id (int): The ID of the quest containing the objectives. 487 | 488 | Expects: 489 | JSON data with 'ordered_ids', a list of objective IDs in the desired order. 490 | 491 | Returns: 492 | JSON: Success message upon successful update. 493 | JSON: An error message with status 400 if an exception occurs during the update. 494 | """ 495 | # Parse JSON data from the request 496 | data = request.get_json() 497 | ordered_ids = data.get('ordered_ids', []) 498 | 499 | try: 500 | # Iterate over the ordered IDs and update each objective's 'order' attribute 501 | for index, objective_id in enumerate(ordered_ids): 502 | objective = Objective.query.get(int(objective_id)) 503 | if objective and objective.list_id == list_id: 504 | objective.order = index 505 | # Commit all changes to the database 506 | db.session.commit() 507 | # Return a success response 508 | return jsonify({'success': True}) 509 | except Exception as e: 510 | # Rollback the session in case of an error 511 | db.session.rollback() 512 | # Return the error message as JSON with status 400 513 | return jsonify({'error': str(e)}), 400 514 | 515 | # ============================ 516 | # 9. Application Entry Point 517 | # ============================ 518 | 519 | if __name__ == '__main__': 520 | """ 521 | Entry point for running the Flask application. 522 | 523 | The application runs in debug mode and listens on all network interfaces at port 8080. 524 | """ 525 | app.run(debug=True, host='0.0.0.0', port=8080) 526 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | SideQuests: 3 | image: need4swede/sidequests:latest 4 | container_name: SideQuests 5 | ports: 6 | - "8080:8080" 7 | volumes: 8 | - ./instance:/app/instance 9 | environment: 10 | - PORT=8080 11 | - ADMIN_USERNAME=admin 12 | - ADMIN_PASSWORD=password 13 | - SECRET_KEY=CHANGEME -------------------------------------------------------------------------------- /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/image.png -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | 3 | db = SQLAlchemy() 4 | 5 | class Quest(db.Model): 6 | """ 7 | Model representing a quest. 8 | """ 9 | id = db.Column(db.Integer, primary_key=True) 10 | name = db.Column(db.String(100), nullable=False) 11 | order = db.Column(db.Integer, default=0) # Order field 12 | 13 | # Relationship to objectives 14 | objectives = db.relationship('Objective', backref='quest', lazy=True) 15 | 16 | class Objective(db.Model): 17 | """ 18 | Model representing an objective within a quest. 19 | """ 20 | id = db.Column(db.Integer, primary_key=True) 21 | title = db.Column(db.String(200), nullable=False) 22 | completed = db.Column(db.Boolean, default=False) 23 | order = db.Column(db.Integer, default=0) # Order field 24 | list_id = db.Column(db.Integer, db.ForeignKey('quest.id'), nullable=False) 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.13.2 2 | blinker==1.8.2 3 | click==8.1.7 4 | Flask==3.0.3 5 | Flask-Migrate==4.0.7 6 | Flask-SQLAlchemy==3.1.1 7 | gunicorn==23.0.0 8 | itsdangerous==2.2.0 9 | Jinja2==3.1.4 10 | Mako==1.3.5 11 | MarkupSafe==2.1.5 12 | packaging==24.1 13 | python-dotenv==1.0.1 14 | SQLAlchemy==2.0.35 15 | typing_extensions==4.12.2 16 | Werkzeug==3.0.4 17 | -------------------------------------------------------------------------------- /static/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/android-icon-144x144.png -------------------------------------------------------------------------------- /static/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/android-icon-192x192.png -------------------------------------------------------------------------------- /static/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/android-icon-36x36.png -------------------------------------------------------------------------------- /static/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/android-icon-48x48.png -------------------------------------------------------------------------------- /static/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/android-icon-72x72.png -------------------------------------------------------------------------------- /static/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/android-icon-96x96.png -------------------------------------------------------------------------------- /static/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-114x114.png -------------------------------------------------------------------------------- /static/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-120x120.png -------------------------------------------------------------------------------- /static/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-144x144.png -------------------------------------------------------------------------------- /static/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-152x152.png -------------------------------------------------------------------------------- /static/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-180x180.png -------------------------------------------------------------------------------- /static/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-57x57.png -------------------------------------------------------------------------------- /static/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-60x60.png -------------------------------------------------------------------------------- /static/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-72x72.png -------------------------------------------------------------------------------- /static/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-76x76.png -------------------------------------------------------------------------------- /static/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon-precomposed.png -------------------------------------------------------------------------------- /static/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/apple-icon.png -------------------------------------------------------------------------------- /static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /*------------------------------------------------------------ 2 | 1. Import Fonts 3 | ------------------------------------------------------------*/ 4 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap'); 5 | 6 | /*------------------------------------------------------------ 7 | 2. Root Variables (Dark Mode Colors) 8 | ------------------------------------------------------------*/ 9 | :root { 10 | --background-color: #121212; 11 | --text-color: #e0e0e0; 12 | --header-background: #1f1f1f; 13 | --card-background: #1e1e1e; 14 | --card-border: #2c2c2c; 15 | --button-background: #007bff; 16 | --button-hover-background: #0056b3; 17 | --button-text-color: #ffffff; 18 | --input-background: #2c2c2c; 19 | --input-border: #444444; 20 | } 21 | 22 | /*------------------------------------------------------------ 23 | 3. Global Styles 24 | ------------------------------------------------------------*/ 25 | body { 26 | margin: 0; 27 | font-family: 'Roboto', sans-serif; 28 | background-color: var(--background-color); 29 | color: var(--text-color); 30 | } 31 | 32 | a { 33 | text-decoration: none; 34 | color: inherit; 35 | } 36 | 37 | a:visited, 38 | a:hover { 39 | color: inherit; 40 | } 41 | 42 | /* Reset margins and padding for all elements */ 43 | * { 44 | margin: 0; 45 | padding: 0; 46 | box-sizing: border-box; 47 | } 48 | 49 | /*------------------------------------------------------------ 50 | 4. Header Styles 51 | ------------------------------------------------------------*/ 52 | header { 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | background-color: var(--header-background); 57 | padding: 15px; 58 | position: sticky; 59 | top: 0; 60 | z-index: 5; 61 | /* Ensure header is below back-button */ 62 | } 63 | 64 | header h1 { 65 | font-size: 28px; 66 | margin: 0; 67 | } 68 | 69 | .back-button { 70 | position: absolute; 71 | left: 15px; 72 | background: none; 73 | border: none; 74 | font-size: 24px; 75 | cursor: pointer; 76 | color: var(--text-color); 77 | } 78 | 79 | /*------------------------------------------------------------ 80 | 5. Forms (Add List and Add Task) 81 | ------------------------------------------------------------*/ 82 | .add-list-form, 83 | .add-task-form { 84 | display: flex; 85 | padding: 15px; 86 | gap: 10px; 87 | } 88 | 89 | .add-list-form input, 90 | .add-task-form input { 91 | flex: 1; 92 | padding: 12px; 93 | font-size: 18px; 94 | background-color: var(--input-background); 95 | border: 1px solid var(--input-border); 96 | border-radius: 5px; 97 | color: var(--text-color); 98 | } 99 | 100 | .add-list-form button, 101 | .add-task-form button { 102 | padding: 12px 24px; 103 | font-size: 18px; 104 | background-color: var(--button-background); 105 | color: var(--button-text-color); 106 | border: none; 107 | border-radius: 5px; 108 | cursor: pointer; 109 | } 110 | 111 | /*------------------------------------------------------------ 112 | 6. Lists and Tasks Containers 113 | ------------------------------------------------------------*/ 114 | .lists-container, 115 | .tasks-container { 116 | display: flex; 117 | flex-direction: column; 118 | padding: 15px; 119 | gap: 15px; 120 | /* This creates space between task cards */ 121 | } 122 | 123 | #active-objectives { 124 | display: flex; 125 | flex-direction: column; 126 | gap: 15px; 127 | } 128 | 129 | /* Completed objectives section */ 130 | #completed-objectives { 131 | margin-top: 20px; 132 | border-top: 1px solid var(--card-border); 133 | padding-top: 20px; 134 | } 135 | 136 | /*------------------------------------------------------------ 137 | 7. List Card Styles 138 | ------------------------------------------------------------*/ 139 | .list-card { 140 | background-color: var(--card-background); 141 | border: 1px solid var(--card-border); 142 | border-radius: 10px; 143 | padding: 20px; 144 | display: flex; 145 | align-items: center; 146 | cursor: pointer; 147 | } 148 | 149 | .list-card:hover { 150 | background-color: rgba(255, 255, 255, 0.05); 151 | } 152 | 153 | .list-header { 154 | display: flex; 155 | align-items: center; 156 | gap: 10px; 157 | } 158 | 159 | .list-name { 160 | font-size: 24px; 161 | font-weight: 500; 162 | } 163 | 164 | /*------------------------------------------------------------ 165 | 8. Task Card Styles 166 | ------------------------------------------------------------*/ 167 | .task-card { 168 | background-color: var(--card-background); 169 | border: 1px solid var(--card-border); 170 | border-radius: 10px; 171 | padding: 20px; 172 | display: flex; 173 | align-items: center; 174 | } 175 | 176 | .task-card:hover { 177 | background-color: rgba(255, 255, 255, 0.05); 178 | } 179 | 180 | .task-content { 181 | display: flex; 182 | align-items: center; 183 | gap: 15px; 184 | cursor: pointer; 185 | flex-grow: 1; 186 | pointer-events: auto; 187 | /* Ensure touch events are allowed */ 188 | } 189 | 190 | .task-buttons { 191 | display: flex; 192 | align-items: center; 193 | } 194 | 195 | .checkbox { 196 | font-size: 24px; 197 | } 198 | 199 | .task-title { 200 | font-size: 22px; 201 | flex: 1; 202 | } 203 | 204 | .completed .task-title { 205 | text-decoration: line-through; 206 | color: #6c757d; 207 | } 208 | 209 | /* Ensure consistent spacing on mobile */ 210 | @media (max-width: 600px) { 211 | 212 | .lists-container, 213 | .tasks-container, 214 | #active-objectives, 215 | #completed-objectives { 216 | gap: 10px; 217 | /* Slightly reduced gap for mobile, but still maintaining space */ 218 | } 219 | 220 | .task-card { 221 | padding: 15px; 222 | /* Slightly reduced padding for mobile */ 223 | } 224 | 225 | #completed-objectives { 226 | margin-top: 15px; 227 | padding-top: 15px; 228 | } 229 | 230 | #completed-objectives::before { 231 | font-size: 16px; 232 | margin-bottom: 10px; 233 | } 234 | } 235 | 236 | /*------------------------------------------------------------ 237 | 9. Buttons (Edit and Delete) 238 | ------------------------------------------------------------*/ 239 | .edit-list-button, 240 | .edit-task-button, 241 | .delete-list-button, 242 | .delete-task-button { 243 | background: none; 244 | border: none; 245 | font-size: 24px; 246 | cursor: pointer; 247 | pointer-events: auto; 248 | /* Ensure touch events are allowed */ 249 | } 250 | 251 | .edit-list-button, 252 | .edit-task-button { 253 | color: #ffc107; 254 | margin-right: 1.5em; 255 | } 256 | 257 | .delete-list-button, 258 | .delete-task-button { 259 | color: #dc3545; 260 | } 261 | 262 | /*------------------------------------------------------------ 263 | 10. Drag Handle Styles 264 | ------------------------------------------------------------*/ 265 | /* Style for the drag handle */ 266 | .drag-handle { 267 | cursor: grab; 268 | /* Indicates draggable area */ 269 | padding: 8px; 270 | margin-right: 8px; 271 | font-size: 24px; 272 | color: #888; 273 | user-select: none; 274 | /* Prevents text selection */ 275 | } 276 | 277 | .drag-handle:active { 278 | cursor: grabbing; 279 | /* Changes cursor when active */ 280 | } 281 | 282 | /* Ensure the card displays flexibly with aligned items */ 283 | .list-card, 284 | .task-card { 285 | display: flex; 286 | align-items: center; 287 | } 288 | 289 | /* Optional: Add spacing between drag handle and content */ 290 | .list-card .list-header, 291 | .task-card .task-content { 292 | display: flex; 293 | align-items: center; 294 | } 295 | 296 | /*------------------------------------------------------------ 297 | 11. Mobile Responsiveness 298 | ------------------------------------------------------------*/ 299 | @media (max-width: 600px) { 300 | header h1 { 301 | font-size: 24px; 302 | } 303 | 304 | .list-name, 305 | .task-title { 306 | font-size: 20px; 307 | } 308 | 309 | .add-list-form input, 310 | .add-task-form input, 311 | .add-list-form button, 312 | .add-task-form button { 313 | font-size: 16px; 314 | } 315 | 316 | /* Adjust drag handle size for smaller screens */ 317 | .drag-handle { 318 | font-size: 20px; 319 | padding: 6px; 320 | margin-right: 6px; 321 | } 322 | 323 | /* Adjust button sizes */ 324 | .edit-list-button, 325 | .edit-task-button, 326 | .delete-list-button, 327 | .delete-task-button { 328 | font-size: 20px; 329 | padding: 6px; 330 | } 331 | } 332 | 333 | /*------------------------------------------------------------ 334 | 12. Modal Styles 335 | ------------------------------------------------------------*/ 336 | /* Modal container */ 337 | .modal { 338 | display: none; 339 | position: fixed; 340 | z-index: 1000; 341 | left: 0; 342 | top: 0; 343 | width: 100%; 344 | height: 100%; 345 | overflow: auto; 346 | background-color: rgba(0, 0, 0, 0.6); 347 | align-items: center; 348 | justify-content: center; 349 | pointer-events: none; 350 | /* Prevent blocking interactions when hidden */ 351 | } 352 | 353 | /* Modal Content */ 354 | .modal-content { 355 | background-color: var(--card-background); 356 | margin: auto; 357 | padding: 20px; 358 | border: 1px solid var(--card-border); 359 | border-radius: 10px; 360 | width: 90%; 361 | max-width: 400px; 362 | position: relative; 363 | animation: fadeIn 0.3s; 364 | pointer-events: auto; 365 | /* Allow interactions inside modal */ 366 | } 367 | 368 | /* Modal Heading */ 369 | #modal-title { 370 | margin-top: 0; 371 | margin-bottom: 4vh; 372 | font-size: 24px; 373 | text-align: center; 374 | } 375 | 376 | /* Modal Form */ 377 | #edit-form { 378 | display: flex; 379 | flex-direction: column; 380 | gap: 15px; 381 | } 382 | 383 | #edit-form input { 384 | padding: 12px; 385 | font-size: 18px; 386 | background-color: var(--input-background); 387 | border: 1px solid var(--input-border); 388 | border-radius: 5px; 389 | color: var(--text-color); 390 | } 391 | 392 | #edit-form button { 393 | padding: 12px; 394 | font-size: 18px; 395 | background-color: var(--button-background); 396 | color: var(--button-text-color); 397 | border: none; 398 | border-radius: 5px; 399 | cursor: pointer; 400 | } 401 | 402 | /* Animation */ 403 | @keyframes fadeIn { 404 | from { 405 | opacity: 0; 406 | transform: translateY(-20px); 407 | } 408 | 409 | to { 410 | opacity: 1; 411 | transform: translateY(0); 412 | } 413 | } 414 | 415 | /* Responsive Modal */ 416 | @media (max-width: 600px) { 417 | .modal-content { 418 | width: 95%; 419 | padding-bottom: 20vh; 420 | } 421 | 422 | #modal-title { 423 | margin-bottom: 2vh; 424 | } 425 | } 426 | 427 | /*------------------------------------------------------------ 428 | 13. Login Page Styles 429 | ------------------------------------------------------------*/ 430 | .login-container { 431 | max-width: 400px; 432 | margin: 50px auto; 433 | background-color: var(--card-background); 434 | padding: 30px; 435 | border: 1px solid var(--card-border); 436 | border-radius: 10px; 437 | text-align: center; 438 | } 439 | 440 | .login-container h2 { 441 | margin-bottom: 20px; 442 | font-size: 28px; 443 | } 444 | 445 | .login-form { 446 | display: flex; 447 | flex-direction: column; 448 | gap: 15px; 449 | } 450 | 451 | .login-form input { 452 | padding: 12px; 453 | font-size: 18px; 454 | background-color: var(--input-background); 455 | border: 1px solid var(--input-border); 456 | border-radius: 5px; 457 | color: var(--text-color); 458 | } 459 | 460 | .login-form button { 461 | padding: 12px; 462 | font-size: 18px; 463 | background-color: var(--button-background); 464 | color: var(--button-text-color); 465 | border: none; 466 | border-radius: 5px; 467 | cursor: pointer; 468 | } 469 | 470 | .flashes { 471 | list-style-type: none; 472 | padding: 0; 473 | margin-bottom: 15px; 474 | } 475 | 476 | .flashes li { 477 | padding: 10px; 478 | border-radius: 5px; 479 | margin-bottom: 10px; 480 | } 481 | 482 | .flashes .success { 483 | background-color: #28a745; 484 | color: white; 485 | } 486 | 487 | .flashes .danger { 488 | background-color: #dc3545; 489 | color: white; 490 | } 491 | 492 | .flashes .info { 493 | background-color: #17a2b8; 494 | color: white; 495 | } 496 | 497 | /*------------------------------------------------------------ 498 | 14. Sorting Buttons and Group Toggle 499 | ------------------------------------------------------------*/ 500 | /* Container for sorting buttons and group toggle */ 501 | .sort-and-group-controls { 502 | margin: 20px 40px; 503 | display: flex; 504 | align-items: center; 505 | flex-wrap: wrap; 506 | gap: 10px; 507 | } 508 | 509 | /* Styling for sorting buttons and group toggle */ 510 | .sort-and-group-controls button, 511 | .group-toggle { 512 | padding: 10px 20px; 513 | background-color: #2c2c2c; 514 | color: #ffffff; 515 | border: 1px solid #444444; 516 | border-radius: 8px; 517 | cursor: pointer; 518 | font-size: 14px; 519 | display: flex; 520 | align-items: center; 521 | transition: background-color 0.3s, border-color 0.3s, transform 0.2s; 522 | white-space: nowrap; 523 | } 524 | 525 | /* Hover effect for buttons and group toggle */ 526 | .sort-and-group-controls button:hover, 527 | .group-toggle:hover { 528 | background-color: #3a3a3a; 529 | border-color: #555555; 530 | transform: translateY(-2px); 531 | } 532 | 533 | /* Active state for buttons */ 534 | .sort-and-group-controls button.active { 535 | background-color: #4a90e2; 536 | border-color: #4a90e2; 537 | color: #ffffff; 538 | cursor: default; 539 | transform: none; 540 | } 541 | 542 | /* Sort icon styling */ 543 | .sort-and-group-controls button .sort-icon { 544 | margin-left: 8px; 545 | font-size: 12px; 546 | transition: transform 0.3s; 547 | } 548 | 549 | /* Rotate sort icon for descending order */ 550 | .sort-and-group-controls button.desc .sort-icon { 551 | transform: rotate(180deg); 552 | } 553 | 554 | /* Group toggle specific styling */ 555 | .group-toggle { 556 | margin-left: auto; 557 | gap: 10px; 558 | } 559 | 560 | .group-toggle label { 561 | cursor: pointer; 562 | } 563 | 564 | .group-toggle input[type="checkbox"] { 565 | appearance: none; 566 | -webkit-appearance: none; 567 | width: 40px; 568 | height: 20px; 569 | background-color: #1a1a1a; 570 | border-radius: 10px; 571 | position: relative; 572 | cursor: pointer; 573 | transition: background-color 0.3s; 574 | } 575 | 576 | .group-toggle input[type="checkbox"]::before { 577 | content: ''; 578 | position: absolute; 579 | width: 16px; 580 | height: 16px; 581 | border-radius: 50%; 582 | top: 2px; 583 | left: 2px; 584 | background-color: #ffffff; 585 | transition: transform 0.3s; 586 | } 587 | 588 | .group-toggle input[type="checkbox"]:checked { 589 | background-color: #4a90e2; 590 | } 591 | 592 | .group-toggle input[type="checkbox"]:checked::before { 593 | transform: translateX(20px); 594 | } 595 | 596 | /* Responsive adjustments */ 597 | @media (max-width: 600px) { 598 | .sort-and-group-controls { 599 | flex-direction: column; 600 | align-items: stretch; 601 | } 602 | 603 | .sort-and-group-controls button, 604 | .group-toggle { 605 | width: 100%; 606 | justify-content: center; 607 | margin-left: 0; 608 | } 609 | 610 | .group-toggle { 611 | margin-top: 10px; 612 | } 613 | } -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/favicon-96x96.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/favicon.png -------------------------------------------------------------------------------- /static/js/Sortable.min.js: -------------------------------------------------------------------------------- 1 | /*! Sortable 1.15.3 - MIT | git://github.com/SortableJS/Sortable.git */ 2 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},pt=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){E(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Et=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&D(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Et){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))D.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>D.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY response.json()) 113 | .then(data => { 114 | if (data.success) { 115 | const taskCard = document.querySelector(`.task-card[data-task-id='${taskId}']`); 116 | if (taskCard) { 117 | taskCard.classList.toggle('completed'); 118 | const checkbox = taskCard.querySelector('.checkbox'); 119 | if (checkbox) { 120 | checkbox.innerHTML = data.completed ? '✓' : '○'; 121 | } 122 | } 123 | // Re-sort tasks after completion status changes 124 | const savedTasksSort = loadSortPreference('tasks'); 125 | if (savedTasksSort) { 126 | sortTasks(savedTasksSort.type, savedTasksSort.order); 127 | } else { 128 | sortTasks(null, null); 129 | } 130 | } else { 131 | alert(data.error); 132 | } 133 | }) 134 | .catch(error => { 135 | console.error('Error:', error); 136 | }); 137 | } 138 | 139 | /** 140 | * Function to delete an objective 141 | * @param {number} listId - ID of the quest 142 | * @param {number} taskId - ID of the objective 143 | */ 144 | function deleteTask(listId, taskId) { 145 | if (confirm('Are you sure you want to delete this objective?')) { 146 | fetch(`/list/${listId}/delete/${taskId}`, { 147 | method: 'DELETE' 148 | }) 149 | .then(response => response.json()) 150 | .then(data => { 151 | if (data.success) { 152 | const taskCard = document.querySelector(`.task-card[data-task-id='${taskId}']`); 153 | if (taskCard) { 154 | taskCard.remove(); 155 | } 156 | // Re-sort tasks after deleting a task 157 | const savedTasksSort = loadSortPreference('tasks'); 158 | if (savedTasksSort) { 159 | sortTasks(savedTasksSort.type, savedTasksSort.order); 160 | } else { 161 | sortTasks(null, null); 162 | } 163 | } else { 164 | alert(data.error); 165 | } 166 | }) 167 | .catch(error => { 168 | console.error('Error:', error); 169 | }); 170 | } 171 | } 172 | 173 | /** 174 | * Function to delete a quest 175 | * @param {number} listId - ID of the quest 176 | */ 177 | function deleteList(listId) { 178 | if (confirm('Are you sure you want to delete this quest and all its objectives?')) { 179 | fetch(`/delete_list/${listId}`, { 180 | method: 'DELETE' 181 | }) 182 | .then(response => response.json()) 183 | .then(data => { 184 | if (data.success) { 185 | const listCard = document.querySelector(`.list-card[data-list-id='${listId}']`); 186 | if (listCard) { 187 | listCard.remove(); 188 | } 189 | } else { 190 | alert(data.error); 191 | } 192 | }) 193 | .catch(error => { 194 | console.error('Error:', error); 195 | }); 196 | } 197 | } 198 | 199 | /** 200 | * Function to update the quest name via AJAX 201 | * @param {number} listId - ID of the quest 202 | * @param {string} newName - New name of the quest 203 | */ 204 | function updateListName(listId, newName) { 205 | fetch(`/update_list/${listId}`, { 206 | method: 'PUT', 207 | headers: { 208 | 'Content-Type': 'application/json', 209 | }, 210 | body: JSON.stringify({ name: newName }), 211 | }) 212 | .then(response => response.json()) 213 | .then(data => { 214 | if (data.success) { 215 | const listCard = document.querySelector(`.list-card[data-list-id='${listId}']`); 216 | if (listCard) { 217 | const listNameElement = listCard.querySelector('.list-name'); 218 | if (listNameElement) { 219 | listNameElement.textContent = newName; 220 | } 221 | } 222 | } else { 223 | alert(data.error); 224 | } 225 | }) 226 | .catch(error => { 227 | console.error('Error:', error); 228 | }); 229 | } 230 | 231 | /** 232 | * Function to update the objective title via AJAX 233 | * @param {number} listId - ID of the quest 234 | * @param {number} taskId - ID of the objective 235 | * @param {string} newTitle - New title of the objective 236 | */ 237 | function updateTaskTitle(listId, taskId, newTitle) { 238 | fetch(`/update_task/${listId}/${taskId}`, { 239 | method: 'PUT', 240 | headers: { 241 | 'Content-Type': 'application/json', 242 | }, 243 | body: JSON.stringify({ title: newTitle }), 244 | }) 245 | .then(response => response.json()) 246 | .then(data => { 247 | if (data.success) { 248 | const taskCard = document.querySelector(`.task-card[data-task-id='${taskId}']`); 249 | if (taskCard) { 250 | const taskTitleElement = taskCard.querySelector('.task-title'); 251 | if (taskTitleElement) { 252 | taskTitleElement.textContent = newTitle; 253 | } 254 | } 255 | // Re-sort tasks after updating task title 256 | const savedTasksSort = loadSortPreference('tasks'); 257 | if (savedTasksSort) { 258 | sortTasks(savedTasksSort.type, savedTasksSort.order); 259 | } else { 260 | sortTasks(null, null); 261 | } 262 | } else { 263 | alert(data.error); 264 | } 265 | }) 266 | .catch(error => { 267 | console.error('Error:', error); 268 | }); 269 | } 270 | 271 | /** 272 | * Function to add a new objective to the DOM 273 | * @param {Object} task - Task object containing id, title, and completed status 274 | */ 275 | function addTaskToDOM(task) { 276 | const tasksContainer = document.querySelector('.tasks-container'); 277 | if (tasksContainer) { 278 | const listId = tasksContainer.getAttribute('data-list-id'); 279 | 280 | const taskCard = document.createElement('div'); 281 | taskCard.classList.add('task-card'); 282 | if (task.completed) { 283 | taskCard.classList.add('completed'); 284 | } 285 | taskCard.setAttribute('data-task-id', task.id); 286 | 287 | // Create Drag Handle 288 | const dragHandle = document.createElement('div'); 289 | dragHandle.classList.add('drag-handle'); 290 | dragHandle.setAttribute('aria-label', 'Drag Handle'); 291 | dragHandle.setAttribute('title', 'Drag to reorder'); 292 | dragHandle.innerHTML = '☰'; // Unicode for the hamburger menu icon 293 | 294 | const taskContent = document.createElement('div'); 295 | taskContent.classList.add('task-content'); 296 | taskContent.style.flexGrow = '1'; 297 | 298 | const checkbox = document.createElement('div'); 299 | checkbox.classList.add('checkbox'); 300 | checkbox.innerHTML = task.completed ? '✓' : '○'; // Checked or unchecked 301 | 302 | const taskTitle = document.createElement('div'); 303 | taskTitle.classList.add('task-title'); 304 | taskTitle.textContent = task.title; 305 | 306 | taskContent.appendChild(checkbox); 307 | taskContent.appendChild(taskTitle); 308 | 309 | const taskButtons = document.createElement('div'); 310 | taskButtons.classList.add('task-buttons'); 311 | 312 | const editButton = document.createElement('button'); 313 | editButton.classList.add('edit-task-button'); 314 | editButton.innerHTML = '✎'; // Pencil icon 315 | 316 | const deleteButton = document.createElement('button'); 317 | deleteButton.classList.add('delete-task-button'); 318 | deleteButton.innerHTML = '×'; // Times (X) icon 319 | 320 | taskButtons.appendChild(editButton); 321 | taskButtons.appendChild(deleteButton); 322 | 323 | taskCard.appendChild(dragHandle); 324 | taskCard.appendChild(taskContent); 325 | taskCard.appendChild(taskButtons); 326 | 327 | // Append the new task to the container 328 | tasksContainer.appendChild(taskCard); 329 | 330 | // Re-sort tasks after adding new task 331 | const savedTasksSort = loadSortPreference('tasks'); 332 | if (savedTasksSort) { 333 | sortTasks(savedTasksSort.type, savedTasksSort.order); 334 | } else { 335 | sortTasks(null, null); 336 | } 337 | 338 | // Add event listeners 339 | if (taskContent) { 340 | taskContent.addEventListener('click', () => toggleComplete(listId, task.id)); 341 | } 342 | if (editButton) { 343 | editButton.addEventListener('click', (event) => { 344 | event.stopPropagation(); 345 | showEditModal('task', task.id, task.title, listId); 346 | }); 347 | } 348 | if (deleteButton) { 349 | deleteButton.addEventListener('click', (event) => { 350 | event.stopPropagation(); 351 | deleteTask(listId, task.id); 352 | }); 353 | } 354 | } 355 | } 356 | 357 | /** 358 | * Function to add a new quest to the DOM 359 | * @param {Object} list - List object containing id and name 360 | */ 361 | function addListToDOM(list) { 362 | const listsContainer = document.querySelector('.lists-container'); 363 | if (listsContainer) { 364 | const listCard = document.createElement('div'); 365 | listCard.classList.add('list-card'); 366 | listCard.setAttribute('data-list-id', list.id); 367 | 368 | // Create Drag Handle 369 | const dragHandle = document.createElement('div'); 370 | dragHandle.classList.add('drag-handle'); 371 | dragHandle.setAttribute('aria-label', 'Drag Handle'); 372 | dragHandle.setAttribute('title', 'Drag to reorder'); 373 | dragHandle.innerHTML = '☰'; // Unicode for the hamburger menu icon 374 | 375 | const listLink = document.createElement('a'); 376 | listLink.href = `/list/${list.id}`; 377 | listLink.style.flexGrow = '1'; 378 | 379 | const listHeader = document.createElement('div'); 380 | listHeader.classList.add('list-header'); 381 | 382 | const listName = document.createElement('span'); 383 | listName.classList.add('list-name'); 384 | listName.textContent = list.name; 385 | 386 | listHeader.appendChild(listName); 387 | listLink.appendChild(listHeader); 388 | 389 | const editButton = document.createElement('button'); 390 | editButton.classList.add('edit-list-button'); 391 | editButton.innerHTML = '✎'; // Pencil icon 392 | 393 | const deleteButton = document.createElement('button'); 394 | deleteButton.classList.add('delete-list-button'); 395 | deleteButton.innerHTML = '×'; // Times (X) icon 396 | 397 | listCard.appendChild(dragHandle); 398 | listCard.appendChild(listLink); 399 | listCard.appendChild(editButton); 400 | listCard.appendChild(deleteButton); 401 | 402 | // Append the new list to the container 403 | listsContainer.appendChild(listCard); 404 | 405 | // Add event listeners 406 | if (editButton) { 407 | editButton.addEventListener('click', (event) => { 408 | event.stopPropagation(); 409 | showEditModal('list', list.id, list.name); 410 | }); 411 | } 412 | if (deleteButton) { 413 | deleteButton.addEventListener('click', (event) => { 414 | event.stopPropagation(); 415 | deleteList(list.id); 416 | }); 417 | } 418 | } 419 | } 420 | 421 | /** 422 | * Function to update the order of quests on the server 423 | */ 424 | function updateQuestOrder() { 425 | const questsContainer = document.getElementById('quests-container'); 426 | if (!questsContainer) return; 427 | 428 | const quests = questsContainer.querySelectorAll('.list-card'); 429 | const orderedIds = Array.from(quests).map(quest => quest.getAttribute('data-list-id')); 430 | 431 | fetch('/update_quest_order', { 432 | method: 'POST', 433 | headers: { 'Content-Type': 'application/json' }, 434 | body: JSON.stringify({ ordered_ids: orderedIds }) 435 | }) 436 | .then(response => response.json()) 437 | .then(data => { 438 | if (!data.success) { 439 | alert('Failed to update quest order.'); 440 | } else { 441 | // Set sort state to manual 442 | questsSortState.type = null; 443 | questsSortState.order = 'asc'; 444 | resetSortButtons('quests'); 445 | 446 | // Clear sorting preference from localStorage 447 | localStorage.removeItem(LOCAL_STORAGE_KEYS.quests); 448 | } 449 | }) 450 | .catch(error => { 451 | console.error('Error:', error); 452 | }); 453 | } 454 | 455 | /** 456 | * Function to update the order of objectives within a quest on the server 457 | */ 458 | function updateObjectiveOrder() { 459 | const objectivesContainer = document.getElementById('objectives-container'); 460 | if (!objectivesContainer) return; 461 | 462 | const listId = objectivesContainer.getAttribute('data-list-id'); 463 | const objectives = objectivesContainer.querySelectorAll('.task-card'); 464 | const orderedIds = Array.from(objectives).map(obj => obj.getAttribute('data-task-id')); 465 | 466 | fetch(`/update_objective_order/${listId}`, { 467 | method: 'POST', 468 | headers: { 'Content-Type': 'application/json' }, 469 | body: JSON.stringify({ ordered_ids: orderedIds }) 470 | }) 471 | .then(response => response.json()) 472 | .then(data => { 473 | if (!data.success) { 474 | alert('Failed to update objective order.'); 475 | } else { 476 | // Set sort state to manual 477 | tasksSortState.type = null; 478 | tasksSortState.order = 'asc'; 479 | resetSortButtons('tasks'); 480 | 481 | // Clear sorting preference from localStorage 482 | localStorage.removeItem(LOCAL_STORAGE_KEYS.tasks); 483 | } 484 | }) 485 | .catch(error => { 486 | console.error('Error:', error); 487 | }); 488 | } 489 | 490 | /** 491 | * Function to handle the back button click 492 | */ 493 | function handleBackButton() { 494 | window.location.href = '/'; // Assuming the root URL is the index page 495 | } 496 | 497 | /** 498 | * Function to initialize SortableJS with drag handles 499 | */ 500 | function initializeSortable() { 501 | // Initialize SortableJS for Quests (Lists) 502 | const questsContainer = document.getElementById('quests-container'); 503 | if (questsContainer) { 504 | Sortable.create(questsContainer, { 505 | animation: 150, 506 | handle: '.drag-handle', 507 | ghostClass: 'sortable-ghost', 508 | onEnd: function (evt) { 509 | // Update quest order when drag-and-drop action ends 510 | updateQuestOrder(); 511 | }, 512 | delay: 150, 513 | touchStartThreshold: 10, 514 | }); 515 | } 516 | 517 | // Initialize SortableJS for Objectives (Tasks) 518 | const objectivesContainer = document.getElementById('objectives-container'); 519 | if (objectivesContainer) { 520 | Sortable.create(objectivesContainer, { 521 | animation: 150, 522 | handle: '.drag-handle', 523 | ghostClass: 'sortable-ghost', 524 | onEnd: function (evt) { 525 | // Update objective order when drag-and-drop action ends 526 | updateObjectiveOrder(); 527 | 528 | // Check if grouping is still valid 529 | if (groupObjectives) { 530 | const groupingValid = checkGroupingValidity(); 531 | if (!groupingValid) { 532 | // Grouping violated, turn off grouping 533 | groupObjectives = false; 534 | const groupObjectivesToggle = document.getElementById('group-objectives-toggle'); 535 | if (groupObjectivesToggle) { 536 | groupObjectivesToggle.checked = false; 537 | } 538 | localStorage.setItem(GROUP_OBJECTIVES_KEY, groupObjectives); 539 | 540 | // Re-sort tasks without grouping 541 | const savedTasksSort = loadSortPreference('tasks'); 542 | if (savedTasksSort) { 543 | sortTasks(savedTasksSort.type, savedTasksSort.order); 544 | } else { 545 | sortTasks(null, null); // Use default sorting 546 | } 547 | } 548 | } 549 | }, 550 | delay: 150, 551 | touchStartThreshold: 10, 552 | }); 553 | } 554 | } 555 | 556 | /** 557 | * Function to sort Quests based on field and order 558 | * @param {string} field - 'name' or 'creation' 559 | * @param {string} order - 'asc' or 'desc' 560 | */ 561 | function sortQuests(field, order) { 562 | const questsContainer = document.getElementById('quests-container'); 563 | if (!questsContainer) return; 564 | 565 | const quests = Array.from(questsContainer.querySelectorAll('.list-card')); 566 | 567 | // If no field specified, default to 'creation' ascending 568 | if (!field) { 569 | field = 'creation'; 570 | order = 'asc'; 571 | } 572 | 573 | // Function to get sort value 574 | function getSortValue(quest) { 575 | let value; 576 | if (field === 'name') { 577 | value = quest.querySelector('.list-name').textContent.toLowerCase(); 578 | } else if (field === 'creation') { 579 | value = parseInt(quest.getAttribute('data-list-id')); 580 | } 581 | return value; 582 | } 583 | 584 | // Sort quests 585 | quests.sort((a, b) => { 586 | const valueA = getSortValue(a); 587 | const valueB = getSortValue(b); 588 | if (valueA < valueB) return order === 'asc' ? -1 : 1; 589 | if (valueA > valueB) return order === 'asc' ? 1 : -1; 590 | return 0; 591 | }); 592 | 593 | // Append sorted quests to the container 594 | quests.forEach(quest => questsContainer.appendChild(quest)); 595 | 596 | // Save sorting preference 597 | saveSortPreference('quests', field, order); 598 | updateSortButtonsVisual('quests', getSortButtonId('quests', field), order); 599 | } 600 | 601 | /** 602 | * Function to sort Tasks based on field and order, considering grouping 603 | * @param {string} field - 'title' or 'creation' 604 | * @param {string} order - 'asc' or 'desc' 605 | */ 606 | function sortTasks(field, order) { 607 | const tasksContainer = document.getElementById('objectives-container'); 608 | if (!tasksContainer) return; 609 | 610 | const tasks = Array.from(tasksContainer.querySelectorAll('.task-card')); 611 | 612 | // If no field specified, default to 'creation' ascending 613 | if (!field) { 614 | field = 'creation'; 615 | order = 'asc'; 616 | } 617 | 618 | // Function to get sort value 619 | function getSortValue(task) { 620 | let value; 621 | if (field === 'title') { 622 | value = task.querySelector('.task-title').textContent.toLowerCase(); 623 | } else if (field === 'creation') { 624 | value = parseInt(task.getAttribute('data-task-id')); 625 | } 626 | return value; 627 | } 628 | 629 | // If groupObjectives is true, separate tasks into incomplete and complete 630 | if (groupObjectives) { 631 | const incompleteTasks = tasks.filter(task => !task.classList.contains('completed')); 632 | const completeTasks = tasks.filter(task => task.classList.contains('completed')); 633 | 634 | // Sort each group 635 | incompleteTasks.sort((a, b) => { 636 | const valueA = getSortValue(a); 637 | const valueB = getSortValue(b); 638 | if (valueA < valueB) return order === 'asc' ? -1 : 1; 639 | if (valueA > valueB) return order === 'asc' ? 1 : -1; 640 | return 0; 641 | }); 642 | 643 | completeTasks.sort((a, b) => { 644 | const valueA = getSortValue(a); 645 | const valueB = getSortValue(b); 646 | if (valueA < valueB) return order === 'asc' ? -1 : 1; 647 | if (valueA > valueB) return order === 'asc' ? 1 : -1; 648 | return 0; 649 | }); 650 | 651 | // Concatenate the groups 652 | const sortedTasks = [...incompleteTasks, ...completeTasks]; 653 | 654 | // Append sorted tasks to the container 655 | sortedTasks.forEach(task => tasksContainer.appendChild(task)); 656 | 657 | } else { 658 | // Just sort all tasks 659 | tasks.sort((a, b) => { 660 | const valueA = getSortValue(a); 661 | const valueB = getSortValue(b); 662 | if (valueA < valueB) return order === 'asc' ? -1 : 1; 663 | if (valueA > valueB) return order === 'asc' ? 1 : -1; 664 | return 0; 665 | }); 666 | 667 | // Append sorted tasks to the container 668 | tasks.forEach(task => tasksContainer.appendChild(task)); 669 | } 670 | } 671 | 672 | /** 673 | * Function to update the visual state of sort buttons 674 | * @param {string} category - 'quests' or 'tasks' 675 | * @param {string} activeButtonId - ID of the currently active sort button 676 | * @param {string} order - 'asc' or 'desc' 677 | */ 678 | function updateSortButtonsVisual(category, activeButtonId, order) { 679 | let sortButtonIds = []; 680 | if (category === 'quests') { 681 | sortButtonIds = ['sort-name-button', 'sort-creation-button']; 682 | } else if (category === 'tasks') { 683 | sortButtonIds = ['sort-title-button', 'sort-creation-task-button']; 684 | } 685 | 686 | sortButtonIds.forEach(buttonId => { 687 | const button = document.getElementById(buttonId); 688 | const sortIcon = button ? button.querySelector('.sort-icon') : null; 689 | if (button) { 690 | if (buttonId === activeButtonId) { 691 | // Update the sort order arrow 692 | if (sortIcon) { 693 | sortIcon.textContent = order === 'asc' ? ' ▲' : ' ▼'; 694 | } else { 695 | // If sort-icon doesn't exist, create it 696 | const newSortIcon = document.createElement('span'); 697 | newSortIcon.classList.add('sort-icon'); 698 | newSortIcon.textContent = order === 'asc' ? ' ▲' : ' ▼'; 699 | button.appendChild(newSortIcon); 700 | } 701 | button.classList.add('active'); 702 | } else { 703 | // Reset other buttons 704 | if (sortIcon) { 705 | sortIcon.textContent = ''; 706 | } 707 | button.classList.remove('active'); 708 | } 709 | } 710 | }); 711 | } 712 | 713 | /** 714 | * Function to reset sort buttons' visual state 715 | * @param {string} category - 'quests' or 'tasks' 716 | */ 717 | function resetSortButtons(category) { 718 | let sortButtonIds = []; 719 | if (category === 'quests') { 720 | sortButtonIds = ['sort-name-button', 'sort-creation-button']; 721 | } else if (category === 'tasks') { 722 | sortButtonIds = ['sort-title-button', 'sort-creation-task-button']; 723 | } 724 | 725 | sortButtonIds.forEach(buttonId => { 726 | const button = document.getElementById(buttonId); 727 | const sortIcon = button ? button.querySelector('.sort-icon') : null; 728 | if (button) { 729 | if (sortIcon) { 730 | sortIcon.textContent = ''; 731 | } 732 | button.classList.remove('active'); 733 | } 734 | }); 735 | } 736 | 737 | /** 738 | * Function to toggle sort order and update button state 739 | * @param {HTMLElement} button - The sort button element 740 | */ 741 | function toggleSortOrder(button) { 742 | const currentOrder = button.getAttribute('data-sort-order'); 743 | const newOrder = currentOrder === 'asc' ? 'desc' : 'asc'; 744 | button.setAttribute('data-sort-order', newOrder); 745 | 746 | const sortIcon = button.querySelector('.sort-icon'); 747 | if (sortIcon) { 748 | sortIcon.textContent = newOrder === 'asc' ? '▲' : '▼'; 749 | } 750 | 751 | // Reset other buttons in the same container 752 | const container = button.closest('.sort-and-group-controls'); 753 | if (container) { 754 | container.querySelectorAll('button').forEach(btn => { 755 | if (btn !== button) { 756 | btn.setAttribute('data-sort-order', 'asc'); 757 | const icon = btn.querySelector('.sort-icon'); 758 | if (icon) icon.textContent = ''; 759 | } 760 | }); 761 | } 762 | } 763 | 764 | /** 765 | * Function to initialize sorting buttons event listeners 766 | */ 767 | function initializeSortingButtons() { 768 | // Quests Sorting Buttons 769 | const sortNameButton = document.getElementById('sort-name-button'); 770 | const sortCreationButton = document.getElementById('sort-creation-button'); 771 | 772 | if (sortNameButton) { 773 | sortNameButton.addEventListener('click', () => { 774 | toggleSortOrder(sortNameButton); 775 | sortQuests('name', sortNameButton.getAttribute('data-sort-order')); 776 | }); 777 | } 778 | 779 | if (sortCreationButton) { 780 | sortCreationButton.addEventListener('click', () => { 781 | toggleSortOrder(sortCreationButton); 782 | sortQuests('creation', sortCreationButton.getAttribute('data-sort-order')); 783 | }); 784 | } 785 | 786 | // Tasks Sorting Buttons 787 | const sortTitleButton = document.getElementById('sort-title-button'); 788 | const sortCreationTaskButton = document.getElementById('sort-creation-task-button'); 789 | 790 | if (sortTitleButton) { 791 | sortTitleButton.addEventListener('click', () => { 792 | toggleSortOrder(sortTitleButton); 793 | sortTasks('title', sortTitleButton.getAttribute('data-sort-order')); 794 | // Save sorting preference 795 | saveSortPreference('tasks', 'title', sortTitleButton.getAttribute('data-sort-order')); 796 | updateSortButtonsVisual('tasks', 'sort-title-button', sortTitleButton.getAttribute('data-sort-order')); 797 | }); 798 | } 799 | 800 | if (sortCreationTaskButton) { 801 | sortCreationTaskButton.addEventListener('click', () => { 802 | toggleSortOrder(sortCreationTaskButton); 803 | sortTasks('creation', sortCreationTaskButton.getAttribute('data-sort-order')); 804 | // Save sorting preference 805 | saveSortPreference('tasks', 'creation', sortCreationTaskButton.getAttribute('data-sort-order')); 806 | updateSortButtonsVisual('tasks', 'sort-creation-task-button', sortCreationTaskButton.getAttribute('data-sort-order')); 807 | }); 808 | } 809 | } 810 | 811 | /** 812 | * Function to save sort preferences to localStorage 813 | * @param {string} category - 'quests' or 'tasks' 814 | * @param {string} type - Sort type (e.g., 'name', 'creation', 'title') 815 | * @param {string} order - 'asc' or 'desc' 816 | */ 817 | function saveSortPreference(category, type, order) { 818 | if (!LOCAL_STORAGE_KEYS[category]) return; 819 | const preference = { 820 | type: type, 821 | order: order 822 | }; 823 | localStorage.setItem(LOCAL_STORAGE_KEYS[category], JSON.stringify(preference)); 824 | } 825 | 826 | /** 827 | * Function to load sort preferences from localStorage 828 | * @param {string} category - 'quests' or 'tasks' 829 | * @returns {Object|null} - Returns the preference object or null if not found 830 | */ 831 | function loadSortPreference(category) { 832 | if (!LOCAL_STORAGE_KEYS[category]) return null; 833 | const preference = localStorage.getItem(LOCAL_STORAGE_KEYS[category]); 834 | return preference ? JSON.parse(preference) : null; 835 | } 836 | 837 | /** 838 | * Helper function to get the sort button ID based on category and type 839 | * @param {string} category - 'quests' or 'tasks' 840 | * @param {string} type - Sort type (e.g., 'name', 'creation', 'title') 841 | * @returns {string|null} - Returns the button ID or null if not found 842 | */ 843 | function getSortButtonId(category, type) { 844 | if (category === 'quests') { 845 | if (type === 'name') return 'sort-name-button'; 846 | if (type === 'creation') return 'sort-creation-button'; 847 | } else if (category === 'tasks') { 848 | if (type === 'title') return 'sort-title-button'; 849 | if (type === 'creation') return 'sort-creation-task-button'; 850 | } 851 | return null; 852 | } 853 | 854 | /** 855 | * Function to apply saved sorting preferences 856 | */ 857 | function applySavedSortingPreferences() { 858 | // Apply Group Objectives Preference 859 | const savedGroupObjectives = localStorage.getItem(GROUP_OBJECTIVES_KEY); 860 | if (savedGroupObjectives !== null) { 861 | groupObjectives = savedGroupObjectives === 'true'; 862 | const groupObjectivesToggle = document.getElementById('group-objectives-toggle'); 863 | if (groupObjectivesToggle) { 864 | groupObjectivesToggle.checked = groupObjectives; 865 | } 866 | } 867 | 868 | // Apply Tasks Sorting Preference 869 | const savedTasksSort = loadSortPreference('tasks'); 870 | if (savedTasksSort) { 871 | sortTasks(savedTasksSort.type, savedTasksSort.order); 872 | updateSortButtonsVisual('tasks', getSortButtonId('tasks', savedTasksSort.type), savedTasksSort.order); 873 | } else { 874 | // If no saved task sort preference, still sort tasks based on groupObjectives 875 | sortTasks(null, null); // Use default sorting 876 | } 877 | 878 | // Apply Quests Sorting Preference 879 | const savedQuestsSort = loadSortPreference('quests'); 880 | if (savedQuestsSort) { 881 | sortQuests(savedQuestsSort.type, savedQuestsSort.order); 882 | updateSortButtonsVisual('quests', getSortButtonId('quests', savedQuestsSort.type), savedQuestsSort.order); 883 | } 884 | } 885 | 886 | /** 887 | * Function to initialize all event listeners 888 | */ 889 | function initializeEventListeners() { 890 | // Back button 891 | const backButton = document.querySelector('.back-button'); 892 | if (backButton) { 893 | backButton.addEventListener('click', handleBackButton); 894 | } 895 | 896 | // Edit form submission 897 | const editForm = document.getElementById('edit-form'); 898 | if (editForm) { 899 | editForm.addEventListener('submit', function (event) { 900 | event.preventDefault(); 901 | 902 | const newName = document.getElementById('edit-input').value.trim(); 903 | 904 | if (newName === '') { 905 | alert('Name cannot be empty.'); 906 | return; 907 | } 908 | 909 | if (currentEditType === 'list') { 910 | updateListName(currentEditId, newName); 911 | } else if (currentEditType === 'task') { 912 | updateTaskTitle(currentListId, currentEditId, newName); 913 | } 914 | 915 | // Close the modal after submission 916 | closeEditModal(); 917 | }); 918 | } 919 | 920 | // Close modal when clicking outside 921 | window.addEventListener('click', function (event) { 922 | const editModal = document.getElementById('edit-modal'); 923 | if (editModal && event.target === editModal) { 924 | closeEditModal(); 925 | } 926 | }); 927 | 928 | // Add Task Form 929 | const addTaskForm = document.querySelector('.add-task-form'); 930 | if (addTaskForm) { 931 | addTaskForm.addEventListener('submit', function (event) { 932 | event.preventDefault(); 933 | 934 | const listId = addTaskForm.getAttribute('data-list-id'); 935 | const formData = new FormData(addTaskForm); 936 | const title = formData.get('title'); 937 | 938 | if (title.trim() === '') { 939 | alert('Please enter an objective title.'); 940 | return; 941 | } 942 | 943 | fetch(`/list/${listId}/add_task`, { 944 | method: 'POST', 945 | body: formData 946 | }) 947 | .then(response => response.json()) 948 | .then(data => { 949 | if (data.error) { 950 | alert(data.error); 951 | } else { 952 | addTaskToDOM(data); 953 | addTaskForm.reset(); 954 | } 955 | }) 956 | .catch(error => { 957 | console.error('Error:', error); 958 | }); 959 | }); 960 | } 961 | 962 | // Add Quest Form 963 | const addListForm = document.querySelector('.add-list-form'); 964 | if (addListForm) { 965 | addListForm.addEventListener('submit', function (event) { 966 | event.preventDefault(); 967 | 968 | const formData = new FormData(addListForm); 969 | let name = formData.get('name'); 970 | 971 | // Trim whitespace 972 | name = name.trim(); 973 | 974 | fetch('/add_list', { 975 | method: 'POST', 976 | body: formData 977 | }) 978 | .then(response => response.json()) 979 | .then(data => { 980 | if (data.error) { 981 | alert(data.error); 982 | } else { 983 | addListToDOM(data); 984 | addListForm.reset(); 985 | } 986 | }) 987 | .catch(error => { 988 | console.error('Error:', error); 989 | }); 990 | }); 991 | } 992 | 993 | // Initialize SortableJS with drag handles 994 | initializeSortable(); 995 | 996 | // Initialize Sorting functionality 997 | initializeSortingButtons(); 998 | 999 | // Apply saved sorting preferences 1000 | applySavedSortingPreferences(); 1001 | 1002 | // Group Objectives Toggle 1003 | const groupObjectivesToggle = document.getElementById('group-objectives-toggle'); 1004 | if (groupObjectivesToggle) { 1005 | groupObjectivesToggle.addEventListener('change', () => { 1006 | groupObjectives = groupObjectivesToggle.checked; 1007 | // Save preference to localStorage 1008 | localStorage.setItem(GROUP_OBJECTIVES_KEY, groupObjectives); 1009 | // Re-sort tasks 1010 | const savedTasksSort = loadSortPreference('tasks'); 1011 | if (savedTasksSort) { 1012 | sortTasks(savedTasksSort.type, savedTasksSort.order); 1013 | } else { 1014 | sortTasks(null, null); // Use default sorting 1015 | } 1016 | }); 1017 | } 1018 | 1019 | // Add event listeners for existing elements 1020 | document.querySelectorAll('.task-card').forEach(card => { 1021 | const listId = card.closest('.tasks-container').dataset.listId; 1022 | const taskId = card.dataset.taskId; 1023 | 1024 | const taskContent = card.querySelector('.task-content'); 1025 | const editButton = card.querySelector('.edit-task-button'); 1026 | const deleteButton = card.querySelector('.delete-task-button'); 1027 | 1028 | if (taskContent) { 1029 | taskContent.addEventListener('click', () => toggleComplete(listId, taskId)); 1030 | } 1031 | 1032 | if (editButton) { 1033 | editButton.addEventListener('click', (event) => { 1034 | event.stopPropagation(); 1035 | const taskTitle = card.querySelector('.task-title').textContent; 1036 | showEditModal('task', taskId, taskTitle, listId); 1037 | }); 1038 | } 1039 | 1040 | if (deleteButton) { 1041 | deleteButton.addEventListener('click', (event) => { 1042 | event.stopPropagation(); 1043 | deleteTask(listId, taskId); 1044 | }); 1045 | } 1046 | }); 1047 | 1048 | document.querySelectorAll('.list-card').forEach(card => { 1049 | const listId = card.dataset.listId; 1050 | 1051 | const editButton = card.querySelector('.edit-list-button'); 1052 | const deleteButton = card.querySelector('.delete-list-button'); 1053 | 1054 | if (editButton) { 1055 | editButton.addEventListener('click', (event) => { 1056 | event.stopPropagation(); 1057 | const listName = card.querySelector('.list-name').textContent; 1058 | showEditModal('list', listId, listName); 1059 | }); 1060 | } 1061 | 1062 | if (deleteButton) { 1063 | deleteButton.addEventListener('click', (event) => { 1064 | event.stopPropagation(); 1065 | deleteList(listId); 1066 | }); 1067 | } 1068 | }); 1069 | } 1070 | 1071 | /** 1072 | * Function to initialize all components when the DOM is loaded 1073 | */ 1074 | document.addEventListener('DOMContentLoaded', function () { 1075 | initializeEventListeners(); 1076 | }); 1077 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "App", 3 | "icons": [ 4 | { 5 | "src": "\/android-icon-36x36.png", 6 | "sizes": "36x36", 7 | "type": "image\/png", 8 | "density": "0.75" 9 | }, 10 | { 11 | "src": "\/android-icon-48x48.png", 12 | "sizes": "48x48", 13 | "type": "image\/png", 14 | "density": "1.0" 15 | }, 16 | { 17 | "src": "\/android-icon-72x72.png", 18 | "sizes": "72x72", 19 | "type": "image\/png", 20 | "density": "1.5" 21 | }, 22 | { 23 | "src": "\/android-icon-96x96.png", 24 | "sizes": "96x96", 25 | "type": "image\/png", 26 | "density": "2.0" 27 | }, 28 | { 29 | "src": "\/android-icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image\/png", 32 | "density": "3.0" 33 | }, 34 | { 35 | "src": "\/android-icon-192x192.png", 36 | "sizes": "192x192", 37 | "type": "image\/png", 38 | "density": "4.0" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /static/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/ms-icon-144x144.png -------------------------------------------------------------------------------- /static/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/ms-icon-150x150.png -------------------------------------------------------------------------------- /static/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/ms-icon-310x310.png -------------------------------------------------------------------------------- /static/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/need4swede/SideQuests/863a721e2c5c6fd2fa37f92884d0fe59c2ddf5d5/static/ms-icon-70x70.png -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | {% block title %}SideQuests{% endblock %} 26 | 27 | 28 | 29 | 30 | {% block content %}{% endblock %} 31 | 32 | 33 | 34 | 35 | Edit 36 | 37 | 38 | Save 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ list.name }} - SideQuests{% endblock %} 3 | {% block content %} 4 | 5 | ← 6 | {{ list.name }} 7 | 8 | 9 | 11 | 12 | Add 13 | 14 | 15 | 16 | {% for objective in tasks %} 17 | 18 | 19 | 20 | {% if objective.completed %} 21 | ✓ 22 | {% else %} 23 | ○ 24 | {% endif %} 25 | 26 | {{ objective.title }} 27 | 28 | 29 | ✎ 30 | × 31 | 32 | 33 | {% endfor %} 34 | 35 | {% endblock %}{% extends "base.html" %} 36 | {% block title %}{{ list.name }} - SideQuests{% endblock %} 37 | {% block content %} 38 | 39 | ← 40 | {{ list.name }} 41 | 42 | 43 | 45 | 46 | Add 47 | 48 | 49 | 50 | {% for objective in tasks %} 51 | 52 | 53 | 54 | {% if objective.completed %} 55 | ✓ 56 | {% else %} 57 | ○ 58 | {% endif %} 59 | 60 | {{ objective.title }} 61 | 62 | 63 | ✎ 64 | × 65 | 66 | 67 | {% endfor %} 68 | 69 | {% endblock %}{% extends "base.html" %} 70 | {% block title %}{{ list.name }} - SideQuests{% endblock %} 71 | {% block content %} 72 | 73 | ← 74 | {{ list.name }} 75 | 76 | 77 | 79 | 80 | Add 81 | 82 | 83 | 84 | {% for objective in tasks %} 85 | 86 | 87 | 88 | {% if objective.completed %} 89 | ✓ 90 | {% else %} 91 | ○ 92 | {% endif %} 93 | 94 | {{ objective.title }} 95 | 96 | 97 | ✎ 98 | × 99 | 100 | 101 | {% endfor %} 102 | 103 | {% endblock %}{% extends "base.html" %} 104 | {% block title %}{{ list.name }} - SideQuests{% endblock %} 105 | {% block content %} 106 | 107 | ← 108 | {{ list.name }} 109 | 110 | 111 | 113 | 114 | Add 115 | 116 | 117 | 118 | {% for objective in tasks %} 119 | 120 | 121 | 122 | {% if objective.completed %} 123 | ✓ 124 | {% else %} 125 | ○ 126 | {% endif %} 127 | 128 | {{ objective.title }} 129 | 130 | 131 | ✎ 132 | × 133 | 134 | 135 | {% endfor %} 136 | 137 | {% endblock %}{% extends "base.html" %} 138 | {% block title %}{{ list.name }} - SideQuests{% endblock %} 139 | {% block content %} 140 | 141 | ← 142 | {{ list.name }} 143 | 144 | 145 | 147 | 148 | Add 149 | 150 | 151 | 152 | {% for objective in tasks %} 153 | 154 | 155 | 156 | {% if objective.completed %} 157 | ✓ 158 | {% else %} 159 | ○ 160 | {% endif %} 161 | 162 | {{ objective.title }} 163 | 164 | 165 | ✎ 166 | × 167 | 168 | 169 | {% endfor %} 170 | 171 | {% endblock %} -------------------------------------------------------------------------------- /templates/list_index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Your Quests - SideQuests{% endblock %} 3 | {% block content %} 4 | 5 | Your Quests 6 | 7 | 8 | 9 | Add 10 | 11 | 12 | 13 | 14 | Sort by Name 15 | 16 | 17 | Sort by Creation 18 | 19 | 20 | 21 | {% for quest in lists %} 22 | 23 | 24 | 25 | ☰ 26 | 27 | 28 | 29 | {{ quest.name }} 30 | 31 | 32 | ✎ 33 | × 34 | 35 | {% endfor %} 36 | 37 | {% endblock %} -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Login - SideQuests{% endblock %} 4 | 5 | {% block content %} 6 | 7 | SideQuests 8 | {% with messages = get_flashed_messages(with_categories=true) %} 9 | {% if messages %} 10 | 11 | {% for category, message in messages %} 12 | {{ message }} 13 | {% endfor %} 14 | 15 | {% endif %} 16 | {% endwith %} 17 | 18 | 19 | 20 | Login 21 | 22 | 23 | {% endblock %} -------------------------------------------------------------------------------- /templates/task_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{ list.name }} - SideQuests{% endblock %} 3 | {% block content %} 4 | 5 | ← 6 | {{ list.name }} 7 | 8 | 9 | 11 | 12 | Add 13 | 14 | 15 | 16 | 17 | 18 | Sort by Title ▲ 19 | 20 | 21 | Sort by Creation ▲ 22 | 23 | 24 | Group Objectives 25 | 26 | 27 | 28 | 29 | 30 | {% for objective in tasks %} 31 | 32 | 33 | 34 | ☰ 35 | 36 | 37 | 38 | 39 | {% if objective.completed %} 40 | ✓ 41 | {% else %} 42 | ○ 43 | {% endif %} 44 | 45 | {{ objective.title }} 46 | 47 | 48 | ✎ 49 | × 50 | 51 | 52 | {% endfor %} 53 | 54 | {% endblock %} --------------------------------------------------------------------------------