├── .dockerignore ├── .github └── workflows │ └── fly.yml ├── .gitignore ├── Dockerfile ├── README.md ├── analytics ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ └── __init__.py ├── models.py ├── templates │ └── analytics.html ├── tests.py ├── urls.py └── views.py ├── core ├── __init__.py ├── asgi.py ├── settings.py ├── urls.py └── wsgi.py ├── entrypoint.sh ├── fixtures └── notes.json ├── fly.toml ├── htmx_messages ├── __init__.py ├── apps.py ├── middleware.py ├── static │ └── toasts.js └── templates │ └── toasts.html ├── manage.py ├── note ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_alter_note_title.py │ ├── 0003_remove_note_completed_note_completed_at.py │ └── __init__.py ├── models.py ├── templates │ └── note │ │ └── home.html ├── tests.py ├── urls.py └── views.py ├── package-lock.json ├── package.json ├── postcss.config.js ├── requirements.txt ├── screenshots ├── desktop_dark.png ├── desktop_light.png ├── mobile_dark.png ├── mobile_dark_create.png ├── mobile_dark_edit.png ├── mobile_dark_menu.png ├── mobile_dark_profile.png └── mobile_light.png ├── static ├── css │ ├── custom │ │ └── custom.css │ └── tailwind │ │ ├── main.css │ │ └── main.min.css ├── favicon.ico ├── js │ ├── alpine.min.js │ └── htmx.min.js └── note_logo.png ├── tailwind.config.js ├── templates └── base.html └── user ├── __init__.py ├── admin.py ├── apps.py ├── migrations ├── 0001_initial.py ├── 0002_noteuser_email_confirmed.py ├── 0003_noteuser_is_dummy.py └── __init__.py ├── models.py ├── templates ├── login.html ├── profile.html └── register.html ├── tests.py ├── urls.py └── views.py /.dockerignore: -------------------------------------------------------------------------------- 1 | fly.toml 2 | .git/ 3 | *.sqlite3 4 | .env 5 | screenshots/ 6 | -------------------------------------------------------------------------------- /.github/workflows/fly.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - develop 7 | jobs: 8 | deploy: 9 | name: Deploy app 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: superfly/flyctl-actions/setup-flyctl@master 14 | - run: flyctl deploy --remote-only 15 | env: 16 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite3 2 | .vscode 3 | __pycache__ 4 | .env 5 | .pwa 6 | node_modules 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION=3.11-slim-bookworm 2 | 3 | FROM python:${PYTHON_VERSION} 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | RUN mkdir -p /code 9 | 10 | WORKDIR /code 11 | 12 | # install psycopg2 dependencies 13 | RUN apt-get update && apt-get install -y \ 14 | libpq-dev \ 15 | gcc \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | COPY requirements.txt /tmp/requirements.txt 19 | 20 | RUN set -ex && \ 21 | pip install --upgrade pip && \ 22 | pip install -r /tmp/requirements.txt && \ 23 | rm -rf /root/.cache/ 24 | 25 | COPY . /code 26 | 27 | RUN ["chmod", "+x", "./entrypoint.sh"] 28 | 29 | EXPOSE 8000 30 | 31 | # run entrypoint.sh 32 | ENTRYPOINT ["./entrypoint.sh"] 33 | 34 | CMD ["gunicorn", "--bind", ":8000", "--workers", "2", "core.wsgi"] 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Just Notes 2 | 3 | ## Simple Notes App 4 | 5 | Light Theme | Dark Theme 6 | :-------------------------:|:-------------------------: 7 | ![screenshot_1](./screenshots/desktop_light.png) | ![screenshot_1](./screenshots/desktop_dark.png) 8 | 9 | 10 | ## Deployed with [Fly.io](https//:fly.io) - [just-notes.fly.dev](https://just-notes.fly.dev/) 11 | 12 | ### Dummy Credentials: 13 | ``` 14 | email: dummy@mail.com 15 | password: dummypassword 16 | ``` 17 | 18 | ### Created using Django, HTMX, Alpine.js, TailwindCSS 19 | 20 | ### Functionality: 21 | 22 | - [x] Register/Login 23 | - [x] Profile Page: 24 | - [x] Profile Details update 25 | - [x] Home (Notes) Page: 26 | - [x] List All Notes 27 | - [x] All Notes counter 28 | - [x] Create a new Note 29 | - [x] Update Note 30 | - [x] Delete Note 31 | - [x] Bulk Actions on Notes: 32 | - [x] Selected Notes counter 33 | - [x] Select All Notes option 34 | - [x] Bulk Delete selected Notes 35 | - [x] Bulk Change 'Completed' Status on selected Notes 36 | - [x] Pagination 37 | - [x] Live Search 38 | - [x] Filter by `is_completed` status 39 | - [ ] Analytics Page (WIP): 40 | - [x] Base Analytics about Completed Notes per Month 41 | - [ ] ... 42 | - [x] Toasts Application to show Django Messages with HTMX 43 | - [x] Auto-dismissed Toasts 44 | - [x] Manual Toast dismiss 45 | - [x] Dark/Light mode: 46 | - [x] Manual switching 47 | - [x] Detecting system theme switching 48 | 49 | ### Stack 50 | - `Django` for the backend server 51 | - `TailwingCSS` for frontend styling 52 | - `HTMX` to make a frontend dynamic and interactive like a "reactive" apps 53 | - `Alpine.js` for state management and frontend interactivity 54 | - `JavaScript` for minor but important for usability(interactivity) things: theme switching, toasts, etc 55 | 56 | #### Steps to run locally: 57 | 1. create a virtual environment 58 | 2. install requirements: 59 | ```pip install -r requirements.txt``` 60 | 3. run migrations: 61 | ```python manage.py migrate``` 62 | 4. start server: 63 | ```python manage.py runserver``` 64 | -------------------------------------------------------------------------------- /analytics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/analytics/__init__.py -------------------------------------------------------------------------------- /analytics/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /analytics/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AnalyticsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'analytics' 7 | -------------------------------------------------------------------------------- /analytics/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/analytics/migrations/__init__.py -------------------------------------------------------------------------------- /analytics/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /analytics/templates/analytics.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |
5 |

Your Analytics about completed Notes

6 | 7 |
8 | {% endblock content %} 9 | 10 | {% block additional_javascript %} 11 | 12 | 13 | 82 | {% endblock additional_javascript %} -------------------------------------------------------------------------------- /analytics/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /analytics/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('analytics/', views.analytics_home, name='analytics_home_view'), 7 | path('analytics/get_data/', views.get_analytics_data, name='analytics_get_data'), 8 | ] 9 | -------------------------------------------------------------------------------- /analytics/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.shortcuts import render, redirect 4 | from django.contrib import messages 5 | from django.db.models.functions import TruncMonth 6 | from django.core.serializers.json import DjangoJSONEncoder 7 | from django.db.models import Count, F 8 | from django.http.response import JsonResponse 9 | from django.utils import timezone 10 | 11 | from note.models import Note 12 | 13 | 14 | def jsonify_queryset(object,fields): 15 | return simplejson.dumps(list(object.values(*fields.split(',')))) 16 | 17 | 18 | # Create your views here. 19 | def analytics_home(request): 20 | template_name = 'analytics.html' 21 | context = {} 22 | user = request.user 23 | if not user.is_authenticated: 24 | messages.add_message( 25 | request, 26 | messages.ERROR, 27 | 'You need to log in first!' 28 | ) 29 | return redirect('login_view') 30 | return render(request, template_name, context) 31 | 32 | 33 | def get_analytics_data(request): 34 | user = request.user 35 | current_date = timezone.now().date() 36 | user_register_date = user.date_joined.date() 37 | 38 | completed_notes_counts = [] 39 | date = user_register_date 40 | date_interval_dates = [] 41 | while date <= current_date: 42 | next_month = (date + timedelta(days=31)).replace(day=1) 43 | notes_completed_count = Note.objects.filter( 44 | author=user, 45 | completed_at__date__gte=date, 46 | completed_at__date__lt=next_month 47 | ).count() 48 | month_name = date.strftime('%B') 49 | completed_notes_counts.append(notes_completed_count) 50 | date_interval_dates.append((month_name, date.year)) 51 | date = next_month 52 | 53 | max_completed = max(completed_notes_counts) 54 | prepared_chart_data = { 55 | 'labels': date_interval_dates, 56 | 'values': completed_notes_counts, 57 | 'maxValueY': max_completed + 3 58 | } 59 | chart_data = prepared_chart_data 60 | return JsonResponse(data=chart_data, safe=False) 61 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/core/__init__.py -------------------------------------------------------------------------------- /core/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for core project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /core/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for core project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | import environ 15 | from django.contrib.messages import constants as messages 16 | from django.core.management.utils import get_random_secret_key 17 | 18 | env = environ.Env( 19 | # set casting, default value 20 | DEBUG=(bool, False) 21 | ) 22 | 23 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 24 | BASE_DIR = Path(__file__).resolve().parent.parent 25 | 26 | # Take environment variables from .env file 27 | environ.Env.read_env(BASE_DIR / '.env') 28 | 29 | # Quick-start development settings - unsuitable for production 30 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 31 | 32 | # SECURITY WARNING: keep the secret key used in production secret! 33 | SECRET_KEY = env.str("SECRET_KEY", default=get_random_secret_key()) 34 | 35 | # SECURITY WARNING: don't run with debug turned on in production! 36 | DEBUG = env('DEBUG') 37 | 38 | ALLOWED_HOSTS = ['localhost', '127.0.0.1', '.fly.dev'] 39 | 40 | CSRF_TRUSTED_ORIGINS = ['https://just-notes.fly.dev', 'https://*.127.0.0.1'] 41 | 42 | # Application definition 43 | 44 | INSTALLED_APPS = [ 45 | 'django.contrib.admin', 46 | 'django.contrib.auth', 47 | 'django.contrib.contenttypes', 48 | 'django.contrib.sessions', 49 | 'django.contrib.messages', 50 | 'whitenoise.runserver_nostatic', 51 | 'django.contrib.staticfiles', 52 | # third-party apps 53 | 'django_htmx', 54 | # apps 55 | 'htmx_messages', 56 | 'user', 57 | 'note', 58 | 'analytics', 59 | ] 60 | 61 | MIDDLEWARE = [ 62 | 'django.middleware.security.SecurityMiddleware', 63 | 'django.contrib.sessions.middleware.SessionMiddleware', 64 | 'django.middleware.common.CommonMiddleware', 65 | 'django.middleware.csrf.CsrfViewMiddleware', 66 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 67 | 'django.contrib.messages.middleware.MessageMiddleware', 68 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 69 | 'django_htmx.middleware.HtmxMiddleware', 70 | 'htmx_messages.middleware.HtmxMessageMiddleware', 71 | 'whitenoise.middleware.WhiteNoiseMiddleware', 72 | ] 73 | 74 | ROOT_URLCONF = 'core.urls' 75 | 76 | TEMPLATES = [ 77 | { 78 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 79 | 'DIRS': ['templates'], 80 | 'APP_DIRS': True, 81 | 'OPTIONS': { 82 | 'context_processors': [ 83 | 'django.template.context_processors.debug', 84 | 'django.template.context_processors.request', 85 | 'django.contrib.auth.context_processors.auth', 86 | 'django.contrib.messages.context_processors.messages', 87 | ], 88 | }, 89 | }, 90 | ] 91 | 92 | WSGI_APPLICATION = 'core.wsgi.application' 93 | 94 | 95 | # Database 96 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 97 | DATABASES = { 98 | # read os.environ['DATABASE_URL'] 99 | 'default': env.db() 100 | } 101 | 102 | # Password validation 103 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 104 | 105 | AUTH_PASSWORD_VALIDATORS = [ 106 | { 107 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 108 | }, 109 | { 110 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 111 | }, 112 | { 113 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 114 | }, 115 | { 116 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 117 | }, 118 | ] 119 | 120 | 121 | # Internationalization 122 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 123 | 124 | LANGUAGE_CODE = 'en-us' 125 | 126 | TIME_ZONE = 'UTC' 127 | 128 | USE_I18N = True 129 | 130 | USE_TZ = True 131 | 132 | 133 | # Static files (CSS, JavaScript, Images) 134 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 135 | 136 | STATIC_URL = 'static/' 137 | STATICFILES_DIRS = [BASE_DIR / "static"] 138 | STATIC_ROOT = BASE_DIR / 'staticfiles' 139 | 140 | STORAGES = { 141 | # ... 142 | "staticfiles": { 143 | "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", 144 | }, 145 | } 146 | 147 | # Media files 148 | MEDIA_ROOT = BASE_DIR / 'media' 149 | MEDIA_URL = '/media/' 150 | 151 | # Default primary key field type 152 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 153 | 154 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 155 | 156 | # Custom User Model 157 | AUTH_USER_MODEL = 'user.NoteUser' 158 | 159 | 160 | # Messages config 161 | MESSAGE_TAGS = { 162 | messages.DEBUG: 'alert-secondary', 163 | messages.INFO: 'alert-info', 164 | messages.SUCCESS: 'success', 165 | messages.WARNING: 'warning', 166 | messages.ERROR: 'danger', 167 | } 168 | -------------------------------------------------------------------------------- /core/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for core project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | 20 | urlpatterns = [ 21 | path('admin/', admin.site.urls), 22 | 23 | path('account/', include('user.urls')), 24 | path('', include('note.urls')), 25 | path('', include('analytics.urls')), 26 | ] 27 | -------------------------------------------------------------------------------- /core/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for core project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python manage.py collectstatic --no-input --clear 4 | 5 | # gunicorn -w 2 -b 0.0.0.0:8000 code/core.wsgi:application 6 | 7 | exec "$@" -------------------------------------------------------------------------------- /fixtures/notes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "note.note", 4 | "fields": { 5 | "title": "This is first note Updated", 6 | "content": "test", 7 | "author": 3, 8 | "completed_at": null, 9 | "created_at": "2023-07-12T17:20:58.808Z", 10 | "updated_at": "2023-07-12T23:24:16.445Z" 11 | } 12 | }, 13 | { 14 | "model": "note.note", 15 | "fields": { 16 | "title": "This is second note (completed_at)", 17 | "content": "test completed_at", 18 | "author": 3, 19 | "completed_at": null, 20 | "created_at": "2023-07-12T17:21:10.641Z", 21 | "updated_at": "2023-07-12T22:51:32.516Z" 22 | } 23 | }, 24 | { 25 | "model": "note.note", 26 | "fields": { 27 | "title": "This is first note Updated", 28 | "content": "test", 29 | "author": 3, 30 | "completed_at": null, 31 | "created_at": "2023-07-12T17:20:58.808Z", 32 | "updated_at": "2023-07-12T23:24:16.445Z" 33 | } 34 | }, 35 | { 36 | "model": "note.note", 37 | "fields": { 38 | "title": "This is second note (completed_at)", 39 | "content": "test completed_at", 40 | "author": 3, 41 | "completed_at": null, 42 | "created_at": "2023-07-12T17:21:10.641Z", 43 | "updated_at": "2023-07-12T22:51:32.516Z" 44 | } 45 | }, 46 | { 47 | "model": "note.note", 48 | "fields": { 49 | "title": "This is first note Updated", 50 | "content": "test", 51 | "author": 3, 52 | "completed_at": null, 53 | "created_at": "2023-07-12T17:20:58.808Z", 54 | "updated_at": "2023-07-12T23:24:16.445Z" 55 | } 56 | }, 57 | { 58 | "model": "note.note", 59 | "fields": { 60 | "title": "This is second note (completed_at)", 61 | "content": "test completed_at", 62 | "author": 3, 63 | "completed_at": null, 64 | "created_at": "2023-07-12T17:21:10.641Z", 65 | "updated_at": "2023-07-12T22:51:32.516Z" 66 | } 67 | }, 68 | { 69 | "model": "note.note", 70 | "fields": { 71 | "title": "This is first note Updated", 72 | "content": "test", 73 | "author": 3, 74 | "completed_at": null, 75 | "created_at": "2023-07-12T17:20:58.808Z", 76 | "updated_at": "2023-07-12T23:24:16.445Z" 77 | } 78 | }, 79 | { 80 | "model": "note.note", 81 | "fields": { 82 | "title": "This is second note (completed_at)", 83 | "content": "test completed_at", 84 | "author": 3, 85 | "completed_at": null, 86 | "created_at": "2023-07-12T17:21:10.641Z", 87 | "updated_at": "2023-07-12T22:51:32.516Z" 88 | } 89 | }, 90 | { 91 | "model": "note.note", 92 | "fields": { 93 | "title": "This is first note Updated", 94 | "content": "test", 95 | "author": 3, 96 | "completed_at": null, 97 | "created_at": "2023-07-12T17:20:58.808Z", 98 | "updated_at": "2023-07-12T23:24:16.445Z" 99 | } 100 | }, 101 | { 102 | "model": "note.note", 103 | "fields": { 104 | "title": "This is second note (completed_at)", 105 | "content": "test completed_at", 106 | "author": 3, 107 | "completed_at": null, 108 | "created_at": "2023-07-12T17:21:10.641Z", 109 | "updated_at": "2023-07-12T22:51:32.516Z" 110 | } 111 | }, 112 | { 113 | "model": "note.note", 114 | "fields": { 115 | "title": "This is first note Updated", 116 | "content": "test", 117 | "author": 3, 118 | "completed_at": null, 119 | "created_at": "2023-07-12T17:20:58.808Z", 120 | "updated_at": "2023-07-12T23:24:16.445Z" 121 | } 122 | }, 123 | { 124 | "model": "note.note", 125 | "fields": { 126 | "title": "This is second note (completed_at)", 127 | "content": "test completed_at", 128 | "author": 3, 129 | "completed_at": null, 130 | "created_at": "2023-07-12T17:21:10.641Z", 131 | "updated_at": "2023-07-12T22:51:32.516Z" 132 | } 133 | }, 134 | { 135 | "model": "note.note", 136 | "fields": { 137 | "title": "This is first note Updated", 138 | "content": "test", 139 | "author": 3, 140 | "completed_at": null, 141 | "created_at": "2023-07-12T17:20:58.808Z", 142 | "updated_at": "2023-07-12T23:24:16.445Z" 143 | } 144 | }, 145 | { 146 | "model": "note.note", 147 | "fields": { 148 | "title": "This is second note (completed_at)", 149 | "content": "test completed_at", 150 | "author": 3, 151 | "completed_at": null, 152 | "created_at": "2023-07-12T17:21:10.641Z", 153 | "updated_at": "2023-07-12T22:51:32.516Z" 154 | } 155 | }, 156 | { 157 | "model": "note.note", 158 | "fields": { 159 | "title": "This is first note Updated", 160 | "content": "test", 161 | "author": 3, 162 | "completed_at": null, 163 | "created_at": "2023-07-12T17:20:58.808Z", 164 | "updated_at": "2023-07-12T23:24:16.445Z" 165 | } 166 | }, 167 | { 168 | "model": "note.note", 169 | "fields": { 170 | "title": "This is first note Updated", 171 | "content": "test", 172 | "author": 3, 173 | "completed_at": null, 174 | "created_at": "2023-07-12T17:20:58.808Z", 175 | "updated_at": "2023-07-12T23:24:16.445Z" 176 | } 177 | }, 178 | { 179 | "model": "note.note", 180 | "fields": { 181 | "title": "This is first note Updated", 182 | "content": "test", 183 | "author": 3, 184 | "completed_at": null, 185 | "created_at": "2023-07-12T17:20:58.808Z", 186 | "updated_at": "2023-07-12T23:24:16.445Z" 187 | } 188 | }, 189 | { 190 | "model": "note.note", 191 | "fields": { 192 | "title": "This is first note Updated", 193 | "content": "test", 194 | "author": 3, 195 | "completed_at": null, 196 | "created_at": "2023-07-12T17:20:58.808Z", 197 | "updated_at": "2023-07-12T23:24:16.445Z" 198 | } 199 | }, 200 | { 201 | "model": "note.note", 202 | "fields": { 203 | "title": "This is first note Updated", 204 | "content": "test", 205 | "author": 3, 206 | "completed_at": null, 207 | "created_at": "2023-07-12T17:20:58.808Z", 208 | "updated_at": "2023-07-12T23:24:16.445Z" 209 | } 210 | }, 211 | { 212 | "model": "note.note", 213 | "fields": { 214 | "title": "This is first note Updated", 215 | "content": "test", 216 | "author": 3, 217 | "completed_at": null, 218 | "created_at": "2023-07-12T17:20:58.808Z", 219 | "updated_at": "2023-07-12T23:24:16.445Z" 220 | } 221 | }, 222 | { 223 | "model": "note.note", 224 | "fields": { 225 | "title": "This is first note Updated", 226 | "content": "test", 227 | "author": 3, 228 | "completed_at": null, 229 | "created_at": "2023-07-12T17:20:58.808Z", 230 | "updated_at": "2023-07-12T23:24:16.445Z" 231 | } 232 | }, 233 | { 234 | "model": "note.note", 235 | "fields": { 236 | "title": "This is first note Updated", 237 | "content": "test", 238 | "author": 3, 239 | "completed_at": null, 240 | "created_at": "2023-07-12T17:20:58.808Z", 241 | "updated_at": "2023-07-12T23:24:16.445Z" 242 | } 243 | }, 244 | { 245 | "model": "note.note", 246 | "fields": { 247 | "title": "This is first note Updated", 248 | "content": "test", 249 | "author": 3, 250 | "completed_at": null, 251 | "created_at": "2023-07-12T17:20:58.808Z", 252 | "updated_at": "2023-07-12T23:24:16.445Z" 253 | } 254 | }, 255 | { 256 | "model": "note.note", 257 | "fields": { 258 | "title": "This is first note Updated", 259 | "content": "test", 260 | "author": 3, 261 | "completed_at": null, 262 | "created_at": "2023-07-12T17:20:58.808Z", 263 | "updated_at": "2023-07-12T23:24:16.445Z" 264 | } 265 | }, 266 | { 267 | "model": "note.note", 268 | "fields": { 269 | "title": "This is first note Updated", 270 | "content": "test", 271 | "author": 3, 272 | "completed_at": null, 273 | "created_at": "2023-07-12T17:20:58.808Z", 274 | "updated_at": "2023-07-12T23:24:16.445Z" 275 | } 276 | }, 277 | { 278 | "model": "note.note", 279 | "fields": { 280 | "title": "This is first note Updated", 281 | "content": "test", 282 | "author": 3, 283 | "completed_at": null, 284 | "created_at": "2023-07-12T17:20:58.808Z", 285 | "updated_at": "2023-07-12T23:24:16.445Z" 286 | } 287 | }, 288 | { 289 | "model": "note.note", 290 | "fields": { 291 | "title": "This is first note Updated", 292 | "content": "test", 293 | "author": 3, 294 | "completed_at": null, 295 | "created_at": "2023-07-12T17:20:58.808Z", 296 | "updated_at": "2023-07-12T23:24:16.445Z" 297 | } 298 | }, 299 | { 300 | "model": "note.note", 301 | "fields": { 302 | "title": "This is first note Updated", 303 | "content": "test", 304 | "author": 3, 305 | "completed_at": null, 306 | "created_at": "2023-07-12T17:20:58.808Z", 307 | "updated_at": "2023-07-12T23:24:16.445Z" 308 | } 309 | }, 310 | { 311 | "model": "note.note", 312 | "fields": { 313 | "title": "This is first note Updated", 314 | "content": "test", 315 | "author": 3, 316 | "completed_at": null, 317 | "created_at": "2023-07-12T17:20:58.808Z", 318 | "updated_at": "2023-07-12T23:24:16.445Z" 319 | } 320 | } 321 | ] 322 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for just-notes on 2023-07-21T17:58:15+03:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "just-notes" 7 | primary_region = "waw" 8 | console_command = "/code/manage.py shell" 9 | 10 | [deploy] 11 | release_command = "python manage.py migrate" 12 | 13 | [env] 14 | PORT = "8000" 15 | 16 | [http_service] 17 | internal_port = 8000 18 | force_https = true 19 | auto_stop_machines = true 20 | auto_start_machines = true 21 | min_machines_running = 0 22 | processes = ["app"] 23 | 24 | [[statics]] 25 | guest_path = "/code/staticfiles" 26 | url_prefix = "/static/" 27 | -------------------------------------------------------------------------------- /htmx_messages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/htmx_messages/__init__.py -------------------------------------------------------------------------------- /htmx_messages/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class HtmxMessagesConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'htmx_messages' 7 | -------------------------------------------------------------------------------- /htmx_messages/middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | from django.contrib.messages import get_messages 3 | from django.http import HttpRequest, HttpResponse 4 | from django.utils.deprecation import MiddlewareMixin 5 | 6 | 7 | class HtmxMessageMiddleware(MiddlewareMixin): 8 | """ 9 | Middleware that moves messages into the HX-Trigger header when request is made with HTMX 10 | """ 11 | 12 | def process_response(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: 13 | 14 | # The HX-Request header indicates that the request was made with HTMX 15 | if "HX-Request" not in request.headers: 16 | return response 17 | 18 | # Ignore redirections because HTMX cannot read the headers 19 | if 300 <= response.status_code < 400: 20 | return response 21 | 22 | # Extract the messages 23 | messages = [ 24 | {"message": message.message, "tags": message.tags} 25 | for message in get_messages(request) 26 | ] 27 | if not messages: 28 | return response 29 | 30 | # Get the existing HX-Trigger that could have been defined by the view 31 | hx_trigger = response.headers.get("HX-Trigger") 32 | 33 | if hx_trigger is None: 34 | # If the HX-Trigger is not set, start with an empty object 35 | hx_trigger = {} 36 | elif hx_trigger.startswith("{"): 37 | # If the HX-Trigger uses the object syntax, parse the object 38 | hx_trigger = json.loads(hx_trigger) 39 | else: 40 | # If the HX-Trigger uses the string syntax, convert to the object syntax 41 | hx_trigger = {hx_trigger: True} 42 | 43 | # Add the messages array in the HX-Trigger object 44 | hx_trigger["messages"] = messages 45 | 46 | # Add or update the HX-Trigger 47 | response.headers["HX-Trigger"] = json.dumps(hx_trigger) 48 | 49 | return response -------------------------------------------------------------------------------- /htmx_messages/static/toasts.js: -------------------------------------------------------------------------------- 1 | ;(function () { 2 | const toastOptions = { delay: 5000 } 3 | 4 | function createToast(message) { 5 | // Clone the template 6 | const element = htmx.find("[data-toast-template]").cloneNode(true) 7 | const success_icon_svg = '' 8 | const warning_icon_svg = '' 9 | const danger_icon_svg = '' 10 | const succes_icon_classes = ' text-green-500 bg-green-100 rounded-lg dark:bg-green-800 dark:text-green-200' 11 | const warning_icon_classes = ' text-yellow-500 bg-yellow-100 rounded-lg dark:bg-yellow-700 dark:text-yellow-200' 12 | const danger_icon_classes = ' text-red-500 bg-red-100 rounded-lg dark:bg-red-800 dark:text-red-200' 13 | 14 | // Remove the data-toast-template attribute 15 | delete element.dataset.toastTemplate 16 | 17 | // Set the CSS class 18 | element.id += message.tags 19 | element.classList.remove('hidden'); 20 | element.classList.add('flex'); 21 | 22 | // Set the text 23 | htmx.find(element, "[data-toast-body]").innerText = message.message 24 | if (message.tags === 'success') { 25 | element.classList += ' border-green-500' 26 | htmx.find(element, "[data-icon-container]").innerHTML = success_icon_svg 27 | htmx.find(element, "[data-icon-container]").classList += succes_icon_classes 28 | } else if (message.tags === 'warning') { 29 | element.classList += ' border-yellow-500' 30 | htmx.find(element, "[data-icon-container]").innerHTML = warning_icon_svg 31 | htmx.find(element, "[data-icon-container]").classList += warning_icon_classes 32 | } else if (message.tags === 'danger') { 33 | element.classList += ' border-red-500' 34 | htmx.find(element, "[data-icon-container]").innerHTML = danger_icon_svg 35 | htmx.find(element, "[data-icon-container]").classList += danger_icon_classes 36 | } 37 | // Add the new element to the container 38 | htmx.find("[data-toast-container]").appendChild(element) 39 | 40 | // Show the toast 41 | 42 | } 43 | 44 | htmx.on("messages", (event) => { 45 | event.detail.value.forEach(createToast); 46 | htmx.findAll(".toast:not([data-toast-template])").forEach((element) => { 47 | setTimeout(function() { 48 | removeFadeOut(element, 1000) 49 | }, 5000) 50 | }) 51 | }) 52 | })() 53 | 54 | function removeFadeOut( el, speed ) { 55 | var seconds = speed/1000; 56 | el.style.transition = "opacity "+seconds+"s ease"; 57 | 58 | el.style.opacity = 0; 59 | setTimeout(function() { 60 | el.parentNode.removeChild(el); 61 | }, speed); 62 | } 63 | 64 | function removeToast(target) { 65 | let toastElement = target.closest('.toast'); 66 | removeFadeOut(toastElement, 1000); 67 | } 68 | -------------------------------------------------------------------------------- /htmx_messages/templates/toasts.html: -------------------------------------------------------------------------------- 1 |
2 | 18 | {% for message in messages %} 19 | 55 | {% endfor %} 56 |
57 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /note/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/note/__init__.py -------------------------------------------------------------------------------- /note/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Note 4 | 5 | # Register your models here. 6 | class NoteAdmin(admin.ModelAdmin): 7 | list_display = ['id', 'title', 'author', 'completed_at', 'updated_at'] 8 | readonly_fields = ['created_at', 'updated_at'] 9 | 10 | admin.site.register(Note, NoteAdmin) 11 | -------------------------------------------------------------------------------- /note/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NoteConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'note' 7 | -------------------------------------------------------------------------------- /note/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-07-12 16:59 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Note', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('title', models.CharField(max_length=255)), 22 | ('content', models.TextField(blank=True, null=True)), 23 | ('completed', models.BooleanField(default=False)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('updated_at', models.DateTimeField(auto_now=True)), 26 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /note/migrations/0002_alter_note_title.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-07-13 10:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('note', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='note', 15 | name='title', 16 | field=models.CharField(blank=True, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /note/migrations/0003_remove_note_completed_note_completed_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-07-23 19:04 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('note', '0002_alter_note_title'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='note', 15 | name='completed', 16 | ), 17 | migrations.AddField( 18 | model_name='note', 19 | name='completed_at', 20 | field=models.DateTimeField(blank=True, default=None, help_text="None if the note isn't completed. Contain datetime when the note was completed", null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /note/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/note/migrations/__init__.py -------------------------------------------------------------------------------- /note/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from user.models import NoteUser 4 | 5 | 6 | # Create your models here. 7 | class Note(models.Model): 8 | title = models.CharField(max_length=255, blank=True, null=True) 9 | content = models.TextField(blank=True, null=True) 10 | author = models.ForeignKey(NoteUser, on_delete=models.CASCADE, 11 | related_name='notes') 12 | completed_at = models.DateTimeField(help_text="None if the note isn't completed. Contain datetime when the note was completed", blank=True, null=True, default=None) 13 | created_at = models.DateTimeField(auto_now_add=True) 14 | updated_at = models.DateTimeField(auto_now=True) 15 | 16 | def __str__(self): 17 | return f'{self.id}: {self.title}' 18 | 19 | @property 20 | def is_completed(self): 21 | '''check if note completed''' 22 | if self.completed_at is not None: 23 | return True 24 | return False 25 | -------------------------------------------------------------------------------- /note/templates/note/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %} 4 | Home | 5 | {% endblock title %} 6 | 7 | {% block additional_css %} 8 | {% endblock additional_css %} 9 | 10 | {% block content %} 11 |
13 | 14 |

Your Notes

15 | 16 | 23 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 61 | 62 | 63 |

Total: {{ notes.paginator.count }}

64 |
65 |
66 |
67 | 68 | 69 |
70 | 71 | 77 | 78 | 79 | Selected: 80 |
81 | 82 | 97 | 112 | 119 | 120 | 121 |
122 |
123 | 127 |
128 | New Note 129 | 130 | 131 | 132 |
133 | 134 |
135 | {% for note in notes %} 136 | 142 | 143 |
{{ note.title }}
144 | 149 |
150 | 151 |

{{ note.content|truncatechars:100 }}

152 | 153 | 154 |

Updated: {{ note.updated_at }}

155 | {% if note.completed_at %} 156 | 157 | 158 | 159 | {% else %} 160 | 161 | 162 | 163 | {% endif %} 164 |
165 |
166 |
167 | {% endfor %} 168 |
169 | 170 | 267 | 268 | 269 | 270 | 346 | 347 | 348 | 393 | 394 | 397 | 400 | 401 |
402 | 403 | {% endblock content %} 404 | 405 | {% block additional_javascript %} 406 | 515 | 525 | 526 | {% endblock additional_javascript %} -------------------------------------------------------------------------------- /note/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /note/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path('', views.home, name='home_view'), 7 | path('note/', views.note_view, name='note_view'), 8 | path('notes/', views.bulk_notes_view, name='bulk_notes_view'), 9 | path('note//', views.delete_note_view, 10 | name='delete_note_view'), 11 | ] 12 | -------------------------------------------------------------------------------- /note/views.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from django_htmx.http import push_url 3 | from django.shortcuts import render, redirect 4 | from django.contrib import messages 5 | from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger 6 | from django.utils import timezone 7 | from django.urls import reverse 8 | from django.db.models import Q 9 | 10 | from note.models import Note 11 | 12 | 13 | def get_paginated_query(query, request): 14 | ''' 15 | helper function to generate paginated query 16 | ''' 17 | paginator = Paginator(query, 19) 18 | page = request.GET.get('page', 1) 19 | try: 20 | sleep(0.1) 21 | query = paginator.page(page) 22 | except EmptyPage: 23 | messages.add_message(request, messages.ERROR, 'Bad page number...') 24 | query = paginator.page(paginator.num_pages) 25 | except PageNotAnInteger: 26 | query = paginator.page(1) 27 | # add context 28 | # config pagination 29 | page_range = paginator.get_elided_page_range(number=query.number, 30 | on_each_side=3, 31 | on_ends=1) 32 | return query, page_range 33 | 34 | 35 | # Create your views here. 36 | def home(request): 37 | ''' 38 | Home page 39 | ''' 40 | if not request.user.is_authenticated: 41 | messages.add_message( 42 | request, 43 | messages.ERROR, 44 | 'You need to log in first!' 45 | ) 46 | return redirect('login_view') 47 | context = {} 48 | # get all notes for user 49 | notes = Note.objects.filter( 50 | author=request.user).order_by('-updated_at') 51 | # if search param in url 52 | query = request.GET.get('search', False) 53 | if query: 54 | sleep(0.1) 55 | notes = notes.filter(Q(title__icontains=query) | Q(content__icontains=query)) 56 | # filter by is_completed status 57 | uncompleted = request.GET.get('uncompleted', False) 58 | if uncompleted == 'on': 59 | sleep(0.1) 60 | notes = notes.filter(completed_at__isnull=True) 61 | # get pagination 62 | context['notes'], context['page_range'] = get_paginated_query(notes, request) 63 | 64 | context['search'] = query 65 | context['uncompleted'] = uncompleted 66 | # template 67 | template_name = 'note/home.html' 68 | return render(request, template_name, context) 69 | 70 | 71 | def note_view(request): 72 | ''' 73 | Update single Note 74 | ''' 75 | context = {} 76 | template_name = 'note/home.html' 77 | if request.htmx: 78 | data = request.POST 79 | # update Note 80 | if data['note_action'] == 'update': 81 | try: 82 | note = Note.objects.get(id=int(data.get('note_id'))) 83 | except (Note.DoesNotExist, Note.MultipleObjectsReturned): 84 | messages.add_message(request, messages.ERROR, 85 | 'Something went wrong...') 86 | # if Note exist - update it 87 | else: 88 | if note: 89 | note.title = data.get('title') 90 | note.content = data.get('content') 91 | # set completed status 92 | if (data.get('completed', None) is not None 93 | and data.get('completed')) == 'on': 94 | note.completed_at = timezone.now() 95 | else: 96 | note.completed = None 97 | note.save() 98 | messages.add_message(request, messages.SUCCESS, 99 | 'Your Note updated!') 100 | # create new Note 101 | if data['note_action'] == 'create': 102 | if ( 103 | (data['title'] is None or len(data['title']) < 1) 104 | and (data['content'] is None or len(data['content']) < 1) 105 | ): 106 | messages.add_message(request, messages.ERROR, 107 | 'Title or Content is required!') 108 | # if at least title or content provided - create Note 109 | else: 110 | new_note = Note.objects.create(title=data['title'], 111 | content=data['content'], 112 | author=request.user) 113 | # set completed status 114 | if (data.get('completed', None) is not None 115 | and data.get('completed')) == 'on': 116 | new_note.completed_at = timezone.now() 117 | new_note.save() 118 | messages.add_message(request, messages.SUCCESS, 119 | 'New Note added!') 120 | # return all notes for user 121 | notes = Note.objects.filter( 122 | author=request.user 123 | ).order_by('-updated_at') 124 | # get pagination 125 | context['notes'], context['page_range'] = get_paginated_query(notes, request) 126 | 127 | response = render(request, template_name, context) 128 | return push_url(response, reverse('home_view')) 129 | 130 | 131 | def delete_note_view(request, note_id): 132 | ''' 133 | Delete single Note 134 | ''' 135 | context = {} 136 | template_name = 'note/home.html' 137 | # delete Note 138 | if request.method == 'DELETE': 139 | try: 140 | note = Note.objects.get(id=note_id) 141 | note.delete() 142 | messages.add_message(request, messages.WARNING, 143 | 'Your Note was deleted!') 144 | except (Note.DoesNotExist, Note.MultipleObjectsReturned): 145 | messages.add_message(request, messages.ERROR, 146 | 'Something went wrong...') 147 | # return all notes for user 148 | notes = Note.objects.filter( 149 | author=request.user 150 | ).order_by('-updated_at') 151 | # get pagination 152 | context['notes'], context['page_range'] = get_paginated_query(notes, request) 153 | response = render(request, template_name, context) 154 | return push_url(response, reverse('home_view')) 155 | 156 | 157 | def bulk_notes_view(request): 158 | ''' 159 | Bulk Notes actions 160 | ''' 161 | context = {} 162 | template_name = 'note/home.html' 163 | if request.htmx: 164 | # get selected Notes ids 165 | selected_notes_ids_raw = request.POST.get('selected-notes-ids') 166 | selected_notes_ids = selected_notes_ids_raw.split(',') 167 | # get nptes action 168 | action = request.POST.get('bulk-notes-action') 169 | # bulk delete Notes 170 | if action == 'bulk_delete': 171 | for note_id in selected_notes_ids: 172 | try: 173 | note = Note.objects.get(id=note_id) 174 | note.delete() 175 | except (Note.DoesNotExist, Note.MultipleObjectsReturned): 176 | messages.add_message(request, messages.ERROR, 177 | 'Something went wrong...') 178 | messages.add_message(request, messages.WARNING, 179 | 'Notes deleted!') 180 | # bulk change completes status for Notes 181 | else: 182 | for note_id in selected_notes_ids: 183 | try: 184 | note = Note.objects.get(id=note_id) 185 | if note.is_completed: 186 | note.completed_at = None 187 | else: 188 | note.completed_at = timezone.now() 189 | note.save() 190 | except (Note.DoesNotExist, Note.MultipleObjectsReturned): 191 | messages.add_message(request, messages.ERROR, 192 | 'Something went wrong...') 193 | messages.add_message(request, messages.SUCCESS, 194 | 'Notes updated!') 195 | # return all notes for user 196 | notes = Note.objects.filter( 197 | author=request.user 198 | ).order_by('-updated_at') 199 | # get pagination 200 | context['notes'], context['page_range'] = get_paginated_query(notes, request) 201 | response = render(request, template_name, context) 202 | return push_url(response, reverse('home_view')) 203 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": { 3 | "build": { 4 | "patterns": [ 5 | "./templates/**/*", 6 | "./**/templates/**/*" 7 | ], 8 | "extensions": "html", 9 | "quiet": false 10 | } 11 | }, 12 | "scripts": { 13 | "build": "postcss static/css/tailwind/main.css -o static/css/tailwind/main.min.css", 14 | "dev": "postcss static/css/tailwind/main.css -o static/css/tailwind/main.min.css --watch", 15 | "watch": "npm-watch" 16 | }, 17 | "devDependencies": { 18 | "autoprefixer": "^10.4.14", 19 | "postcss": "^8.4.27", 20 | "postcss-cli": "^10.1.0", 21 | "tailwindcss": "^3.3.3" 22 | }, 23 | "dependencies": { 24 | "npm-watch": "^0.11.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.2 2 | django-htmx==1.15.0 3 | gunicorn==21.2.0 4 | whitenoise==6.5.0 5 | psycopg2==2.9.6 6 | django-environ==0.10.0 7 | -------------------------------------------------------------------------------- /screenshots/desktop_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/desktop_dark.png -------------------------------------------------------------------------------- /screenshots/desktop_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/desktop_light.png -------------------------------------------------------------------------------- /screenshots/mobile_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/mobile_dark.png -------------------------------------------------------------------------------- /screenshots/mobile_dark_create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/mobile_dark_create.png -------------------------------------------------------------------------------- /screenshots/mobile_dark_edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/mobile_dark_edit.png -------------------------------------------------------------------------------- /screenshots/mobile_dark_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/mobile_dark_menu.png -------------------------------------------------------------------------------- /screenshots/mobile_dark_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/mobile_dark_profile.png -------------------------------------------------------------------------------- /screenshots/mobile_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/screenshots/mobile_light.png -------------------------------------------------------------------------------- /static/css/custom/custom.css: -------------------------------------------------------------------------------- 1 | [x-cloak] { display: none !important; } 2 | 3 | .invalid-feedback { 4 | display: none; 5 | } 6 | .invalid-feedback.show { 7 | color: red; 8 | display: block; 9 | } 10 | 11 | .theme-light { 12 | --text: #080703; 13 | --text-secondary: #201c0b; 14 | --text-minor: #545454; 15 | --text-light: #f0f0f0; 16 | --text-dark: #080703; 17 | --background: #fafafa; 18 | --background-secondary: #ececec; 19 | --primary: #85b03b; 20 | --secondary: #bac0e8; 21 | --secondary-dark: #959aba; 22 | --accent: #5d36a1; 23 | } 24 | .theme-dark { 25 | --text: #fcf7f7; 26 | --text-secondary: #dfdfdf; 27 | --text-minor: #bdbdbd; 28 | --text-light: #f0f0f0; 29 | --text-dark: #080703; 30 | --background: #2e2c54; 31 | --background-secondary: #242247; 32 | --primary: #85b03b; 33 | --secondary: #393760; 34 | --secondary-dark: #4d4b70; 35 | --accent: #8871e6; 36 | } 37 | body { 38 | font-family: 'Exo 2', sans-serif; 39 | } 40 | 41 | .error-message { 42 | color:red; 43 | } 44 | input.validation-error { 45 | box-shadow: 0 0 3px #CC0000; 46 | } 47 | input.valid { 48 | box-shadow: 0 0 3px #36cc00; 49 | } -------------------------------------------------------------------------------- /static/css/tailwind/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /static/css/tailwind/main.min.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com 3 | *//* 4 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 5 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 6 | */ 7 | 8 | *, 9 | ::before, 10 | ::after { 11 | box-sizing: border-box; /* 1 */ 12 | border-width: 0; /* 2 */ 13 | border-style: solid; /* 2 */ 14 | border-color: #e5e7eb; /* 2 */ 15 | } 16 | 17 | ::before, 18 | ::after { 19 | --tw-content: ''; 20 | } 21 | 22 | /* 23 | 1. Use a consistent sensible line-height in all browsers. 24 | 2. Prevent adjustments of font size after orientation changes in iOS. 25 | 3. Use a more readable tab size. 26 | 4. Use the user's configured `sans` font-family by default. 27 | 5. Use the user's configured `sans` font-feature-settings by default. 28 | 6. Use the user's configured `sans` font-variation-settings by default. 29 | */ 30 | 31 | html { 32 | line-height: 1.5; /* 1 */ 33 | -webkit-text-size-adjust: 100%; /* 2 */ 34 | -moz-tab-size: 4; /* 3 */ 35 | -o-tab-size: 4; 36 | tab-size: 4; /* 3 */ 37 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ 38 | font-feature-settings: normal; /* 5 */ 39 | font-variation-settings: normal; /* 6 */ 40 | } 41 | 42 | /* 43 | 1. Remove the margin in all browsers. 44 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 45 | */ 46 | 47 | body { 48 | margin: 0; /* 1 */ 49 | line-height: inherit; /* 2 */ 50 | } 51 | 52 | /* 53 | 1. Add the correct height in Firefox. 54 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 55 | 3. Ensure horizontal rules are visible by default. 56 | */ 57 | 58 | hr { 59 | height: 0; /* 1 */ 60 | color: inherit; /* 2 */ 61 | border-top-width: 1px; /* 3 */ 62 | } 63 | 64 | /* 65 | Add the correct text decoration in Chrome, Edge, and Safari. 66 | */ 67 | 68 | abbr:where([title]) { 69 | -webkit-text-decoration: underline dotted; 70 | text-decoration: underline dotted; 71 | } 72 | 73 | /* 74 | Remove the default font size and weight for headings. 75 | */ 76 | 77 | h1, 78 | h2, 79 | h3, 80 | h4, 81 | h5, 82 | h6 { 83 | font-size: inherit; 84 | font-weight: inherit; 85 | } 86 | 87 | /* 88 | Reset links to optimize for opt-in styling instead of opt-out. 89 | */ 90 | 91 | a { 92 | color: inherit; 93 | text-decoration: inherit; 94 | } 95 | 96 | /* 97 | Add the correct font weight in Edge and Safari. 98 | */ 99 | 100 | b, 101 | strong { 102 | font-weight: bolder; 103 | } 104 | 105 | /* 106 | 1. Use the user's configured `mono` font family by default. 107 | 2. Correct the odd `em` font sizing in all browsers. 108 | */ 109 | 110 | code, 111 | kbd, 112 | samp, 113 | pre { 114 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */ 115 | font-size: 1em; /* 2 */ 116 | } 117 | 118 | /* 119 | Add the correct font size in all browsers. 120 | */ 121 | 122 | small { 123 | font-size: 80%; 124 | } 125 | 126 | /* 127 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 128 | */ 129 | 130 | sub, 131 | sup { 132 | font-size: 75%; 133 | line-height: 0; 134 | position: relative; 135 | vertical-align: baseline; 136 | } 137 | 138 | sub { 139 | bottom: -0.25em; 140 | } 141 | 142 | sup { 143 | top: -0.5em; 144 | } 145 | 146 | /* 147 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 148 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 149 | 3. Remove gaps between table borders by default. 150 | */ 151 | 152 | table { 153 | text-indent: 0; /* 1 */ 154 | border-color: inherit; /* 2 */ 155 | border-collapse: collapse; /* 3 */ 156 | } 157 | 158 | /* 159 | 1. Change the font styles in all browsers. 160 | 2. Remove the margin in Firefox and Safari. 161 | 3. Remove default padding in all browsers. 162 | */ 163 | 164 | button, 165 | input, 166 | optgroup, 167 | select, 168 | textarea { 169 | font-family: inherit; /* 1 */ 170 | font-feature-settings: inherit; /* 1 */ 171 | font-variation-settings: inherit; /* 1 */ 172 | font-size: 100%; /* 1 */ 173 | font-weight: inherit; /* 1 */ 174 | line-height: inherit; /* 1 */ 175 | color: inherit; /* 1 */ 176 | margin: 0; /* 2 */ 177 | padding: 0; /* 3 */ 178 | } 179 | 180 | /* 181 | Remove the inheritance of text transform in Edge and Firefox. 182 | */ 183 | 184 | button, 185 | select { 186 | text-transform: none; 187 | } 188 | 189 | /* 190 | 1. Correct the inability to style clickable types in iOS and Safari. 191 | 2. Remove default button styles. 192 | */ 193 | 194 | button, 195 | [type='button'], 196 | [type='reset'], 197 | [type='submit'] { 198 | -webkit-appearance: button; /* 1 */ 199 | background-color: transparent; /* 2 */ 200 | background-image: none; /* 2 */ 201 | } 202 | 203 | /* 204 | Use the modern Firefox focus style for all focusable elements. 205 | */ 206 | 207 | :-moz-focusring { 208 | outline: auto; 209 | } 210 | 211 | /* 212 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 213 | */ 214 | 215 | :-moz-ui-invalid { 216 | box-shadow: none; 217 | } 218 | 219 | /* 220 | Add the correct vertical alignment in Chrome and Firefox. 221 | */ 222 | 223 | progress { 224 | vertical-align: baseline; 225 | } 226 | 227 | /* 228 | Correct the cursor style of increment and decrement buttons in Safari. 229 | */ 230 | 231 | ::-webkit-inner-spin-button, 232 | ::-webkit-outer-spin-button { 233 | height: auto; 234 | } 235 | 236 | /* 237 | 1. Correct the odd appearance in Chrome and Safari. 238 | 2. Correct the outline style in Safari. 239 | */ 240 | 241 | [type='search'] { 242 | -webkit-appearance: textfield; /* 1 */ 243 | outline-offset: -2px; /* 2 */ 244 | } 245 | 246 | /* 247 | Remove the inner padding in Chrome and Safari on macOS. 248 | */ 249 | 250 | ::-webkit-search-decoration { 251 | -webkit-appearance: none; 252 | } 253 | 254 | /* 255 | 1. Correct the inability to style clickable types in iOS and Safari. 256 | 2. Change font properties to `inherit` in Safari. 257 | */ 258 | 259 | ::-webkit-file-upload-button { 260 | -webkit-appearance: button; /* 1 */ 261 | font: inherit; /* 2 */ 262 | } 263 | 264 | /* 265 | Add the correct display in Chrome and Safari. 266 | */ 267 | 268 | summary { 269 | display: list-item; 270 | } 271 | 272 | /* 273 | Removes the default spacing and border for appropriate elements. 274 | */ 275 | 276 | blockquote, 277 | dl, 278 | dd, 279 | h1, 280 | h2, 281 | h3, 282 | h4, 283 | h5, 284 | h6, 285 | hr, 286 | figure, 287 | p, 288 | pre { 289 | margin: 0; 290 | } 291 | 292 | fieldset { 293 | margin: 0; 294 | padding: 0; 295 | } 296 | 297 | legend { 298 | padding: 0; 299 | } 300 | 301 | ol, 302 | ul, 303 | menu { 304 | list-style: none; 305 | margin: 0; 306 | padding: 0; 307 | } 308 | 309 | /* 310 | Reset default styling for dialogs. 311 | */ 312 | dialog { 313 | padding: 0; 314 | } 315 | 316 | /* 317 | Prevent resizing textareas horizontally by default. 318 | */ 319 | 320 | textarea { 321 | resize: vertical; 322 | } 323 | 324 | /* 325 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 326 | 2. Set the default placeholder color to the user's configured gray 400 color. 327 | */ 328 | 329 | input::-moz-placeholder, textarea::-moz-placeholder { 330 | opacity: 1; /* 1 */ 331 | color: #9ca3af; /* 2 */ 332 | } 333 | 334 | input::placeholder, 335 | textarea::placeholder { 336 | opacity: 1; /* 1 */ 337 | color: #9ca3af; /* 2 */ 338 | } 339 | 340 | /* 341 | Set the default cursor for buttons. 342 | */ 343 | 344 | button, 345 | [role="button"] { 346 | cursor: pointer; 347 | } 348 | 349 | /* 350 | Make sure disabled buttons don't get the pointer cursor. 351 | */ 352 | :disabled { 353 | cursor: default; 354 | } 355 | 356 | /* 357 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 358 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 359 | This can trigger a poorly considered lint error in some tools but is included by design. 360 | */ 361 | 362 | img, 363 | svg, 364 | video, 365 | canvas, 366 | audio, 367 | iframe, 368 | embed, 369 | object { 370 | display: block; /* 1 */ 371 | vertical-align: middle; /* 2 */ 372 | } 373 | 374 | /* 375 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 376 | */ 377 | 378 | img, 379 | video { 380 | max-width: 100%; 381 | height: auto; 382 | } 383 | 384 | /* Make elements with the HTML hidden attribute stay hidden by default */ 385 | [hidden] { 386 | display: none; 387 | } 388 | 389 | *, ::before, ::after { 390 | --tw-border-spacing-x: 0; 391 | --tw-border-spacing-y: 0; 392 | --tw-translate-x: 0; 393 | --tw-translate-y: 0; 394 | --tw-rotate: 0; 395 | --tw-skew-x: 0; 396 | --tw-skew-y: 0; 397 | --tw-scale-x: 1; 398 | --tw-scale-y: 1; 399 | --tw-pan-x: ; 400 | --tw-pan-y: ; 401 | --tw-pinch-zoom: ; 402 | --tw-scroll-snap-strictness: proximity; 403 | --tw-gradient-from-position: ; 404 | --tw-gradient-via-position: ; 405 | --tw-gradient-to-position: ; 406 | --tw-ordinal: ; 407 | --tw-slashed-zero: ; 408 | --tw-numeric-figure: ; 409 | --tw-numeric-spacing: ; 410 | --tw-numeric-fraction: ; 411 | --tw-ring-inset: ; 412 | --tw-ring-offset-width: 0px; 413 | --tw-ring-offset-color: #fff; 414 | --tw-ring-color: rgb(59 130 246 / 0.5); 415 | --tw-ring-offset-shadow: 0 0 #0000; 416 | --tw-ring-shadow: 0 0 #0000; 417 | --tw-shadow: 0 0 #0000; 418 | --tw-shadow-colored: 0 0 #0000; 419 | --tw-blur: ; 420 | --tw-brightness: ; 421 | --tw-contrast: ; 422 | --tw-grayscale: ; 423 | --tw-hue-rotate: ; 424 | --tw-invert: ; 425 | --tw-saturate: ; 426 | --tw-sepia: ; 427 | --tw-drop-shadow: ; 428 | --tw-backdrop-blur: ; 429 | --tw-backdrop-brightness: ; 430 | --tw-backdrop-contrast: ; 431 | --tw-backdrop-grayscale: ; 432 | --tw-backdrop-hue-rotate: ; 433 | --tw-backdrop-invert: ; 434 | --tw-backdrop-opacity: ; 435 | --tw-backdrop-saturate: ; 436 | --tw-backdrop-sepia: ; 437 | } 438 | 439 | ::backdrop { 440 | --tw-border-spacing-x: 0; 441 | --tw-border-spacing-y: 0; 442 | --tw-translate-x: 0; 443 | --tw-translate-y: 0; 444 | --tw-rotate: 0; 445 | --tw-skew-x: 0; 446 | --tw-skew-y: 0; 447 | --tw-scale-x: 1; 448 | --tw-scale-y: 1; 449 | --tw-pan-x: ; 450 | --tw-pan-y: ; 451 | --tw-pinch-zoom: ; 452 | --tw-scroll-snap-strictness: proximity; 453 | --tw-gradient-from-position: ; 454 | --tw-gradient-via-position: ; 455 | --tw-gradient-to-position: ; 456 | --tw-ordinal: ; 457 | --tw-slashed-zero: ; 458 | --tw-numeric-figure: ; 459 | --tw-numeric-spacing: ; 460 | --tw-numeric-fraction: ; 461 | --tw-ring-inset: ; 462 | --tw-ring-offset-width: 0px; 463 | --tw-ring-offset-color: #fff; 464 | --tw-ring-color: rgb(59 130 246 / 0.5); 465 | --tw-ring-offset-shadow: 0 0 #0000; 466 | --tw-ring-shadow: 0 0 #0000; 467 | --tw-shadow: 0 0 #0000; 468 | --tw-shadow-colored: 0 0 #0000; 469 | --tw-blur: ; 470 | --tw-brightness: ; 471 | --tw-contrast: ; 472 | --tw-grayscale: ; 473 | --tw-hue-rotate: ; 474 | --tw-invert: ; 475 | --tw-saturate: ; 476 | --tw-sepia: ; 477 | --tw-drop-shadow: ; 478 | --tw-backdrop-blur: ; 479 | --tw-backdrop-brightness: ; 480 | --tw-backdrop-contrast: ; 481 | --tw-backdrop-grayscale: ; 482 | --tw-backdrop-hue-rotate: ; 483 | --tw-backdrop-invert: ; 484 | --tw-backdrop-opacity: ; 485 | --tw-backdrop-saturate: ; 486 | --tw-backdrop-sepia: ; 487 | } 488 | .sr-only { 489 | position: absolute; 490 | width: 1px; 491 | height: 1px; 492 | padding: 0; 493 | margin: -1px; 494 | overflow: hidden; 495 | clip: rect(0, 0, 0, 0); 496 | white-space: nowrap; 497 | border-width: 0; 498 | } 499 | .static { 500 | position: static; 501 | } 502 | .fixed { 503 | position: fixed; 504 | } 505 | .absolute { 506 | position: absolute; 507 | } 508 | .relative { 509 | position: relative; 510 | } 511 | .inset-0 { 512 | inset: 0px; 513 | } 514 | .bottom-\[2vh\] { 515 | bottom: 2vh; 516 | } 517 | .left-0 { 518 | left: 0px; 519 | } 520 | .right-0 { 521 | right: 0px; 522 | } 523 | .right-2 { 524 | right: 0.5rem; 525 | } 526 | .right-2\.5 { 527 | right: 0.625rem; 528 | } 529 | .right-\[5vw\] { 530 | right: 5vw; 531 | } 532 | .top-0 { 533 | top: 0px; 534 | } 535 | .top-3 { 536 | top: 0.75rem; 537 | } 538 | .top-4 { 539 | top: 1rem; 540 | } 541 | .z-20 { 542 | z-index: 20; 543 | } 544 | .z-40 { 545 | z-index: 40; 546 | } 547 | .z-50 { 548 | z-index: 50; 549 | } 550 | .order-2 { 551 | order: 2; 552 | } 553 | .order-3 { 554 | order: 3; 555 | } 556 | .order-4 { 557 | order: 4; 558 | } 559 | .-mx-1 { 560 | margin-left: -0.25rem; 561 | margin-right: -0.25rem; 562 | } 563 | .-mx-1\.5 { 564 | margin-left: -0.375rem; 565 | margin-right: -0.375rem; 566 | } 567 | .-my-1 { 568 | margin-top: -0.25rem; 569 | margin-bottom: -0.25rem; 570 | } 571 | .-my-1\.5 { 572 | margin-top: -0.375rem; 573 | margin-bottom: -0.375rem; 574 | } 575 | .mx-\[5vw\] { 576 | margin-left: 5vw; 577 | margin-right: 5vw; 578 | } 579 | .mx-auto { 580 | margin-left: auto; 581 | margin-right: auto; 582 | } 583 | .my-4 { 584 | margin-top: 1rem; 585 | margin-bottom: 1rem; 586 | } 587 | .mb-2 { 588 | margin-bottom: 0.5rem; 589 | } 590 | .mb-3 { 591 | margin-bottom: 0.75rem; 592 | } 593 | .mb-4 { 594 | margin-bottom: 1rem; 595 | } 596 | .mb-5 { 597 | margin-bottom: 1.25rem; 598 | } 599 | .ml-2 { 600 | margin-left: 0.5rem; 601 | } 602 | .ml-3 { 603 | margin-left: 0.75rem; 604 | } 605 | .ml-auto { 606 | margin-left: auto; 607 | } 608 | .mr-3 { 609 | margin-right: 0.75rem; 610 | } 611 | .mt-1 { 612 | margin-top: 0.25rem; 613 | } 614 | .mt-1\.5 { 615 | margin-top: 0.375rem; 616 | } 617 | .mt-10 { 618 | margin-top: 2.5rem; 619 | } 620 | .mt-2 { 621 | margin-top: 0.5rem; 622 | } 623 | .mt-4 { 624 | margin-top: 1rem; 625 | } 626 | .mt-5 { 627 | margin-top: 1.25rem; 628 | } 629 | .block { 630 | display: block; 631 | } 632 | .flex { 633 | display: flex; 634 | } 635 | .inline-flex { 636 | display: inline-flex; 637 | } 638 | .grid { 639 | display: grid; 640 | } 641 | .hidden { 642 | display: none; 643 | } 644 | .h-10 { 645 | height: 2.5rem; 646 | } 647 | .h-3 { 648 | height: 0.75rem; 649 | } 650 | .h-4 { 651 | height: 1rem; 652 | } 653 | .h-5 { 654 | height: 1.25rem; 655 | } 656 | .h-6 { 657 | height: 1.5rem; 658 | } 659 | .h-8 { 660 | height: 2rem; 661 | } 662 | .h-\[calc\(100\%-1rem\)\] { 663 | height: calc(100% - 1rem); 664 | } 665 | .h-full { 666 | height: 100%; 667 | } 668 | .max-h-full { 669 | max-height: 100%; 670 | } 671 | .min-h-\[80vh\] { 672 | min-height: 80vh; 673 | } 674 | .w-1\/3 { 675 | width: 33.333333%; 676 | } 677 | .w-1\/4 { 678 | width: 25%; 679 | } 680 | .w-10 { 681 | width: 2.5rem; 682 | } 683 | .w-11 { 684 | width: 2.75rem; 685 | } 686 | .w-16 { 687 | width: 4rem; 688 | } 689 | .w-2\/3 { 690 | width: 66.666667%; 691 | } 692 | .w-3 { 693 | width: 0.75rem; 694 | } 695 | .w-3\/4 { 696 | width: 75%; 697 | } 698 | .w-4 { 699 | width: 1rem; 700 | } 701 | .w-5 { 702 | width: 1.25rem; 703 | } 704 | .w-6 { 705 | width: 1.5rem; 706 | } 707 | .w-8 { 708 | width: 2rem; 709 | } 710 | .w-\[90vw\] { 711 | width: 90vw; 712 | } 713 | .w-auto { 714 | width: auto; 715 | } 716 | .w-fit { 717 | width: -moz-fit-content; 718 | width: fit-content; 719 | } 720 | .w-full { 721 | width: 100%; 722 | } 723 | .max-w-2xl { 724 | max-width: 42rem; 725 | } 726 | .max-w-md { 727 | max-width: 28rem; 728 | } 729 | .flex-shrink-0 { 730 | flex-shrink: 0; 731 | } 732 | .transform { 733 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 734 | } 735 | @keyframes spin { 736 | 737 | to { 738 | transform: rotate(360deg); 739 | } 740 | } 741 | .animate-spin { 742 | animation: spin 1s linear infinite; 743 | } 744 | .cursor-pointer { 745 | cursor: pointer; 746 | } 747 | .resize-none { 748 | resize: none; 749 | } 750 | .appearance-none { 751 | -webkit-appearance: none; 752 | -moz-appearance: none; 753 | appearance: none; 754 | } 755 | .grid-flow-col { 756 | grid-auto-flow: column; 757 | } 758 | .grid-cols-1 { 759 | grid-template-columns: repeat(1, minmax(0, 1fr)); 760 | } 761 | .flex-col { 762 | flex-direction: column; 763 | } 764 | .flex-wrap { 765 | flex-wrap: wrap; 766 | } 767 | .items-start { 768 | align-items: flex-start; 769 | } 770 | .items-center { 771 | align-items: center; 772 | } 773 | .justify-center { 774 | justify-content: center; 775 | } 776 | .justify-between { 777 | justify-content: space-between; 778 | } 779 | .gap-1 { 780 | gap: 0.25rem; 781 | } 782 | .gap-2 { 783 | gap: 0.5rem; 784 | } 785 | .gap-3 { 786 | gap: 0.75rem; 787 | } 788 | .space-x-2 > :not([hidden]) ~ :not([hidden]) { 789 | --tw-space-x-reverse: 0; 790 | margin-right: calc(0.5rem * var(--tw-space-x-reverse)); 791 | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); 792 | } 793 | .space-x-4 > :not([hidden]) ~ :not([hidden]) { 794 | --tw-space-x-reverse: 0; 795 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 796 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 797 | } 798 | .space-y-2 > :not([hidden]) ~ :not([hidden]) { 799 | --tw-space-y-reverse: 0; 800 | margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); 801 | margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); 802 | } 803 | .space-y-3 > :not([hidden]) ~ :not([hidden]) { 804 | --tw-space-y-reverse: 0; 805 | margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); 806 | margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); 807 | } 808 | .space-y-5 > :not([hidden]) ~ :not([hidden]) { 809 | --tw-space-y-reverse: 0; 810 | margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse))); 811 | margin-bottom: calc(1.25rem * var(--tw-space-y-reverse)); 812 | } 813 | .space-y-6 > :not([hidden]) ~ :not([hidden]) { 814 | --tw-space-y-reverse: 0; 815 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); 816 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); 817 | } 818 | .self-start { 819 | align-self: flex-start; 820 | } 821 | .self-center { 822 | align-self: center; 823 | } 824 | .overflow-y-auto { 825 | overflow-y: auto; 826 | } 827 | .overflow-x-hidden { 828 | overflow-x: hidden; 829 | } 830 | .rounded { 831 | border-radius: 0.25rem; 832 | } 833 | .rounded-full { 834 | border-radius: 9999px; 835 | } 836 | .rounded-lg { 837 | border-radius: 0.5rem; 838 | } 839 | .rounded-l-none { 840 | border-top-left-radius: 0px; 841 | border-bottom-left-radius: 0px; 842 | } 843 | .rounded-r-none { 844 | border-top-right-radius: 0px; 845 | border-bottom-right-radius: 0px; 846 | } 847 | .border { 848 | border-width: 1px; 849 | } 850 | .border-2 { 851 | border-width: 2px; 852 | } 853 | .border-\[--secondary\] { 854 | border-color: var(--secondary); 855 | } 856 | .border-gray-300 { 857 | --tw-border-opacity: 1; 858 | border-color: rgb(209 213 219 / var(--tw-border-opacity)); 859 | } 860 | .border-green-500 { 861 | --tw-border-opacity: 1; 862 | border-color: rgb(34 197 94 / var(--tw-border-opacity)); 863 | } 864 | .border-red-500 { 865 | --tw-border-opacity: 1; 866 | border-color: rgb(239 68 68 / var(--tw-border-opacity)); 867 | } 868 | .border-yellow-500 { 869 | --tw-border-opacity: 1; 870 | border-color: rgb(234 179 8 / var(--tw-border-opacity)); 871 | } 872 | .bg-\[--background-secondary\] { 873 | background-color: var(--background-secondary); 874 | } 875 | .bg-\[--background\] { 876 | background-color: var(--background); 877 | } 878 | .bg-\[--primary\] { 879 | background-color: var(--primary); 880 | } 881 | .bg-\[--secondary\] { 882 | background-color: var(--secondary); 883 | } 884 | .bg-gray-50 { 885 | --tw-bg-opacity: 1; 886 | background-color: rgb(249 250 251 / var(--tw-bg-opacity)); 887 | } 888 | .bg-gray-900 { 889 | --tw-bg-opacity: 1; 890 | background-color: rgb(17 24 39 / var(--tw-bg-opacity)); 891 | } 892 | .bg-green-100 { 893 | --tw-bg-opacity: 1; 894 | background-color: rgb(220 252 231 / var(--tw-bg-opacity)); 895 | } 896 | .bg-red-100 { 897 | --tw-bg-opacity: 1; 898 | background-color: rgb(254 226 226 / var(--tw-bg-opacity)); 899 | } 900 | .bg-red-900 { 901 | --tw-bg-opacity: 1; 902 | background-color: rgb(127 29 29 / var(--tw-bg-opacity)); 903 | } 904 | .bg-yellow-100 { 905 | --tw-bg-opacity: 1; 906 | background-color: rgb(254 249 195 / var(--tw-bg-opacity)); 907 | } 908 | .bg-opacity-50 { 909 | --tw-bg-opacity: 0.5; 910 | } 911 | .stroke-\[--accent\] { 912 | stroke: var(--accent); 913 | } 914 | .stroke-green-700 { 915 | stroke: #15803d; 916 | } 917 | .stroke-red-500 { 918 | stroke: #ef4444; 919 | } 920 | .p-1 { 921 | padding: 0.25rem; 922 | } 923 | .p-1\.5 { 924 | padding: 0.375rem; 925 | } 926 | .p-2 { 927 | padding: 0.5rem; 928 | } 929 | .p-2\.5 { 930 | padding: 0.625rem; 931 | } 932 | .p-3 { 933 | padding: 0.75rem; 934 | } 935 | .p-4 { 936 | padding: 1rem; 937 | } 938 | .p-6 { 939 | padding: 1.5rem; 940 | } 941 | .px-2 { 942 | padding-left: 0.5rem; 943 | padding-right: 0.5rem; 944 | } 945 | .px-4 { 946 | padding-left: 1rem; 947 | padding-right: 1rem; 948 | } 949 | .px-6 { 950 | padding-left: 1.5rem; 951 | padding-right: 1.5rem; 952 | } 953 | .px-\[10vw\] { 954 | padding-left: 10vw; 955 | padding-right: 10vw; 956 | } 957 | .px-\[2vw\] { 958 | padding-left: 2vw; 959 | padding-right: 2vw; 960 | } 961 | .px-\[3vw\] { 962 | padding-left: 3vw; 963 | padding-right: 3vw; 964 | } 965 | .py-2 { 966 | padding-top: 0.5rem; 967 | padding-bottom: 0.5rem; 968 | } 969 | .py-4 { 970 | padding-top: 1rem; 971 | padding-bottom: 1rem; 972 | } 973 | .py-6 { 974 | padding-top: 1.5rem; 975 | padding-bottom: 1.5rem; 976 | } 977 | .pb-5 { 978 | padding-bottom: 1.25rem; 979 | } 980 | .pt-10 { 981 | padding-top: 2.5rem; 982 | } 983 | .text-center { 984 | text-align: center; 985 | } 986 | .text-2xl { 987 | font-size: 1.5rem; 988 | line-height: 2rem; 989 | } 990 | .text-3xl { 991 | font-size: 1.875rem; 992 | line-height: 2.25rem; 993 | } 994 | .text-\[min\(10vw\2c 30px\)\] { 995 | font-size: min(10vw,30px); 996 | } 997 | .text-lg { 998 | font-size: 1.125rem; 999 | line-height: 1.75rem; 1000 | } 1001 | .text-sm { 1002 | font-size: 0.875rem; 1003 | line-height: 1.25rem; 1004 | } 1005 | .text-xl { 1006 | font-size: 1.25rem; 1007 | line-height: 1.75rem; 1008 | } 1009 | .text-xs { 1010 | font-size: 0.75rem; 1011 | line-height: 1rem; 1012 | } 1013 | .font-bold { 1014 | font-weight: 700; 1015 | } 1016 | .font-extrabold { 1017 | font-weight: 800; 1018 | } 1019 | .font-light { 1020 | font-weight: 300; 1021 | } 1022 | .font-medium { 1023 | font-weight: 500; 1024 | } 1025 | .font-normal { 1026 | font-weight: 400; 1027 | } 1028 | .font-semibold { 1029 | font-weight: 600; 1030 | } 1031 | .tracking-tight { 1032 | letter-spacing: -0.025em; 1033 | } 1034 | .text-\[--accent\] { 1035 | color: var(--accent); 1036 | } 1037 | .text-\[--text-dark\] { 1038 | color: var(--text-dark); 1039 | } 1040 | .text-\[--text-light\] { 1041 | color: var(--text-light); 1042 | } 1043 | .text-\[--text-minor\] { 1044 | color: var(--text-minor); 1045 | } 1046 | .text-\[--text-secondary\] { 1047 | color: var(--text-secondary); 1048 | } 1049 | .text-\[--text\] { 1050 | color: var(--text); 1051 | } 1052 | .text-green-500 { 1053 | --tw-text-opacity: 1; 1054 | color: rgb(34 197 94 / var(--tw-text-opacity)); 1055 | } 1056 | .text-red-500 { 1057 | --tw-text-opacity: 1; 1058 | color: rgb(239 68 68 / var(--tw-text-opacity)); 1059 | } 1060 | .text-yellow-500 { 1061 | --tw-text-opacity: 1; 1062 | color: rgb(234 179 8 / var(--tw-text-opacity)); 1063 | } 1064 | .placeholder-gray-600::-moz-placeholder { 1065 | --tw-placeholder-opacity: 1; 1066 | color: rgb(75 85 99 / var(--tw-placeholder-opacity)); 1067 | } 1068 | .placeholder-gray-600::placeholder { 1069 | --tw-placeholder-opacity: 1; 1070 | color: rgb(75 85 99 / var(--tw-placeholder-opacity)); 1071 | } 1072 | .opacity-0 { 1073 | opacity: 0; 1074 | } 1075 | .shadow { 1076 | --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); 1077 | --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); 1078 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1079 | } 1080 | .transition { 1081 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; 1082 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; 1083 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter; 1084 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1085 | transition-duration: 150ms; 1086 | } 1087 | .duration-200 { 1088 | transition-duration: 200ms; 1089 | } 1090 | .ease-in-out { 1091 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1092 | } 1093 | .before\:content-\[\'Show_All\'\]::before { 1094 | --tw-content: 'Show All'; 1095 | content: var(--tw-content); 1096 | } 1097 | .after\:absolute::after { 1098 | content: var(--tw-content); 1099 | position: absolute; 1100 | } 1101 | .after\:left-\[2px\]::after { 1102 | content: var(--tw-content); 1103 | left: 2px; 1104 | } 1105 | .after\:top-0::after { 1106 | content: var(--tw-content); 1107 | top: 0px; 1108 | } 1109 | .after\:top-0\.5::after { 1110 | content: var(--tw-content); 1111 | top: 0.125rem; 1112 | } 1113 | .after\:h-5::after { 1114 | content: var(--tw-content); 1115 | height: 1.25rem; 1116 | } 1117 | .after\:w-5::after { 1118 | content: var(--tw-content); 1119 | width: 1.25rem; 1120 | } 1121 | .after\:rounded-full::after { 1122 | content: var(--tw-content); 1123 | border-radius: 9999px; 1124 | } 1125 | .after\:border-white::after { 1126 | content: var(--tw-content); 1127 | --tw-border-opacity: 1; 1128 | border-color: rgb(255 255 255 / var(--tw-border-opacity)); 1129 | } 1130 | .after\:bg-\[--secondary\]::after { 1131 | content: var(--tw-content); 1132 | background-color: var(--secondary); 1133 | } 1134 | .after\:bg-white::after { 1135 | content: var(--tw-content); 1136 | --tw-bg-opacity: 1; 1137 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1138 | } 1139 | .after\:transition-all::after { 1140 | content: var(--tw-content); 1141 | transition-property: all; 1142 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1143 | transition-duration: 150ms; 1144 | } 1145 | .after\:content-\[\'\'\]::after { 1146 | --tw-content: ''; 1147 | content: var(--tw-content); 1148 | } 1149 | .checked\:opacity-100:checked { 1150 | opacity: 1; 1151 | } 1152 | .hover\:origin-top:hover { 1153 | transform-origin: top; 1154 | } 1155 | .hover\:translate-y-\[-3px\]:hover { 1156 | --tw-translate-y: -3px; 1157 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1158 | } 1159 | .hover\:cursor-default:hover { 1160 | cursor: default; 1161 | } 1162 | .hover\:cursor-pointer:hover { 1163 | cursor: pointer; 1164 | } 1165 | .hover\:cursor-text:hover { 1166 | cursor: text; 1167 | } 1168 | .hover\:bg-\[--secondary-dark\]:hover { 1169 | background-color: var(--secondary-dark); 1170 | } 1171 | .hover\:text-\[--accent\]:hover { 1172 | color: var(--accent); 1173 | } 1174 | .hover\:text-gray-900:hover { 1175 | --tw-text-opacity: 1; 1176 | color: rgb(17 24 39 / var(--tw-text-opacity)); 1177 | } 1178 | .focus\:outline-none:focus { 1179 | outline: 2px solid transparent; 1180 | outline-offset: 2px; 1181 | } 1182 | .focus\:ring-2:focus { 1183 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1184 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1185 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); 1186 | } 1187 | .focus\:ring-blue-300:focus { 1188 | --tw-ring-opacity: 1; 1189 | --tw-ring-color: rgb(147 197 253 / var(--tw-ring-opacity)); 1190 | } 1191 | .focus\:ring-gray-300:focus { 1192 | --tw-ring-opacity: 1; 1193 | --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); 1194 | } 1195 | .focus-visible\:outline-none:focus-visible { 1196 | outline: 2px solid transparent; 1197 | outline-offset: 2px; 1198 | } 1199 | .disabled\:cursor-not-allowed:disabled { 1200 | cursor: not-allowed; 1201 | } 1202 | .disabled\:text-gray-600:disabled { 1203 | --tw-text-opacity: 1; 1204 | color: rgb(75 85 99 / var(--tw-text-opacity)); 1205 | } 1206 | .group:hover .group-hover\:opacity-100 { 1207 | opacity: 1; 1208 | } 1209 | .peer:checked ~ .peer-checked\:border-\[--accent\] { 1210 | border-color: var(--accent); 1211 | } 1212 | .peer:checked ~ .peer-checked\:bg-\[--accent\] { 1213 | background-color: var(--accent); 1214 | } 1215 | .peer:checked ~ .peer-checked\:text-\[--text\] { 1216 | color: var(--text); 1217 | } 1218 | .peer:checked ~ .peer-checked\:before\:content-\[\'Show_Uncompleted\'\]::before { 1219 | --tw-content: 'Show Uncompleted'; 1220 | content: var(--tw-content); 1221 | } 1222 | .peer:checked ~ .peer-checked\:after\:translate-x-full::after { 1223 | content: var(--tw-content); 1224 | --tw-translate-x: 100%; 1225 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1226 | } 1227 | .peer:checked ~ .peer-checked\:after\:border-white::after { 1228 | content: var(--tw-content); 1229 | --tw-border-opacity: 1; 1230 | border-color: rgb(255 255 255 / var(--tw-border-opacity)); 1231 | } 1232 | .peer:checked ~ .peer-checked\:after\:bg-white::after { 1233 | content: var(--tw-content); 1234 | --tw-bg-opacity: 1; 1235 | background-color: rgb(255 255 255 / var(--tw-bg-opacity)); 1236 | } 1237 | @media (prefers-color-scheme: dark) { 1238 | 1239 | .dark\:border-gray-500 { 1240 | --tw-border-opacity: 1; 1241 | border-color: rgb(107 114 128 / var(--tw-border-opacity)); 1242 | } 1243 | 1244 | .dark\:bg-gray-600 { 1245 | --tw-bg-opacity: 1; 1246 | background-color: rgb(75 85 99 / var(--tw-bg-opacity)); 1247 | } 1248 | 1249 | .dark\:bg-green-800 { 1250 | --tw-bg-opacity: 1; 1251 | background-color: rgb(22 101 52 / var(--tw-bg-opacity)); 1252 | } 1253 | 1254 | .dark\:bg-red-800 { 1255 | --tw-bg-opacity: 1; 1256 | background-color: rgb(153 27 27 / var(--tw-bg-opacity)); 1257 | } 1258 | 1259 | .dark\:bg-yellow-700 { 1260 | --tw-bg-opacity: 1; 1261 | background-color: rgb(161 98 7 / var(--tw-bg-opacity)); 1262 | } 1263 | 1264 | .dark\:bg-opacity-80 { 1265 | --tw-bg-opacity: 0.8; 1266 | } 1267 | 1268 | .dark\:text-green-200 { 1269 | --tw-text-opacity: 1; 1270 | color: rgb(187 247 208 / var(--tw-text-opacity)); 1271 | } 1272 | 1273 | .dark\:text-red-200 { 1274 | --tw-text-opacity: 1; 1275 | color: rgb(254 202 202 / var(--tw-text-opacity)); 1276 | } 1277 | 1278 | .dark\:text-yellow-200 { 1279 | --tw-text-opacity: 1; 1280 | color: rgb(254 240 138 / var(--tw-text-opacity)); 1281 | } 1282 | 1283 | .dark\:ring-offset-gray-800 { 1284 | --tw-ring-offset-color: #1f2937; 1285 | } 1286 | 1287 | .dark\:hover\:text-white:hover { 1288 | --tw-text-opacity: 1; 1289 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1290 | } 1291 | 1292 | .dark\:focus\:ring-blue-600:focus { 1293 | --tw-ring-opacity: 1; 1294 | --tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity)); 1295 | } 1296 | 1297 | .dark\:focus\:ring-offset-gray-800:focus { 1298 | --tw-ring-offset-color: #1f2937; 1299 | } 1300 | } 1301 | @media not all and (min-width: 768px) { 1302 | 1303 | .max-md\:justify-center { 1304 | justify-content: center; 1305 | } 1306 | } 1307 | @media (min-width: 640px) { 1308 | 1309 | .sm\:right-\[30vw\] { 1310 | right: 30vw; 1311 | } 1312 | 1313 | .sm\:ml-10 { 1314 | margin-left: 2.5rem; 1315 | } 1316 | 1317 | .sm\:ml-3 { 1318 | margin-left: 0.75rem; 1319 | } 1320 | 1321 | .sm\:mt-0 { 1322 | margin-top: 0px; 1323 | } 1324 | 1325 | .sm\:w-\[40vw\] { 1326 | width: 40vw; 1327 | } 1328 | 1329 | .sm\:grid-cols-2 { 1330 | grid-template-columns: repeat(2, minmax(0, 1fr)); 1331 | } 1332 | 1333 | .sm\:flex-row { 1334 | flex-direction: row; 1335 | } 1336 | 1337 | .sm\:items-center { 1338 | align-items: center; 1339 | } 1340 | 1341 | .sm\:px-\[20vw\] { 1342 | padding-left: 20vw; 1343 | padding-right: 20vw; 1344 | } 1345 | } 1346 | @media (min-width: 768px) { 1347 | 1348 | .md\:inset-0 { 1349 | inset: 0px; 1350 | } 1351 | 1352 | .md\:order-1 { 1353 | order: 1; 1354 | } 1355 | 1356 | .md\:order-2 { 1357 | order: 2; 1358 | } 1359 | 1360 | .md\:order-3 { 1361 | order: 3; 1362 | } 1363 | 1364 | .md\:order-4 { 1365 | order: 4; 1366 | } 1367 | 1368 | .md\:ml-0 { 1369 | margin-left: 0px; 1370 | } 1371 | 1372 | .md\:mt-0 { 1373 | margin-top: 0px; 1374 | } 1375 | 1376 | .md\:flex { 1377 | display: flex; 1378 | } 1379 | 1380 | .md\:hidden { 1381 | display: none; 1382 | } 1383 | 1384 | .md\:w-1\/4 { 1385 | width: 25%; 1386 | } 1387 | 1388 | .md\:w-2\/4 { 1389 | width: 50%; 1390 | } 1391 | 1392 | .md\:w-auto { 1393 | width: auto; 1394 | } 1395 | 1396 | .md\:w-fit { 1397 | width: -moz-fit-content; 1398 | width: fit-content; 1399 | } 1400 | 1401 | .md\:grid-cols-3 { 1402 | grid-template-columns: repeat(3, minmax(0, 1fr)); 1403 | } 1404 | 1405 | .md\:flex-row { 1406 | flex-direction: row; 1407 | } 1408 | 1409 | .md\:space-x-4 > :not([hidden]) ~ :not([hidden]) { 1410 | --tw-space-x-reverse: 0; 1411 | margin-right: calc(1rem * var(--tw-space-x-reverse)); 1412 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); 1413 | } 1414 | 1415 | .md\:space-y-0 > :not([hidden]) ~ :not([hidden]) { 1416 | --tw-space-y-reverse: 0; 1417 | margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))); 1418 | margin-bottom: calc(0px * var(--tw-space-y-reverse)); 1419 | } 1420 | 1421 | .md\:p-4 { 1422 | padding: 1rem; 1423 | } 1424 | 1425 | .md\:px-\[30vw\] { 1426 | padding-left: 30vw; 1427 | padding-right: 30vw; 1428 | } 1429 | 1430 | .md\:text-2xl { 1431 | font-size: 1.5rem; 1432 | line-height: 2rem; 1433 | } 1434 | } 1435 | @media (min-width: 1024px) { 1436 | 1437 | .lg\:ml-2 { 1438 | margin-left: 0.5rem; 1439 | } 1440 | 1441 | .lg\:block { 1442 | display: block; 1443 | } 1444 | 1445 | .lg\:px-8 { 1446 | padding-left: 2rem; 1447 | padding-right: 2rem; 1448 | } 1449 | 1450 | .lg\:px-\[5vw\] { 1451 | padding-left: 5vw; 1452 | padding-right: 5vw; 1453 | } 1454 | } 1455 | @media (min-width: 1280px) { 1456 | 1457 | .xl\:grid-cols-4 { 1458 | grid-template-columns: repeat(4, minmax(0, 1fr)); 1459 | } 1460 | } 1461 | @media (min-width: 1536px) { 1462 | 1463 | .\32xl\:grid-cols-5 { 1464 | grid-template-columns: repeat(5, minmax(0, 1fr)); 1465 | } 1466 | } -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/static/favicon.ico -------------------------------------------------------------------------------- /static/js/alpine.min.js: -------------------------------------------------------------------------------- 1 | (()=>{var Xe=!1,Ze=!1,V=[],Qe=-1;function Kt(e){En(e)}function En(e){V.includes(e)||V.push(e),Sn()}function ye(e){let t=V.indexOf(e);t!==-1&&t>Qe&&V.splice(t,1)}function Sn(){!Ze&&!Xe&&(Xe=!0,queueMicrotask(An))}function An(){Xe=!1,Ze=!0;for(let e=0;ee.effect(t,{scheduler:r=>{tt?Kt(r):r()}}),et=e.raw}function rt(e){D=e}function Ht(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}var qt=[],Ut=[],Wt=[];function Gt(e){Wt.push(e)}function be(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Ut.push(t))}function Jt(e){qt.push(e)}function Yt(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function nt(e,t){!e._x_attributeCleanups||Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}var ot=new MutationObserver(it),st=!1;function se(){ot.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),st=!0}function at(){On(),ot.disconnect(),st=!1}var ae=[],ct=!1;function On(){ae=ae.concat(ot.takeRecords()),ae.length&&!ct&&(ct=!0,queueMicrotask(()=>{Tn(),ct=!1}))}function Tn(){it(ae),ae.length=0}function h(e){if(!st)return e();at();let t=e();return se(),t}var lt=!1,ve=[];function Xt(){lt=!0}function Zt(){lt=!1,it(ve),ve=[]}function it(e){if(lt){ve=ve.concat(e);return}let t=[],r=[],n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.push(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.push(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{nt(s,o)}),n.forEach((o,s)=>{qt.forEach(a=>a(s,o))});for(let o of r)if(!t.includes(o)&&(Ut.forEach(s=>s(o)),o._x_cleanups))for(;o._x_cleanups.length;)o._x_cleanups.pop()();t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.includes(o)||!o.isConnected||(delete o._x_ignoreSelf,delete o._x_ignore,Wt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function we(e){return F(L(e))}function N(e,t,r){return e._x_dataStack=[t,...L(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function L(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?L(e.host):e.parentNode?L(e.parentNode):[]}function F(e){let t=new Proxy({},{ownKeys:()=>Array.from(new Set(e.flatMap(r=>Object.keys(r)))),has:(r,n)=>e.some(i=>i.hasOwnProperty(n)),get:(r,n)=>(e.find(i=>{if(i.hasOwnProperty(n)){let o=Object.getOwnPropertyDescriptor(i,n);if(o.get&&o.get._x_alreadyBound||o.set&&o.set._x_alreadyBound)return!0;if((o.get||o.set)&&o.enumerable){let s=o.get,a=o.set,c=o;s=s&&s.bind(t),a=a&&a.bind(t),s&&(s._x_alreadyBound=!0),a&&(a._x_alreadyBound=!0),Object.defineProperty(i,n,{...c,get:s,set:a})}return!0}return!1})||{})[n],set:(r,n,i)=>{let o=e.find(s=>s.hasOwnProperty(n));return o?o[n]=i:e[e.length-1][n]=i,!0}});return t}function Ee(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Se(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>Cn(n,i),s=>ut(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function Cn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ut(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ut(e[t[0]],t.slice(1),r)}}var Qt={};function y(e,t){Qt[e]=t}function ce(e,t){return Object.entries(Qt).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=ft(t);return i={interceptor:Se,...s},be(t,a),i}}Object.defineProperty(e,`$${r}`,{get(){return n(t,o())},enumerable:!1})}),e}function er(e,t,r,...n){try{return r(...n)}catch(i){X(i,e,t)}}function X(e,t,r=void 0){Object.assign(e,{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} 2 | 3 | ${r?'Expression: "'+r+`" 4 | 5 | `:""}`,t),setTimeout(()=>{throw e},0)}var Ae=!0;function Oe(e){let t=Ae;Ae=!1;let r=e();return Ae=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return tr(...e)}var tr=dt;function rr(e){tr=e}function dt(e,t){let r={};ce(r,e);let n=[r,...L(e)],i=typeof t=="function"?Rn(n,t):Mn(n,t,e);return er.bind(null,e,t,i)}function Rn(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);Te(r,o)}}var pt={};function Nn(e,t){if(pt[e])return pt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e)||/^(let|const)\s/.test(e)?`(async()=>{ ${e} })()`:e,o=(()=>{try{return new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`)}catch(s){return X(s,t,e),Promise.resolve()}})();return pt[e]=o,o}function Mn(e,t,r){let n=Nn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>X(l,r,t));n.finished?(Te(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Te(i,l,a,s,r)}).catch(l=>X(l,r,t)).finally(()=>n.result=void 0)}}}function Te(e,t,r,n,i){if(Ae&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Te(e,s,r,n)).catch(s=>X(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var mt="x-";function O(e=""){return mt+e}function nr(e){mt=e}var ht={};function p(e,t){return ht[e]=t,{before(r){if(!ht[r]){console.warn("Cannot find directive `${directive}`. `${name}` will use the default order of execution");return}let n=H.indexOf(r);H.splice(n>=0?n:H.indexOf("DEFAULT"),0,e)}}}function le(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=_t(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(ir((o,s)=>n[o]=s)).filter(or).map(In(n,r)).sort(Dn).map(o=>Pn(e,o))}function _t(e){return Array.from(e).map(ir()).filter(t=>!or(t))}var gt=!1,ue=new Map,sr=Symbol();function ar(e){gt=!0;let t=Symbol();sr=t,ue.set(t,[]);let r=()=>{for(;ue.get(t).length;)ue.get(t).shift()();ue.delete(t)},n=()=>{gt=!1,r()};e(r),n()}function ft(e){let t=[],r=a=>t.push(a),[n,i]=Ht(e);return t.push(i),[{Alpine:j,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Pn(e,t){let r=()=>{},n=ht[t.type]||r,[i,o]=ft(e);Yt(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),gt?ue.get(sr).push(n):n())};return s.runCleanups=o,s}var Ce=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Re=e=>e;function ir(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=cr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var cr=[];function Z(e){cr.push(e)}function or({name:e}){return lr().test(e)}var lr=()=>new RegExp(`^${mt}([^:^.]+)\\b`);function In(e,t){return({name:r,value:n})=>{let i=r.match(lr()),o=r.match(/:([a-zA-Z0-9\-:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xt="DEFAULT",H=["ignore","ref","data","id","bind","init","for","model","modelable","transition","show","if",xt,"teleport"];function Dn(e,t){let r=H.indexOf(e.type)===-1?xt:e.type,n=H.indexOf(t.type)===-1?xt:t.type;return H.indexOf(r)-H.indexOf(n)}function q(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function T(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>T(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)T(n,t,!1),n=n.nextElementSibling}function S(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ur=!1;function dr(){ur&&S("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ur=!0,document.body||S("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` 13 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | {% block additional_head_links %}{% endblock additional_head_links %} 24 | {% block additional_css %}{% endblock additional_css %} 25 | 26 | 31 | 150 |
151 | {% block content %} 152 | {% endblock content %} 153 |
154 |
155 |
156 | 157 | Design/Development: 158 | oleksandrdev.com 159 | 160 | 161 | Github Repo: 162 | just-notes-htmx 163 | 164 | 165 | Copyright © 2023 166 | 167 |
168 |
169 | {% include "toasts.html" %} 170 | 171 | 174 | 175 | 176 | 224 | 225 | {% block additional_javascript %}{% endblock additional_javascript %} 226 | 227 | -------------------------------------------------------------------------------- /user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/user/__init__.py -------------------------------------------------------------------------------- /user/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import NoteUser 4 | 5 | 6 | class NoteUserAdmin(admin.ModelAdmin): 7 | list_display = ['email', 'first_name', 'last_name', 'email_confirmed'] 8 | 9 | 10 | # Register your models here. 11 | admin.site.register(NoteUser, NoteUserAdmin) 12 | -------------------------------------------------------------------------------- /user/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'user' 7 | -------------------------------------------------------------------------------- /user/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-07-10 10:23 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import user.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('auth', '0012_alter_user_first_name_max_length'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='NoteUser', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 24 | ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), 25 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 26 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 27 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 28 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 29 | ('email', models.EmailField(max_length=254, unique=True)), 30 | ('phone_number', models.CharField(blank=True, max_length=255, null=True)), 31 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), 32 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), 33 | ], 34 | managers=[ 35 | ('objects', user.models.CustomUserManager()), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /user/migrations/0002_noteuser_email_confirmed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-07-28 09:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('user', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='noteuser', 15 | name='email_confirmed', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /user/migrations/0003_noteuser_is_dummy.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2 on 2023-08-28 19:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('user', '0002_noteuser_email_confirmed'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='noteuser', 15 | name='is_dummy', 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olksndrdevhub/just-notes-htmx/598805beb63f550535b041e41744e3a04c8a1ac1/user/migrations/__init__.py -------------------------------------------------------------------------------- /user/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.base_user import BaseUserManager 3 | from django.contrib.auth.models import AbstractUser 4 | 5 | 6 | # Create your models here. 7 | class CustomUserManager(BaseUserManager): 8 | """ 9 | Custom User Model Manager where email ins e unique indetifier 10 | for authentication instead of the username 11 | """ 12 | use_in_migrations = True 13 | 14 | def _create_user(self, email, password, **extra_fields): 15 | if not email: 16 | raise ValueError("User require an email field") 17 | email = self.normalize_email(email) 18 | user = self.model(email=email, **extra_fields) 19 | user.set_password(password) 20 | user.save(using=self._db) 21 | return user 22 | 23 | def create_user(self, email, password=None, **extra_fields): 24 | extra_fields.setdefault("is_staff", False) 25 | extra_fields.setdefault("is_superuser", False) 26 | extra_fields.setdefault("is_active", False) 27 | return self._create_user(email, password, **extra_fields) 28 | 29 | def create_superuser(self, email, password, **extra_fields): 30 | extra_fields.setdefault("is_staff", True) 31 | extra_fields.setdefault("is_superuser", True) 32 | 33 | if extra_fields.get("is_staff") is not True: 34 | raise ValueError("Superuser must have is_staff=True") 35 | if extra_fields.get("is_superuser") is not True: 36 | raise ValueError("Superuser must have is_superuser=True") 37 | 38 | return self._create_user(email, password, **extra_fields) 39 | 40 | 41 | class NoteUser(AbstractUser): 42 | """ 43 | Custom User model 44 | """ 45 | username = None 46 | email = models.EmailField(unique=True) 47 | email_confirmed = models.BooleanField(default=False) 48 | phone_number = models.CharField(max_length=255, blank=True, null=True) 49 | 50 | # dummy user fields 51 | is_dummy = models.BooleanField(default=False) 52 | 53 | USERNAME_FIELD = "email" 54 | REQUIRED_FIELDS = [] 55 | 56 | objects = CustomUserManager() 57 | 58 | class Meta: 59 | pass 60 | 61 | def __str__(self): 62 | return f'{self.id}: Email: {self.email}' 63 | -------------------------------------------------------------------------------- /user/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %} 4 | Login | 5 | {% endblock title %} 6 | 7 | {% block content %} 8 |
9 |

Login

10 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | Don't have account? Register now! 25 |
26 | {% endblock content %} -------------------------------------------------------------------------------- /user/templates/profile.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %} 4 | Profile | 5 | {% endblock title %} 6 | 7 | {% block content %} 8 |
11 |

Your Profile

12 |
17 | 18 | 19 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 37 | 38 | 44 | 45 | 50 | 51 | Edit Profile 52 | 53 | 54 | 55 | 56 | 57 | 67 | 68 | Save 69 | 70 | 71 | 72 | 73 | 74 | 79 | 80 | Cancel 81 | 82 | 83 | 84 | 85 | 86 |
87 | 88 | 89 |

Or

90 |
91 | 92 | 97 | 98 | Change Password 99 | 100 | 101 | 102 | 103 | 104 |
111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 130 | 131 | Update Password 132 | 133 | 134 | 135 | 136 | 137 | 141 | 142 | Cancel 143 | 144 | 145 | 146 | 147 | 148 |
149 |
150 | {% endblock content %} 151 | 152 | {% block additional_javascript %} 153 | 183 | {% endblock additional_javascript %} -------------------------------------------------------------------------------- /user/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %} 4 | Register | 5 | {% endblock title %} 6 | 7 | {% block content %} 8 |
9 |

Registration

10 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | Already have account? Login now! 39 |
40 | {% endblock content %} -------------------------------------------------------------------------------- /user/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /user/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path('me/', views.profile_view, name='profile_view'), 8 | 9 | path('login/', views.login_view, name='login_view'), 10 | path('register/', views.register_view, name='register_view'), 11 | path("logout/", views.logout_view, name="logout_view"), 12 | 13 | path('hx-send-email-confirmation/', views.hx_send_email_confirmation, name='hx_send_email_confirmation'), 14 | ] 15 | -------------------------------------------------------------------------------- /user/views.py: -------------------------------------------------------------------------------- 1 | from django_htmx.http import HttpResponseClientRedirect, reswap, trigger_client_event 2 | 3 | from django.shortcuts import render, redirect 4 | from django.contrib import messages 5 | from django import forms 6 | from django.contrib.auth import authenticate, login, logout 7 | from django.http.response import HttpResponse 8 | from django.contrib.auth.password_validation import validate_password 9 | 10 | from .models import NoteUser 11 | 12 | 13 | # Create your views here. 14 | def profile_view(request): 15 | ''' 16 | Profile Page 17 | ''' 18 | context = {} 19 | template_name = 'profile.html' 20 | 21 | # update user profile 22 | data = request.POST 23 | user = request.user 24 | 25 | # update password if needed 26 | if 'currentPassword' in request.POST: 27 | # check if currentPassword is valid 28 | if user and user.check_password(request.POST.get('currentPassword')): 29 | # check if new password matched with confirmation 30 | if request.POST.get('password') != request.POST.get('password2'): 31 | # return an error if not matched 32 | messages.add_message(request, messages.ERROR, 33 | "Passwords didn't match!") 34 | response = render(request, template_name, context) 35 | response = reswap(response, 'none') 36 | response = trigger_client_event( 37 | response, 38 | "passwordValidation", 39 | {"result": "error", 40 | "fieldsIds": ['password', 'password2']}, 41 | after='swap') 42 | return response 43 | try: 44 | # try to validate a password 45 | validate_password(request.POST.get('password')) 46 | except forms.ValidationError as errors: 47 | # return errors if password not valid 48 | for error in errors: 49 | messages.add_message(request, messages.ERROR, f'Error: {error}') 50 | response = render(request, template_name, context) 51 | response = reswap(response, 'none') 52 | response = trigger_client_event( 53 | response, 54 | "passwordValidation", 55 | {"result": "error", 56 | "fieldsIds": ['password', 'password2']}, 57 | after='swap') 58 | return response 59 | # update password for user 60 | if user.is_dummy: 61 | messages.add_message(request, messages.WARNING, 62 | "You can't change password for dummy user!") 63 | else: 64 | user.set_password(request.POST.get('password')) 65 | # add success message 66 | messages.add_message(request, messages.SUCCESS, "Password changed!") 67 | else: 68 | # return error if currentPassword is not valid 69 | messages.add_message(request, messages.ERROR, 70 | "Invalid current password!") 71 | response = render(request, template_name, context) 72 | response = reswap(response, 'none') 73 | response = trigger_client_event( 74 | response, 75 | "passwordValidation", 76 | {"result": "error", 77 | "fieldsIds": ['currentPassword']}, 78 | after='swap' 79 | ) 80 | return response 81 | 82 | if 'email' in data: 83 | try: 84 | # check if user with provided email exist 85 | match_user = NoteUser.objects.get(email=data['email']) 86 | # if user exist and are not the same user -> error 87 | # else if users are the same - we didn't need to update email 88 | if match_user and match_user.id is not user.id: 89 | messages.add_message( 90 | request, 91 | messages.ERROR, 92 | f'Error: email {data["email"]} already used...' 93 | ) 94 | return render(request, template_name, context) 95 | # if muptiple users finded with provided email -> eror 96 | except NoteUser.MultipleObjectsReturned: 97 | messages.add_message( 98 | request, 99 | messages.ERROR, 100 | f'Error: email {data["email"]} already used...' 101 | ) 102 | return render(request, template_name, context) 103 | except NoteUser.DoesNotExist: 104 | if user.is_dummy: 105 | messages.add_message(request, messages.WARNING, 106 | "You can't change email for dummy user!") 107 | else: 108 | # email not used, can be saved for user 109 | user.email = data['email'] 110 | 111 | if 'first_name' in data: 112 | user.first_name = data['first_name'] 113 | if 'last_name' in data: 114 | user.last_name = data['last_name'] 115 | user.save() 116 | messages.add_message(request, messages.SUCCESS, 117 | 'Profile updated!') 118 | 119 | response = render(request, template_name, context) 120 | response = trigger_client_event( 121 | response, 122 | "passwordValidation", 123 | {"result": "success"}, 124 | after='swap' 125 | ) 126 | return response 127 | 128 | 129 | def logout_view(request): 130 | ''' 131 | Just Log Out view 132 | ''' 133 | logout(request) 134 | messages.add_message(request, messages.WARNING, 'You was log out!') 135 | return redirect('login_view') 136 | 137 | 138 | def login_view(request): 139 | ''' 140 | Log In Page 141 | ''' 142 | context = {} 143 | template_name = 'login.html' 144 | if request.method == 'POST': 145 | email = request.POST.get('email') 146 | password = request.POST.get('password') 147 | 148 | user = authenticate(email=email, password=password) 149 | if user is not None: 150 | login(request, user) 151 | messages.add_message(request, messages.SUCCESS, 152 | 'You successfully log in!') 153 | return HttpResponseClientRedirect('/') 154 | messages.add_message(request, messages.ERROR, 155 | 'Error! Wrong email or password...') 156 | return render(request, template_name, context) 157 | 158 | 159 | def register_view(request): 160 | ''' 161 | Register Page 162 | ''' 163 | context = {} 164 | tempalte_name = 'register.html' 165 | if request.method == 'POST': 166 | first_name = request.POST.get('first_name') 167 | last_name = request.POST.get('last_name') 168 | email = request.POST.get('email') 169 | password = request.POST.get('password') 170 | password2 = request.POST.get('password2') 171 | if NoteUser.objects.filter(email=email).exists(): 172 | messages.add_message( 173 | request, 174 | messages.ERROR, 175 | f"User with email {email} already exists!" 176 | ) 177 | elif password != password2: 178 | messages.add_message( 179 | request, 180 | messages.ERROR, 181 | "Your password didn't match!" 182 | ) 183 | else: 184 | user = NoteUser.objects.create_user( 185 | email=email, 186 | first_name=first_name, 187 | last_name=last_name, 188 | ) 189 | user.set_password(password) 190 | user.is_active = True 191 | user.save() 192 | messages.add_message( 193 | request, 194 | messages.SUCCESS, 195 | "Registration success! You can log in now!" 196 | ) 197 | return HttpResponseClientRedirect('/account/login/') 198 | return HttpResponse('Response') 199 | return render(request, tempalte_name, context) 200 | 201 | 202 | def hx_send_email_confirmation(request): 203 | if request.htmx: 204 | # TODO send an email 205 | print('send email confirmation') 206 | return HttpResponse('Success, check your email!') 207 | --------------------------------------------------------------------------------