commit c5b202a42f1f7f98be322af9a3b1ba6e85d798bb Author: Vasily Zubarev Date: Tue Jan 10 16:00:15 2023 +0100 initial commit... diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..28cf9b2 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +SECRET_KEY="" + +TELEGRAM_TOKEN="" +TELEGRAM_MAIN_CHAT_ID="" + +SENTRY_DSN="" + +PATREON_CLIENT_ID="" +PATREON_CLIENT_SECRET="" + +JWT_SECRET="" + +EMAIL_HOST="email-smtp.eu-central-1.amazonaws.com" +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cf5173 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +*.py[co] + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +sdist +develop-eggs +.installed.cfg + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox + +# Translations +*.mo + +# IDEA +.idea + +# Mac OS X +.DS_Store + +# Node +node_modules/ +.npm +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.pnp.* +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +webpack-stats.json + +# Python +__pycache__ +.mypy_cache +private_settings.py +local_settings.py + +vas3k_blog/.env +.env +db.sqlite3 +*.session +venv/ + +.history diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5d75ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM ubuntu:22.04 + +ENV PIP_NO_CACHE_DIR=true +ENV POETRY_VIRTUALENVS_CREATE=false +ENV PIP_DISABLE_PIP_VERSION_CHECK=true +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install --no-install-recommends -yq \ + build-essential \ + python3 \ + python3-dev \ + python3-pip \ + libpq-dev \ + make \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY . /app + +RUN pip3 install poetry +RUN poetry install --no-interaction --no-root + +CMD ["make", "docker-run-production"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..53fb172 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Set the default goal if no targets were specified on the command line +.DEFAULT_GOAL = run +# Makes shell non-interactive and exit on any error +.SHELLFLAGS = -ec + +PROJECT_NAME=vas3k_blog + +run-dev: ## Runs dev server locally + poetry run python manage.py runserver 0.0.0.0:8000 + +docker-run-dev: ## Runs dev server in docker + python3 ./utils/wait_for_postgres.py + python3 manage.py migrate + python3 manage.py runserver 0.0.0.0:8000 + +docker-run-production: ## Runs production server in docker + python3 manage.py migrate + gunicorn vas3k_blog.asgi:application -w 7 -k uvicorn.workers.UvicornWorker --bind=0.0.0.0:8022 --capture-output --log-level debug --access-logfile - --error-logfile - + +help: ## Display this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | sort \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[0;32m%-30s\033[0m %s\n", $$1, $$2}' + +migrate: ## Migrate database to the latest version + poetry run python3 manage.py migrate + +.PHONY: \ + docker-run-dev \ + docker-run-production \ + run-dev diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d9d173 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# My blog source code + +![](https://i.vas3k.ru/dxq.jpg) + +## ШТО? + +Да, это реальный код vas3k.ru, который сейчас крутится на продакшене. +Раз у гитхаба появились приватные репо, пусть полежит тут. +Теперь это основная ветка разработки. Когда я фикшу баги и пилю фичи — они будут появляться тут. + +## Как запускать? + +Чтобы всё красиво заработало, нужен дамп базы. Дать я его не могу потому что там имейлы, айпишники и другие приватные данные, которые я задолбаюсь вычищать чтобы выложить. + +Но раз уж вы здесь, вы наверняка не боитесь трудностей, потому готовы развернуть голый движок и видеть нихуя. Потому сейчас быстро расскажу как тут и чо. + +Сайт написан на Python 3.6+, Django, PostgreSQL 10+. Стиль кода такой, чтобы в нём разобрался одноглазый инвалид после комы. Именно так я вижу свой код когда месяца два к нему не подходил. + +Некоторые фрагменты тут написаны аж в 2013-м, потому что изначально всё делалось тупо и просто как батины сапоги. Чтобы на века! Тут даже докера с кубернетесом нет, что просто невидано по современным меркам. + +Предположим, вы установили питон и постгрес на свою систему. Дальше делаем так: + +``` +$ git clone git@github.com:vas3k/vas3k.ru.git +$ cd vas3k.ru +$ pip3 install -U -r requirements.txt # ставим все зависимости +$ python3 manage.py migrate # накатываем пустые таблички в базу +$ python3 manage.py runserver 8080 # запускаем сервер на localhost:8080 +``` + +Всё. Идем в браузер на localhost:8080 и наслаждаемся пустым сайтом. + +Скорее всего он выдаст какую-нибудь ошибку из-за нехватки постов на главной. Это нормально. Джанговские трейсбеки и `DEBUG = True` помогут вам разобраться что и как. \ No newline at end of file diff --git a/authn/__init__.py b/authn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authn/admin.py b/authn/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/authn/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/authn/apps.py b/authn/apps.py new file mode 100644 index 0000000..a5d2623 --- /dev/null +++ b/authn/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthnConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "authn" diff --git a/authn/club.py b/authn/club.py new file mode 100644 index 0000000..01589fc --- /dev/null +++ b/authn/club.py @@ -0,0 +1,18 @@ +import logging + +import requests + +log = logging.getLogger(__name__) + + +def parse_membership(user_slug, jwt_token): + try: + return requests.get( + url=f"https://vas3k.club/user/{user_slug}.json", + params={ + "jwt": jwt_token + } + ).json() + except Exception as ex: + log.exception(ex) + return None diff --git a/authn/exceptions.py b/authn/exceptions.py new file mode 100644 index 0000000..991f749 --- /dev/null +++ b/authn/exceptions.py @@ -0,0 +1,2 @@ +class PatreonException(Exception): + pass diff --git a/authn/migrations/__init__.py b/authn/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authn/models.py b/authn/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/authn/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/authn/patreon.py b/authn/patreon.py new file mode 100644 index 0000000..c0c1eb8 --- /dev/null +++ b/authn/patreon.py @@ -0,0 +1,110 @@ +import logging +from json import JSONDecodeError + +import requests +from django.conf import settings + +from authn.exceptions import PatreonException + +logger = logging.getLogger(__name__) + + +def fetch_auth_data(code, original_redirect_uri): + try: + response = requests.post( + url=settings.PATREON_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "code": code, + "grant_type": "authorization_code", + "client_id": settings.PATREON_CLIENT_ID, + "client_secret": settings.PATREON_CLIENT_SECRET, + "redirect_uri": original_redirect_uri, + }, + ) + except requests.exceptions.RequestException as ex: + if "invalid_grant" not in str(ex): + logger.exception(f"Patreon error on login: {ex}") + raise PatreonException(ex) + + if response.status_code >= 400: + logger.error(f"Patreon error on login {response.status_code}: {response.text}") + raise PatreonException(response.text) + + try: + return response.json() + except JSONDecodeError: + raise PatreonException("Patreon is down") + + +def refresh_auth_data(refresh_token): + try: + response = requests.post( + url=settings.PATREON_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "refresh_token": refresh_token, + "grant_type": "refresh_token", + "client_id": settings.PATREON_CLIENT_ID, + "client_secret": settings.PATREON_CLIENT_SECRET, + }, + ) + except requests.exceptions.RequestException as ex: + logger.exception(f"Patreon error on refreshing token: {ex}") + raise PatreonException(ex) + + if response.status_code >= 400: + logger.error( + f"Patreon error on refreshing token {response.status_code}: {response.text}" + ) + raise PatreonException(response.text) + + try: + return response.json() + except JSONDecodeError: + raise PatreonException("Patreon is down") + + +def fetch_user_data(access_token): + logger.info(f"Fetching user data with access token: {access_token}") + try: + response = requests.get( + url=settings.PATREON_USER_URL, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Bearer {access_token}", + }, + params={ + "include": "memberships", + "fields[user]": "full_name,email,image_url,about", + "fields[member]": "patron_status,last_charge_status,pledge_relationship_start,lifetime_support_cents", + }, + ) + except requests.exceptions.RequestException as ex: + logger.exception(f"Patreon error on fetching user data: {ex}") + raise PatreonException(ex) + + if response.status_code >= 400: # unauthorized etc + logger.warning( + f"Patreon error on fetching user data {response.status_code}: {response.text}" + ) + raise PatreonException(response.text) + + try: + return response.json() + except JSONDecodeError: + raise PatreonException("Patreon is down") + + +def parse_my_membership(user_data): + if not user_data or not user_data.get("data") or not user_data.get("included"): + return None + + for membership in user_data["included"]: + if ( + membership["attributes"]["patron_status"] == "active_patron" + and membership["attributes"]["last_charge_status"] == "Paid" + ): + return membership["attributes"] + + return None diff --git a/authn/tests.py b/authn/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/authn/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/authn/views.py b/authn/views.py new file mode 100644 index 0000000..a2ddb7d --- /dev/null +++ b/authn/views.py @@ -0,0 +1,172 @@ +import logging +from urllib.parse import urlencode, quote, urlparse + +import jwt +from django.conf import settings +from django.contrib.auth import logout, login +from django.db.models import Q +from django.shortcuts import render, redirect +from django.urls import reverse + +from authn import club, patreon +from authn.exceptions import PatreonException +from users.models import User + +log = logging.getLogger(__name__) + + +def log_in(request): + return render(request, "users/login.html") + + +def log_out(request): + logout(request) + return redirect("index") + + +def login_club(request): + if request.user.is_authenticated: + return redirect("profile") + + goto = request.GET.get("goto") + params = urlencode({ + "app_id": "vas3k_blog", + "redirect": f"https://{request.get_host()}/auth/club_callback/" + (f"?goto={quote(goto)}" if goto else "") + }) + return redirect(f"{settings.CLUB_AUTH_URL}?{params}") + + +def club_callback(request): + token = request.GET.get("jwt") + if not token: + return render(request, "error.html", { + "title": "Что-то пошло не так", + "message": "При авторизации потерялся токен. Попробуйте войти еще раз." + }) + + try: + payload = jwt.decode(token, settings.JWT_PUBLIC_KEY, algorithms=[settings.JWT_ALGORITHM]) + except (jwt.DecodeError, jwt.ExpiredSignatureError) as ex: + log.error(f"JWT token error: {ex}") + return render(request, "error.html", { + "title": "Что-то сломалось", + "message": "Неправильный ключ. Наверное, что-то сломалось. Либо ты ХАКИР!!11" + }) + + user_slug = payload["user_slug"] + club_profile = club.parse_membership(user_slug, token) + if not club_profile or not club_profile.get("user"): + return render(request, "error.html", { + "message": f"Член Клуба с именем {user_slug} не найден. " + "Попробуйте войти в свой " + "аккаунт и потом авторизоваться здесь снова." + }) + + if club_profile["user"]["payment_status"] != "active": + return render(request, "error.html", { + "message": "Ваша подписка истекла. " + "Продлите её здесь." + }) + + user = User.objects.filter(Q(email=payload["user_email"]) | Q(vas3k_club_slug=payload["user_slug"])).first() + if user: + user.vas3k_club_slug = payload["user_slug"] + user.email = payload["user_email"] + user.country = club_profile["user"]["country"] + user.city = club_profile["user"]["city"] + user.save() + else: + user = User.objects.create_user( + vas3k_club_slug=payload["user_slug"], + username=club_profile["user"]["full_name"][:20], + email=payload["user_email"], + ) + + login(request, user) + + goto = request.GET.get("goto") + if goto and urlparse(goto).netloc == request.get_host(): + redirect_to = goto + else: + redirect_to = reverse("profile") + + return redirect(redirect_to) + + +def login_patreon(request): + if request.user.is_authenticated: + return redirect("profile") + + state = {} + goto = request.GET.get("goto") + if goto: + state["goto"] = goto + + query_string = urlencode({ + "response_type": "code", + "client_id": settings.PATREON_CLIENT_ID, + "redirect_uri": f"https://{request.get_host()}/auth/patreon_callback/", + "scope": settings.PATREON_SCOPE, + "state": urlencode(state) if state else "" + }) + return redirect(f"{settings.PATREON_AUTH_URL}?{query_string}") + + +def patreon_callback(request): + code = request.GET.get("code") + if not code: + return render(request, "error.html", { + "message": "Что-то сломалось между нами и патреоном. Так бывает. Попробуйте залогиниться еще раз." + }) + + try: + auth_data = patreon.fetch_auth_data( + code=code, + original_redirect_uri=f"https://{request.get_host()}/auth/patreon_callback/" + ) + user_data = patreon.fetch_user_data(auth_data["access_token"]) + except PatreonException as ex: + if "invalid_grant" in str(ex): + return render(request, "error.html", { + "message": "Тут такое дело. Авторизация патреона — говно. " + "Она не сразу понимает, что вы стали моим патроном и отдаёт мне ошибку. " + "Саппорт молчит, так что единственный рабочий вариант — вернуться и авторизоваться еще раз. " + "Обычно тогда срабатывает." + }) + + return render(request, "error.html", { + "message": "Не получилось загрузить ваш профиль с серверов патреона. " + "Попробуйте еще раз, наверняка оно починится. " + f"Но если нет, то вот текст ошибки, с которым можно пожаловаться мне в личку: {ex}" + }) + + membership = patreon.parse_my_membership(user_data) + if not membership: + return render(request, "error.html", { + "message": "Надо быть активным патроном, чтобы комментировать на сайте.
" + "Станьте им здесь!" + }) + + user = User.objects\ + .filter(Q(email=user_data["data"]["attributes"]["email"]) | Q(patreon_id=user_data["data"]["id"]))\ + .first() + + if user: + user.patreon_id = user_data["data"]["id"] + user.save() + else: + user = User.objects.create_user( + patreon_id=user_data["data"]["id"], + username=str(user_data["data"]["attributes"]["full_name"])[:20], + email=user_data["data"]["attributes"]["email"], + ) + + login(request, user) + + goto = request.GET.get("goto") + if goto and urlparse(goto).netloc == request.get_host(): + redirect_to = goto + else: + redirect_to = reverse("profile") + + return redirect(redirect_to) diff --git a/clickers/__init__.py b/clickers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clickers/admin.py b/clickers/admin.py new file mode 100644 index 0000000..d41476a --- /dev/null +++ b/clickers/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from clickers.models import Clicker + +admin.site.register(Clicker) diff --git a/clickers/apps.py b/clickers/apps.py new file mode 100644 index 0000000..ad74b9c --- /dev/null +++ b/clickers/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ClickersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "clickers" diff --git a/clickers/management/__init__.py b/clickers/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clickers/management/commands/__init__.py b/clickers/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clickers/management/commands/migrate_old_clickers.py b/clickers/management/commands/migrate_old_clickers.py new file mode 100644 index 0000000..16acb2a --- /dev/null +++ b/clickers/management/commands/migrate_old_clickers.py @@ -0,0 +1,50 @@ +import logging + +from django.core.management import BaseCommand +from django.db import connections, IntegrityError + +from clickers.models import Clicker +from posts.models import Post + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Migrate clickers from old database to the new one" + + def handle(self, *args, **options): + with connections["old"].cursor() as cursor: + cursor.execute( + "select *, (select type from stories where clickers.story_id = stories.id) as post_type, " + "(select slug from stories where clickers.story_id = stories.id) as post_slug from clickers" + ) + for row in dictfetchall(cursor): + post = Post.objects.filter(slug=row["post_slug"], type=row["post_type"]).first() + if not post: + continue + + try: + clicker, _ = Clicker.objects.update_or_create( + post_id=post.id, + comment_id=row["comment_id"], + block=row["block"], + ipaddress=row["ip"], + defaults=dict( + created_at=row["created_at"], + useragent=row["useragent"], + ) + ) + except IntegrityError: + continue + + self.stdout.write(f"Clicker {clicker.id} updated...") + + self.stdout.write("Done 🥙") + + +def dictfetchall(cursor): + columns = [col[0] for col in cursor.description] + return [ + dict(zip(columns, row)) + for row in cursor.fetchall() + ] diff --git a/clickers/migrations/0001_initial.py b/clickers/migrations/0001_initial.py new file mode 100644 index 0000000..452398a --- /dev/null +++ b/clickers/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 4.1.4 on 2023-01-06 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Clicker", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("block", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("ipaddress", models.GenericIPAddressField(db_index=True)), + ("useragent", models.CharField(max_length=256)), + ], + options={ + "db_table": "clickers", + }, + ), + ] diff --git a/clickers/migrations/0002_initial.py b/clickers/migrations/0002_initial.py new file mode 100644 index 0000000..aa9aa9f --- /dev/null +++ b/clickers/migrations/0002_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 4.1.4 on 2023-01-06 17:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("comments", "0001_initial"), + ("clickers", "0001_initial"), + ("posts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="clicker", + name="comment", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="clickers", + to="comments.comment", + ), + ), + migrations.AddField( + model_name="clicker", + name="post", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="clickers", + to="posts.post", + ), + ), + ] diff --git a/clickers/migrations/__init__.py b/clickers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clickers/models.py b/clickers/models.py new file mode 100644 index 0000000..d869ed1 --- /dev/null +++ b/clickers/models.py @@ -0,0 +1,13 @@ +from django.db import models + + +class Clicker(models.Model): + post = models.ForeignKey("posts.Post", related_name="clickers", on_delete=models.CASCADE) + comment = models.ForeignKey("comments.Comment", related_name="clickers", null=True, on_delete=models.CASCADE) + block = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + ipaddress = models.GenericIPAddressField(db_index=True) + useragent = models.CharField(max_length=256) + + class Meta: + db_table = "clickers" diff --git a/clickers/views.py b/clickers/views.py new file mode 100644 index 0000000..e59cbb9 --- /dev/null +++ b/clickers/views.py @@ -0,0 +1,55 @@ +from django.db.models import F +from django.http import Http404, JsonResponse, HttpResponseNotAllowed, HttpResponse +from django.shortcuts import get_object_or_404 + +from clickers.models import Clicker +from comments.models import Comment +from posts.models import Post +from utils.request import parse_ip_address + + +def click_comment(request, comment_id): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + comment = get_object_or_404(Comment, id=comment_id) + + clicker, is_created = Clicker.objects.get_or_create( + post_id=comment.post_id, + block=comment.id, + ipaddress=parse_ip_address(request), + defaults=dict( + post_id=comment.post_id, + comment_id=comment.id, + block=comment.id, + useragent=request.META.get("HTTP_USER_AGENT", ""), + ) + ) + + if is_created: + Comment.objects.filter(id=comment.id).update(upvotes=F("upvotes") + 1) + return HttpResponse(comment.upvotes + 1) + + return HttpResponse(comment.upvotes) + + +def click_block(request, post_slug, block): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + post = get_object_or_404(Post, slug=post_slug) + + clicker, is_created = Clicker.objects.get_or_create( + post=post, + block=block, + ipaddress=parse_ip_address(request), + defaults=dict( + useragent=request.META.get("HTTP_USER_AGENT", ""), + ) + ) + + if is_created: + total_clicks = Clicker.objects.filter(post=post, block=block).count() + return HttpResponse(total_clicks) + + return HttpResponse("x") diff --git a/comments/__init__.py b/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/admin.py b/comments/admin.py new file mode 100644 index 0000000..e3862b2 --- /dev/null +++ b/comments/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from comments.models import Comment + + +class CommentAdmin(admin.ModelAdmin): + list_display = ( + "author_name", "author", "created_at", "ipaddress", "text", "upvotes" + ) + + +admin.site.register(Comment, CommentAdmin) diff --git a/comments/apps.py b/comments/apps.py new file mode 100644 index 0000000..6aa3483 --- /dev/null +++ b/comments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "comments" diff --git a/comments/forms.py b/comments/forms.py new file mode 100644 index 0000000..44ac7c2 --- /dev/null +++ b/comments/forms.py @@ -0,0 +1,35 @@ +from django import forms +from django.forms import ModelForm + +from comments.models import Comment + + +class CommentForm(ModelForm): + post_slug = forms.CharField( + label="ID поста", + required=True, + max_length=54, + widget=forms.HiddenInput + ) + + block = forms.CharField( + label="ID блока", + max_length=54, + widget=forms.HiddenInput, + required=False, + ) + + text = forms.CharField( + label="Текст", + min_length=3, + max_length=10000, + required=True, + ) + + class Meta: + model = Comment + fields = [ + "block", + "text", + ] + diff --git a/comments/management/__init__.py b/comments/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/management/commands/__init__.py b/comments/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/management/commands/migrate_old_comments.py b/comments/management/commands/migrate_old_comments.py new file mode 100644 index 0000000..ccc6bd5 --- /dev/null +++ b/comments/management/commands/migrate_old_comments.py @@ -0,0 +1,73 @@ +import logging + +from django.core.management import BaseCommand +from django.db import connections + +from comments.models import Comment +from posts.models import Post +from users.models import User + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Migrate comments from old database to the new one" + + def handle(self, *args, **options): + with connections["old"].cursor() as cursor: + cursor.execute( + "select *, (select type from stories where comments.story_id = stories.id) as post_type, " + "(select slug from stories where comments.story_id = stories.id) as post_slug from comments" + ) + for row in dictfetchall(cursor): + post = Post.objects.filter(slug=row["post_slug"], type=row["post_type"]).first() + if not post: + continue + + user = None + if row["user_id"]: + cursor.execute("select * from users where id = %s", [row["user_id"]]) + old_user = dictfetchall(cursor) + if old_user: + old_user = old_user.pop() + old_user_email = old_user["email"] or old_user["name"] + "@legacy.vas3k.ru" + user, _ = User.objects.get_or_create( + email=old_user_email, + defaults=dict( + username=old_user["name"], + patreon_id=old_user["platform_id"] if old_user["platform_id"].isnumeric() else None, + vas3k_club_slug=old_user["platform_id"] if not old_user["platform_id"].isnumeric() else None, + avatar=old_user["avatar"], + ) + ) + + comment, _ = Comment.objects.update_or_create( + id=row["id"], + defaults=dict( + author_id=user.id if user else None, + author_name=row["author"], + post_id=post.id, + text=row["text"], + block=row["block"], + metadata=row["data"], + ipaddress=row["ip"], + useragent=row["useragent"], + created_at=row["created_at"], + updated_at=row["created_at"], + upvotes=row["rating"], + is_visible=row["is_visible"], + is_deleted=False, + is_pinned=False, + ) + ) + self.stdout.write(f"Comment {comment.id} updated...") + + self.stdout.write("Done 🥙") + + +def dictfetchall(cursor): + columns = [col[0] for col in cursor.description] + return [ + dict(zip(columns, row)) + for row in cursor.fetchall() + ] diff --git a/comments/migrations/0001_initial.py b/comments/migrations/0001_initial.py new file mode 100644 index 0000000..16288fd --- /dev/null +++ b/comments/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 4.1.4 on 2023-01-06 17:54 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "author_name", + models.CharField(blank=True, max_length=128, null=True), + ), + ("block", models.CharField(blank=True, max_length=128, null=True)), + ("text", models.TextField()), + ("html_cache", models.TextField(null=True)), + ("metadata", models.JSONField(null=True)), + ("ipaddress", models.GenericIPAddressField(null=True)), + ("useragent", models.CharField(max_length=512, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("upvotes", models.IntegerField(db_index=True, default=0)), + ("is_visible", models.BooleanField(default=True)), + ("is_deleted", models.BooleanField(default=False)), + ("is_pinned", models.BooleanField(default=False)), + ("deleted_by", models.UUIDField(null=True)), + ], + options={ + "db_table": "comments", + "ordering": ["created_at"], + }, + ), + ] diff --git a/comments/migrations/0002_initial.py b/comments/migrations/0002_initial.py new file mode 100644 index 0000000..7a031af --- /dev/null +++ b/comments/migrations/0002_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.1.4 on 2023-01-06 17:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("comments", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("posts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="author", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="comment", + name="post", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="posts.post", + ), + ), + migrations.AddField( + model_name="comment", + name="reply_to", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="replies", + to="comments.comment", + ), + ), + ] diff --git a/comments/migrations/0003_alter_comment_options_alter_comment_created_at_and_more.py b/comments/migrations/0003_alter_comment_options_alter_comment_created_at_and_more.py new file mode 100644 index 0000000..f619751 --- /dev/null +++ b/comments/migrations/0003_alter_comment_options_alter_comment_created_at_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.1.4 on 2023-01-08 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("comments", "0002_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="comment", + options={"ordering": ("-created_at",)}, + ), + migrations.AlterField( + model_name="comment", + name="created_at", + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name="comment", + name="updated_at", + field=models.DateTimeField(), + ), + ] diff --git a/comments/migrations/__init__.py b/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/models.py b/comments/models.py new file mode 100644 index 0000000..7bf63b2 --- /dev/null +++ b/comments/models.py @@ -0,0 +1,92 @@ +import random +from datetime import datetime, timedelta +from uuid import uuid4 + +from django.contrib.humanize.templatetags.humanize import naturaltime +from django.template.defaultfilters import date as django_date +from django.db import models +from django.db.models import F + +from posts.models import Post +from users.avatars import AVATARS +from users.models import User +from vas3k_blog.exceptions import BadRequest + + +class Comment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + + author = models.ForeignKey(User, related_name="comments", null=True, on_delete=models.SET_NULL) + author_name = models.CharField(max_length=128, null=True, blank=True) + post = models.ForeignKey(Post, related_name="comments", on_delete=models.CASCADE) + + reply_to = models.ForeignKey("self", related_name="replies", null=True, on_delete=models.CASCADE) + block = models.CharField(max_length=128, null=True, blank=True) + + text = models.TextField(null=False) + html_cache = models.TextField(null=True) + + metadata = models.JSONField(null=True) + + ipaddress = models.GenericIPAddressField(null=True) + useragent = models.CharField(max_length=512, null=True) + + created_at = models.DateTimeField() + updated_at = models.DateTimeField() + + upvotes = models.IntegerField(default=0, db_index=True) + + is_visible = models.BooleanField(default=True) + is_deleted = models.BooleanField(default=False) + is_pinned = models.BooleanField(default=False) + + deleted_by = models.UUIDField(null=True) + + class Meta: + db_table = "comments" + ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self.reply_to and self.reply_to.reply_to and self.reply_to.reply_to.reply_to_id: + raise BadRequest(message="3 уровня комментариев это максимум") + + self.updated_at = datetime.utcnow() + return super().save(*args, **kwargs) + + def delete(self, deleted_by=None, *args, **kwargs): + self.is_deleted = True + self.save() + + def undelete(self, *args, **kwargs): + self.is_deleted = False + self.save() + + def increment_vote_count(self): + return Comment.objects.filter(id=self.id).update(upvotes=F("upvotes") + 1) + + def decrement_vote_count(self): + return Comment.objects.filter(id=self.id).update(upvotes=F("upvotes") - 1) + + def is_deletable_by(self, user): + return user == self.author or user.is_admin() + + @classmethod + def visible_objects(cls, show_deleted=False): + comments = cls.objects\ + .filter(is_visible=True)\ + .select_related("author", "post", "reply_to") + + if not show_deleted: + comments = comments.filter(is_deleted=False) + + return comments + + def natural_created_at(self): + if self.created_at > datetime.utcnow() - timedelta(days=7): + return naturaltime(self.created_at) + return django_date(self.created_at, "d E Y в H:i") + + def get_avatar(self): + if self.author: + return self.author.get_avatar() + return random.choice(AVATARS) diff --git a/comments/templatetags/__init__.py b/comments/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comments/templatetags/comments.py b/comments/templatetags/comments.py new file mode 100755 index 0000000..12bd3c7 --- /dev/null +++ b/comments/templatetags/comments.py @@ -0,0 +1,25 @@ +from django import template +from django.utils.safestring import mark_safe + +from common.markdown.markdown import markdown_comment + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def show_comment(context, comment): + return mark_safe(markdown_comment(comment.text)) + + +@register.simple_tag(takes_context=True) +def mark_if_voted(context, comment, css_class, other_css_class="", ipaddress=None): + user_votes = context.get("user_votes") + if user_votes: + return css_class if str(comment.id) in user_votes else other_css_class + else: + return other_css_class + + +@register.filter(is_safe=True) +def without_inline_comments(comments): + return [c for c in comments if not c.block] diff --git a/comments/views.py b/comments/views.py new file mode 100644 index 0000000..9258c32 --- /dev/null +++ b/comments/views.py @@ -0,0 +1,74 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.http import HttpResponseForbidden, HttpResponseNotAllowed, HttpResponse, HttpResponseBadRequest +from django.shortcuts import get_object_or_404, render + +from comments.forms import CommentForm +from comments.models import Comment +from posts.models import Post +from utils.request import parse_ip_address + + +def create_comment(request): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + form = CommentForm(request.POST) + if not form.is_valid(): + return HttpResponseBadRequest("post_slug is required") + + post = get_object_or_404(Post, slug=form.cleaned_data.get("post_slug")) + if not post.is_commentable: + return render( + request, "error.html", { + "message": "Нельзя комментировать этот пост" + } + ) + + ipaddress = parse_ip_address(request) + same_ip_comments_24h = Comment.objects.filter( + ipaddress=ipaddress, + created_at__gte=datetime.utcnow() - timedelta(hours=24) + ).count() + if same_ip_comments_24h >= settings.MAX_COMMENTS_PER_24H: + return HttpResponseBadRequest( + "Вы оставили слишком много комментариев, остановитесь" + ) + + comment = Comment.objects.create( + author=request.user, + author_name=request.user.username, + post=post, + block=form.cleaned_data.get("block"), + text=form.cleaned_data.get("text"), + created_at=datetime.utcnow(), + ) + + Post.objects.filter(id=post.id).update( + comment_count=Comment.objects.filter(post=post, is_visible=True, is_deleted=False).count() + ) + + if comment.block: + response_template = "comments/partials/create-inline-comment-response.html" + else: + response_template = "comments/partials/create-comment-response.html" + + return render(request, response_template, { + "post": post, + "comment": comment, + "block": comment.block, + }) + + +def delete_comment(request, comment_id): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + if not request.user.is_superuser: + return HttpResponseForbidden() + + comment = get_object_or_404(Comment, id=comment_id) + comment.delete() + + return HttpResponse("☠️ Комментарий удален") diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/emoji.py b/common/emoji.py new file mode 100644 index 0000000..540e1ea --- /dev/null +++ b/common/emoji.py @@ -0,0 +1,1764 @@ +# Original: https://github.com/jonathan-kosgei/emoji-flags + +EMOJI_FLAGS = [ + { + "code": "AD", + "emoji": "🇦🇩", + "unicode": "U+1F1E6 U+1F1E9", + "name": "Andorra", + "title": "flag for Andorra" + }, + { + "code": "AE", + "emoji": "🇦🇪", + "unicode": "U+1F1E6 U+1F1EA", + "name": "United Arab Emirates", + "title": "flag for United Arab Emirates" + }, + { + "code": "AF", + "emoji": "🇦🇫", + "unicode": "U+1F1E6 U+1F1EB", + "name": "Afghanistan", + "title": "flag for Afghanistan" + }, + { + "code": "AG", + "emoji": "🇦🇬", + "unicode": "U+1F1E6 U+1F1EC", + "name": "Antigua and Barbuda", + "title": "flag for Antigua and Barbuda" + }, + { + "code": "AI", + "emoji": "🇦🇮", + "unicode": "U+1F1E6 U+1F1EE", + "name": "Anguilla", + "title": "flag for Anguilla" + }, + { + "code": "AL", + "emoji": "🇦🇱", + "unicode": "U+1F1E6 U+1F1F1", + "name": "Albania", + "title": "flag for Albania" + }, + { + "code": "AM", + "emoji": "🇦🇲", + "unicode": "U+1F1E6 U+1F1F2", + "name": "Armenia", + "title": "flag for Armenia" + }, + { + "code": "AO", + "emoji": "🇦🇴", + "unicode": "U+1F1E6 U+1F1F4", + "name": "Angola", + "title": "flag for Angola" + }, + { + "code": "AQ", + "emoji": "🇦🇶", + "unicode": "U+1F1E6 U+1F1F6", + "name": "Antarctica", + "title": "flag for Antarctica" + }, + { + "code": "AR", + "emoji": "🇦🇷", + "unicode": "U+1F1E6 U+1F1F7", + "name": "Argentina", + "title": "flag for Argentina" + }, + { + "code": "AS", + "emoji": "🇦🇸", + "unicode": "U+1F1E6 U+1F1F8", + "name": "American Samoa", + "title": "flag for American Samoa" + }, + { + "code": "AT", + "emoji": "🇦🇹", + "unicode": "U+1F1E6 U+1F1F9", + "name": "Austria", + "title": "flag for Austria" + }, + { + "code": "AU", + "emoji": "🇦🇺", + "unicode": "U+1F1E6 U+1F1FA", + "name": "Australia", + "title": "flag for Australia" + }, + { + "code": "AW", + "emoji": "🇦🇼", + "unicode": "U+1F1E6 U+1F1FC", + "name": "Aruba", + "title": "flag for Aruba" + }, + { + "code": "AX", + "emoji": "🇦🇽", + "unicode": "U+1F1E6 U+1F1FD", + "name": "Åland Islands", + "title": "flag for Åland Islands" + }, + { + "code": "AZ", + "emoji": "🇦🇿", + "unicode": "U+1F1E6 U+1F1FF", + "name": "Azerbaijan", + "title": "flag for Azerbaijan" + }, + { + "code": "BA", + "emoji": "🇧🇦", + "unicode": "U+1F1E7 U+1F1E6", + "name": "Bosnia and Herzegovina", + "title": "flag for Bosnia and Herzegovina" + }, + { + "code": "BB", + "emoji": "🇧🇧", + "unicode": "U+1F1E7 U+1F1E7", + "name": "Barbados", + "title": "flag for Barbados" + }, + { + "code": "BD", + "emoji": "🇧🇩", + "unicode": "U+1F1E7 U+1F1E9", + "name": "Bangladesh", + "title": "flag for Bangladesh" + }, + { + "code": "BE", + "emoji": "🇧🇪", + "unicode": "U+1F1E7 U+1F1EA", + "name": "Belgium", + "title": "flag for Belgium" + }, + { + "code": "BF", + "emoji": "🇧🇫", + "unicode": "U+1F1E7 U+1F1EB", + "name": "Burkina Faso", + "title": "flag for Burkina Faso" + }, + { + "code": "BG", + "emoji": "🇧🇬", + "unicode": "U+1F1E7 U+1F1EC", + "name": "Bulgaria", + "title": "flag for Bulgaria" + }, + { + "code": "BH", + "emoji": "🇧🇭", + "unicode": "U+1F1E7 U+1F1ED", + "name": "Bahrain", + "title": "flag for Bahrain" + }, + { + "code": "BI", + "emoji": "🇧🇮", + "unicode": "U+1F1E7 U+1F1EE", + "name": "Burundi", + "title": "flag for Burundi" + }, + { + "code": "BJ", + "emoji": "🇧🇯", + "unicode": "U+1F1E7 U+1F1EF", + "name": "Benin", + "title": "flag for Benin" + }, + { + "code": "BL", + "emoji": "🇧🇱", + "unicode": "U+1F1E7 U+1F1F1", + "name": "Saint Barthélemy", + "title": "flag for Saint Barthélemy" + }, + { + "code": "BM", + "emoji": "🇧🇲", + "unicode": "U+1F1E7 U+1F1F2", + "name": "Bermuda", + "title": "flag for Bermuda" + }, + { + "code": "BN", + "emoji": "🇧🇳", + "unicode": "U+1F1E7 U+1F1F3", + "name": "Brunei Darussalam", + "title": "flag for Brunei Darussalam" + }, + { + "code": "BO", + "emoji": "🇧🇴", + "unicode": "U+1F1E7 U+1F1F4", + "name": "Bolivia", + "title": "flag for Bolivia" + }, + { + "code": "BQ", + "emoji": "🇧🇶", + "unicode": "U+1F1E7 U+1F1F6", + "name": "Bonaire, Sint Eustatius and Saba", + "title": "flag for Bonaire, Sint Eustatius and Saba" + }, + { + "code": "BR", + "emoji": "🇧🇷", + "unicode": "U+1F1E7 U+1F1F7", + "name": "Brazil", + "title": "flag for Brazil" + }, + { + "code": "BS", + "emoji": "🇧🇸", + "unicode": "U+1F1E7 U+1F1F8", + "name": "Bahamas", + "title": "flag for Bahamas" + }, + { + "code": "BT", + "emoji": "🇧🇹", + "unicode": "U+1F1E7 U+1F1F9", + "name": "Bhutan", + "title": "flag for Bhutan" + }, + { + "code": "BV", + "emoji": "🇧🇻", + "unicode": "U+1F1E7 U+1F1FB", + "name": "Bouvet Island", + "title": "flag for Bouvet Island" + }, + { + "code": "BW", + "emoji": "🇧🇼", + "unicode": "U+1F1E7 U+1F1FC", + "name": "Botswana", + "title": "flag for Botswana" + }, + { + "code": "BY", + "emoji": "🇧🇾", + "unicode": "U+1F1E7 U+1F1FE", + "name": "Belarus", + "title": "flag for Belarus" + }, + { + "code": "BZ", + "emoji": "🇧🇿", + "unicode": "U+1F1E7 U+1F1FF", + "name": "Belize", + "title": "flag for Belize" + }, + { + "code": "CA", + "emoji": "🇨🇦", + "unicode": "U+1F1E8 U+1F1E6", + "name": "Canada", + "title": "flag for Canada" + }, + { + "code": "CC", + "emoji": "🇨🇨", + "unicode": "U+1F1E8 U+1F1E8", + "name": "Cocos (Keeling) Islands", + "title": "flag for Cocos (Keeling) Islands" + }, + { + "code": "CD", + "emoji": "🇨🇩", + "unicode": "U+1F1E8 U+1F1E9", + "name": "Congo", + "title": "flag for Congo" + }, + { + "code": "CF", + "emoji": "🇨🇫", + "unicode": "U+1F1E8 U+1F1EB", + "name": "Central African Republic", + "title": "flag for Central African Republic" + }, + { + "code": "CG", + "emoji": "🇨🇬", + "unicode": "U+1F1E8 U+1F1EC", + "name": "Congo", + "title": "flag for Congo" + }, + { + "code": "CH", + "emoji": "🇨🇭", + "unicode": "U+1F1E8 U+1F1ED", + "name": "Switzerland", + "title": "flag for Switzerland" + }, + { + "code": "CI", + "emoji": "🇨🇮", + "unicode": "U+1F1E8 U+1F1EE", + "name": "Côte D'Ivoire", + "title": "flag for Côte D'Ivoire" + }, + { + "code": "CK", + "emoji": "🇨🇰", + "unicode": "U+1F1E8 U+1F1F0", + "name": "Cook Islands", + "title": "flag for Cook Islands" + }, + { + "code": "CL", + "emoji": "🇨🇱", + "unicode": "U+1F1E8 U+1F1F1", + "name": "Chile", + "title": "flag for Chile" + }, + { + "code": "CM", + "emoji": "🇨🇲", + "unicode": "U+1F1E8 U+1F1F2", + "name": "Cameroon", + "title": "flag for Cameroon" + }, + { + "code": "CN", + "emoji": "🇨🇳", + "unicode": "U+1F1E8 U+1F1F3", + "name": "China", + "title": "flag for China" + }, + { + "code": "CO", + "emoji": "🇨🇴", + "unicode": "U+1F1E8 U+1F1F4", + "name": "Colombia", + "title": "flag for Colombia" + }, + { + "code": "CR", + "emoji": "🇨🇷", + "unicode": "U+1F1E8 U+1F1F7", + "name": "Costa Rica", + "title": "flag for Costa Rica" + }, + { + "code": "CU", + "emoji": "🇨🇺", + "unicode": "U+1F1E8 U+1F1FA", + "name": "Cuba", + "title": "flag for Cuba" + }, + { + "code": "CV", + "emoji": "🇨🇻", + "unicode": "U+1F1E8 U+1F1FB", + "name": "Cape Verde", + "title": "flag for Cape Verde" + }, + { + "code": "CW", + "emoji": "🇨🇼", + "unicode": "U+1F1E8 U+1F1FC", + "name": "Curaçao", + "title": "flag for Curaçao" + }, + { + "code": "CX", + "emoji": "🇨🇽", + "unicode": "U+1F1E8 U+1F1FD", + "name": "Christmas Island", + "title": "flag for Christmas Island" + }, + { + "code": "CY", + "emoji": "🇨🇾", + "unicode": "U+1F1E8 U+1F1FE", + "name": "Cyprus", + "title": "flag for Cyprus" + }, + { + "code": "CZ", + "emoji": "🇨🇿", + "unicode": "U+1F1E8 U+1F1FF", + "name": "Czech Republic", + "title": "flag for Czech Republic" + }, + { + "code": "DE", + "emoji": "🇩🇪", + "unicode": "U+1F1E9 U+1F1EA", + "name": "Germany", + "title": "flag for Germany" + }, + { + "code": "DJ", + "emoji": "🇩🇯", + "unicode": "U+1F1E9 U+1F1EF", + "name": "Djibouti", + "title": "flag for Djibouti" + }, + { + "code": "DK", + "emoji": "🇩🇰", + "unicode": "U+1F1E9 U+1F1F0", + "name": "Denmark", + "title": "flag for Denmark" + }, + { + "code": "DM", + "emoji": "🇩🇲", + "unicode": "U+1F1E9 U+1F1F2", + "name": "Dominica", + "title": "flag for Dominica" + }, + { + "code": "DO", + "emoji": "🇩🇴", + "unicode": "U+1F1E9 U+1F1F4", + "name": "Dominican Republic", + "title": "flag for Dominican Republic" + }, + { + "code": "DZ", + "emoji": "🇩🇿", + "unicode": "U+1F1E9 U+1F1FF", + "name": "Algeria", + "title": "flag for Algeria" + }, + { + "code": "EC", + "emoji": "🇪🇨", + "unicode": "U+1F1EA U+1F1E8", + "name": "Ecuador", + "title": "flag for Ecuador" + }, + { + "code": "EE", + "emoji": "🇪🇪", + "unicode": "U+1F1EA U+1F1EA", + "name": "Estonia", + "title": "flag for Estonia" + }, + { + "code": "EG", + "emoji": "🇪🇬", + "unicode": "U+1F1EA U+1F1EC", + "name": "Egypt", + "title": "flag for Egypt" + }, + { + "code": "EH", + "emoji": "🇪🇭", + "unicode": "U+1F1EA U+1F1ED", + "name": "Western Sahara", + "title": "flag for Western Sahara" + }, + { + "code": "ER", + "emoji": "🇪🇷", + "unicode": "U+1F1EA U+1F1F7", + "name": "Eritrea", + "title": "flag for Eritrea" + }, + { + "code": "ES", + "emoji": "🇪🇸", + "unicode": "U+1F1EA U+1F1F8", + "name": "Spain", + "title": "flag for Spain" + }, + { + "code": "ET", + "emoji": "🇪🇹", + "unicode": "U+1F1EA U+1F1F9", + "name": "Ethiopia", + "title": "flag for Ethiopia" + }, + { + "code": "EU", + "emoji": "🇪🇺", + "unicode": "U+1F1EA U+1F1FA", + "name": "European Union", + "title": "flag for European Union" + }, + { + "code": "FI", + "emoji": "🇫🇮", + "unicode": "U+1F1EB U+1F1EE", + "name": "Finland", + "title": "flag for Finland" + }, + { + "code": "FJ", + "emoji": "🇫🇯", + "unicode": "U+1F1EB U+1F1EF", + "name": "Fiji", + "title": "flag for Fiji" + }, + { + "code": "FK", + "emoji": "🇫🇰", + "unicode": "U+1F1EB U+1F1F0", + "name": "Falkland Islands (Malvinas)", + "title": "flag for Falkland Islands (Malvinas)" + }, + { + "code": "FM", + "emoji": "🇫🇲", + "unicode": "U+1F1EB U+1F1F2", + "name": "Micronesia", + "title": "flag for Micronesia" + }, + { + "code": "FO", + "emoji": "🇫🇴", + "unicode": "U+1F1EB U+1F1F4", + "name": "Faroe Islands", + "title": "flag for Faroe Islands" + }, + { + "code": "FR", + "emoji": "🇫🇷", + "unicode": "U+1F1EB U+1F1F7", + "name": "France", + "title": "flag for France" + }, + { + "code": "GA", + "emoji": "🇬🇦", + "unicode": "U+1F1EC U+1F1E6", + "name": "Gabon", + "title": "flag for Gabon" + }, + { + "code": "GB", + "emoji": "🇬🇧", + "unicode": "U+1F1EC U+1F1E7", + "name": "United Kingdom", + "title": "flag for United Kingdom" + }, + { + "code": "GD", + "emoji": "🇬🇩", + "unicode": "U+1F1EC U+1F1E9", + "name": "Grenada", + "title": "flag for Grenada" + }, + { + "code": "GE", + "emoji": "🇬🇪", + "unicode": "U+1F1EC U+1F1EA", + "name": "Georgia", + "title": "flag for Georgia" + }, + { + "code": "GF", + "emoji": "🇬🇫", + "unicode": "U+1F1EC U+1F1EB", + "name": "French Guiana", + "title": "flag for French Guiana" + }, + { + "code": "GG", + "emoji": "🇬🇬", + "unicode": "U+1F1EC U+1F1EC", + "name": "Guernsey", + "title": "flag for Guernsey" + }, + { + "code": "GH", + "emoji": "🇬🇭", + "unicode": "U+1F1EC U+1F1ED", + "name": "Ghana", + "title": "flag for Ghana" + }, + { + "code": "GI", + "emoji": "🇬🇮", + "unicode": "U+1F1EC U+1F1EE", + "name": "Gibraltar", + "title": "flag for Gibraltar" + }, + { + "code": "GL", + "emoji": "🇬🇱", + "unicode": "U+1F1EC U+1F1F1", + "name": "Greenland", + "title": "flag for Greenland" + }, + { + "code": "GM", + "emoji": "🇬🇲", + "unicode": "U+1F1EC U+1F1F2", + "name": "Gambia", + "title": "flag for Gambia" + }, + { + "code": "GN", + "emoji": "🇬🇳", + "unicode": "U+1F1EC U+1F1F3", + "name": "Guinea", + "title": "flag for Guinea" + }, + { + "code": "GP", + "emoji": "🇬🇵", + "unicode": "U+1F1EC U+1F1F5", + "name": "Guadeloupe", + "title": "flag for Guadeloupe" + }, + { + "code": "GQ", + "emoji": "🇬🇶", + "unicode": "U+1F1EC U+1F1F6", + "name": "Equatorial Guinea", + "title": "flag for Equatorial Guinea" + }, + { + "code": "GR", + "emoji": "🇬🇷", + "unicode": "U+1F1EC U+1F1F7", + "name": "Greece", + "title": "flag for Greece" + }, + { + "code": "GS", + "emoji": "🇬🇸", + "unicode": "U+1F1EC U+1F1F8", + "name": "South Georgia", + "title": "flag for South Georgia" + }, + { + "code": "GT", + "emoji": "🇬🇹", + "unicode": "U+1F1EC U+1F1F9", + "name": "Guatemala", + "title": "flag for Guatemala" + }, + { + "code": "GU", + "emoji": "🇬🇺", + "unicode": "U+1F1EC U+1F1FA", + "name": "Guam", + "title": "flag for Guam" + }, + { + "code": "GW", + "emoji": "🇬🇼", + "unicode": "U+1F1EC U+1F1FC", + "name": "Guinea-Bissau", + "title": "flag for Guinea-Bissau" + }, + { + "code": "GY", + "emoji": "🇬🇾", + "unicode": "U+1F1EC U+1F1FE", + "name": "Guyana", + "title": "flag for Guyana" + }, + { + "code": "HK", + "emoji": "🇭🇰", + "unicode": "U+1F1ED U+1F1F0", + "name": "Hong Kong", + "title": "flag for Hong Kong" + }, + { + "code": "HM", + "emoji": "🇭🇲", + "unicode": "U+1F1ED U+1F1F2", + "name": "Heard Island and Mcdonald Islands", + "title": "flag for Heard Island and Mcdonald Islands" + }, + { + "code": "HN", + "emoji": "🇭🇳", + "unicode": "U+1F1ED U+1F1F3", + "name": "Honduras", + "title": "flag for Honduras" + }, + { + "code": "HR", + "emoji": "🇭🇷", + "unicode": "U+1F1ED U+1F1F7", + "name": "Croatia", + "title": "flag for Croatia" + }, + { + "code": "HT", + "emoji": "🇭🇹", + "unicode": "U+1F1ED U+1F1F9", + "name": "Haiti", + "title": "flag for Haiti" + }, + { + "code": "HU", + "emoji": "🇭🇺", + "unicode": "U+1F1ED U+1F1FA", + "name": "Hungary", + "title": "flag for Hungary" + }, + { + "code": "ID", + "emoji": "🇮🇩", + "unicode": "U+1F1EE U+1F1E9", + "name": "Indonesia", + "title": "flag for Indonesia" + }, + { + "code": "IE", + "emoji": "🇮🇪", + "unicode": "U+1F1EE U+1F1EA", + "name": "Ireland", + "title": "flag for Ireland" + }, + { + "code": "IL", + "emoji": "🇮🇱", + "unicode": "U+1F1EE U+1F1F1", + "name": "Israel", + "title": "flag for Israel" + }, + { + "code": "IM", + "emoji": "🇮🇲", + "unicode": "U+1F1EE U+1F1F2", + "name": "Isle of Man", + "title": "flag for Isle of Man" + }, + { + "code": "IN", + "emoji": "🇮🇳", + "unicode": "U+1F1EE U+1F1F3", + "name": "India", + "title": "flag for India" + }, + { + "code": "IO", + "emoji": "🇮🇴", + "unicode": "U+1F1EE U+1F1F4", + "name": "British Indian Ocean Territory", + "title": "flag for British Indian Ocean Territory" + }, + { + "code": "IQ", + "emoji": "🇮🇶", + "unicode": "U+1F1EE U+1F1F6", + "name": "Iraq", + "title": "flag for Iraq" + }, + { + "code": "IR", + "emoji": "🇮🇷", + "unicode": "U+1F1EE U+1F1F7", + "name": "Iran", + "title": "flag for Iran" + }, + { + "code": "IS", + "emoji": "🇮🇸", + "unicode": "U+1F1EE U+1F1F8", + "name": "Iceland", + "title": "flag for Iceland" + }, + { + "code": "IT", + "emoji": "🇮🇹", + "unicode": "U+1F1EE U+1F1F9", + "name": "Italy", + "title": "flag for Italy" + }, + { + "code": "JE", + "emoji": "🇯🇪", + "unicode": "U+1F1EF U+1F1EA", + "name": "Jersey", + "title": "flag for Jersey" + }, + { + "code": "JM", + "emoji": "🇯🇲", + "unicode": "U+1F1EF U+1F1F2", + "name": "Jamaica", + "title": "flag for Jamaica" + }, + { + "code": "JO", + "emoji": "🇯🇴", + "unicode": "U+1F1EF U+1F1F4", + "name": "Jordan", + "title": "flag for Jordan" + }, + { + "code": "JP", + "emoji": "🇯🇵", + "unicode": "U+1F1EF U+1F1F5", + "name": "Japan", + "title": "flag for Japan" + }, + { + "code": "KE", + "emoji": "🇰🇪", + "unicode": "U+1F1F0 U+1F1EA", + "name": "Kenya", + "title": "flag for Kenya" + }, + { + "code": "KG", + "emoji": "🇰🇬", + "unicode": "U+1F1F0 U+1F1EC", + "name": "Kyrgyzstan", + "title": "flag for Kyrgyzstan" + }, + { + "code": "KH", + "emoji": "🇰🇭", + "unicode": "U+1F1F0 U+1F1ED", + "name": "Cambodia", + "title": "flag for Cambodia" + }, + { + "code": "KI", + "emoji": "🇰🇮", + "unicode": "U+1F1F0 U+1F1EE", + "name": "Kiribati", + "title": "flag for Kiribati" + }, + { + "code": "KM", + "emoji": "🇰🇲", + "unicode": "U+1F1F0 U+1F1F2", + "name": "Comoros", + "title": "flag for Comoros" + }, + { + "code": "KN", + "emoji": "🇰🇳", + "unicode": "U+1F1F0 U+1F1F3", + "name": "Saint Kitts and Nevis", + "title": "flag for Saint Kitts and Nevis" + }, + { + "code": "KP", + "emoji": "🇰🇵", + "unicode": "U+1F1F0 U+1F1F5", + "name": "North Korea", + "title": "flag for North Korea" + }, + { + "code": "KR", + "emoji": "🇰🇷", + "unicode": "U+1F1F0 U+1F1F7", + "name": "South Korea", + "title": "flag for South Korea" + }, + { + "code": "KW", + "emoji": "🇰🇼", + "unicode": "U+1F1F0 U+1F1FC", + "name": "Kuwait", + "title": "flag for Kuwait" + }, + { + "code": "KY", + "emoji": "🇰🇾", + "unicode": "U+1F1F0 U+1F1FE", + "name": "Cayman Islands", + "title": "flag for Cayman Islands" + }, + { + "code": "KZ", + "emoji": "🇰🇿", + "unicode": "U+1F1F0 U+1F1FF", + "name": "Kazakhstan", + "title": "flag for Kazakhstan" + }, + { + "code": "LA", + "emoji": "🇱🇦", + "unicode": "U+1F1F1 U+1F1E6", + "name": "Lao People's Democratic Republic", + "title": "flag for Lao People's Democratic Republic" + }, + { + "code": "LB", + "emoji": "🇱🇧", + "unicode": "U+1F1F1 U+1F1E7", + "name": "Lebanon", + "title": "flag for Lebanon" + }, + { + "code": "LC", + "emoji": "🇱🇨", + "unicode": "U+1F1F1 U+1F1E8", + "name": "Saint Lucia", + "title": "flag for Saint Lucia" + }, + { + "code": "LI", + "emoji": "🇱🇮", + "unicode": "U+1F1F1 U+1F1EE", + "name": "Liechtenstein", + "title": "flag for Liechtenstein" + }, + { + "code": "LK", + "emoji": "🇱🇰", + "unicode": "U+1F1F1 U+1F1F0", + "name": "Sri Lanka", + "title": "flag for Sri Lanka" + }, + { + "code": "LR", + "emoji": "🇱🇷", + "unicode": "U+1F1F1 U+1F1F7", + "name": "Liberia", + "title": "flag for Liberia" + }, + { + "code": "LS", + "emoji": "🇱🇸", + "unicode": "U+1F1F1 U+1F1F8", + "name": "Lesotho", + "title": "flag for Lesotho" + }, + { + "code": "LT", + "emoji": "🇱🇹", + "unicode": "U+1F1F1 U+1F1F9", + "name": "Lithuania", + "title": "flag for Lithuania" + }, + { + "code": "LU", + "emoji": "🇱🇺", + "unicode": "U+1F1F1 U+1F1FA", + "name": "Luxembourg", + "title": "flag for Luxembourg" + }, + { + "code": "LV", + "emoji": "🇱🇻", + "unicode": "U+1F1F1 U+1F1FB", + "name": "Latvia", + "title": "flag for Latvia" + }, + { + "code": "LY", + "emoji": "🇱🇾", + "unicode": "U+1F1F1 U+1F1FE", + "name": "Libya", + "title": "flag for Libya" + }, + { + "code": "MA", + "emoji": "🇲🇦", + "unicode": "U+1F1F2 U+1F1E6", + "name": "Morocco", + "title": "flag for Morocco" + }, + { + "code": "MC", + "emoji": "🇲🇨", + "unicode": "U+1F1F2 U+1F1E8", + "name": "Monaco", + "title": "flag for Monaco" + }, + { + "code": "MD", + "emoji": "🇲🇩", + "unicode": "U+1F1F2 U+1F1E9", + "name": "Moldova", + "title": "flag for Moldova" + }, + { + "code": "ME", + "emoji": "🇲🇪", + "unicode": "U+1F1F2 U+1F1EA", + "name": "Montenegro", + "title": "flag for Montenegro" + }, + { + "code": "MF", + "emoji": "🇲🇫", + "unicode": "U+1F1F2 U+1F1EB", + "name": "Saint Martin (French Part)", + "title": "flag for Saint Martin (French Part)" + }, + { + "code": "MG", + "emoji": "🇲🇬", + "unicode": "U+1F1F2 U+1F1EC", + "name": "Madagascar", + "title": "flag for Madagascar" + }, + { + "code": "MH", + "emoji": "🇲🇭", + "unicode": "U+1F1F2 U+1F1ED", + "name": "Marshall Islands", + "title": "flag for Marshall Islands" + }, + { + "code": "MK", + "emoji": "🇲🇰", + "unicode": "U+1F1F2 U+1F1F0", + "name": "Macedonia", + "title": "flag for Macedonia" + }, + { + "code": "ML", + "emoji": "🇲🇱", + "unicode": "U+1F1F2 U+1F1F1", + "name": "Mali", + "title": "flag for Mali" + }, + { + "code": "MM", + "emoji": "🇲🇲", + "unicode": "U+1F1F2 U+1F1F2", + "name": "Myanmar", + "title": "flag for Myanmar" + }, + { + "code": "MN", + "emoji": "🇲🇳", + "unicode": "U+1F1F2 U+1F1F3", + "name": "Mongolia", + "title": "flag for Mongolia" + }, + { + "code": "MO", + "emoji": "🇲🇴", + "unicode": "U+1F1F2 U+1F1F4", + "name": "Macao", + "title": "flag for Macao" + }, + { + "code": "MP", + "emoji": "🇲🇵", + "unicode": "U+1F1F2 U+1F1F5", + "name": "Northern Mariana Islands", + "title": "flag for Northern Mariana Islands" + }, + { + "code": "MQ", + "emoji": "🇲🇶", + "unicode": "U+1F1F2 U+1F1F6", + "name": "Martinique", + "title": "flag for Martinique" + }, + { + "code": "MR", + "emoji": "🇲🇷", + "unicode": "U+1F1F2 U+1F1F7", + "name": "Mauritania", + "title": "flag for Mauritania" + }, + { + "code": "MS", + "emoji": "🇲🇸", + "unicode": "U+1F1F2 U+1F1F8", + "name": "Montserrat", + "title": "flag for Montserrat" + }, + { + "code": "MT", + "emoji": "🇲🇹", + "unicode": "U+1F1F2 U+1F1F9", + "name": "Malta", + "title": "flag for Malta" + }, + { + "code": "MU", + "emoji": "🇲🇺", + "unicode": "U+1F1F2 U+1F1FA", + "name": "Mauritius", + "title": "flag for Mauritius" + }, + { + "code": "MV", + "emoji": "🇲🇻", + "unicode": "U+1F1F2 U+1F1FB", + "name": "Maldives", + "title": "flag for Maldives" + }, + { + "code": "MW", + "emoji": "🇲🇼", + "unicode": "U+1F1F2 U+1F1FC", + "name": "Malawi", + "title": "flag for Malawi" + }, + { + "code": "MX", + "emoji": "🇲🇽", + "unicode": "U+1F1F2 U+1F1FD", + "name": "Mexico", + "title": "flag for Mexico" + }, + { + "code": "MY", + "emoji": "🇲🇾", + "unicode": "U+1F1F2 U+1F1FE", + "name": "Malaysia", + "title": "flag for Malaysia" + }, + { + "code": "MZ", + "emoji": "🇲🇿", + "unicode": "U+1F1F2 U+1F1FF", + "name": "Mozambique", + "title": "flag for Mozambique" + }, + { + "code": "NA", + "emoji": "🇳🇦", + "unicode": "U+1F1F3 U+1F1E6", + "name": "Namibia", + "title": "flag for Namibia" + }, + { + "code": "NC", + "emoji": "🇳🇨", + "unicode": "U+1F1F3 U+1F1E8", + "name": "New Caledonia", + "title": "flag for New Caledonia" + }, + { + "code": "NE", + "emoji": "🇳🇪", + "unicode": "U+1F1F3 U+1F1EA", + "name": "Niger", + "title": "flag for Niger" + }, + { + "code": "NF", + "emoji": "🇳🇫", + "unicode": "U+1F1F3 U+1F1EB", + "name": "Norfolk Island", + "title": "flag for Norfolk Island" + }, + { + "code": "NG", + "emoji": "🇳🇬", + "unicode": "U+1F1F3 U+1F1EC", + "name": "Nigeria", + "title": "flag for Nigeria" + }, + { + "code": "NI", + "emoji": "🇳🇮", + "unicode": "U+1F1F3 U+1F1EE", + "name": "Nicaragua", + "title": "flag for Nicaragua" + }, + { + "code": "NL", + "emoji": "🇳🇱", + "unicode": "U+1F1F3 U+1F1F1", + "name": "Netherlands", + "title": "flag for Netherlands" + }, + { + "code": "NO", + "emoji": "🇳🇴", + "unicode": "U+1F1F3 U+1F1F4", + "name": "Norway", + "title": "flag for Norway" + }, + { + "code": "NP", + "emoji": "🇳🇵", + "unicode": "U+1F1F3 U+1F1F5", + "name": "Nepal", + "title": "flag for Nepal" + }, + { + "code": "NR", + "emoji": "🇳🇷", + "unicode": "U+1F1F3 U+1F1F7", + "name": "Nauru", + "title": "flag for Nauru" + }, + { + "code": "NU", + "emoji": "🇳🇺", + "unicode": "U+1F1F3 U+1F1FA", + "name": "Niue", + "title": "flag for Niue" + }, + { + "code": "NZ", + "emoji": "🇳🇿", + "unicode": "U+1F1F3 U+1F1FF", + "name": "New Zealand", + "title": "flag for New Zealand" + }, + { + "code": "OM", + "emoji": "🇴🇲", + "unicode": "U+1F1F4 U+1F1F2", + "name": "Oman", + "title": "flag for Oman" + }, + { + "code": "PA", + "emoji": "🇵🇦", + "unicode": "U+1F1F5 U+1F1E6", + "name": "Panama", + "title": "flag for Panama" + }, + { + "code": "PE", + "emoji": "🇵🇪", + "unicode": "U+1F1F5 U+1F1EA", + "name": "Peru", + "title": "flag for Peru" + }, + { + "code": "PF", + "emoji": "🇵🇫", + "unicode": "U+1F1F5 U+1F1EB", + "name": "French Polynesia", + "title": "flag for French Polynesia" + }, + { + "code": "PG", + "emoji": "🇵🇬", + "unicode": "U+1F1F5 U+1F1EC", + "name": "Papua New Guinea", + "title": "flag for Papua New Guinea" + }, + { + "code": "PH", + "emoji": "🇵🇭", + "unicode": "U+1F1F5 U+1F1ED", + "name": "Philippines", + "title": "flag for Philippines" + }, + { + "code": "PK", + "emoji": "🇵🇰", + "unicode": "U+1F1F5 U+1F1F0", + "name": "Pakistan", + "title": "flag for Pakistan" + }, + { + "code": "PL", + "emoji": "🇵🇱", + "unicode": "U+1F1F5 U+1F1F1", + "name": "Poland", + "title": "flag for Poland" + }, + { + "code": "PM", + "emoji": "🇵🇲", + "unicode": "U+1F1F5 U+1F1F2", + "name": "Saint Pierre and Miquelon", + "title": "flag for Saint Pierre and Miquelon" + }, + { + "code": "PN", + "emoji": "🇵🇳", + "unicode": "U+1F1F5 U+1F1F3", + "name": "Pitcairn", + "title": "flag for Pitcairn" + }, + { + "code": "PR", + "emoji": "🇵🇷", + "unicode": "U+1F1F5 U+1F1F7", + "name": "Puerto Rico", + "title": "flag for Puerto Rico" + }, + { + "code": "PS", + "emoji": "🇵🇸", + "unicode": "U+1F1F5 U+1F1F8", + "name": "Palestinian Territory", + "title": "flag for Palestinian Territory" + }, + { + "code": "PT", + "emoji": "🇵🇹", + "unicode": "U+1F1F5 U+1F1F9", + "name": "Portugal", + "title": "flag for Portugal" + }, + { + "code": "PW", + "emoji": "🇵🇼", + "unicode": "U+1F1F5 U+1F1FC", + "name": "Palau", + "title": "flag for Palau" + }, + { + "code": "PY", + "emoji": "🇵🇾", + "unicode": "U+1F1F5 U+1F1FE", + "name": "Paraguay", + "title": "flag for Paraguay" + }, + { + "code": "QA", + "emoji": "🇶🇦", + "unicode": "U+1F1F6 U+1F1E6", + "name": "Qatar", + "title": "flag for Qatar" + }, + { + "code": "RE", + "emoji": "🇷🇪", + "unicode": "U+1F1F7 U+1F1EA", + "name": "Réunion", + "title": "flag for Réunion" + }, + { + "code": "RO", + "emoji": "🇷🇴", + "unicode": "U+1F1F7 U+1F1F4", + "name": "Romania", + "title": "flag for Romania" + }, + { + "code": "RS", + "emoji": "🇷🇸", + "unicode": "U+1F1F7 U+1F1F8", + "name": "Serbia", + "title": "flag for Serbia" + }, + { + "code": "RU", + "emoji": "🇷🇺", + "unicode": "U+1F1F7 U+1F1FA", + "name": "Russia", + "title": "flag for Russia" + }, + { + "code": "RW", + "emoji": "🇷🇼", + "unicode": "U+1F1F7 U+1F1FC", + "name": "Rwanda", + "title": "flag for Rwanda" + }, + { + "code": "SA", + "emoji": "🇸🇦", + "unicode": "U+1F1F8 U+1F1E6", + "name": "Saudi Arabia", + "title": "flag for Saudi Arabia" + }, + { + "code": "SB", + "emoji": "🇸🇧", + "unicode": "U+1F1F8 U+1F1E7", + "name": "Solomon Islands", + "title": "flag for Solomon Islands" + }, + { + "code": "SC", + "emoji": "🇸🇨", + "unicode": "U+1F1F8 U+1F1E8", + "name": "Seychelles", + "title": "flag for Seychelles" + }, + { + "code": "SD", + "emoji": "🇸🇩", + "unicode": "U+1F1F8 U+1F1E9", + "name": "Sudan", + "title": "flag for Sudan" + }, + { + "code": "SE", + "emoji": "🇸🇪", + "unicode": "U+1F1F8 U+1F1EA", + "name": "Sweden", + "title": "flag for Sweden" + }, + { + "code": "SG", + "emoji": "🇸🇬", + "unicode": "U+1F1F8 U+1F1EC", + "name": "Singapore", + "title": "flag for Singapore" + }, + { + "code": "SH", + "emoji": "🇸🇭", + "unicode": "U+1F1F8 U+1F1ED", + "name": "Saint Helena, Ascension and Tristan Da Cunha", + "title": "flag for Saint Helena, Ascension and Tristan Da Cunha" + }, + { + "code": "SI", + "emoji": "🇸🇮", + "unicode": "U+1F1F8 U+1F1EE", + "name": "Slovenia", + "title": "flag for Slovenia" + }, + { + "code": "SJ", + "emoji": "🇸🇯", + "unicode": "U+1F1F8 U+1F1EF", + "name": "Svalbard and Jan Mayen", + "title": "flag for Svalbard and Jan Mayen" + }, + { + "code": "SK", + "emoji": "🇸🇰", + "unicode": "U+1F1F8 U+1F1F0", + "name": "Slovakia", + "title": "flag for Slovakia" + }, + { + "code": "SL", + "emoji": "🇸🇱", + "unicode": "U+1F1F8 U+1F1F1", + "name": "Sierra Leone", + "title": "flag for Sierra Leone" + }, + { + "code": "SM", + "emoji": "🇸🇲", + "unicode": "U+1F1F8 U+1F1F2", + "name": "San Marino", + "title": "flag for San Marino" + }, + { + "code": "SN", + "emoji": "🇸🇳", + "unicode": "U+1F1F8 U+1F1F3", + "name": "Senegal", + "title": "flag for Senegal" + }, + { + "code": "SO", + "emoji": "🇸🇴", + "unicode": "U+1F1F8 U+1F1F4", + "name": "Somalia", + "title": "flag for Somalia" + }, + { + "code": "SR", + "emoji": "🇸🇷", + "unicode": "U+1F1F8 U+1F1F7", + "name": "Suriname", + "title": "flag for Suriname" + }, + { + "code": "SS", + "emoji": "🇸🇸", + "unicode": "U+1F1F8 U+1F1F8", + "name": "South Sudan", + "title": "flag for South Sudan" + }, + { + "code": "ST", + "emoji": "🇸🇹", + "unicode": "U+1F1F8 U+1F1F9", + "name": "Sao Tome and Principe", + "title": "flag for Sao Tome and Principe" + }, + { + "code": "SV", + "emoji": "🇸🇻", + "unicode": "U+1F1F8 U+1F1FB", + "name": "El Salvador", + "title": "flag for El Salvador" + }, + { + "code": "SX", + "emoji": "🇸🇽", + "unicode": "U+1F1F8 U+1F1FD", + "name": "Sint Maarten (Dutch Part)", + "title": "flag for Sint Maarten (Dutch Part)" + }, + { + "code": "SY", + "emoji": "🇸🇾", + "unicode": "U+1F1F8 U+1F1FE", + "name": "Syrian Arab Republic", + "title": "flag for Syrian Arab Republic" + }, + { + "code": "SZ", + "emoji": "🇸🇿", + "unicode": "U+1F1F8 U+1F1FF", + "name": "Swaziland", + "title": "flag for Swaziland" + }, + { + "code": "TC", + "emoji": "🇹🇨", + "unicode": "U+1F1F9 U+1F1E8", + "name": "Turks and Caicos Islands", + "title": "flag for Turks and Caicos Islands" + }, + { + "code": "TD", + "emoji": "🇹🇩", + "unicode": "U+1F1F9 U+1F1E9", + "name": "Chad", + "title": "flag for Chad" + }, + { + "code": "TF", + "emoji": "🇹🇫", + "unicode": "U+1F1F9 U+1F1EB", + "name": "French Southern Territories", + "title": "flag for French Southern Territories" + }, + { + "code": "TG", + "emoji": "🇹🇬", + "unicode": "U+1F1F9 U+1F1EC", + "name": "Togo", + "title": "flag for Togo" + }, + { + "code": "TH", + "emoji": "🇹🇭", + "unicode": "U+1F1F9 U+1F1ED", + "name": "Thailand", + "title": "flag for Thailand" + }, + { + "code": "TJ", + "emoji": "🇹🇯", + "unicode": "U+1F1F9 U+1F1EF", + "name": "Tajikistan", + "title": "flag for Tajikistan" + }, + { + "code": "TK", + "emoji": "🇹🇰", + "unicode": "U+1F1F9 U+1F1F0", + "name": "Tokelau", + "title": "flag for Tokelau" + }, + { + "code": "TL", + "emoji": "🇹🇱", + "unicode": "U+1F1F9 U+1F1F1", + "name": "Timor-Leste", + "title": "flag for Timor-Leste" + }, + { + "code": "TM", + "emoji": "🇹🇲", + "unicode": "U+1F1F9 U+1F1F2", + "name": "Turkmenistan", + "title": "flag for Turkmenistan" + }, + { + "code": "TN", + "emoji": "🇹🇳", + "unicode": "U+1F1F9 U+1F1F3", + "name": "Tunisia", + "title": "flag for Tunisia" + }, + { + "code": "TO", + "emoji": "🇹🇴", + "unicode": "U+1F1F9 U+1F1F4", + "name": "Tonga", + "title": "flag for Tonga" + }, + { + "code": "TR", + "emoji": "🇹🇷", + "unicode": "U+1F1F9 U+1F1F7", + "name": "Turkey", + "title": "flag for Turkey" + }, + { + "code": "TT", + "emoji": "🇹🇹", + "unicode": "U+1F1F9 U+1F1F9", + "name": "Trinidad and Tobago", + "title": "flag for Trinidad and Tobago" + }, + { + "code": "TV", + "emoji": "🇹🇻", + "unicode": "U+1F1F9 U+1F1FB", + "name": "Tuvalu", + "title": "flag for Tuvalu" + }, + { + "code": "TW", + "emoji": "🇹🇼", + "unicode": "U+1F1F9 U+1F1FC", + "name": "Taiwan", + "title": "flag for Taiwan" + }, + { + "code": "TZ", + "emoji": "🇹🇿", + "unicode": "U+1F1F9 U+1F1FF", + "name": "Tanzania", + "title": "flag for Tanzania" + }, + { + "code": "UA", + "emoji": "🇺🇦", + "unicode": "U+1F1FA U+1F1E6", + "name": "Ukraine", + "title": "flag for Ukraine" + }, + { + "code": "UG", + "emoji": "🇺🇬", + "unicode": "U+1F1FA U+1F1EC", + "name": "Uganda", + "title": "flag for Uganda" + }, + { + "code": "UM", + "emoji": "🇺🇲", + "unicode": "U+1F1FA U+1F1F2", + "name": "United States Minor Outlying Islands", + "title": "flag for United States Minor Outlying Islands" + }, + { + "code": "US", + "emoji": "🇺🇸", + "unicode": "U+1F1FA U+1F1F8", + "name": "United States", + "title": "flag for United States" + }, + { + "code": "UY", + "emoji": "🇺🇾", + "unicode": "U+1F1FA U+1F1FE", + "name": "Uruguay", + "title": "flag for Uruguay" + }, + { + "code": "UZ", + "emoji": "🇺🇿", + "unicode": "U+1F1FA U+1F1FF", + "name": "Uzbekistan", + "title": "flag for Uzbekistan" + }, + { + "code": "VA", + "emoji": "🇻🇦", + "unicode": "U+1F1FB U+1F1E6", + "name": "Vatican City", + "title": "flag for Vatican City" + }, + { + "code": "VC", + "emoji": "🇻🇨", + "unicode": "U+1F1FB U+1F1E8", + "name": "Saint Vincent and The Grenadines", + "title": "flag for Saint Vincent and The Grenadines" + }, + { + "code": "VE", + "emoji": "🇻🇪", + "unicode": "U+1F1FB U+1F1EA", + "name": "Venezuela", + "title": "flag for Venezuela" + }, + { + "code": "VG", + "emoji": "🇻🇬", + "unicode": "U+1F1FB U+1F1EC", + "name": "Virgin Islands, British", + "title": "flag for Virgin Islands, British" + }, + { + "code": "VI", + "emoji": "🇻🇮", + "unicode": "U+1F1FB U+1F1EE", + "name": "Virgin Islands, U.S.", + "title": "flag for Virgin Islands, U.S." + }, + { + "code": "VN", + "emoji": "🇻🇳", + "unicode": "U+1F1FB U+1F1F3", + "name": "Viet Nam", + "title": "flag for Viet Nam" + }, + { + "code": "VU", + "emoji": "🇻🇺", + "unicode": "U+1F1FB U+1F1FA", + "name": "Vanuatu", + "title": "flag for Vanuatu" + }, + { + "code": "WF", + "emoji": "🇼🇫", + "unicode": "U+1F1FC U+1F1EB", + "name": "Wallis and Futuna", + "title": "flag for Wallis and Futuna" + }, + { + "code": "WS", + "emoji": "🇼🇸", + "unicode": "U+1F1FC U+1F1F8", + "name": "Samoa", + "title": "flag for Samoa" + }, + { + "code": "YE", + "emoji": "🇾🇪", + "unicode": "U+1F1FE U+1F1EA", + "name": "Yemen", + "title": "flag for Yemen" + }, + { + "code": "YT", + "emoji": "🇾🇹", + "unicode": "U+1F1FE U+1F1F9", + "name": "Mayotte", + "title": "flag for Mayotte" + }, + { + "code": "ZA", + "emoji": "🇿🇦", + "unicode": "U+1F1FF U+1F1E6", + "name": "South Africa", + "title": "flag for South Africa" + }, + { + "code": "ZM", + "emoji": "🇿🇲", + "unicode": "U+1F1FF U+1F1F2", + "name": "Zambia", + "title": "flag for Zambia" + }, + { + "code": "ZW", + "emoji": "🇿🇼", + "unicode": "U+1F1FF U+1F1FC", + "name": "Zimbabwe", + "title": "flag for Zimbabwe" + } +] + +EMOJI_FLAG_MAP = {} +for flag in EMOJI_FLAGS: + EMOJI_FLAG_MAP[flag["code"]] = flag["emoji"] + + +def emoji_flag_lookup(country_code): + if country_code in EMOJI_FLAG_MAP: + return EMOJI_FLAG_MAP[country_code] + return None diff --git a/common/epub/__init__.py b/common/epub/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/epub/epub.py b/common/epub/epub.py new file mode 100644 index 0000000..aa57b3c --- /dev/null +++ b/common/epub/epub.py @@ -0,0 +1,109 @@ +import io +import re +import mimetypes +from urllib.parse import urlparse + +import requests +from django.template.loader import render_to_string +from ebooklib import epub + +from common.markdown.epub import markdown_epub + + +def generate_epub(story): + book = epub.EpubBook() + + book.set_identifier("vas3k_ru_{}".format(story.slug)) + book.set_language("ru") + book.add_author("vas3k.ru") + book.add_metadata('DC', 'description', story.preview_text or story.subtitle) + if story.subtitle: + book.set_title("{}. {}".format(story.title, story.subtitle)) + else: + book.set_title(story.title) + + if story.book_image: + book.set_cover("cover.jpg", requests.get(story.book_image).content) + else: + book.set_cover("cover.jpg", requests.get(story.image).content) + + css = epub.EpubItem( + uid="style", + file_name="styles/index.css", + media_type="text/css", + content=render_to_string("epub/style.css") + ) + book.add_item(css) + + intro = epub.EpubHtml(title=story.title, file_name="title.xhtml", lang="ru") + intro.content = render_to_string("epub/title.html", { + "story": story + }) + book.add_item(intro) + spine = [intro] + + xhtml = markdown_epub(story.text) + xhtml = bundle_images(from_text=xhtml, book=book) + + for index, content in break_pages(xhtml): + if not content: + continue + + chapter = epub.EpubHtml( + uid="chap_{}".format(index), + title=story.title, + file_name="chap_{}.xhtml".format(index), + lang="ru" + ) + chapter.content = content + chapter.add_item(css) + book.add_item(chapter) + spine.append(chapter) + + # add default NCX and Nav file + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + + # create spine (required for epub) + book.spine = spine + + mem_file = io.BytesIO() + epub.write_epub(mem_file, book, {}) + return mem_file.getvalue() + + +def bundle_images(from_text, book): + pattern = r"" + images = re.findall(pattern, from_text) + for image in set(list(images)): + if "youtu" in image: + continue + + file_name = image[image.rfind("/") + 1:] + file_type = guess_mimetype(file_name) + content = requests.get(image).content + book.add_item( + epub.EpubItem( + uid=file_name, + file_name="images/{}".format(file_name), + media_type=file_type, + content=content + ) + ) + from_text = from_text.replace(image, "images/{}".format(file_name)) + + return from_text + + +def guess_mimetype(file_name): + mime, _ = mimetypes.guess_type(file_name) + if not mime: + path = urlparse(file_name).path + mime, _ = mimetypes.guess_type(path or "") + if not mime: + mime = "application/octet-stream" + return mime + + +def break_pages(text): + return enumerate(text.split("")) diff --git a/common/geoip.py b/common/geoip.py new file mode 100644 index 0000000..5fe503d --- /dev/null +++ b/common/geoip.py @@ -0,0 +1,3 @@ +from geolite2 import geolite2 + +geoip = geolite2.reader() diff --git a/common/languages.py b/common/languages.py new file mode 100644 index 0000000..cf7e27f --- /dev/null +++ b/common/languages.py @@ -0,0 +1,20 @@ +DEFAULT_ALLOWED = ["ru", "en"] +DEFAULT = "en" + + +def request_language(request, allowed=DEFAULT_ALLOWED, default=DEFAULT): + for locale in allowed: + if locale in request.GET: + return locale + + header = request.META.get("HTTP_ACCEPT_LANGUAGE", "") # e.g. en-gb,en;q=0.8,es-es;q=0.5,eu;q=0.3 + for locale in header.split(","): + try: + locale = locale.lower() + locale = locale.split(";")[0].lower()[:2] + except (KeyError, ValueError): + continue + else: + if locale in allowed: + return locale + return default diff --git a/common/markdown/__init__.py b/common/markdown/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/markdown/club_renderer.py b/common/markdown/club_renderer.py new file mode 100644 index 0000000..3c1414e --- /dev/null +++ b/common/markdown/club_renderer.py @@ -0,0 +1,93 @@ +import html +import mistune +from urllib.parse import unquote +from slugify import slugify + +from common.regexp import IMAGE_RE, VIDEO_RE, YOUTUBE_RE, TWITTER_RE + + +class Vas3kRenderer(mistune.HTMLRenderer): + block_counter = 0 + + def paragraph(self, text: str) -> str: + if not text: + return "" # hide empty

+ return super().paragraph(text) + + def heading(self, text, level, **attrs): + anchor = slugify(text[:24]) + return f"
{text}
\n" + + def link(self, text, url, title=None): + if not text and not title: + # it's a pure link (without link tag) and we can try to parse it + embed = self.embed(url, text or "", title or "") + if embed: + return embed + + if text is None: + text = url + + # here's some magic of unescape->unquote->escape + # to fix cyrillic (and other non-latin) wikipedia URLs + return f'{text or url}' + + def image(self, alt, url, title=None): + embed = self.embed(url, alt, title) + if embed: + return embed + + # users can try to "hack" our parser by using non-image urls + # so, if its not an image or video, display it as a link to avoid auto-loading + return f'{mistune.escape(url)}' + + def embed(self, src, alt="", title=None): + if IMAGE_RE.match(src): + return self.simple_image(src, alt, title) + + if YOUTUBE_RE.match(src): + return self.youtube(src, alt, title) + + if VIDEO_RE.match(src): + return self.video(src, alt, title) + + if TWITTER_RE.match(src): + return self.tweet(src, alt, title) + + return None + + def simple_image(self, src, alt="", title=None): + title = title or alt + image_tag = f'{mistune.escape(title)}' + caption = f"
{title}
" if title else "" + return f'
{image_tag}{caption}
' + + def youtube(self, src, alt="", title=None): + youtube_match = YOUTUBE_RE.match(src) + playlist = "" + if youtube_match.group(2): + playlist = f"list={mistune.escape(youtube_match.group(2))}&listType=playlist&" + video_tag = ( + f'' + f'' + f"" + ) + caption = f"
{mistune.escape(title)}
" if title else "" + return f"
{video_tag}{caption}
" + + def video(self, src, alt="", title=None): + video_tag = ( + f'' + ) + caption = f"
{mistune.escape(title)}
" if title else "" + return f"
{video_tag}{caption}
" + + def tweet(self, src, alt="", title=None): + tweet_match = TWITTER_RE.match(src) + twitter_tag = f'
' \ + f'

' \ + f'{src}' + return twitter_tag diff --git a/common/markdown/email_renderer.py b/common/markdown/email_renderer.py new file mode 100644 index 0000000..551933b --- /dev/null +++ b/common/markdown/email_renderer.py @@ -0,0 +1,26 @@ +import mistune + +from common.markdown.club_renderer import Vas3kRenderer +from common.regexp import YOUTUBE_RE + + +class EmailRenderer(Vas3kRenderer): + def simple_image(self, src, alt="", title=None): + return f"""{alt}
{title or ""}""" + + def youtube(self, src, alt="", title=None): + youtube_match = YOUTUBE_RE.match(src) + youtube_id = mistune.escape(youtube_match.group(1) or "") + return f'' \ + f'
{mistune.escape(title or "")}' + + def video(self, src, alt="", title=None): + return f'
{title or ""}' + + def tweet(self, src, alt="", title=None): + return f'{mistune.escape(src)}
{mistune.escape(title or "")}' + + def heading(self, text, level, **attrs): + tag = f"h{level}" + return f"<{tag}>{text}\n" diff --git a/common/markdown/markdown.py b/common/markdown/markdown.py new file mode 100644 index 0000000..8803edb --- /dev/null +++ b/common/markdown/markdown.py @@ -0,0 +1,50 @@ +import mistune + +from common.markdown.club_renderer import Vas3kRenderer +from common.markdown.email_renderer import EmailRenderer +from common.markdown.plain_renderer import PlainRenderer +from common.markdown.plugins.cite_block import cite_block +from common.markdown.plugins.media_block import media_block +from common.markdown.plugins.spoiler import spoiler +from common.markdown.plugins.text_block import text_block + + +def markdown_text(text, renderer=Vas3kRenderer): + markdown = mistune.create_markdown( + escape=False, + hard_wrap=True, + renderer=renderer(escape=False) if renderer else None, + plugins=[ + "strikethrough", + "url", + "table", + "speedup", + text_block, + media_block, + spoiler, + cite_block, + ] + ) + return markdown(text) + + +def markdown_comment(text, renderer=Vas3kRenderer): + markdown = mistune.create_markdown( + escape=True, + hard_wrap=True, + renderer=renderer(escape=True) if renderer else None, + plugins=[ + "strikethrough", + "url", + "speedup", + ] + ) + return markdown(text) + + +def markdown_plain(text): + return markdown_text(text, renderer=PlainRenderer) + + +def markdown_email(text): + return markdown_text(text, renderer=EmailRenderer) diff --git a/common/markdown/plain_renderer.py b/common/markdown/plain_renderer.py new file mode 100644 index 0000000..4918588 --- /dev/null +++ b/common/markdown/plain_renderer.py @@ -0,0 +1,48 @@ +import mistune + + +class PlainRenderer(mistune.HTMLRenderer): + def link(self, link, text=None, title=None): + if text: + return f'[{text}]({link})' + else: + return f'({link})' + + def image(self, src, alt="", title=None): + return "🖼" + + def emphasis(self, text): + return text + + def strong(self, text): + return text + + def codespan(self, text): + return text + + def linebreak(self): + return "\n" + + def paragraph(self, text): + return text + "\n\n" + + def heading(self, text, level): + return text + "\n\n" + + def newline(self): + return "\n" + + def block_quote(self, text): + return "> " + text + + def block_code(self, code, info=None): + return code + + def list(self, text, ordered, level, start=None): + return text + + def list_item(self, text, level): + return "- " + text + "\n" + + def thematic_break(self): + return '---\n' diff --git a/common/markdown/plugins/__init__.py b/common/markdown/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/markdown/plugins/cite_block.py b/common/markdown/plugins/cite_block.py new file mode 100644 index 0000000..e00f065 --- /dev/null +++ b/common/markdown/plugins/cite_block.py @@ -0,0 +1,32 @@ +__all__ = ["cite_block"] + +from common.markdown.plugins.utils import parse_classes + +CITE_BLOCK_PATTERN = r'^\% (?P[\s\S]+?)$' + + +def parse_cite_block(block, m, state): + text = m.group("cite_block_text") + + child = state.child_state(text) + block.parse(child) + + state.append_token({"type": "cite_block", "children": child.tokens}) + return m.end() + + +def render_cite_block(renderer, text, **attrs): + return f'
{text}
\n' + + +def cite_block(md): + """ + Custom plugin which supports block-like things: + + % some text + + They are custom to vas3k blog + """ + md.block.register("cite_block", CITE_BLOCK_PATTERN, parse_cite_block) + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("cite_block", render_cite_block) diff --git a/common/markdown/plugins/media_block.py b/common/markdown/plugins/media_block.py new file mode 100644 index 0000000..6ea51b9 --- /dev/null +++ b/common/markdown/plugins/media_block.py @@ -0,0 +1,37 @@ +__all__ = ["media_block"] + +from common.markdown.plugins.utils import parse_classes_and_ids + +MEDIA_BLOCK_PATTERN = r'\{{3}(?P[^\s]+)?(?P[\s\S]+?)\}{3}' + + +def parse_media_block(block, m, state): + text = m.group("media_block_text") + classes = m.group("media_block_classes") + + child = state.child_state(text) + block.parse(child) + + state.append_token({"type": "media_block", "children": child.tokens, "attrs": {"classes": classes}}) + return m.end() + + +def render_media_block(renderer, text, **attrs): + text = text.replace("

", "").replace("

", "") # dirty hack to fix some browsers + classes, ids = parse_classes_and_ids(attrs.get("classes") or "") + return f'
{text}
\n' + + +def media_block(md): + """ + Custom plugin which supports media-like things: + + {{{ + some text + }}} + + They are custom to vas3k blog + """ + md.block.register("media_block", MEDIA_BLOCK_PATTERN, parse_media_block) + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("media_block", render_media_block) diff --git a/common/markdown/plugins/spoiler.py b/common/markdown/plugins/spoiler.py new file mode 100644 index 0000000..f599c97 --- /dev/null +++ b/common/markdown/plugins/spoiler.py @@ -0,0 +1,34 @@ +__all__ = ["spoiler"] + +SPOILER_BLOCK_PATTERN = r'\[\?(?P.+?)\?\]' + + +def parse_spoiler_block(inline, m, state): + text = m.group("spoiler_text") + + new_state = state.copy() + new_state.src = text + children = inline.render(new_state) + + state.append_token({"type": "spoiler", "children": children}) + return m.end() + + +def render_spoiler(renderer, text): + return f"" \ + f"?" \ + f"{text}" \ + f"\n\n" + + +def spoiler(md): + """ + Custom plugin which supports spoilers in text + + Some text [? spoiler ?] other text + + They are custom to vas3k blog + """ + md.inline.register("spoiler", SPOILER_BLOCK_PATTERN, parse_spoiler_block, before="link") + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("spoiler", render_spoiler) diff --git a/common/markdown/plugins/text_block.py b/common/markdown/plugins/text_block.py new file mode 100644 index 0000000..fcdfc69 --- /dev/null +++ b/common/markdown/plugins/text_block.py @@ -0,0 +1,42 @@ +__all__ = ["text_block"] + +from common.markdown.plugins.utils import parse_classes_and_ids + +TEXT_BLOCK_PATTERN = r'\[{3}(?P[^\s]+)?(?P[\s\S]+?)\]{3}' + + +def parse_text_block(block, m, state): + text = m.group("text_block_text") + classes = m.group("text_block_classes") + + child = state.child_state(text) + block.parse(child) + + state.append_token({"type": "text_block", "children": child.tokens, "attrs": {"classes": classes}}) + return m.end() + + +def render_text_block(renderer, text, **attrs): + block_counter = 0 + if hasattr(renderer, "block_counter"): + renderer.block_counter += 10 + block_counter = renderer.block_counter + classes, ids = parse_classes_and_ids(attrs.get("classes") or "") + return f'
' \ + f'{text}' \ + f'

[commentable {block_counter}]
\n' + + +def text_block(md): + """ + Custom plugin which supports block-like things: + + [[[ + some text + ]]] + + They are custom to vas3k blog + """ + md.block.register("text_block", TEXT_BLOCK_PATTERN, parse_text_block) + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("text_block", render_text_block) diff --git a/common/markdown/plugins/utils.py b/common/markdown/plugins/utils.py new file mode 100644 index 0000000..9c077e5 --- /dev/null +++ b/common/markdown/plugins/utils.py @@ -0,0 +1,16 @@ +def parse_classes(value): + return [ + klass for klass in str(value or "").split(".") if klass and not klass.startswith("#") + ] + + +def parse_ids(value): + return [ + klass[1:] for klass in str(value or "").split(".") if klass and klass.startswith("#") + ] + + +def parse_classes_and_ids(value): + classes = parse_classes(value) + ids = parse_ids(value) + return classes, ids diff --git a/common/parsers.py b/common/parsers.py new file mode 100644 index 0000000..554f4a3 --- /dev/null +++ b/common/parsers.py @@ -0,0 +1,53 @@ +import re +import requests +from bs4 import BeautifulSoup + +TAGS = { + "title": "title" +} +OG_TAGS = { + "og:image": "image", + "og:title": "title", + "og:description": "description", + "og:url": "url" +} +TWITTER_TAGS = { + "twitter:image": "image", + "twitter:image:src": "image", + "twitter:title": "title", + "twitter:description": "description", + "twitter:url": "url" +} +USERAGENT = "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) " \ + "Chrome/41.0.2228.0 Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + + +def parse_metatags(url): + try: + page = requests.get(url, headers={"User-Agent": USERAGENT}, timeout=5.0) + except: + return {} + + if not page.headers.get("Content-Type", "").startswith("text/html"): + return {} + + document = BeautifulSoup(page.text, "html.parser") + metadata = { + "title": document.title.string + } + + for meta in document.html.head.findAll(property=re.compile(r'^twitter')): + meta_property = meta.get("property") + if meta_property and meta_property in TWITTER_TAGS: + meta_content = meta.get("content") + if meta_content: + metadata[TWITTER_TAGS[meta_property]] = meta_content + + for meta in document.html.head.findAll(property=re.compile(r'^og')): + meta_property = meta.get("property") + if meta_property and meta_property in OG_TAGS: + meta_content = meta.get("content") + if meta_content: + metadata[OG_TAGS[meta_property]] = meta_content + + return metadata diff --git a/common/regexp.py b/common/regexp.py new file mode 100644 index 0000000..ae624bd --- /dev/null +++ b/common/regexp.py @@ -0,0 +1,29 @@ +import re + +IMAGE_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|jpeg|gif|png)") +VIDEO_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:mov|mp4|m4v|webm)") +YOUTUBE_RE = re.compile( + ( + r"http(?:s?):\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|playlist\?)|youtu\.be\/)" + r"(?:(?:(?<=\bv=)|(?<=youtu\.be\/))([\w\-\_]*))?(?:.*list=(PL[\w\-\_]*))?" + ) +) +TWITTER_RE = re.compile(r"(https?:\/\/twitter.com\/[a-zA-Z0-9_]+\/status\/[\d]+)") +FAVICON_RE = re.compile(r"(http(s?):)([/|.|\w|\s|-])*\.(?:jpg|jpeg|gif|png|ico)") + +EMOJI_RE = re.compile( + "[" + "\U0001F1E0-\U0001F1FF" # flags (iOS) + "\U0001F300-\U0001F5FF" # symbols & pictographs + "\U0001F600-\U0001F64F" # emoticons + "\U0001F680-\U0001F6FF" # transport & map symbols + "\U0001F700-\U0001F77F" # alchemical symbols + "\U0001F780-\U0001F7FF" # Geometric Shapes Extended + "\U0001F800-\U0001F8FF" # Supplemental Arrows-C + "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs + "\U0001FA00-\U0001FA6F" # Chess Symbols + "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A + "\U00002702-\U000027B0" # Dingbats + "\U000024C2-\U0001F251" + "]+" +) diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..d218eda --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,35 @@ +version: "3" +services: + club_app: + image: ghcr.io/vas3k/blog:${GITHUB_SHA:-latest} + command: make docker-run-production + container_name: blog_app + environment: + - PYTHONUNBUFFERED=1 + - DEBUG=false + - POSTGRES_DB=vas3k_blog + - POSTGRES_USER=vas3k + - POSTGRES_PASSWORD=vas3k + - POSTGRES_HOST=postgres + - EMAIL_HOST + - EMAIL_PORT + env_file: + - .env + restart: always + depends_on: + - postgres + ports: + - "127.0.0.1:8814:8814" + + postgres: + image: postgres:15 + container_name: blog_postgres + restart: always + environment: + POSTGRES_USER: vas3k + POSTGRES_PASSWORD: vas3k + POSTGRES_DB: vas3k_blog + volumes: + - /home/vas3k/pgdata:/var/lib/postgresql/data:rw + ports: + - "127.0.0.1:54334:5432" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c0aed9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: "3.7" + +services: + club_app: + build: + dockerfile: Dockerfile + context: . + command: make docker-run-dev + container_name: blog_app + environment: + - DEBUG=true + - PYTHONUNBUFFERED=1 + - POSTGRES_DB=vas3k_blog + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST=postgres + env_file: + - .env + restart: always + volumes: + - .:/app:delegated # enable hot code reload in debug mode + depends_on: + - postgres + ports: + - "8000:8000" + + postgres: + image: postgres:15 + container_name: blog_postgres + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=vas3k_blog + ports: + - "5432:5432" diff --git a/etc/nginx/vas3k.blog.conf b/etc/nginx/vas3k.blog.conf new file mode 100644 index 0000000..8e9862a --- /dev/null +++ b/etc/nginx/vas3k.blog.conf @@ -0,0 +1,65 @@ +limit_req_zone $binary_remote_addr zone=vas3k_blog_limit:10m rate=3r/s; + +server { + listen 80; + listen [::]:80; + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name www.vas3k.blog www.vas3k.ru vas3k.ru; + rewrite ^(.*) https://vas3k.blog$1 permanent; +} + +server { + listen 80; + listen [::]:80; + server_name vas3k.blog vas3k.ru; + return 301 https://vas3k.blog$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name vas3k.blog; + + charset utf-8; + client_max_body_size 15M; + index index.html index.htm; + + set_real_ip_from 172.17.0.0/16; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + rewrite ^/favicon.ico$ https://vas3k.blog/static/images/favicon.ico; + rewrite ^/favicon.png$ https://vas3k.blog/static/images/favicon_32.png; + + ssl_certificate /home/vas3k/certs/pubkey.pem; + ssl_certificate_key /home/vas3k/certs/privkey.pem; + + location /static/ { + root /home/vas3k/vas3k.blog/frontend/; + gzip_static on; + expires max; + add_header Cache-Control "public"; + } + + location / { + limit_req zone=vas3k_blog_limit burst=50 nodelay; + + add_header "Access-Control-Allow-Origin" "*"; + add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS"; + add_header "Access-Control-Allow-Headers" "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range"; + add_header "Access-Control-Expose-Headers" "Content-Length,Content-Range"; + add_header "Strict-Transport-Security" "max-age=31536000;includeSubDomains"; + add_header "X-Content-Type-Options" "nosniff"; + add_header "Referrer-Policy" "strict-origin-when-cross-origin"; + add_header "Permissions-Policy" "accelerometer=(),camera=(),geolocation=(),gyroscope=(),magnetometer=(),microphone=(),payment=(),usb=()"; + + proxy_set_header "Host" $http_host; + proxy_set_header "X-Forwarded-For" $proxy_add_x_forwarded_for; + proxy_set_header "X-Forwarded-Proto" $scheme; + proxy_redirect off; + proxy_buffering off; + + proxy_pass http://0.0.0.0:8020; + } +} diff --git a/frontend/html/clickers/clicker.html b/frontend/html/clickers/clicker.html new file mode 100755 index 0000000..6ce987a --- /dev/null +++ b/frontend/html/clickers/clicker.html @@ -0,0 +1,10 @@ +
+
+ {{ votes }} + {{ clicker }} + + + + +
+
diff --git a/frontend/html/club/block_placeholder.html b/frontend/html/club/block_placeholder.html new file mode 100644 index 0000000..282a903 --- /dev/null +++ b/frontend/html/club/block_placeholder.html @@ -0,0 +1,5 @@ + diff --git a/frontend/html/comments/comment-form.html b/frontend/html/comments/comment-form.html new file mode 100644 index 0000000..0e601c8 --- /dev/null +++ b/frontend/html/comments/comment-form.html @@ -0,0 +1,27 @@ +
+ {% csrf_token %} + + +
+
+ +
+ + +
+ + +
\ No newline at end of file diff --git a/frontend/html/comments/comment-list.html b/frontend/html/comments/comment-list.html new file mode 100755 index 0000000..f38d0d9 --- /dev/null +++ b/frontend/html/comments/comment-list.html @@ -0,0 +1,24 @@ +{% load text_filters %} +{% load comments %} + +
+ {% if post.is_commentable or comments|length > 0 %} + Комментарии 👇 + {% endif %} + +
+ {% for comment in comments|without_inline_comments %} + {% include "comments/comment.html" %} + {% endfor %} +
+
+ +{% if post.is_commentable %} + {% if request.user.is_authenticated %} + {% include "comments/comment-form.html" %} + {% else %} + + {% endif %} +{% endif %} \ No newline at end of file diff --git a/frontend/html/comments/comment.html b/frontend/html/comments/comment.html new file mode 100644 index 0000000..08a8ae0 --- /dev/null +++ b/frontend/html/comments/comment.html @@ -0,0 +1,42 @@ +{% load comments %} +{% load humanize %} +
+
+ {% if comment.user %} + + {% else %} + + {% endif %} + + + {{ comment.author_name }} + + + {{ comment.natural_created_at }} + + # + +
{{ comment.upvotes }}
+
+ +
+ {% show_comment comment %} +
+ + {% if request.user.is_superuser %} + + {% endif %} +
diff --git a/frontend/html/comments/form.html b/frontend/html/comments/form.html new file mode 100755 index 0000000..834c36e --- /dev/null +++ b/frontend/html/comments/form.html @@ -0,0 +1,34 @@ +{% if request.user.is_authenticated %} +
+ {% csrf_token %} + + +
+
+ +
+ + +
+ + +
+{% else %} + +{% endif %} diff --git a/frontend/html/comments/inline-comment-form.html b/frontend/html/comments/inline-comment-form.html new file mode 100644 index 0000000..27890d5 --- /dev/null +++ b/frontend/html/comments/inline-comment-form.html @@ -0,0 +1,18 @@ +
+
+ {% csrf_token %} + + +
+ + +
+
+
diff --git a/frontend/html/comments/inline-comment-list.html b/frontend/html/comments/inline-comment-list.html new file mode 100755 index 0000000..f5a9983 --- /dev/null +++ b/frontend/html/comments/inline-comment-list.html @@ -0,0 +1,32 @@ +{% load comments %} +{% load text_filters %} + +
+
+ {% if block_comments|length > 0 %} + {{ block_comments|length }} {{ block_comments|length|rupluralize:"комментарий,комментария,комментариев" }} + {% else %} + Комментировать + {% endif %} +
+ +
+ {% if block_comments %} +
+ {% for comment in block_comments %} + {% include "comments/inline-comment.html" %} + {% endfor %} +
+ {% endif %} + + {% if post.is_commentable %} + {% if request.user.is_authenticated %} + {% include "comments/inline-comment-form.html" %} + {% else %} + + {% endif %} + {% endif %} +
+
diff --git a/frontend/html/comments/inline-comment.html b/frontend/html/comments/inline-comment.html new file mode 100644 index 0000000..fd90d07 --- /dev/null +++ b/frontend/html/comments/inline-comment.html @@ -0,0 +1,31 @@ +{% load comments %} +
+
+
{{ comment.upvotes }}
+
+ +
+ {{ comment.author_name }} +
+ +
+ {% show_comment comment %} +
+ + {% if request.user.is_superuser %} + + {% endif %} +
\ No newline at end of file diff --git a/frontend/html/comments/partials/create-comment-response.html b/frontend/html/comments/partials/create-comment-response.html new file mode 100644 index 0000000..6dda687 --- /dev/null +++ b/frontend/html/comments/partials/create-comment-response.html @@ -0,0 +1,5 @@ +
+ {% include "comments/comment.html" %} +
+ +{% include "comments/comment-form.html" %} diff --git a/frontend/html/comments/partials/create-inline-comment-response.html b/frontend/html/comments/partials/create-inline-comment-response.html new file mode 100644 index 0000000..ead4d0f --- /dev/null +++ b/frontend/html/comments/partials/create-inline-comment-response.html @@ -0,0 +1,3 @@ +{% include "comments/inline-comment.html" %} + +{% include "comments/inline-comment-form.html" %} diff --git a/frontend/html/common/contacts.html b/frontend/html/common/contacts.html new file mode 100644 index 0000000..a87ce23 --- /dev/null +++ b/frontend/html/common/contacts.html @@ -0,0 +1,36 @@ +{% load static %} +
+ + Телеграм-канал + + + + + Твиттер + + + + + Мастодон + + + + + Патреон + + + + + Гитхаб + + + + + me@vas3k.ru + + + + + RSS + +
\ No newline at end of file diff --git a/frontend/html/common/favicon.html b/frontend/html/common/favicon.html new file mode 100644 index 0000000..daf617b --- /dev/null +++ b/frontend/html/common/favicon.html @@ -0,0 +1,7 @@ +{% load static %} + + + + + + diff --git a/frontend/html/common/header.html b/frontend/html/common/header.html new file mode 100644 index 0000000..fa8c579 --- /dev/null +++ b/frontend/html/common/header.html @@ -0,0 +1,49 @@ +{% load static %} + + + + diff --git a/frontend/html/common/paginator.html b/frontend/html/common/paginator.html new file mode 100755 index 0000000..b2810df --- /dev/null +++ b/frontend/html/common/paginator.html @@ -0,0 +1,28 @@ +
+{% if items and num_pages > 1 %} +
+ {% if items.has_previous %} + + {% endif %} + + {% if show_first %} + 1 + ... + {% endif %} + + {% for page in page_numbers %} + + {{ page }} + + {% endfor %} + + {% if show_last %} + ... + {{ num_pages }} + {% endif %} + + {% if items.has_next %} + + {% endif %} +
+{% endif %} diff --git a/frontend/html/common/post_footer.html b/frontend/html/common/post_footer.html new file mode 100644 index 0000000..29f3858 --- /dev/null +++ b/frontend/html/common/post_footer.html @@ -0,0 +1,22 @@ +{% load static %} + + diff --git a/frontend/html/common/post_related.html b/frontend/html/common/post_related.html new file mode 100644 index 0000000..a1052b8 --- /dev/null +++ b/frontend/html/common/post_related.html @@ -0,0 +1,12 @@ +{% load static %} +{% load text_filters %} +{% load posts %} + +
+
Еще? Тогда вот
+
+ {% for post in related %} + {% include "posts/cards/horizontal.html" %} + {% endfor %} +
+
diff --git a/frontend/html/common/post_subscribe.html b/frontend/html/common/post_subscribe.html new file mode 100644 index 0000000..e2b6038 --- /dev/null +++ b/frontend/html/common/post_subscribe.html @@ -0,0 +1,38 @@ +
+
+ ✅ Подписка на Вастрика +
+ +
+ 🚨 Роскомнадзор банит одну соцсеть за другой, + поэтому я рекомендую подписаться как по почте (её сложнее заблокировать), + так и на вашей любимой площадке. Тогда у нас будет хотя бы два канала для связи. +
+ +
+
+ +
+ {% csrf_token %} + + + +
+
+ * никакого спама, только мои уведомления +
+
+
+ +
+ Другие площадки 👇 +
+ +
+ Телеграм-канал + Патреон + Мастодон + Твиттер + RSS +
+
diff --git a/frontend/html/common/rss.html b/frontend/html/common/rss.html new file mode 100644 index 0000000..4be0116 --- /dev/null +++ b/frontend/html/common/rss.html @@ -0,0 +1 @@ + diff --git a/frontend/html/common/scripts.html b/frontend/html/common/scripts.html new file mode 100644 index 0000000..3c12e66 --- /dev/null +++ b/frontend/html/common/scripts.html @@ -0,0 +1,14 @@ +{% load static %} + + + + + + + + + diff --git a/frontend/html/common/styles.html b/frontend/html/common/styles.html new file mode 100644 index 0000000..74d6722 --- /dev/null +++ b/frontend/html/common/styles.html @@ -0,0 +1,13 @@ +{% load static %} + + + + + + + + + + + + diff --git a/frontend/html/donate.html b/frontend/html/donate.html new file mode 100644 index 0000000..cfa6389 --- /dev/null +++ b/frontend/html/donate.html @@ -0,0 +1,172 @@ +{% extends "layout.html" %} +{% load static %} +{% load text_filters %} + +{% block title %} + Донат — {{ settings.TITLE }} +{% endblock %} + +{% block meta %} + + + + + + + + + + +{% endblock %} + +{% block body %} +
+ + + + + + + + + + + + +
+ + +{% endblock %} diff --git a/frontend/html/emails/layout.html b/frontend/html/emails/layout.html new file mode 100644 index 0000000..f272947 --- /dev/null +++ b/frontend/html/emails/layout.html @@ -0,0 +1,144 @@ + + + + Вастрик.ру + + + + + + + + +
+ {% block preview %}{% endblock %} +
+ + + + + +
+ +
+ +
+
+

+ {% block title %}{% endblock %}
+ {% block subtitle %}{% endblock %} +

+
+ + +
+ {% block body %}{% endblock %} +
+ +



+ + + + +
+ + + \ No newline at end of file diff --git a/frontend/html/emails/new_post.html b/frontend/html/emails/new_post.html new file mode 100644 index 0000000..38bf404 --- /dev/null +++ b/frontend/html/emails/new_post.html @@ -0,0 +1,60 @@ +{% extends "emails/layout.html" %} +{% load text_filters %} +{% load posts %} + +{% block preview %} + {{ post.title }}{% if post.subtitle %}. {{ post.subtitle }}{% endif %} +{% endblock %} + +{% block title %} + {{ post.title }} +{% endblock %} + +{% block subtitle %} + Новый пост на Вастрике +{% endblock %} + +{% block body %} +

+ Привет, Олимпийский! +

+ +

+ Это Вастрик. У меня в блоге сегодня вышел новый пост и я, как и обещал, присылаю вам его одними из первых. +

+ + + {{ post.title }} + + + {% if post.preview_text %} +

+ {{ post.preview_text | markdown | safe }} +

+ {% endif %} + +

+ + 👉 Читать пост + +

+ +




+ +
+ +

+ Я стараюсь слать письма только тем, кто их хочет и читает. + Поэтому вот вам большая кнопка, чтобы вы любой момент могли отписаться от моих писем и удалить свой e-mail из базы. +

+ +

+ + ☠️ Отписаться + +

+{% endblock %} + +{% block footer %} + Отписаться +{% endblock %} diff --git a/frontend/html/emails/opt_in.html b/frontend/html/emails/opt_in.html new file mode 100644 index 0000000..2512853 --- /dev/null +++ b/frontend/html/emails/opt_in.html @@ -0,0 +1,30 @@ +{% extends "emails/layout.html" %} + +{% block title %} + Подтверждение подписки +{% endblock %} + +{% block body %} +

+ Привет! +

+ +

+ Кто-то, возможно даже вы, хочет получать новые посты с блога Вастрика на этот адрес. + Нажмите сюда, чтобы подтвердить это 👇 +

+ +

+ + 👍 Да, я хочу подписаться + +

+ +

+ +

+ Если вы не знаете о чём идет речь, значит кто-то по ошибке ввёл адрес вашей почты. + Просто проигнорируйте это письмо или нажмите сюда, чтобы полностью стереть свой e-mail из базы. + В любом случае, без вашего согласия по кнопке выше я ничего слать не буду. +

+{% endblock %} diff --git a/frontend/html/error.html b/frontend/html/error.html new file mode 100755 index 0000000..f5d80df --- /dev/null +++ b/frontend/html/error.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+ + {% if title %} +
{{ title }}
+ {% endif %} + +
+ {{ message | safe }} +
+
+{% endblock %} + diff --git a/frontend/html/index.html b/frontend/html/index.html new file mode 100755 index 0000000..cfc7480 --- /dev/null +++ b/frontend/html/index.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} + +{% block body %} + {% for block in blocks %} + {% include block.template %} + {% endfor %} +{% endblock %} diff --git a/frontend/html/index/about.html b/frontend/html/index/about.html new file mode 100644 index 0000000..feedcb6 --- /dev/null +++ b/frontend/html/index/about.html @@ -0,0 +1,27 @@ +
+
+

+ Привет 👋 Я Вастрик! +

+
+ +
+

+ В этом блоге я пишу о технологиях, всратом айти и выживании в творящемся вокруг киберпанке. Делаю это уже больше 10 лет. +

+ +

+ Я программист, техлид. Живу в Берлине 🇩🇪 и нежно люблю его. + До этого жил в Литве 🇱🇹, а рос и учился и Новосибирске 🥶 +

+ +

+ У меня есть пёс, Бус и Клуб. + Ну и еще мелкие пет-проджекты. +

+
+ +
+ {% include "common/contacts.html" %} +
+
diff --git a/frontend/html/index/main.html b/frontend/html/index/main.html new file mode 100644 index 0000000..b3813c8 --- /dev/null +++ b/frontend/html/index/main.html @@ -0,0 +1,7 @@ +
+
+ {% with post=block.post %} + {% include "posts/cards/aspect.html" %} + {% endwith %} +
+
diff --git a/frontend/html/index/posts2.html b/frontend/html/index/posts2.html new file mode 100644 index 0000000..1703b9b --- /dev/null +++ b/frontend/html/index/posts2.html @@ -0,0 +1,17 @@ +
+ {% if block.title %} +
+ {% if block.url %} + {{ block.title }} + {% else %} + {{ block.title }} + {% endif %} +
+ {% endif %} + +
+ {% for post in block.posts %} + {% include "posts/cards/horizontal.html" %} + {% endfor %} +
+
diff --git a/frontend/html/index/posts3.html b/frontend/html/index/posts3.html new file mode 100644 index 0000000..b46f66c --- /dev/null +++ b/frontend/html/index/posts3.html @@ -0,0 +1,18 @@ +
+ {% if block.title %} +
+ {% if block.url %} + {{ block.title }} + {% else %} + {{ block.title }} + {% endif %} +
+ {% endif %} + +
+ {% for post in block.posts %} + {% include "posts/cards/horizontal.html" %} + {% endfor %} +
+
+ diff --git a/frontend/html/index/posts4.html b/frontend/html/index/posts4.html new file mode 100644 index 0000000..dab16cc --- /dev/null +++ b/frontend/html/index/posts4.html @@ -0,0 +1,23 @@ +
+ {% if block.title %} +
+ {% if block.url %} + {{ block.title }} + {% else %} + {{ block.title }} + {% endif %} +
+ {% endif %} + + {% if block.subtitle %} +
+ {{ block.subtitle }} +
+ {% endif %} + +
+ {% for post in block.posts %} + {% include "posts/cards/vertical.html" %} + {% endfor %} +
+
\ No newline at end of file diff --git a/frontend/html/index/projects.html b/frontend/html/index/projects.html new file mode 100644 index 0000000..cf5f43e --- /dev/null +++ b/frontend/html/index/projects.html @@ -0,0 +1,30 @@ +
+ {% if block.title %} +
+ {{ block.title }} +
+ {% endif %} + + +
diff --git a/frontend/html/layout.html b/frontend/html/layout.html new file mode 100644 index 0000000..4c11356 --- /dev/null +++ b/frontend/html/layout.html @@ -0,0 +1,69 @@ +{% load static %} + + + {% block title %}{{ settings.TITLE | safe }}{% endblock %} + + + + + + + + + {% block meta %} + + + + + + + + + + + + {% endblock %} + {% include "common/rss.html" %} + {% include "common/favicon.html" %} + {% include "common/styles.html" %} + {% include "common/scripts.html" %} + {% block css %}{% endblock %} + + + {% block menu %} + {% include "common/header.html" %} + {% endblock %} + + {% block body %}{% endblock %} + + {% block footer %} +
+ {% block footer_contacts %} + + {% endblock %} + + +
+ {% endblock %} + + {% block js %}{% endblock %} + + diff --git a/frontend/html/message.html b/frontend/html/message.html new file mode 100755 index 0000000..c86d8c9 --- /dev/null +++ b/frontend/html/message.html @@ -0,0 +1,15 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+ + {% if title %} +
{{ title }}
+ {% endif %} + +
+ {{ message | safe }} +
+
+{% endblock %} diff --git a/frontend/html/posts/cards/aspect.html b/frontend/html/posts/cards/aspect.html new file mode 100644 index 0000000..9ff6d10 --- /dev/null +++ b/frontend/html/posts/cards/aspect.html @@ -0,0 +1,18 @@ +{% load text_filters %} +{% load posts %} + + + {% if post.is_members_only %} + + {% if me %}{% else %}{% endif %} + + {% endif %} +  {{ post.view_count|cool_number }} + + {{ post.title }} + {% if post.subtitle %} + + {{ post.subtitle }} + {% endif %} + + diff --git a/frontend/html/posts/cards/horizontal.html b/frontend/html/posts/cards/horizontal.html new file mode 100644 index 0000000..8dcc78a --- /dev/null +++ b/frontend/html/posts/cards/horizontal.html @@ -0,0 +1,17 @@ +{% load text_filters %} +{% load posts %} + + {% if post.is_members_only %} + + {% if me %}{% else %}{% endif %} + + {% endif %} +  {{ post.view_count|cool_number }} + + {{ post.title }} + {% if post.subtitle %} + + {{ post.subtitle }} + {% endif %} + + diff --git a/frontend/html/posts/cards/vertical.html b/frontend/html/posts/cards/vertical.html new file mode 100644 index 0000000..5248c0c --- /dev/null +++ b/frontend/html/posts/cards/vertical.html @@ -0,0 +1,17 @@ +{% load text_filters %} +{% load posts %} + + {% if post.is_members_only %} + + {% if me %}{% else %}{% endif %} + + {% endif %} +  {{ post.view_count|cool_number }} + + {{ post.title }} + {% if post.subtitle %} + + {{ post.subtitle }} + {% endif %} + + diff --git a/frontend/html/posts/edit.html b/frontend/html/posts/edit.html new file mode 100644 index 0000000..360df1a --- /dev/null +++ b/frontend/html/posts/edit.html @@ -0,0 +1,55 @@ +{% extends "layout.html" %} + +{% block body %} +
+
+ {% csrf_token %} + + {% if form.errors %} +
{{ form.errors }}
+ {% endif %} + +
+ + {{ form.title }} +
+ +
+ + {{ form.subtitle }} +
+ +
+ + {{ form.image }} +
+ +
+ {{ form.text }} +
+ +
+ +
+
+
+{% endblock %} + +{% block js %} + + + +{% endblock %} diff --git a/frontend/html/posts/full/blog.html b/frontend/html/posts/full/blog.html new file mode 100755 index 0000000..3acfd7b --- /dev/null +++ b/frontend/html/posts/full/blog.html @@ -0,0 +1 @@ +{% extends "posts/full/layout.html" %} diff --git a/frontend/html/posts/full/headlines/headline-cover.html b/frontend/html/posts/full/headlines/headline-cover.html new file mode 100644 index 0000000..57744e7 --- /dev/null +++ b/frontend/html/posts/full/headlines/headline-cover.html @@ -0,0 +1,40 @@ +{% load text_filters %} +{% load posts %} + + + +
+ {% if post.image %} +
+ +
+ {% endif %} + +
+
+ {% if request.user.is_superuser %}✏️{% endif %} + {{ post.title | safe }} +
+ {% if post.subtitle %} +
+
{{ post.subtitle | safe }}
+ {% endif %} +
+
{{ post.published_at|date:"d E Y"|lower }} — {{ post.comment_count }} {{ post.comment_count|rupluralize:"комментарий,комментария,комментариев" }} — {{ post.view_count }} {{ post.view_count|rupluralize:"просмотр,просмотра,просмотров" }} — {{ post.word_count }} {{ post.word_count|rupluralize:"слово,слова,слов" }}
+ +
+
diff --git a/frontend/html/posts/full/headlines/headline-simple.html b/frontend/html/posts/full/headlines/headline-simple.html new file mode 100644 index 0000000..781eaed --- /dev/null +++ b/frontend/html/posts/full/headlines/headline-simple.html @@ -0,0 +1,37 @@ +{% load text_filters %} +{% load posts %} + + + +
+
{{ post.published_at | date:"d E Y" | lower }}
+ +
+ {% if request.user.is_superuser %}✏️{% endif %} + {{ post.title | safe }} +
+ + + + {% if post.subtitle %} +
{{ post.subtitle | safe }}
+ {% endif %} + + {% if post.is_members_only %} +
+    пост только для своих +
+ {% endif %} + + {% if post.image %} +
+ +
+ {% endif %} +
diff --git a/frontend/html/posts/full/layout.html b/frontend/html/posts/full/layout.html new file mode 100755 index 0000000..1b67ffb --- /dev/null +++ b/frontend/html/posts/full/layout.html @@ -0,0 +1,75 @@ +{% extends "layout.html" %} +{% load static %} +{% load text_filters %} +{% load posts %} +{% load comments %} + +{% block title %} + {{ post.title }}{% if post.subtitle %} — {{ post.subtitle }}{% endif %} — {{ post_type_config.name }} {{ block.super }} +{% endblock %} + +{% block body_class %} + {% if "body_class" in post.data %}{{ post.data.body_class }}{% endif %} +{% endblock %} + +{% block body_styles %} + {% if "background_color" in post.data %}background-color: {{ post.data.background_color }};{% endif %} + {% if "color" in post.data %}color: {{ post.data.color }};{% endif %} +{% endblock %} + +{% block meta %} + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block body %} +
+ {% block headline %} +
+ {% include "posts/full/headlines/headline-cover.html" %} +
+ {% endblock %} + + {% block post %} +
+
+ {% show_post post %} +
+
+ {% endblock %} + + {% block post_footer %} +
+ {% include "common/post_footer.html" %} +
+ {% endblock %} + + {% block comments %} +
+ {% include "comments/comment-list.html" %} +
+ {% endblock %} + + {% block post_related %} +
+ {% include "common/post_related.html" %} +
+ {% endblock %} +
+{% endblock %} diff --git a/frontend/html/posts/full/legacy/challenge.html b/frontend/html/posts/full/legacy/challenge.html new file mode 100644 index 0000000..d8ecaa1 --- /dev/null +++ b/frontend/html/posts/full/legacy/challenge.html @@ -0,0 +1,335 @@ +{% extends "layout.html" %} +{% load static %} +{% load text_filters %} +{% load posts %} +{% load comments %} + +{% block title %} + {{ post.title }} — {{ block.super }} +{% endblock %} + +{% block meta %} + + + + + + + + + + +{% endblock %} + +{% block body_styles %} + {% if post.data and "background_color" in post.data %}background-color: {{ post.data.background_color }};{% endif %} + {% if post.data and "color" in post.data %}color: {{ post.data.color }};{% endif %} +{% endblock %} + +{% block css %} + +{% endblock %} + +{% block body %} +
+
+
+

{{ post.title }}

+ +

{{ post.text | nl2br | safe }}

+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% show_post post %} + +
+

Результат
+ 0 из 10 +

+ +
+ + + + + + + + + +
+ начать заново +
+
+
+
+{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/frontend/html/posts/full/legacy/gallery.html b/frontend/html/posts/full/legacy/gallery.html new file mode 100755 index 0000000..2bc74c1 --- /dev/null +++ b/frontend/html/posts/full/legacy/gallery.html @@ -0,0 +1,8 @@ +{% extends "full/../layout.html" %} +{% load posts %} + +{% block headline %} + +{% endblock %} diff --git a/frontend/html/posts/full/legacy/inside.html b/frontend/html/posts/full/legacy/inside.html new file mode 100644 index 0000000..4c88a23 --- /dev/null +++ b/frontend/html/posts/full/legacy/inside.html @@ -0,0 +1,497 @@ +{% load text_filters %} +{% load posts %}{% if post.html %}{{ post.html|safe }}{% else %} + + + + + + + + + + + + + + + + + +Вастрик.Инсайд #{{ post.slug }}: {{ post.title }} + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + +{% endif %} \ No newline at end of file diff --git a/frontend/html/posts/full/notes.html b/frontend/html/posts/full/notes.html new file mode 100755 index 0000000..b7cef44 --- /dev/null +++ b/frontend/html/posts/full/notes.html @@ -0,0 +1,8 @@ +{% extends "posts/full/layout.html" %} +{% load posts %} + +{% block headline %} +
+ {% include "posts/full/headlines/headline-simple.html" %} +
+{% endblock %} diff --git a/frontend/html/posts/lists/all.html b/frontend/html/posts/lists/all.html new file mode 100755 index 0000000..eb88c95 --- /dev/null +++ b/frontend/html/posts/lists/all.html @@ -0,0 +1,31 @@ +{% extends "posts/lists/layout.html" %} +{% load static %} +{% load text_filters %} +{% load posts %} +{% load paginator %} + +{% block body %} +
+ {% for post_card_type, posts in post_blocks %} + {% if post_card_type == "posts/cards/vertical.html" %} +
+ {% for post in posts %} + {% include "posts/cards/vertical.html" %} + {% endfor %} +
+ {% else %} +
+ {% for post in posts %} + {% include "posts/cards/horizontal.html" %} + {% endfor %} +
+ {% endif %} +
+ {% endfor %} + +
+ {% paginator posts %} +
+
+{% endblock %} + diff --git a/frontend/html/posts/lists/blog.html b/frontend/html/posts/lists/blog.html new file mode 100755 index 0000000..e7d1667 --- /dev/null +++ b/frontend/html/posts/lists/blog.html @@ -0,0 +1 @@ +{% extends "posts/lists/layout.html" %} diff --git a/frontend/html/posts/lists/layout.html b/frontend/html/posts/lists/layout.html new file mode 100755 index 0000000..9604d44 --- /dev/null +++ b/frontend/html/posts/lists/layout.html @@ -0,0 +1,30 @@ +{% extends "layout.html" %} +{% load static %} +{% load text_filters %} +{% load posts %} +{% load paginator %} + +{% block title %} + {{ post_type_config.name }} — {{ settings.TITLE }} +{% endblock %} + +{% block meta %} + + + + + +{% endblock %} + +{% block body %} +
+
+ {% for post in posts %} + {% include "posts/cards/aspect.html" %} + {% endfor %} +
+ + {% paginator posts %} +
+{% endblock %} + diff --git a/frontend/html/posts/lists/notes.html b/frontend/html/posts/lists/notes.html new file mode 100755 index 0000000..e7d1667 --- /dev/null +++ b/frontend/html/posts/lists/notes.html @@ -0,0 +1 @@ +{% extends "posts/lists/layout.html" %} diff --git a/frontend/html/subscribe.html b/frontend/html/subscribe.html new file mode 100644 index 0000000..a1dfc3e --- /dev/null +++ b/frontend/html/subscribe.html @@ -0,0 +1,26 @@ +{% extends "layout.html" %} +{% load static %} +{% load text_filters %} + +{% block title %} + Подписаться — {{ block.super }} +{% endblock %} + +{% block meta %} + + + + + + + + + + +{% endblock %} + +{% block body %} +
+ {% include "common/post_subscribe.html" %} +
+{% endblock %} diff --git a/frontend/html/users/login.html b/frontend/html/users/login.html new file mode 100644 index 0000000..af72376 --- /dev/null +++ b/frontend/html/users/login.html @@ -0,0 +1,35 @@ +{% extends "layout.html" %} + +{% block title %} + Вход — {{ block.super }} +{% endblock %} + +{% block body %} +
+
Входить здесь 👤
+ +
+ Раньше комментарии были открыты для всех, но со временем в них стало много идиотизма и спама, + поэтому теперь комментарии могут писать только мои патроны. +
+ + + +
+ Однако, в тестовом режиме у нас есть еще и авторизация через телеграм, с ограничением 1 комментарий в сутки. +
+ +
+ +
+
+{% endblock %} + diff --git a/frontend/html/users/post_access_denied.html b/frontend/html/users/post_access_denied.html new file mode 100644 index 0000000..b4a7e21 --- /dev/null +++ b/frontend/html/users/post_access_denied.html @@ -0,0 +1,26 @@ +{% extends "posts/full/layout.html" %} + +{% block body %} +
+
+
+
+ {% include "posts/cards/vertical.html" %} +
+ +
+

🎩 Пост только для своих

+ +

+ К сожалению, некоторые темы приходится обсуждать вдали от большого интернета. Эта — одна из них. +

+ +

+ Пост доступен только тем, кто заносит мне донаты. Такая схема отлично позволяет отсеивать проходимцев. +

+ + Войти
+
+
+
+{% endblock %} diff --git a/frontend/html/users/profile.html b/frontend/html/users/profile.html new file mode 100644 index 0000000..14db954 --- /dev/null +++ b/frontend/html/users/profile.html @@ -0,0 +1,59 @@ +{% extends "layout.html" %} +{% load static %} +{% load text_filters %} +{% load posts %} + +{% block title %} + {{ user.username }} — {{ block.super }} +{% endblock %} + +{% block menu %} + {% include "common/header.html" %} +{% endblock %} + +{% block body %} +
+
+
+ {% if request.user.is_superuser %} + Админка + {% endif %} + + Выйти +
+ +
👤 Настройки профиля
+ +
+ {% csrf_token %} +
+ + {{ form.username }} +
+
+ +
+ {% for avatar_field in form.avatar %} +
+ + {{ avatar_field.tag }} +
+ {% endfor %} +
+ + {% if form.errors %} +
+ {{ form.errors }} +
+ {% endif %} +
+ + +
+
+
+{% endblock %} + +{% block footer_contacts %}{% endblock %} diff --git a/frontend/static/css/base.css b/frontend/static/css/base.css new file mode 100644 index 0000000..7fc3e21 --- /dev/null +++ b/frontend/static/css/base.css @@ -0,0 +1,362 @@ +h1, +.header-1 { + text-align: center; + font-weight: 500; + font-size: 270%; + line-height: 1.3em; + padding: 150px 0 50px; + margin: 0 auto; + max-width: var(--max-content-width); +} + +h2, +.header-2 { + text-align: center; + font-weight: 500; + font-size: 200%; + line-height: 1.3em; + padding: 70px 0 30px; + margin: 0 auto; + max-width: var(--max-content-width); +} + +h3, +.header-3 { + text-align: left; + font-weight: 500; + font-size: 170%; + line-height: 1.3em; + padding: 30px 0 20px; + margin: 0 auto; + max-width: var(--max-content-width); +} + +.header-1 > a, +.header-2 > a, +.header-3 > a, +.header-4 > a { + color: var(--text-color); + text-decoration: none; +} + +h1 + h2, +.header-1 + .header-2, +h2 + h3, +.header-2 + .header-3 { + padding-top: 10px; +} + +.icon { + width: 14px; + height: 14px; + vertical-align: middle; + opacity: 0.8; +} + +.clearfix { + width: 100%; + height: 0; + display: block; + clear: both; +} + +.clearfix10 { + width: 100%; + height: 10px; + display: block; + clear: both; +} + +.clearfix20 { + width: 100%; + height: 20px; + display: block; + clear: both; +} + +.clearfix50 { + width: 100%; + height: 50px; + display: block; + clear: both; +} + +.clearfix100 { + width: 100%; + height: 100px; + display: block; + clear: both; +} + +.clickable { + cursor: pointer; +} + +.block { + padding: 30px; + margin-bottom: 30px; + box-sizing: border-box; + box-shadow: var(--block-shadow); + background-color: var(--block-bg-color); + border-radius: var(--block-border-radius); +} + + .block-title { + display: block; + width: 95%; + clear: both; + text-align: center; + font-size: 240%; + font-weight: 500; + margin: 0 auto; + padding: 70px 0 30px; + box-sizing: border-box; + } + + .block-description { + width: 95%; + font-size: 120%; + max-width: 800px; + text-align: center; + margin: 0 auto; + padding: 0 0 70px; + box-sizing: border-box; + } + + .block-description-center { + text-align: center; + } + + .block-icon { + font-size: 50px; + text-align: center; + padding: 30px 10px; + } + +.avatar { + display: inline-block; + width: 42px; + height: 42px; + border-radius: 50%; + background-size: cover; + background-color: var(--block-bg-color); + color: var(--opposite-text-color); +} + +.button { + display: inline-block; + padding: 15px 18px; + box-sizing: border-box; + text-decoration: none; + border-radius: var(--button-border-radius); + background-color: var(--button-bg-color); + border: var(--button-border); + color: var(--button-color); + text-align: center; + cursor: pointer; + line-height: 1em; + font-weight: 500; +} + + .button-round { + border-radius: 50%; + } + + .button:hover { + color: var(--button-hover-color); + background-color: var(--button-hover-bg-color); + border: var(--button-hover-border); + } + + .button:disabled { + background-color: var(--button-disabled-bg-color); + border-color: var(--button-disabled-bg-color); + } + + .button:disabled:hover { + color: var(--button-color); + background-color: var(--button-disabled-bg-color); + border-color: var(--button-disabled-bg-color); + } + + .button-inverted { + background-color: var(--button-color); + color: var(--button-bg-color); + } + + .button-inverted:hover { + background-color: var(--button-bg-color); + color: var(--button-color); + } + + .button-big { + padding: 15px 30px; + font-size: 160%; + font-weight: bold; + margin: 50px auto 0; + } + + .button-huuuuge { + padding: 20px 45px; + font-size: 200%; + font-weight: bold; + margin: 50px auto 0; + } + + .button-small { + padding: 5px 10px; + font-size: 90%; + font-weight: normal; + } + + .button-red { + background-color: #ff1917; + border-color: #ff1917; + } + + .button-blue { + background-color: #0088cc; + border-color: #0088cc; + } + + +.form { + display: flex; + flex-direction: column; + gap: 30px; + font-size: 130%; + width: 100%; +} + + .form-row { + display: flex; + flex-wrap: wrap; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + width: 100%; + } + + .form-row input, + .form-row textarea { + padding: 10px 15px; + } + + .form-row-row { + flex-direction: row; + } + + .form-row-right { + justify-content: flex-end; + align-items: flex-end; + } + +.clicker { + display: inline-block; + position: relative; + padding: 4px 15px 5px 10px; + margin-bottom: 20px; + min-height: 35px; + max-width: 340px; + color: #333; + border-radius: 7px; + background-color: rgba(128, 128, 128, 0.6); + font-family: var(--sans-font); + -webkit-transition: background-color 0.1s ease-in, color 0.1s ease-in; + -moz-transition: background-color 0.1s ease-in, color 0.1s ease-in; + transition: background-color 0.1s ease-in, color 0.1s ease-in; +} + + .clicker:not(.status-voted):hover { + background-color: rgba(0, 0, 0, 0.6); + color: #EEE; + } + + .clicker.status-voted { + background-color: #16a085; + color: #EEE; + } + + .clicker.status-error { + background-color: #c0392b; + color: #EEE; + } + + .clicker-text { + z-index: 10; + position: relative; + font-size: 16px; + font-weight: 400; + margin-left: 40px; + min-height: 35px; + vertical-align: middle; + line-height: 1.3em; + display: flex; + justify-content: center; + flex-direction: column; + } + + .clicker-votes { + position: absolute; + left: 10px; + top: 50%; + margin-top: -15px; + display: inline-block; + z-index: 10; + color: #333; + width: 30px; + height: 30px; + text-align: center; + font-size: 14px; + font-weight: 500; + vertical-align: middle; + line-height: 30px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.9); + } + + .clicker-button { + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + border: none; + outline: none; + background-color: transparent; + z-index: 30; + } + + .clicker.status-voted .clicker-button { + cursor: auto; + } + +.show-on-iphone { + display: none !important; +} + +@media only screen and (max-width : 570px) { + .hide-on-iphone { + display: none !important; + } + + .show-on-iphone { + display: block !important; + } +} + +@media only screen and (max-width : 1024px) { + .hide-on-ipad { + display: none; + } +} + +img.emoji { + height: 1.2em; + width: 1.2em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; +} \ No newline at end of file diff --git a/frontend/static/css/cards.css b/frontend/static/css/cards.css new file mode 100644 index 0000000..dfe491a --- /dev/null +++ b/frontend/static/css/cards.css @@ -0,0 +1,177 @@ +.card { + display: block; + overflow: hidden; + position: relative; + width: 100%; + min-width: 300px; + min-height: 200px; + aspect-ratio: 3 / 2; + background-size: cover; + background-repeat: no-repeat; + background-position: 50% 50%; + -webkit-transition: all 0.1s linear; + box-shadow: 10px 10px 30px #CCC; + transition: all 0.5s; + transform: translateZ(0); + border-radius: 30px; +} + + .card:hover { + transform: scale(1.02); + transition: all 0.2s; + } + + .card-stretch-img { + visibility: hidden; + width: 100%; + min-height: 270px; + } + + .card-info { + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + color: var(--opposite-text-color); + z-index: 20; + padding: 0 20px 40px; + box-sizing: border-box; + text-align: center; + } + + .card-views { + color: var(--opposite-text-color); + position: absolute; + top: 20px; + right: 20px; + font-size: 12px; + } + + .card-icon-lock { + color: var(--opposite-text-color); + position: absolute; + top: 20px; + left: 20px; + font-size: 26px; + } + + .card:hover .card-views, + .card:hover .card-icon-lock { + color: var(--text-color); + } + + .card-title { + font-size: 200%; + display: inline; + margin-top: 10px; + font-weight: 500; + line-height: 1.3em; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + background-color: var(--block-bg-color); + color: var(--text-color); + padding: 5px 10px; + box-sizing: border-box; + transition: all 0.5s; + } + + .card:hover .card-title { + background-color: #333; + color: var(--opposite-text-color); + transition: all 0.3s; + } + + .card-subtitle { + font-size: 130%; + display: inline; + margin-top: 10px; + font-weight: 400; + clear: both; + padding: 3px 10px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + background-color: var(--block-bg-color); + color: var(--text-color); + line-height: 1.3em; + transition: all 0.5s; + } + + .card:hover .card-subtitle { + background-color: #333; + color: var(--opposite-text-color); + transition: all 0.3s; + } + + .card-vertical { + aspect-ratio: 2 / 3; + min-height: 400px; + } + + .card-vertical .card-info { + background-color: var(--block-bg-color); + color: var(--text-color); + padding: 20px; + text-align: left; + } + + .card-vertical:hover .card-info { + background-color: #333; + color: var(--opposite-text-color); + transition: all 0.3s; + } + + .card-vertical .card-title { + font-size: 160%; + } + + .card-vertical .card-subtitle { + font-size: 115%; + } + + .card-vertical .card-title, + .card-vertical .card-subtitle { + background-color: transparent; + } + + .card-vertical:hover .card-title, + .card-vertical:hover .card-subtitle { + background-color: transparent; + } + +.cards-group { + display: flex; + flex-wrap: wrap; + gap: 30px; + justify-content: center; + align-items: flex-start; +} + + .cards-group-1x { + gap: 50px; + width: 100%; + font-size: 180%; + } + + @media only screen and (max-width : 570px) { + .cards-group-1x { + font-size: 120%; + } + } + + .cards-group-2x .card { + width: calc(50% - 30px); + } + + .cards-group-3x .card { + width: calc(33% - 30px); + } + + .cards-group-4x .card { + width: calc(24% - 30px) + } + + .cards-group-projects .card { + background-size: contain; + background-position: 50% 10px; + } \ No newline at end of file diff --git a/frontend/static/css/comments.css b/frontend/static/css/comments.css new file mode 100644 index 0000000..9af34be --- /dev/null +++ b/frontend/static/css/comments.css @@ -0,0 +1,268 @@ +.comments { + margin: 0 auto; + max-width: 800px; + font-size: 110%; + padding: 50px 0; +} + + .comments-title { + text-align: center; + } + + .comments-list { + display: flex; + flex-direction: column; + gap: 50px; + padding-top: 50px; + } + + .comment { + padding: 20px; + background-color: var(--block-bg-color); + border-radius: var(--block-border-radius); + border: var(--block-border); + box-shadow: var(--block-shadow); + } + + .comment:target { + background-color: rgba(154, 255, 178, 0.4); + } + + .comment-head { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + } + + .comment-author { + cursor: pointer; + font-size: 110%; + font-weight: 500; + } + + .comment-date, .comment-geo, .comment-id { + opacity: 0.7; + } + + .comment-id { + color: var(--text-color); + } + + .comment-rating { + display: inline-block; + padding: 10px; + font-size: 120%; + font-weight: 700; + margin-left: auto; + } + + .comment-rating:before { + content: "+"; + } + + .inline-comment-rating.status-voted, + .comment-rating.status-voted { + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color); + } + + .comment-body { + width: 100%; + display: block; + line-height: 1.6em; + padding: 5px 0 0 55px; + box-sizing: border-box; + font-size: 110%; + } + + .comment-footer { + display: flex; + justify-content: flex-end; + width: 100%; + } + +.comments-form-login { + padding: 0 0 150px; + display: flex; + justify-content: center; +} + +.comments-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; + padding: 50px 0 300px; +} + + .comments-form-inputs { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + } + + .comments-form-textarea { + flex: 1; + padding: 10px 15px; + min-height: 5em; + font-size: 130%; + line-height: 1.5em; + } + + .comments-form-footer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-left: 65px; + box-sizing: border-box; + } + +.inline-comments { + position: relative; + display: flex; + flex-direction: column; + gap: 10px; + padding: 45px 20px 20px; + font-family: var(--sans-font); + font-size: 90%; + border-radius: var(--block-border-radius); + background-color: var(--bg-color); + width: 100%; + box-sizing: border-box; +} + + .inline-comments-header { + cursor: pointer; + display: block; + position: absolute; + padding: 15px 20px; + box-sizing: border-box; + border-radius: var(--block-border-radius); + top: 0; + left: 0; + width: 100%; + font-weight: 700; + font-size: 110%; + } + + .inline-comments-header:hover { + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color); + } + + .inline-comments-content { + display: block; + padding-top: 30px; + } + + .inline-comments-content-hidden { + display: none; + } + + .inline-comments-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .inline-comment { + display: block; + } + + .inline-comment:target { + background-color: rgba(154, 255, 178, 0.4); + } + + .inline-comment-upvotes { + float: left; + } + + .inline-comment-rating { + font-size: 90%; + border-radius: 10px; + padding: 4px 5px 4px; + } + + .inline-comment-rating:before { + content: "+"; + } + + .inline-comments-author { + cursor: pointer; + float: left; + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color); + font-size: 90%; + padding: 2px 10px 1px; + margin: 0 7px; + border-radius: var(--button-border-radius); + } + + .inline-comments-body { + line-height: 1.7em; + } + + .inline-comments-footer { + position: relative; + top: -1em; + } + + .inline-comments-login { + padding-top: 20px; + } + + .inline-comments-form { + padding-top: 20px; + display: block; + } + + .inline-comments-form-inputs { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + gap: 5px; + } + + .inline-comments-form-textarea { + flex: 1; + padding: 7px 10px; + line-height: 1.5em; + min-height: 3em; + } + + .inline-comments-form-submit { + padding: 10px; + } + +.inline-comment p, +.comment p { + margin: 0; + padding: 0 0 1.2em; +} + +.inline-comment blockquote, +.comment blockquote { + display: inline; + opacity: 0.6; + font-size: inherit; + text-align: left; + padding: 0; + margin: 0; +} + + .comment blockquote p, + .inline-comment blockquote p { + display: inline; + } + + .comment blockquote:before, + .inline-comment blockquote:before { + content: ">"; + } diff --git a/frontend/static/css/donates.css b/frontend/static/css/donates.css new file mode 100644 index 0000000..24615c2 --- /dev/null +++ b/frontend/static/css/donates.css @@ -0,0 +1,162 @@ +.donate { + padding: 10px; + margin-bottom: 50px; +} + +.donate-selector { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + width: 95%; + max-width: 800px; + overflow: hidden; + margin: 0 auto; + box-sizing: border-box; + font-size: 160%; +} + + .donate-selector-service { + font-size: 18px; + } + + .donate-selector-item { + box-sizing: border-box; + padding: 10px 20px; + line-height: 1em; + text-align: center; + border-radius: 5px; + cursor: pointer; + min-width: 80px; + } + + .donate-selector-item-active { + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color); + } + +.donate-amount { + display: none; + width: 95%; + max-width: 600px; + margin: 40px auto 20px; + font-size: 18px; + background-color: #FFF; + padding: 40px; + box-sizing: border-box; + overflow: hidden; +} + + .donate-amount-active { + display: block; + } + + .donate-amount strong { + display: inline-block; + font-size: 140%; + clear: both; + padding-bottom: 10px; + } + + .donate-amount img { + float: left; + margin-right: 30px; + width: 150px; + } + + +.donate-service { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + gap: 30px; + font-size: 120%; + width: 95%; + max-width: 800px; + margin: 80px auto; + box-sizing: border-box; +} + + .donate-service-column { + flex-direction: column; + } + + .donate-service-description { + width: 100%; + max-width: 65%; + box-sizing: border-box; + } + + .donate-service-description strong { + display: inline-block; + font-size: 140%; + clear: both; + margin-bottom: 15px; + } + + .donate-service-button { + min-width: 200px; + text-align: right; + } + + .donate-service small { + opacity: 0.7; + font-size: 80%; + line-height: 1.3em; + } + + .donate-service-selector { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: wrap; + gap: 20px; + padding-top: 30px; + } + + .donate-service-selector-item { + text-align: center; + max-width: 200px; + overflow: hidden; + overflow-wrap: anywhere; + word-break: break-all; + } + + +@media only screen and (max-width : 570px) { + .donate { + font-size: 14px; + } + + .donate-amount img { + float: none; + margin: 0 auto 40px; + display: block; + } +} + +.post-donate { + width: 100%; + max-width: 800px; + min-width: 300px; + background: rgba(0, 0, 0, 0.7); + margin: 0 auto; + padding-bottom: 10px; +} + + .post-donate__header { + font-size: 36px; + color: #FFF; + text-align: center; + padding: 30px 0; + } + + .post-donate .donate-selector { + color: #FFF; + } + + .post-donate .donate-selector__item_active { + background-color: #999; + color: #FFF; + } \ No newline at end of file diff --git a/frontend/static/css/fonts.css b/frontend/static/css/fonts.css new file mode 100644 index 0000000..b5c541d --- /dev/null +++ b/frontend/static/css/fonts.css @@ -0,0 +1,94 @@ +@font-face { + font-family: "Merriweather"; + src: url("../fonts/Merriweather-Italic.woff2") format("woff2"), + url("../fonts/Merriweather-Italic.woff") format("woff"); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "Merriweather"; + src: url("../fonts/Merriweather-Black.woff2") format("woff2"), + url("../fonts/Merriweather-Black.woff") format("woff"); + font-weight: 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Merriweather"; + src: url("../fonts/Merriweather-Bold.woff2") format("woff2"), + url("../fonts/Merriweather-Bold.woff") format("woff"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Merriweather"; + src: url("../fonts/Merriweather-Light.woff2") format("woff2"), + url("../fonts/Merriweather-Light.woff") format("woff"); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Merriweather"; + src: url("../fonts/Merriweather-Regular.woff2") format("woff2"), + url("../fonts/Merriweather-Regular.woff") format("woff"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Ubuntu"; + src: url("../fonts/Ubuntu-Italic.woff2") format("woff2"), url("../fonts/Ubuntu-Italic.woff") format("woff"); + font-weight: normal; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "Ubuntu"; + src: url("../fonts/Ubuntu-BoldItalic.woff2") format("woff2"), + url("../fonts/Ubuntu-BoldItalic.woff") format("woff"); + font-weight: bold; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "Ubuntu"; + src: url("../fonts/Ubuntu-Medium.woff2") format("woff2"), url("../fonts/Ubuntu-Medium.woff") format("woff"); + font-weight: 500; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Ubuntu"; + src: url("../fonts/Ubuntu-Bold.woff2") format("woff2"), url("../fonts/Ubuntu-Bold.woff") format("woff"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Ubuntu"; + src: url("../fonts/Ubuntu-Regular.woff2") format("woff2"), url("../fonts/Ubuntu-Regular.woff") format("woff"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Ubuntu"; + src: url("../fonts/Ubuntu-MediumItalic.woff2") format("woff2"), + url("../fonts/Ubuntu-MediumItalic.woff") format("woff"); + font-weight: 500; + font-style: italic; + font-display: swap; +} \ No newline at end of file diff --git a/frontend/static/css/highlight/monokai_sublime.css b/frontend/static/css/highlight/monokai_sublime.css new file mode 100644 index 0000000..8f23994 --- /dev/null +++ b/frontend/static/css/highlight/monokai_sublime.css @@ -0,0 +1,154 @@ +/* + +Monokai Sublime style. Derived from Monokai by noformnocontent http://nn.mit-license.org/ + +*/ + +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + background: #23241f; + -webkit-text-size-adjust: none; +} + +.hljs, +.hljs-tag, +.css .hljs-rules, +.css .hljs-value, +.aspectj .hljs-function, +.css .hljs-function +.hljs-preprocessor, +.hljs-pragma { + color: #f8f8f2; +} + +.hljs-strongemphasis, +.hljs-strong, +.hljs-emphasis { + color: #a8a8a2; +} + +.hljs-bullet, +.hljs-blockquote, +.hljs-horizontal_rule, +.hljs-number, +.hljs-regexp, +.alias .hljs-keyword, +.hljs-literal, +.hljs-hexcolor { + color: #ae81ff; +} + +.hljs-tag .hljs-value, +.hljs-code, +.hljs-title, +.css .hljs-class, +.hljs-class .hljs-title:last-child { + color: #a6e22e; +} + +.hljs-link_url { + font-size: 80%; +} + +.hljs-strong, +.hljs-strongemphasis { + font-weight: bold; +} + +.hljs-emphasis, +.hljs-strongemphasis, +.hljs-class .hljs-title:last-child, +.hljs-typename { + font-style: italic; +} + +.hljs-keyword, +.ruby .hljs-class .hljs-keyword:first-child, +.ruby .hljs-function .hljs-keyword, +.hljs-function, +.hljs-change, +.hljs-winutils, +.hljs-flow, +.nginx .hljs-title, +.tex .hljs-special, +.hljs-header, +.hljs-attribute, +.hljs-symbol, +.hljs-symbol .hljs-string, +.hljs-tag .hljs-title, +.hljs-value, +.alias .hljs-keyword:first-child, +.css .hljs-tag, +.css .unit, +.css .hljs-important { + color: #f92672; +} + +.hljs-function .hljs-keyword, +.hljs-class .hljs-keyword:first-child, +.hljs-aspect .hljs-keyword:first-child, +.hljs-constant, +.hljs-typename, +.css .hljs-attribute { + color: #66d9ef; +} + +.hljs-variable, +.hljs-params, +.hljs-class .hljs-title, +.hljs-aspect .hljs-title { + color: #f8f8f2; +} + +.hljs-string, +.css .hljs-id, +.hljs-subst, +.hljs-type, +.ruby .hljs-class .hljs-parent, +.hljs-built_in, +.django .hljs-template_tag, +.django .hljs-variable, +.smalltalk .hljs-class, +.django .hljs-filter .hljs-argument, +.smalltalk .hljs-localvars, +.smalltalk .hljs-array, +.hljs-attr_selector, +.hljs-pseudo, +.hljs-addition, +.hljs-stream, +.hljs-envvar, +.apache .hljs-tag, +.apache .hljs-cbracket, +.tex .hljs-command, +.hljs-prompt, +.hljs-link_label, +.hljs-link_url { + color: #e6db74; +} + +.hljs-comment, +.hljs-javadoc, +.hljs-annotation, +.hljs-decorator, +.hljs-pi, +.hljs-doctype, +.hljs-deletion, +.hljs-shebang, +.apache .hljs-sqbracket, +.tex .hljs-formula { + color: #75715e; +} + +.coffeescript .javascript, +.javascript .xml, +.tex .hljs-formula, +.xml .javascript, +.xml .vbscript, +.xml .css, +.xml .hljs-cdata, +.xml .php, +.php .xml { + opacity: 0.5; +} diff --git a/frontend/static/css/index.css b/frontend/static/css/index.css new file mode 100644 index 0000000..e7a0b8c --- /dev/null +++ b/frontend/static/css/index.css @@ -0,0 +1,9 @@ +@charset "utf-8"; + +@import "theme.css"; +@import "base.css"; +@import "layout.css"; +@import "cards.css"; +@import "posts.css"; +@import "comments.css"; +@import "donates.css"; diff --git a/frontend/static/css/layout.css b/frontend/static/css/layout.css new file mode 100644 index 0000000..2a49a6a --- /dev/null +++ b/frontend/static/css/layout.css @@ -0,0 +1,395 @@ +:root { + --max-content-width: 1400px; + --normal-content-width: 1000px; +} + +.header { + position: relative; + max-width: var(--max-content-width); + margin: 0 auto; + display: flex; + flex-wrap: wrap; + flex-direction: row; + gap: 30px; + justify-content: center; + align-items: center; + padding: 40px 10px; + box-sizing: border-box; +} + + .header-logo { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + text-decoration: none; + font-size: 160%; + font-weight: 600; + } + + .header-logo-image { + width: 48px; + height: 48px; + } + + .header-menu { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 20px; + } + + .header-menu-item { + position: relative; + font-size: 120%; + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; + padding: 11px 20px; + } + + .header-menu-round { + display: flex; + justify-content: center; + width: 45px; + min-width: 45px; + height: 45px; + padding: 0; + text-align: center; + border-radius: 50%; + } + + .header-menu-full { + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color)l + } + + .header a { + color: var(--text-color); + text-decoration: none; + } + +.header-search { + margin: 0 auto; + max-width: var(--normal-content-width); + display: flex; + justify-content: flex-end; + position: relative; + top: -20px; +} + + .header-search-hidden { + display: none; + } + + .header-search-form { + display: block; + width: 90%; + max-width: 600px; + } + + .header-search-form-input { + padding: 11px 20px; + width: 100%; + box-sizing: border-box; + } + + .header-search-form-submit { + position: absolute; + -webkit-appearance: none; + background: transparent; + border: none; + appearance: none; + top: 10px; + right: 10px; + } + +.container { + width: 100%; + max-width: var(--normal-content-width); + margin: 0 auto; + padding: 10px; + box-sizing: border-box; +} + + .container-width-max { + max-width: var(--max-content-width); + } + + .container-width-full { + max-width: 100%; + } + +.contacts { + display: flex; + flex-wrap: wrap; + gap: 30px; + justify-content: center; + align-items: center; + font-size: 120%; + padding: 20px 10px; +} + + .contacts-item { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + } + + .contacts a { + color: var(--text-color); + text-decoration: none; + } + + .contacts span { + text-decoration: underline; + } + +.paginator { + display: block; + clear: both; + font-size: 24px; + margin: 50px auto 100px; + overflow: hidden; + text-align: center; +} + + .paginator-page { + display: inline-block; + width: 30px; + height: 30px; + text-align: center; + line-height: 30px; + color: var(--text-color); + text-decoration: none; + margin-right: 10px; + padding: 10px; + } + + .paginator-page-active { + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color); + } + +.footer { + width: 100%; + max-width: var(--normal-content-width); + margin: 0 auto; + padding: 100px 20px 150px; + box-sizing: border-box; +} + + .footer-disclaimer { + text-align: center; + padding-top: 40px; + } + + +.headline { + position: relative; + display: block; + overflow: hidden; + width: 100%; + min-height: 450px; +} + + .headline-image { + position: relative; + display: inline-block; + width: 100%; + z-index: 10; + background-size: cover; + background-position: 50% 50%; + min-height: 300px; + } + + .headline-image img, + .headline-image video { + visibility: hidden; + width: 100%; + min-height: 270px; + } + + .headline-image-link { + position: absolute; + bottom: 20px; + right: 20px; + color: #EEE; + z-index: 200; + font-size: 14px; + opacity: 0.7; + } + + .headline-info { + position: absolute; + display: block; + bottom: 20px; + font-weight: 400; + width: 100%; + margin: 27px auto; + box-sizing: border-box; + text-align: center; + z-index: 20; + font-size: 300%; + } + + .headline-info-title { + display: inline; + font-size: 150%; + font-weight: 500; + line-height: 1.3em; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + background-color: var(--block-bg-color); + color: var(--text-color); + padding: 5px 10px; + box-sizing: border-box; + } + + .headline-info-subtitle { + display: inline; + font-weight: 100; + clear: both; + padding: 3px 10px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + background-color: var(--block-bg-color); + color: var(--text-color); + line-height: 1.3em; + } + + .headline-info-date { + display: inline-block; + font-size: 14px; + color: #FEFEFE; + } + +.simple-headline { + display: block; + margin: 60px auto 0; + overflow: hidden; + text-align: center; +} + + .simple-headline-date { + font-size: 90%; + color: #333; + } + + .simple-headline-title, + .simple-headline-subtitle { + margin: 30px auto 0; + max-width: 800px; + font-size: 290%; + font-weight: 500; + } + + .simple-headline-subtitle { + font-size: 190%; + margin-top: 10px; + } + + .simple-headline-image { + display: block; + width: 100%; + text-align: center; + margin: 60px auto 20px; + } + + .simple-headline-image img, + .simple-headline-image video { + max-width: 100%; + max-height: 675px; + } + +.index-block-about { + margin-top: 150px; + margin-bottom: 20px; + font-size: 130%; + padding: 30px 40px; + line-height: 1.5em; + max-width: 900px; +} + + .index-block-about a { + color: var(--text-color); + } + + .index-block-about-title { + font-weight: 500; + font-size: 130%; + } + + .index-block-about-description { + + } + + .index-block-about-contacts .contacts { + font-size: 110%; + gap: 25px; + justify-content: flex-start; + padding: 10px 0 0; + } + + +.members-only { + width: 100%; + display: flex; + margin-top: 100px; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; +} + + .members-only-story { + position: relative; + width: 320px; + } + + .members-only-story-cover { + z-index: 999; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + .members-only-story .story { + font-size: 12px; + } + + .members-only-info { + max-width: 600px; + padding: 20px 20px 20px 40px; + box-sizing: border-box; + font-size: 110%; + } + + .members-only-info h2 { + font-size: 200%; + margin: 20px 0 40px; + padding: 0; + text-align: left; + } + + .members-only-info p { + font-size: 110%; + } + + .members-only-button { + margin-top: 40px; + } + +.gallery .simple-headline-image img { + max-width: 100%; + max-height: none; +} + +.lightense-open { + /* Zooming images */ + border-radius: 0 !important; +} + diff --git a/frontend/static/css/normalize.css b/frontend/static/css/normalize.css new file mode 100644 index 0000000..192eb9c --- /dev/null +++ b/frontend/static/css/normalize.css @@ -0,0 +1,349 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/frontend/static/css/posts.css b/frontend/static/css/posts.css new file mode 100644 index 0000000..68ce40e --- /dev/null +++ b/frontend/static/css/posts.css @@ -0,0 +1,847 @@ +#post-editor { + display: none; +} + +.post { + width: 100%; + display: block; + overflow: hidden; + position: relative; + z-index: 5; + letter-spacing: 0.01rem; + font-style: normal; + font-size: 19px; + line-height: 1.67; + color: #464848; + font-family: var(--serif-font); + font-weight: 300; +} + + .post p, + .post h1, + .post h2, + .post h3, + .post dl, + .block-text { + width: 100%; + max-width: 900px; + min-width: 200px; + font-weight: 400; + font-style: normal; + box-sizing: content-box; + padding: 0; + } + + .post > p, + .post > h1, + .post > h2, + .post > h3 { + width: 90%; + margin: 27px auto 0 35px; + } + + .post h1 strong, + .post h2 strong, + .post h3 strong, + .post .header-1 strong, + .post .header-2 strong, + .post .header-3 strong { + font-size: 200%; + } + + .post h1 small, + .post h2 small, + .post h3 small, + .post .header-1 small, + .post .header-2 small, + .post .header-3 small, + .post .header-4 small { + opacity: 0.5; + } + + .post h1 a, + .post h2 a, + .post h3 a, + .post .header-1 a, + .post .header-2 a, + .post .header-3 a { + color: #464848; + } + + .post hr { + display: block; + height: 1px; + border: 0; + border-top: dashed 2px #BDC3C7; + margin: 1em 0 2em; + padding: 0; + } + + + .post h1 + h1, + .post h1 + h2, + .post .header-1 + .header-1, + .post .header-1 + .header-2 { + padding-top: 10px; + } + + .post h2 + h2, + .post h2 + h3, + .post .header-2 + .header-2, + .post .header-2 + .header-3 { + padding-top: 10px; + } + + .post h1 a, + .post h2 a, + .post h3 a, + .post .header-1 a, + .post .header-2 a, + .post .header-3 a { + text-decoration: none !important; + } + + .post ul, .post ol { + padding-left: 35px; + width: 95%; + max-width: 800px; + min-width: 300px; + padding-bottom: 1.2em; + font-weight: 400; + font-style: normal; + letter-spacing: -0.003em; + box-sizing: border-box; + } + + .post li { + margin-bottom: 15px; + } + + .post figure a { + border-bottom: none; + text-decoration: none; + } + + .post dl { + padding-left: 0; + } + + .post dd { + margin-top: 10px; + margin-left: 20px; + } + + .post dt { + margin-bottom: 20px; + line-height: 1.3em; + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + font-weight: bold; + margin-top: 40px; + padding: 0; + } + + .post .code p { + margin-bottom: 0; + font-family: monospace; + } + + .post code { + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + } + + .post pre code { + font-family: monospace; + border-radius: 20px; + white-space: pre; + padding: 20px 30px; + } + + .post figure { + display: inline-block; + vertical-align: top; + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + font-size: 95%; + color: #999; + font-weight: 300; + } + + .post figure * { + width: 100%; + } + + .post figure img { + border-radius: 30px; + } + + .post img { + /* иначе легаси едет */ + max-width: 100%; + } + + .post big { + font-size: 120%; + } + + .post .initial { + display: inline-block; + float: left; + font-weight: normal; + font-size: 420%; + padding-right: 10px; + line-height: 1.0em; + } + + .post strong > em, + .post .starting { + font-size: 110%; + font-style: normal; + height: 35px; + font-weight: 400; + vertical-align: bottom; + background-color: #4E4E4E; + color: #EEE; + padding: 4px 7px 5px; + line-height: 1.6em; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + } + + .post strong > em a, + .post .starting a { + color: #EEE; + } + + .post table { + table-layout: fixed; + border-spacing: 10px; + border-collapse: collapse; + } + + .post table thead { + text-align: left; + font-size: 120%; + } + + .post table thead th { + padding-bottom: 30px; + } + + .post table td { + width: 1%; + padding: 0 50px 30px 0; + vertical-align: top; + box-sizing: border-box; + } + +/* Text blocks */ + +.block-text { + width: 100%; + margin: 40px auto 40px; + position: relative; + display: block; + padding: 40px 60px; + clear: both; + box-sizing: border-box; + border-radius: 30px; + background-color: var(--block-bg-color); + color: var(--text-color); +} + + .block-text p { + padding-bottom: 1.2em; + } + + .block-background__black .block-text { + background-color: rgba(255, 255, 255, 0.1); + } + + .block-text__black { + color: var(--opposite-text-color); + background-color: var(--opposite-block-bg-color); + } + + .block-text__right { + float: right; + width: 50% !important; + margin-right: 12% !important; + } + + .block-text:last-child .inline-comments { + display: none; + } + +.block-background { + width: 100%; + clear: both; + position: relative; + margin: 50px auto; + padding-bottom: 30px; + display: block; + overflow: hidden; + min-height: 200px; + background: transparent repeat-y fixed center center; + background-size: cover; +} + + .block-background > p, + .block-background > h1, + .block-background > h2, + .block-background > h3 { + margin: 27px auto 35px; + width: 95%; + } + + .block-background > .block-background__background { + margin: 0; + position: relative; + background-attachment: fixed; + background-size: cover; + padding-top: 100px; + padding-bottom: 100px; + } + +.block-paid { + background-color: #fff3d4 !important; + color: #413d3b !important; + font-size: 105%; + font-family: Ubuntu, Helvetica, Arial, sans-serif; + padding: 30px 40px; +} + + .block-paid .inline-comments { + display: none; + } + + .block-paid a { + color: #201e1d; + } + + .block-paid p { + padding-bottom: 1em; + margin: 0; + line-height: 1.6em; + } + +.block-extra { + background-color: #FFE9EC; +} + + .block-extra:before { + position: absolute; + top: 25px; + right: 40px; + font-size: 70%; + opacity: 0.6; + text-align: right; + content: "Инфа для своих"; + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + } + + .block-extra-placeholder { + text-align: center; + } + + .block-extra-placeholder:before { + content: ""; + } + + .block-extra-placeholder-text { + display: inline-block; + margin: 40px 20px; + } + +.block-link { + margin-top: 10px; + padding: 3px 8px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + background-color: var(--link-color); + color: var(--opposite-text-color); +} + + .block-link:hover { + background-color: var(--opposite-block-bg-color); + color: var(--opposite-text-color); + } + +.block-button { + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + display: inline-block; + vertical-align: top; + padding: 20px 25px; + background-color: #767676; + border: solid 2px #767676; + color: var(--opposite-text-color); + margin: 10px; + text-decoration: none; + border-radius: 20px; + line-height: 1.2em; +} + + .block-button:hover { + background-color: transparent; + color: #333; + } + +/* Multiple photo */ + +.block-media { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + align-items: stretch; + gap: 10px; + position: relative; + box-sizing: border-box; + margin: 50px auto; + max-width: 1000px; + text-align: center; +} + + .block-media a { + cursor: zoom-in; + } + + .block-media figure { + box-sizing: border-box; + } + + .block-media figcaption { + font-weight: 500; + color: #666; + padding: 10px; + box-sizing: border-box; + } + + .block-media__right { + width: 480px; + float: right; + margin-top: 0; + padding: 0 0 20px 20px; + } + + .block-media__left { + width: 480px; + float: left; + margin-top: 0; + padding: 0 20px 20px 0; + } + + .block-media__full { + width: 100%; + max-width: none; + } + + .block-media__full figure img { + border-radius: 0; + } + + .block-media__full figcaption { + position: absolute; + bottom: 40px; + right: 0; + width: 40%; + padding: 5px 10px; + box-sizing: border-box; + background-color: rgba(0, 0, 0, 0.5); + color: #FFF; + font-weight: 400; + } + +/* Text */ + +.post cite, +.block-cite { + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + display: block; + text-align: left; + max-width: 800px; + margin: 20px 0 40px; + color: #999; + box-sizing: border-box; + border-left: solid 2px #999; + padding: 0 15px; +} + +.post blockquote, +.block-quote { + display: block; + text-align: center; + font-size: 130%; + width: 90%; + margin: 10px auto; + font-style: italic; + padding: 10px; + box-sizing: border-box; +} + +.block-side { + position: relative; + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + font-size: 90%; + font-weight: 200; + line-height: 1.3em; + margin-top: 0; + padding: 10px; + width: 45%; +} + + .block-side__right { + float: right; + margin-left: 40px; + margin-top: 0 !important; + text-align: right; + padding-right: 0; + } + + .block-side__left { + float: left; + margin-right: 40px; + padding-left: 0; + } + + .block-side figure { + width: 100%; + margin-bottom: 10px; + } + + .block-side figcaption { + padding-top: 20px; + } + +.block-number { + display: inline-block; + max-width: 200px; + font-size: 150%; + font-family: Ubuntu, Helvetica, Arial, sans-serif; + margin-right: 20px; + margin-bottom: 20px; +} + + .block-number strong { + font-weight: 400; + display: inline-block; + width: 100%; + clear: both; + } + + .block-number small { + font-size: 60%; + font-weight: 400; + display: inline-block; + width: 100%; + clear: both; + position: relative; + top: -10px; + } + +.block-iframe { + position: relative; + width: 100%; + padding-bottom: 54%; + padding-top: 30px; + height: 0; + overflow: hidden; +} + + .block-iframe iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + +.block-media__noclick { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +} + +.block-cite > p, +.block-side > p, +.block-media > p { + padding: 0; + margin: 0; +} + +.block-20 { + width: 20%; +} + +.block-30 { + width: 30%; +} + +.block-spoiler { + cursor: pointer; +} + + .block-spoiler > .block-spoiler-button { + display: inline; + font-weight: bold; + padding: 3px 15px 2px; + border-radius: 10px; + background-color: #4E4E4E; + color: #FFF; + } + + .block-spoiler > .block-spoiler-button-hidden { + display: none; + } + + .block-spoiler > .block-spoiler-text { + display: none; + font-weight: normal; + padding-left: 10px; + opacity: 0.5; + } + + .block-spoiler > .block-spoiler-text-visible { + display: inline; + } + +/* Footer and subscription */ + +.post-footer { + display: block; + width: 100%; + text-align: center; + background: transparent; + font-family: 'Ubuntu', Helvetica, Verdana, sans-serif; + font-size: 16px; + overflow: hidden; +} + + .post-footer__inside { + width: 100%; + max-width: 1000px; + margin: 100px auto 0; + position: relative; + display: block; + padding: 0; + clear: both; + min-height: 330px; + } + +.post-footer-buttons { + width: 100%; + margin-bottom: 70px; + text-align: center; +} + +.post-footer__button { + display: inline-block; + vertical-align: top; + padding: 5px 10px; + border: solid 2px; + border-radius: 10px; + margin-left: 20px; + margin-top: 20px; + opacity: 0.8; + text-decoration: none; + line-height: 1.5em; +} + + .post-footer__button:hover { + opacity: 1.0; + } + + .post-footer__button i { + margin-right: 4px; + } + +.post-members-badge { + display: inline-block; + margin: 40px auto; + padding: 10px 20px; + box-sizing: border-box; + text-align: center; + font-size: 120%; + border: solid 2px #ee5162; + background-color: #ee5162; + color: #FFF; + border-radius: 20px; +} + +.post-members-share { + display: block; + margin: 40px auto; + width: 90%; + max-width: 800px; + padding: 30px; + box-sizing: border-box; + text-align: center; + font-size: 110%; + border: solid 2px #ee5162; + background-color: #ee5162; + color: #FFF; + border-radius: 20px; +} + + .post-members-share-heart { + font-size: 80px; + } + + .post-members-share h3 { + font-weight: normal; + font-size: 160%; + } + + .post-members-share ul { + list-style: none; + margin-top: 40px; + } + + .post-members-share li { + margin-top: 20px; + margin-bottom: 30px; + font-size: 120%; + } + +.post-subscribe { + display: block; + width: 100%; + padding: 40px; + font-size: 120%; + box-sizing: border-box; + text-align: center; + margin-top: 100px; + background-color: var(--block-bg-color); + color: var(--text-color); +} + + .post-subscribe-header { + font-size: 180%; + font-weight: 500; + padding-bottom: 10px; + } + + .post-subscribe-sub-header { + font-size: 130%; + padding-bottom: 10px; + } + + .post-subscribe-description { + margin: 0 auto; + max-width: 800px; + padding: 20px 0; + box-sizing: border-box; + } + + .post-subscribe-form { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + margin: 20px auto 60px; + max-width: 550px; + min-width: 280px; + padding: 20px; + box-sizing: border-box; + color: #FFF; + background-color: #333; + text-align: left; + } + + .post-subscribe-form-label { + } + + .post-subscribe-form-fields { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + width: 100%; + justify-content: space-between; + gap: 10px; + font-size: 120%; + padding-top: 10px; + } + + @media only screen and (max-width : 512px) { + .post-subscribe-form-fields { + flex-direction: column; + } + } + + .post-subscribe-form input[name=email] { + padding: 7px 0 4px; + border: solid 2px #FFF; + border-radius: 5px; + box-sizing: border-box; + text-align: center; + height: 50px; + width: 100%; + } + + .post-subscribe-form button { + padding: 7px 10px 4px; + line-height: 1em; + border-radius: 5px; + box-sizing: border-box; + height: 50px; + width: 190px; + } + + .post-subscribe-form-hint { + font-size: 80%; + opacity: 0.4; + padding-top: 10px; + } + + .post-subscribe-items { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + } + + .post-subscribe-items a { + display: inline-block; + margin: 20px 10px 0; + } + +.post-related { +} + + .post-related-title { + display: block; + position: relative; + font-size: 18px; + text-align: center; + margin-bottom: 30px; + } + + .post-related-title span { + position: relative; + display: inline-block; + background-color: #f5f6f8; + padding: 5px 20px; + z-index: 10; + } + + .post-related-title::after { + content: ""; + position: absolute; + top: 50%; + display: block; + border-bottom: solid 1.5px #333; + width: 100%; + z-index: 1; + } + +.width-25 { + width: 25%; +} + +.width-50 { + width: 50%; +} + +.width-75 { + width: 75%; +} diff --git a/frontend/static/css/theme.css b/frontend/static/css/theme.css new file mode 100644 index 0000000..8c833c0 --- /dev/null +++ b/frontend/static/css/theme.css @@ -0,0 +1,201 @@ +:root { + --sans-font: "Ubuntu", Helvetica, Verdana, sans-serif; + --serif-font: "Merriweather", Georgia, Times, serif; + + --block-border-radius: 15px; + --button-border-radius: 15px; + + --button-color: #FFF; + --button-bg-color: #333; + --button-border: solid 2px #333; + --button-hover-color: #333; + --button-hover-bg-color: #FFF; + --button-hover-border: solid 2px #333; + + --input-border: solid 2px #CCC; + --input-border-focus: solid 2px #333; + + --badge-color: rgba(255, 196, 85, 0.91); +} + +html, html[theme="light"] { + --bg-color: #f5f6f8; + --opposite-bg-color: #282c35; + + --text-color: #333; + --brighter-text-color: #000; + --opposite-text-color: #DDD; + + --block-bg-color: #FFF; + --opposite-block-bg-color: #282c35; + --block-shadow: 10px 15px 40px rgba(83, 91, 110, 0.11); + --block-border: none; + + --link-color: #16a6ff; + --link-hover-color: #999; + --visited-link-color: #333; + + --button-color: #FFF; + --button-bg-color: #333; + --button-disabled-bg-color: #DDD; + --button-border: solid 2px #333; + --button-hover-color: #333; + --button-hover-bg-color: #FFF; + --button-hover-border: solid 2px #333; + + --select-color: #333; + --select-bg-color: #FFF; + + --input-border: solid 2px #CCC; + --input-border-focus: solid 2px #333; +} + +html[theme="dark"] { + --bg-color: #282c35; + --opposite-bg-color: #f5f6f8; + + --text-color: #DDD; + --brighter-text-color: #FFF; + --opposite-text-color: #333; + + --block-bg-color: #1B1B1C; + --opposite-block-bg-color: #FFF; + --block-shadow: 0px 0px 0px #000; + --block-border: solid 1px #FCFDFF; + + --link-color: #16a6ff; + --link-hover-color: #FFF; + --visited-link-color: #737373; + + --button-color: #333; + --button-bg-color: #FFF; + --button-disabled-bg-color: #8A8A8A; + --button-border: solid 2px #FFF; + --button-hover-color: #FFF; + --button-hover-bg-color: #333; + --button-hover-border: solid 2px #FFF; + + --select-color: #333; + --select-bg-color: #FFF; + + --input-border: solid 2px #CCC; +} + +/*@media (prefers-color-scheme: dark) {*/ +/* html {*/ +/* --bg-color: #282c35;*/ +/* --opposite-bg-color: #f5f6f8;*/ + +/* --text-color: #DDD;*/ +/* --brighter-text-color: #FFF;*/ +/* --opposite-text-color: #333;*/ + +/* --block-bg-color: #1B1B1C;*/ +/* --opposite-block-bg-color: #FFF;*/ +/* --block-shadow: 0px 0px 0px #000;*/ +/* --block-border: solid 1px #FCFDFF;*/ + +/* --link-color: #DDD;*/ +/* --link-hover-color: #FFF;*/ +/* --visited-link-color: #737373;*/ + +/* --button-color: #333;*/ +/* --button-bg-color: #FFF;*/ +/* --button-border: solid 2px #FFF;*/ +/* --button-hover-color: #FFF;*/ +/* --button-hover-bg-color: #333;*/ +/* --button-hover-border: solid 2px #FFF;*/ +/* }*/ +/*}*/ + +body { + font-family: var(--sans-font); + font-size: 16px; + line-height: 1.42; + color: var(--text-color); + background-color: var(--bg-color); + text-rendering: optimizeSpeed; + transition: 0.5s ease-out; + -webkit-font-smoothing: antialiased; + letter-spacing: 0.01rem; +} + + @media only screen and (min-device-width : 768px) + and (max-device-width : 1024px) + and (orientation : portrait) { + body { + font-size: 14px; + } + } + + @media only screen and (max-width : 800px) { + body { + font-size: 13px; + } + } + +a { + color: var(--link-color); + transition: color linear .1s; +} + + a:hover { + color: var(--link-hover-color); + } + + +figure { + margin: 0; +} + +img { + max-width: 100%; +} + +select { + color: var(--select-color); + background-color: var(--select-bg-color); +} + +h1, h2, h3, h4, h5, +.header-1,.header-2,.header-3,.header-4,.header-5 { + font-family: var(--sans-font); + scroll-margin-top: 30px; +} + +li { + margin-bottom: 0.8em; +} + +h1 > a, +h2 > a, +h3 > a, +h4 > a, +h5 > a, +.header-1 > a, +.header-2 > a, +.header-3 > a, +.header-4 > a, +.header-5 > a { + text-decoration: none; +} + +input[type=text], +input[type=email], +input[type=url], +.CodeMirror, +textarea { + -webkit-appearance: none; + appearance: none; + border: var(--input-border); + border-radius: var(--button-border-radius); + box-sizing: border-box; + outline: none; +} + +input[type=text]:focus, +input[type=email]:focus, +input[type=url]:focus, +textarea:focus { + border: var(--input-border-focus); +} \ No newline at end of file diff --git a/frontend/static/css/users.css b/frontend/static/css/users.css new file mode 100644 index 0000000..4388061 --- /dev/null +++ b/frontend/static/css/users.css @@ -0,0 +1,62 @@ +.profile { + max-width: 600px; + margin: 0 auto; +} + +.profile-edit-form { + font-size: 150%; + display: flex; + flex-direction: column; + gap: 60px; + width: 100%; + justify-content: center; + align-items: center; + text-align: center; + padding-top: 40px; +} + + .profile-edit-form label { + font-weight: 700; + } + + .profile-edit-form input[type=text] { + padding: 5px 10px; + text-align: center; + border-radius: var(--button-border-radius); + } + + .profile-edit-form-username { + display: flex; + flex-direction: column; + gap: 20px; + } + + .profile-edit-form-avatars { + display: flex; + flex-direction: column; + gap: 20px; + } + + .profile-edit-form-avatars-list { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + gap: 25px; + } + + .profile-edit-form-avatars-list-item { + display: flex; + flex-direction: column; + line-height: 1em; + justify-content: center; + } + + .profile-edit-form-avatars-list-item .avatar { + width: 55px; + height: 55px; + } + + .profile-edit-form-errors { + background-color: rgba(255, 65, 65, 0.6); + } diff --git a/frontend/static/fonts/FontAwesome.otf b/frontend/static/fonts/FontAwesome.otf new file mode 100644 index 0000000..401ec0f Binary files /dev/null and b/frontend/static/fonts/FontAwesome.otf differ diff --git a/frontend/static/fonts/Merriweather-Black.woff b/frontend/static/fonts/Merriweather-Black.woff new file mode 100644 index 0000000..a8745a2 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Black.woff differ diff --git a/frontend/static/fonts/Merriweather-Black.woff2 b/frontend/static/fonts/Merriweather-Black.woff2 new file mode 100644 index 0000000..eca67e3 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Black.woff2 differ diff --git a/frontend/static/fonts/Merriweather-BlackItalic.woff b/frontend/static/fonts/Merriweather-BlackItalic.woff new file mode 100644 index 0000000..caeeabd Binary files /dev/null and b/frontend/static/fonts/Merriweather-BlackItalic.woff differ diff --git a/frontend/static/fonts/Merriweather-BlackItalic.woff2 b/frontend/static/fonts/Merriweather-BlackItalic.woff2 new file mode 100644 index 0000000..fb43fe2 Binary files /dev/null and b/frontend/static/fonts/Merriweather-BlackItalic.woff2 differ diff --git a/frontend/static/fonts/Merriweather-Bold.woff b/frontend/static/fonts/Merriweather-Bold.woff new file mode 100644 index 0000000..195b8bf Binary files /dev/null and b/frontend/static/fonts/Merriweather-Bold.woff differ diff --git a/frontend/static/fonts/Merriweather-Bold.woff2 b/frontend/static/fonts/Merriweather-Bold.woff2 new file mode 100644 index 0000000..661725b Binary files /dev/null and b/frontend/static/fonts/Merriweather-Bold.woff2 differ diff --git a/frontend/static/fonts/Merriweather-BoldItalic.woff b/frontend/static/fonts/Merriweather-BoldItalic.woff new file mode 100644 index 0000000..ce9a9c2 Binary files /dev/null and b/frontend/static/fonts/Merriweather-BoldItalic.woff differ diff --git a/frontend/static/fonts/Merriweather-BoldItalic.woff2 b/frontend/static/fonts/Merriweather-BoldItalic.woff2 new file mode 100644 index 0000000..a7a9e54 Binary files /dev/null and b/frontend/static/fonts/Merriweather-BoldItalic.woff2 differ diff --git a/frontend/static/fonts/Merriweather-Italic.woff b/frontend/static/fonts/Merriweather-Italic.woff new file mode 100644 index 0000000..46476d3 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Italic.woff differ diff --git a/frontend/static/fonts/Merriweather-Italic.woff2 b/frontend/static/fonts/Merriweather-Italic.woff2 new file mode 100644 index 0000000..3740513 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Italic.woff2 differ diff --git a/frontend/static/fonts/Merriweather-Light.woff b/frontend/static/fonts/Merriweather-Light.woff new file mode 100644 index 0000000..f17f3de Binary files /dev/null and b/frontend/static/fonts/Merriweather-Light.woff differ diff --git a/frontend/static/fonts/Merriweather-Light.woff2 b/frontend/static/fonts/Merriweather-Light.woff2 new file mode 100644 index 0000000..f82fd40 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Light.woff2 differ diff --git a/frontend/static/fonts/Merriweather-LightItalic.woff b/frontend/static/fonts/Merriweather-LightItalic.woff new file mode 100644 index 0000000..7a72f8c Binary files /dev/null and b/frontend/static/fonts/Merriweather-LightItalic.woff differ diff --git a/frontend/static/fonts/Merriweather-LightItalic.woff2 b/frontend/static/fonts/Merriweather-LightItalic.woff2 new file mode 100644 index 0000000..3978964 Binary files /dev/null and b/frontend/static/fonts/Merriweather-LightItalic.woff2 differ diff --git a/frontend/static/fonts/Merriweather-Regular.woff b/frontend/static/fonts/Merriweather-Regular.woff new file mode 100644 index 0000000..6cd8e41 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Regular.woff differ diff --git a/frontend/static/fonts/Merriweather-Regular.woff2 b/frontend/static/fonts/Merriweather-Regular.woff2 new file mode 100644 index 0000000..9bbcdb9 Binary files /dev/null and b/frontend/static/fonts/Merriweather-Regular.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-Bold.woff b/frontend/static/fonts/Ubuntu-Bold.woff new file mode 100644 index 0000000..381d856 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Bold.woff differ diff --git a/frontend/static/fonts/Ubuntu-Bold.woff2 b/frontend/static/fonts/Ubuntu-Bold.woff2 new file mode 100644 index 0000000..5129bfd Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Bold.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-BoldItalic.woff b/frontend/static/fonts/Ubuntu-BoldItalic.woff new file mode 100644 index 0000000..59e9c5c Binary files /dev/null and b/frontend/static/fonts/Ubuntu-BoldItalic.woff differ diff --git a/frontend/static/fonts/Ubuntu-BoldItalic.woff2 b/frontend/static/fonts/Ubuntu-BoldItalic.woff2 new file mode 100644 index 0000000..8ab8e02 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-BoldItalic.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-Italic.woff b/frontend/static/fonts/Ubuntu-Italic.woff new file mode 100644 index 0000000..02b350d Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Italic.woff differ diff --git a/frontend/static/fonts/Ubuntu-Italic.woff2 b/frontend/static/fonts/Ubuntu-Italic.woff2 new file mode 100644 index 0000000..498a0c8 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Italic.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-Light.woff b/frontend/static/fonts/Ubuntu-Light.woff new file mode 100644 index 0000000..8e9fb67 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Light.woff differ diff --git a/frontend/static/fonts/Ubuntu-Light.woff2 b/frontend/static/fonts/Ubuntu-Light.woff2 new file mode 100644 index 0000000..8a847d6 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Light.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-LightItalic.woff b/frontend/static/fonts/Ubuntu-LightItalic.woff new file mode 100644 index 0000000..95c2cd6 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-LightItalic.woff differ diff --git a/frontend/static/fonts/Ubuntu-LightItalic.woff2 b/frontend/static/fonts/Ubuntu-LightItalic.woff2 new file mode 100644 index 0000000..8fbaf02 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-LightItalic.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-Medium.woff b/frontend/static/fonts/Ubuntu-Medium.woff new file mode 100644 index 0000000..1430373 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Medium.woff differ diff --git a/frontend/static/fonts/Ubuntu-Medium.woff2 b/frontend/static/fonts/Ubuntu-Medium.woff2 new file mode 100644 index 0000000..05ea47b Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Medium.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-MediumItalic.woff b/frontend/static/fonts/Ubuntu-MediumItalic.woff new file mode 100644 index 0000000..53dbf50 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-MediumItalic.woff differ diff --git a/frontend/static/fonts/Ubuntu-MediumItalic.woff2 b/frontend/static/fonts/Ubuntu-MediumItalic.woff2 new file mode 100644 index 0000000..4b16c02 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-MediumItalic.woff2 differ diff --git a/frontend/static/fonts/Ubuntu-Regular.woff b/frontend/static/fonts/Ubuntu-Regular.woff new file mode 100644 index 0000000..da92552 Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Regular.woff differ diff --git a/frontend/static/fonts/Ubuntu-Regular.woff2 b/frontend/static/fonts/Ubuntu-Regular.woff2 new file mode 100644 index 0000000..1a011ac Binary files /dev/null and b/frontend/static/fonts/Ubuntu-Regular.woff2 differ diff --git a/frontend/static/fonts/fontawesome-webfont.eot b/frontend/static/fonts/fontawesome-webfont.eot new file mode 100644 index 0000000..e9f60ca Binary files /dev/null and b/frontend/static/fonts/fontawesome-webfont.eot differ diff --git a/frontend/static/fonts/fontawesome-webfont.svg b/frontend/static/fonts/fontawesome-webfont.svg new file mode 100644 index 0000000..855c845 --- /dev/null +++ b/frontend/static/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/static/fonts/fontawesome-webfont.ttf b/frontend/static/fonts/fontawesome-webfont.ttf new file mode 100644 index 0000000..35acda2 Binary files /dev/null and b/frontend/static/fonts/fontawesome-webfont.ttf differ diff --git a/frontend/static/fonts/fontawesome-webfont.woff b/frontend/static/fonts/fontawesome-webfont.woff new file mode 100644 index 0000000..400014a Binary files /dev/null and b/frontend/static/fonts/fontawesome-webfont.woff differ diff --git a/frontend/static/fonts/fontawesome-webfont.woff2 b/frontend/static/fonts/fontawesome-webfont.woff2 new file mode 100644 index 0000000..4d13fc6 Binary files /dev/null and b/frontend/static/fonts/fontawesome-webfont.woff2 differ diff --git a/frontend/static/images/btc.png b/frontend/static/images/btc.png new file mode 100644 index 0000000..5a41bfc Binary files /dev/null and b/frontend/static/images/btc.png differ diff --git a/frontend/static/images/donate/beers.jpg b/frontend/static/images/donate/beers.jpg new file mode 100644 index 0000000..5d94043 Binary files /dev/null and b/frontend/static/images/donate/beers.jpg differ diff --git a/frontend/static/images/donate/mindset.jpg b/frontend/static/images/donate/mindset.jpg new file mode 100644 index 0000000..57cb349 Binary files /dev/null and b/frontend/static/images/donate/mindset.jpg differ diff --git a/frontend/static/images/donate/oleg.jpg b/frontend/static/images/donate/oleg.jpg new file mode 100644 index 0000000..f120698 Binary files /dev/null and b/frontend/static/images/donate/oleg.jpg differ diff --git a/frontend/static/images/donate/other.jpg b/frontend/static/images/donate/other.jpg new file mode 100644 index 0000000..be4dcb3 Binary files /dev/null and b/frontend/static/images/donate/other.jpg differ diff --git a/frontend/static/images/donate/server.jpg b/frontend/static/images/donate/server.jpg new file mode 100644 index 0000000..61a3222 Binary files /dev/null and b/frontend/static/images/donate/server.jpg differ diff --git a/frontend/static/images/donate_beer.png b/frontend/static/images/donate_beer.png new file mode 100644 index 0000000..a871619 Binary files /dev/null and b/frontend/static/images/donate_beer.png differ diff --git a/frontend/static/images/dot.png b/frontend/static/images/dot.png new file mode 100644 index 0000000..ee0681f Binary files /dev/null and b/frontend/static/images/dot.png differ diff --git a/frontend/static/images/email.png b/frontend/static/images/email.png new file mode 100644 index 0000000..bc2b2c0 Binary files /dev/null and b/frontend/static/images/email.png differ diff --git a/frontend/static/images/eth.png b/frontend/static/images/eth.png new file mode 100644 index 0000000..383a129 Binary files /dev/null and b/frontend/static/images/eth.png differ diff --git a/frontend/static/images/favicon.ico b/frontend/static/images/favicon.ico new file mode 100755 index 0000000..2c765e2 Binary files /dev/null and b/frontend/static/images/favicon.ico differ diff --git a/frontend/static/images/favicon_128.png b/frontend/static/images/favicon_128.png new file mode 100644 index 0000000..e9a1040 Binary files /dev/null and b/frontend/static/images/favicon_128.png differ diff --git a/frontend/static/images/favicon_32.png b/frontend/static/images/favicon_32.png new file mode 100644 index 0000000..6ba884c Binary files /dev/null and b/frontend/static/images/favicon_32.png differ diff --git a/frontend/static/images/favicon_64.png b/frontend/static/images/favicon_64.png new file mode 100644 index 0000000..8bfdec0 Binary files /dev/null and b/frontend/static/images/favicon_64.png differ diff --git a/frontend/static/images/favicon_square.png b/frontend/static/images/favicon_square.png new file mode 100755 index 0000000..c456b77 Binary files /dev/null and b/frontend/static/images/favicon_square.png differ diff --git a/frontend/static/images/godmode_logo.png b/frontend/static/images/godmode_logo.png new file mode 100644 index 0000000..2a241ca Binary files /dev/null and b/frontend/static/images/godmode_logo.png differ diff --git a/frontend/static/images/inside-window.png b/frontend/static/images/inside-window.png new file mode 100644 index 0000000..7fde54a Binary files /dev/null and b/frontend/static/images/inside-window.png differ diff --git a/frontend/static/images/logo.png b/frontend/static/images/logo.png new file mode 100644 index 0000000..e9a1040 Binary files /dev/null and b/frontend/static/images/logo.png differ diff --git a/frontend/static/images/logos/channel.jpg b/frontend/static/images/logos/channel.jpg new file mode 100644 index 0000000..be65f0d Binary files /dev/null and b/frontend/static/images/logos/channel.jpg differ diff --git a/frontend/static/images/logos/chat.jpg b/frontend/static/images/logos/chat.jpg new file mode 100644 index 0000000..b953e16 Binary files /dev/null and b/frontend/static/images/logos/chat.jpg differ diff --git a/frontend/static/images/logos/inside.png b/frontend/static/images/logos/inside.png new file mode 100644 index 0000000..92a2bd6 Binary files /dev/null and b/frontend/static/images/logos/inside.png differ diff --git a/frontend/static/images/logos/instagram.png b/frontend/static/images/logos/instagram.png new file mode 100644 index 0000000..f2eb0e5 Binary files /dev/null and b/frontend/static/images/logos/instagram.png differ diff --git a/frontend/static/images/logos/patreon.png b/frontend/static/images/logos/patreon.png new file mode 100644 index 0000000..46bddbf Binary files /dev/null and b/frontend/static/images/logos/patreon.png differ diff --git a/frontend/static/images/logos/telegram.png b/frontend/static/images/logos/telegram.png new file mode 100644 index 0000000..87ff466 Binary files /dev/null and b/frontend/static/images/logos/telegram.png differ diff --git a/frontend/static/images/logos/twitter.png b/frontend/static/images/logos/twitter.png new file mode 100644 index 0000000..f43296b Binary files /dev/null and b/frontend/static/images/logos/twitter.png differ diff --git a/frontend/static/images/patterns/1.jpg b/frontend/static/images/patterns/1.jpg new file mode 100644 index 0000000..48cbf32 Binary files /dev/null and b/frontend/static/images/patterns/1.jpg differ diff --git a/frontend/static/images/patterns/10.jpg b/frontend/static/images/patterns/10.jpg new file mode 100644 index 0000000..93877d0 Binary files /dev/null and b/frontend/static/images/patterns/10.jpg differ diff --git a/frontend/static/images/patterns/11.jpg b/frontend/static/images/patterns/11.jpg new file mode 100644 index 0000000..d5988d6 Binary files /dev/null and b/frontend/static/images/patterns/11.jpg differ diff --git a/frontend/static/images/patterns/12.jpg b/frontend/static/images/patterns/12.jpg new file mode 100644 index 0000000..b3781ba Binary files /dev/null and b/frontend/static/images/patterns/12.jpg differ diff --git a/frontend/static/images/patterns/13.png b/frontend/static/images/patterns/13.png new file mode 100644 index 0000000..855a89e Binary files /dev/null and b/frontend/static/images/patterns/13.png differ diff --git a/frontend/static/images/patterns/14.png b/frontend/static/images/patterns/14.png new file mode 100644 index 0000000..d20f222 Binary files /dev/null and b/frontend/static/images/patterns/14.png differ diff --git a/frontend/static/images/patterns/15.png b/frontend/static/images/patterns/15.png new file mode 100644 index 0000000..1932277 Binary files /dev/null and b/frontend/static/images/patterns/15.png differ diff --git a/frontend/static/images/patterns/16.png b/frontend/static/images/patterns/16.png new file mode 100644 index 0000000..847384f Binary files /dev/null and b/frontend/static/images/patterns/16.png differ diff --git a/frontend/static/images/patterns/17.png b/frontend/static/images/patterns/17.png new file mode 100644 index 0000000..64c4823 Binary files /dev/null and b/frontend/static/images/patterns/17.png differ diff --git a/frontend/static/images/patterns/18.png b/frontend/static/images/patterns/18.png new file mode 100644 index 0000000..a009e12 Binary files /dev/null and b/frontend/static/images/patterns/18.png differ diff --git a/frontend/static/images/patterns/2.jpg b/frontend/static/images/patterns/2.jpg new file mode 100644 index 0000000..eb7835d Binary files /dev/null and b/frontend/static/images/patterns/2.jpg differ diff --git a/frontend/static/images/patterns/3.jpg b/frontend/static/images/patterns/3.jpg new file mode 100644 index 0000000..a641124 Binary files /dev/null and b/frontend/static/images/patterns/3.jpg differ diff --git a/frontend/static/images/patterns/4.jpg b/frontend/static/images/patterns/4.jpg new file mode 100644 index 0000000..e793ebd Binary files /dev/null and b/frontend/static/images/patterns/4.jpg differ diff --git a/frontend/static/images/patterns/5.jpg b/frontend/static/images/patterns/5.jpg new file mode 100644 index 0000000..1c9055f Binary files /dev/null and b/frontend/static/images/patterns/5.jpg differ diff --git a/frontend/static/images/patterns/6.jpg b/frontend/static/images/patterns/6.jpg new file mode 100644 index 0000000..eaa1e34 Binary files /dev/null and b/frontend/static/images/patterns/6.jpg differ diff --git a/frontend/static/images/patterns/7.jpg b/frontend/static/images/patterns/7.jpg new file mode 100644 index 0000000..8813de0 Binary files /dev/null and b/frontend/static/images/patterns/7.jpg differ diff --git a/frontend/static/images/patterns/8.jpg b/frontend/static/images/patterns/8.jpg new file mode 100644 index 0000000..872780a Binary files /dev/null and b/frontend/static/images/patterns/8.jpg differ diff --git a/frontend/static/images/patterns/9.jpg b/frontend/static/images/patterns/9.jpg new file mode 100644 index 0000000..742593d Binary files /dev/null and b/frontend/static/images/patterns/9.jpg differ diff --git a/frontend/static/images/patterns/battle.png b/frontend/static/images/patterns/battle.png new file mode 100644 index 0000000..e0b8fb3 Binary files /dev/null and b/frontend/static/images/patterns/battle.png differ diff --git a/frontend/static/images/patterns/inside.jpg b/frontend/static/images/patterns/inside.jpg new file mode 100644 index 0000000..48cbf32 Binary files /dev/null and b/frontend/static/images/patterns/inside.jpg differ diff --git a/frontend/static/images/timeline.png b/frontend/static/images/timeline.png new file mode 100644 index 0000000..22a397b Binary files /dev/null and b/frontend/static/images/timeline.png differ diff --git a/frontend/static/js/comments.js b/frontend/static/js/comments.js new file mode 100644 index 0000000..92604e0 --- /dev/null +++ b/frontend/static/js/comments.js @@ -0,0 +1,17 @@ +function nick(nickname, targetSelector) { + let target = document.querySelector(targetSelector); + if (!target) return; + + target.setRangeText( + `**${nickname}**, `, + target.selectionStart, + target.selectionEnd, + "end" + ); + target.focus(); +} + +function toggle(event, targetSelector, toggleClass) { + let element = event.target; + element.parentElement.querySelector(targetSelector).classList.toggle(toggleClass); +} diff --git a/frontend/static/js/main.js b/frontend/static/js/main.js new file mode 100755 index 0000000..605dbac --- /dev/null +++ b/frontend/static/js/main.js @@ -0,0 +1,72 @@ +function initializeImageZoom() { + Lightense(document.querySelectorAll(".post img"), { + time: 50, + padding: 40, + offset: 40, + keyboard: true, + cubicBezier: "cubic-bezier(.2, 0, .1, 1)", + background: "rgba(0, 0, 0, .1)", + zIndex: 6, + }); +} + +function initializeHighlightJS() { + hljs.initHighlightingOnLoad(); +} + +function initializePoorManEmoji() { + let isApple = /iPad|iPhone|iPod|OS X/.test(navigator.userAgent) && !window.MSStream; + if (!isApple) { + document.body = twemoji.parse(document.body); + } +} + +function initializeExternalLinks() { + let anchors = document.querySelectorAll("a"); + anchors.forEach(anchor => { + if (anchor.hostname !== window.location.hostname) { + anchor.setAttribute("target", "_blank"); + anchor.setAttribute("rel", "noopener"); + } + }); +} + +function initializeAutoResizableTextareas() { + function onTextareaInput() { + this.style.height = 0; + this.style.height = (this.scrollHeight) + "px"; + } + + const textareas = document.querySelectorAll("textarea"); + textareas.forEach(textarea => { + textarea.setAttribute("style", "height:" + (textarea.scrollHeight) + "px;overflow-y:hidden;"); + textarea.addEventListener("input", onTextareaInput, false); + }); +} + +function initializeSpoilers() { + let spoilers = document.querySelectorAll(".block-spoiler"); + spoilers.forEach(spoiler => spoiler.addEventListener("click", event => { + spoiler.querySelector(".block-spoiler-button").classList.toggle("block-spoiler-button-hidden"); + spoiler.querySelector(".block-spoiler-text").classList.toggle("block-spoiler-text-visible"); + })); +} + +function toggleHeaderSearch(event, targetId) { + let searchForm = document.querySelector(targetId); + searchForm.classList.toggle("header-search-hidden"); + searchForm.querySelector(".header-search-form-input").focus(); + + event.target.classList.toggle("header-menu-full"); +} + +window.addEventListener("DOMContentLoaded", function() { + console.log("Initializing js...") + initializeImageZoom(); + initializePoorManEmoji(); + initializeSpoilers(); + initializeExternalLinks(); + initializeAutoResizableTextareas(); + initializeHighlightJS(); + console.log("Done") +}); diff --git a/frontend/static/js/vendor/highlight.pack.js b/frontend/static/js/vendor/highlight.pack.js new file mode 100644 index 0000000..1fcd2df --- /dev/null +++ b/frontend/static/js/vendor/highlight.pack.js @@ -0,0 +1 @@ +!function(e){"undefined"!=typeof exports?e(exports):(window.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return window.hljs}))}(function(e){function n(e){return e.replace(/&/gm,"&").replace(//gm,">")}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0==t.index}function a(e){var n=(e.className+" "+(e.parentNode?e.parentNode.className:"")).split(/\s+/);return n=n.map(function(e){return e.replace(/^lang(uage)?-/,"")}),n.filter(function(e){return N(e)||/no(-?)highlight/.test(e)})[0]}function o(e,n){var t={};for(var r in e)t[r]=e[r];if(n)for(var r in n)t[r]=n[r];return t}function i(e){var n=[];return function r(e,a){for(var o=e.firstChild;o;o=o.nextSibling)3==o.nodeType?a+=o.nodeValue.length:1==o.nodeType&&(n.push({event:"start",offset:a,node:o}),a=r(o,a),t(o).match(/br|hr|img|input/)||n.push({event:"stop",offset:a,node:o}));return a}(e,0),n}function c(e,r,a){function o(){return e.length&&r.length?e[0].offset!=r[0].offset?e[0].offset"}function c(e){l+=""}function u(e){("start"==e.event?i:c)(e.node)}for(var s=0,l="",f=[];e.length||r.length;){var g=o();if(l+=n(a.substr(s,g[0].offset-s)),s=g[0].offset,g==e){f.reverse().forEach(c);do u(g.splice(0,1)[0]),g=o();while(g==e&&g.length&&g[0].offset==s);f.reverse().forEach(i)}else"start"==g[0].event?f.push(g[0].node):f.pop(),u(g.splice(0,1)[0])}return l+n(a.substr(s))}function u(e){function n(e){return e&&e.source||e}function t(t,r){return RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,i){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var c={},u=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");c[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof a.k?u("keyword",a.k):Object.keys(a.k).forEach(function(e){u(e,a.k[e])}),a.k=c}a.lR=t(a.l||/\b[A-Za-z0-9_]+\b/,!0),i&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=t(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=t(a.e)),a.tE=n(a.e)||"",a.eW&&i.tE&&(a.tE+=(a.e?"|":"")+i.tE)),a.i&&(a.iR=t(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var s=[];a.c.forEach(function(e){e.v?e.v.forEach(function(n){s.push(o(e,n))}):s.push("self"==e?a:e)}),a.c=s,a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,i);var l=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(n).filter(Boolean);a.t=l.length?t(l.join("|"),!0):{exec:function(){return null}}}}r(e)}function s(e,t,a,o){function i(e,n){for(var t=0;t";return o+=e+'">',o+n+i}function d(){if(!w.k)return n(y);var e="",t=0;w.lR.lastIndex=0;for(var r=w.lR.exec(y);r;){e+=n(y.substr(t,r.index-t));var a=g(w,r);a?(B+=a[1],e+=p(a[0],n(r[0]))):e+=n(r[0]),t=w.lR.lastIndex,r=w.lR.exec(y)}return e+n(y.substr(t))}function h(){if(w.sL&&!R[w.sL])return n(y);var e=w.sL?s(w.sL,y,!0,L[w.sL]):l(y);return w.r>0&&(B+=e.r),"continuous"==w.subLanguageMode&&(L[w.sL]=e.top),p(e.language,e.value,!1,!0)}function v(){return void 0!==w.sL?h():d()}function b(e,t){var r=e.cN?p(e.cN,"",!0):"";e.rB?(M+=r,y=""):e.eB?(M+=n(t)+r,y=""):(M+=r,y=t),w=Object.create(e,{parent:{value:w}})}function m(e,t){if(y+=e,void 0===t)return M+=v(),0;var r=i(t,w);if(r)return M+=v(),b(r,t),r.rB?0:t.length;var a=c(w,t);if(a){var o=w;o.rE||o.eE||(y+=t),M+=v();do w.cN&&(M+=""),B+=w.r,w=w.parent;while(w!=a.parent);return o.eE&&(M+=n(t)),y="",a.starts&&b(a.starts,""),o.rE?0:t.length}if(f(t,w))throw new Error('Illegal lexeme "'+t+'" for mode "'+(w.cN||"")+'"');return y+=t,t.length||1}var x=N(e);if(!x)throw new Error('Unknown language: "'+e+'"');u(x);for(var w=o||x,L={},M="",k=w;k!=x;k=k.parent)k.cN&&(M=p(k.cN,"",!0)+M);var y="",B=0;try{for(var C,j,I=0;;){if(w.t.lastIndex=I,C=w.t.exec(t),!C)break;j=m(t.substr(I,C.index-I),C[0]),I=C.index+j}m(t.substr(I));for(var k=w;k.parent;k=k.parent)k.cN&&(M+="");return{r:B,value:M,language:e,top:w}}catch(A){if(-1!=A.message.indexOf("Illegal"))return{r:0,value:n(t)};throw A}}function l(e,t){t=t||E.languages||Object.keys(R);var r={r:0,value:n(e)},a=r;return t.forEach(function(n){if(N(n)){var t=s(n,e,!1);t.language=n,t.r>a.r&&(a=t),t.r>r.r&&(a=r,r=t)}}),a.language&&(r.second_best=a),r}function f(e){return E.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,n){return n.replace(/\t/g,E.tabReplace)})),E.useBR&&(e=e.replace(/\n/g,"
")),e}function g(e,n,t){var r=n?x[n]:t,a=[e.trim()];return e.match(/(\s|^)hljs(\s|$)/)||a.push("hljs"),r&&a.push(r),a.join(" ").trim()}function p(e){var n=a(e);if(!/no(-?)highlight/.test(n)){var t;E.useBR?(t=document.createElementNS("http://www.w3.org/1999/xhtml","div"),t.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):t=e;var r=t.textContent,o=n?s(n,r,!0):l(r),u=i(t);if(u.length){var p=document.createElementNS("http://www.w3.org/1999/xhtml","div");p.innerHTML=o.value,o.value=c(u,i(p),r)}o.value=f(o.value),e.innerHTML=o.value,e.className=g(e.className,n,o.language),e.result={language:o.language,re:o.r},o.second_best&&(e.second_best={language:o.second_best.language,re:o.second_best.r})}}function d(e){E=o(E,e)}function h(){if(!h.called){h.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,p)}}function v(){addEventListener("DOMContentLoaded",h,!1),addEventListener("load",h,!1)}function b(n,t){var r=R[n]=t(e);r.aliases&&r.aliases.forEach(function(e){x[e]=n})}function m(){return Object.keys(R)}function N(e){return R[e]||R[x[e]]}var E={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},R={},x={};return e.highlight=s,e.highlightAuto=l,e.fixMarkup=f,e.highlightBlock=p,e.configure=d,e.initHighlighting=h,e.initHighlightingOnLoad=v,e.registerLanguage=b,e.listLanguages=m,e.getLanguage=N,e.inherit=o,e.IR="[a-zA-Z][a-zA-Z0-9_]*",e.UIR="[a-zA-Z_][a-zA-Z0-9_]*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such)\b/},e.CLCM={cN:"comment",b:"//",e:"$",c:[e.PWM]},e.CBCM={cN:"comment",b:"/\\*",e:"\\*/",c:[e.PWM]},e.HCM={cN:"comment",b:"#",e:"$",c:[e.PWM]},e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e});hljs.registerLanguage("cpp",function(t){var i={keyword:"false int float while private char catch export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const struct for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using true class asm case typeid short reinterpret_cast|10 default double register explicit signed typename try this switch continue wchar_t inline delete alignof char16_t char32_t constexpr decltype noexcept nullptr static_assert thread_local restrict _Bool complex _Complex _Imaginaryintmax_t uintmax_t int8_t uint8_t int16_t uint16_t int32_t uint32_t int64_t uint64_tint_least8_t uint_least8_t int_least16_t uint_least16_t int_least32_t uint_least32_tint_least64_t uint_least64_t int_fast8_t uint_fast8_t int_fast16_t uint_fast16_t int_fast32_tuint_fast32_t int_fast64_t uint_fast64_t intptr_t uintptr_t atomic_bool atomic_char atomic_scharatomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llongatomic_ullong atomic_wchar_t atomic_char16_t atomic_char32_t atomic_intmax_t atomic_uintmax_tatomic_intptr_t atomic_uintptr_t atomic_size_t atomic_ptrdiff_t atomic_int_least8_t atomic_int_least16_tatomic_int_least32_t atomic_int_least64_t atomic_uint_least8_t atomic_uint_least16_t atomic_uint_least32_tatomic_uint_least64_t atomic_int_fast8_t atomic_int_fast16_t atomic_int_fast32_t atomic_int_fast64_tatomic_uint_fast8_t atomic_uint_fast16_t atomic_uint_fast32_t atomic_uint_fast64_t",built_in:"std string cin cout cerr clog stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap array shared_ptr abort abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf"};return{aliases:["c","h","c++","h++"],k:i,i:""]',k:"include",i:"\\n"},t.CLCM]},{cN:"stl_container",b:"\\b(deque|list|queue|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<",e:">",k:i,c:["self"]},{b:t.IR+"::"},{bK:"new throw return",r:0},{cN:"function",b:"("+t.IR+"\\s+)+"+t.IR+"\\s*\\(",rB:!0,e:/[{;=]/,eE:!0,k:i,c:[{b:t.IR+"\\s*\\(",rB:!0,c:[t.TM],r:0},{cN:"params",b:/\(/,e:/\)/,k:i,r:0,c:[t.CBCM]},t.CLCM,t.CBCM]}]}});hljs.registerLanguage("ruby",function(e){var b="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",r="and false then defined module in return redo if BEGIN retry end for true self when next until do begin unless END rescue nil else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",c={cN:"yardoctag",b:"@[A-Za-z]+"},a={cN:"value",b:"#<",e:">"},s={cN:"comment",v:[{b:"#",e:"$",c:[c]},{b:"^\\=begin",e:"^\\=end",c:[c],r:10},{b:"^__END__",e:"\\n$"}]},n={cN:"subst",b:"#\\{",e:"}",k:r},t={cN:"string",c:[e.BE,n],v:[{b:/'/,e:/'/},{b:/"/,e:/"/},{b:/`/,e:/`/},{b:"%[qQwWx]?\\(",e:"\\)"},{b:"%[qQwWx]?\\[",e:"\\]"},{b:"%[qQwWx]?{",e:"}"},{b:"%[qQwWx]?<",e:">"},{b:"%[qQwWx]?/",e:"/"},{b:"%[qQwWx]?%",e:"%"},{b:"%[qQwWx]?-",e:"-"},{b:"%[qQwWx]?\\|",e:"\\|"},{b:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/}]},i={cN:"params",b:"\\(",e:"\\)",k:r},d=[t,a,s,{cN:"class",bK:"class module",e:"$|;",i:/=/,c:[e.inherit(e.TM,{b:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{cN:"inheritance",b:"<\\s*",c:[{cN:"parent",b:"("+e.IR+"::)?"+e.IR}]},s]},{cN:"function",bK:"def",e:" |$|;",r:0,c:[e.inherit(e.TM,{b:b}),i,s]},{cN:"constant",b:"(::)?(\\b[A-Z]\\w*(::)?)+",r:0},{cN:"symbol",b:e.UIR+"(\\!|\\?)?:",r:0},{cN:"symbol",b:":",c:[t,{b:b}],r:0},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{cN:"variable",b:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{b:"("+e.RSR+")\\s*",c:[a,s,{cN:"regexp",c:[e.BE,n],i:/\n/,v:[{b:"/",e:"/[a-z]*"},{b:"%r{",e:"}[a-z]*"},{b:"%r\\(",e:"\\)[a-z]*"},{b:"%r!",e:"![a-z]*"},{b:"%r\\[",e:"\\][a-z]*"}]}],r:0}];n.c=d,i.c=d;var l="[>?]>",u="[\\w#]+\\(\\w+\\):\\d+:\\d+>",N="(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>",o=[{b:/^\s*=>/,cN:"status",starts:{e:"$",c:d}},{cN:"prompt",b:"^("+l+"|"+u+"|"+N+")",starts:{e:"$",c:d}}];return{aliases:["rb","gemspec","podspec","thor","irb"],k:r,c:[s].concat(o).concat(d)}});hljs.registerLanguage("python",function(e){var r={cN:"prompt",b:/^(>>>|\.\.\.) /},b={cN:"string",c:[e.BE],v:[{b:/(u|b)?r?'''/,e:/'''/,c:[r],r:10},{b:/(u|b)?r?"""/,e:/"""/,c:[r],r:10},{b:/(u|r|ur)'/,e:/'/,r:10},{b:/(u|r|ur)"/,e:/"/,r:10},{b:/(b|br)'/,e:/'/},{b:/(b|br)"/,e:/"/},e.ASM,e.QSM]},l={cN:"number",r:0,v:[{b:e.BNR+"[lLjJ]?"},{b:"\\b(0o[0-7]+)[lLjJ]?"},{b:e.CNR+"[lLjJ]?"}]},c={cN:"params",b:/\(/,e:/\)/,c:["self",r,l,b]};return{aliases:["py","gyp"],k:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},i:/(<\/|->|\?)/,c:[r,l,b,e.HCM,{v:[{cN:"function",bK:"def",r:10},{cN:"class",bK:"class"}],e:/:/,i:/[${=;\n]/,c:[e.UTM,c]},{cN:"decorator",b:/@/,e:/$/},{b:/\b(print|exec)\(/}]}});hljs.registerLanguage("javascript",function(r){return{aliases:["js"],k:{keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document"},c:[{cN:"pi",r:10,v:[{b:/^\s*('|")use strict('|")/},{b:/^\s*('|")use asm('|")/}]},r.ASM,r.QSM,r.CLCM,r.CBCM,r.CNM,{b:"("+r.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[r.CLCM,r.CBCM,r.RM,{b:/;/,r:0,sL:"xml"}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[r.inherit(r.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,c:[r.CLCM,r.CBCM],i:/["'\(]/}],i:/\[|%/},{b:/\$[(.]/},{b:"\\."+r.IR,r:0}]}});hljs.registerLanguage("http",function(){return{i:"\\S",c:[{cN:"status",b:"^HTTP/[0-9\\.]+",e:"$",c:[{cN:"number",b:"\\b\\d{3}\\b"}]},{cN:"request",b:"^[A-Z]+ (.*?) HTTP/[0-9\\.]+$",rB:!0,e:"$",c:[{cN:"string",b:" ",e:" ",eB:!0,eE:!0}]},{cN:"attribute",b:"^\\w",e:": ",eE:!0,i:"\\n|\\s|=",starts:{cN:"string",e:"$"}},{b:"\\n\\n",starts:{sL:"",eW:!0}}]}});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",a={cN:"function",b:c+"\\(",rB:!0,eE:!0,e:"\\("};return{cI:!0,i:"[=/|']",c:[e.CBCM,{cN:"id",b:"\\#[A-Za-z0-9_-]+"},{cN:"class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"attr_selector",b:"\\[",e:"\\]",i:"$"},{cN:"pseudo",b:":(:)?[a-zA-Z0-9\\_\\-\\+\\(\\)\\\"\\']+"},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",c:[{cN:"keyword",b:/\S+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[a,e.ASM,e.QSM,e.CSSNM]}]},{cN:"tag",b:c,r:0},{cN:"rules",b:"{",e:"}",i:"[^\\s]",r:0,c:[e.CBCM,{cN:"rule",b:"[^\\s]",rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:!0,i:"[^\\s]",starts:{cN:"value",eW:!0,eE:!0,c:[a,e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"hexcolor",b:"#[0-9A-Fa-f]+"},{cN:"important",b:"!important"}]}}]}]}]}});hljs.registerLanguage("ini",function(e){return{cI:!0,i:/\S/,c:[{cN:"comment",b:";",e:"$"},{cN:"title",b:"^\\[",e:"\\]"},{cN:"setting",b:"^[a-z0-9\\[\\]_-]+[ \\t]*=[ \\t]*",e:"$",c:[{cN:"value",eW:!0,k:"on off true false yes no",c:[e.QSM,e.NM],r:0}]}]}});hljs.registerLanguage("objectivec",function(e){var t={keyword:"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required",literal:"false true FALSE TRUE nil YES NO NULL",built_in:"NSString NSData NSDictionary CGRect CGPoint UIButton UILabel UITextView UIWebView MKMapView NSView NSViewController NSWindow NSWindowController NSSet NSUUID NSIndexSet UISegmentedControl NSObject UITableViewDelegate UITableViewDataSource NSThread UIActivityIndicator UITabbar UIToolBar UIBarButtonItem UIImageView NSAutoreleasePool UITableView BOOL NSInteger CGFloat NSException NSLog NSMutableString NSMutableArray NSMutableDictionary NSURL NSIndexPath CGSize UITableViewCell UIView UIViewController UINavigationBar UINavigationController UITabBarController UIPopoverController UIPopoverControllerDelegate UIImage NSNumber UISearchBar NSFetchedResultsController NSFetchedResultsChangeType UIScrollView UIScrollViewDelegate UIEdgeInsets UIColor UIFont UIApplication NSNotFound NSNotificationCenter NSNotification UILocalNotification NSBundle NSFileManager NSTimeInterval NSDate NSCalendar NSUserDefaults UIWindow NSRange NSArray NSError NSURLRequest NSURLConnection NSURLSession NSURLSessionDataTask NSURLSessionDownloadTask NSURLSessionUploadTask NSURLResponseUIInterfaceOrientation MPMoviePlayerController dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once"},o=/[a-zA-Z@][a-zA-Z0-9_]*/,a="@interface @class @protocol @implementation";return{aliases:["m","mm","objc","obj-c"],k:t,l:o,i:""}]}]},{cN:"class",b:"("+a.split(" ").join("|")+")\\b",e:"({|$)",eE:!0,k:a,l:o,c:[e.UTM]},{cN:"variable",b:"\\."+e.UIR,r:0}]}});hljs.registerLanguage("bash",function(e){var t={cN:"variable",v:[{b:/\$[\w\d#@][\w\d_]*/},{b:/\$\{(.*?)\}/}]},s={cN:"string",b:/"/,e:/"/,c:[e.BE,t,{cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]}]},a={cN:"string",b:/'/,e:/'/};return{aliases:["sh","zsh"],l:/-?[a-z\.]+/,k:{keyword:"if then else elif fi for while in do done case esac function",literal:"true false",built_in:"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp",operator:"-ne -eq -lt -gt -f -d -e -s -l -a"},c:[{cN:"shebang",b:/^#![^\n]+sh\s*$/,r:10},{cN:"function",b:/\w[\w\d_]*\s*\(\s*\)\s*\{/,rB:!0,c:[e.inherit(e.TM,{b:/\w[\w\d_]*/})],r:0},e.HCM,e.NM,s,a,t]}});hljs.registerLanguage("markdown",function(){return{aliases:["md","mkdown","mkd"],c:[{cN:"header",v:[{b:"^#{1,6}",e:"$"},{b:"^.+?\\n[=-]{2,}$"}]},{b:"<",e:">",sL:"xml",r:0},{cN:"bullet",b:"^([*+-]|(\\d+\\.))\\s+"},{cN:"strong",b:"[*_]{2}.+?[*_]{2}"},{cN:"emphasis",v:[{b:"\\*.+?\\*"},{b:"_.+?_",r:0}]},{cN:"blockquote",b:"^>\\s+",e:"$"},{cN:"code",v:[{b:"`.+?`"},{b:"^( {4}| )",e:"$",r:0}]},{cN:"horizontal_rule",b:"^[-\\*]{3,}",e:"$"},{b:"\\[.+?\\][\\(\\[].*?[\\)\\]]",rB:!0,c:[{cN:"link_label",b:"\\[",e:"\\]",eB:!0,rE:!0,r:0},{cN:"link_url",b:"\\]\\(",e:"\\)",eB:!0,eE:!0},{cN:"link_reference",b:"\\]\\[",e:"\\]",eB:!0,eE:!0}],r:10},{b:"^\\[.+\\]:",rB:!0,c:[{cN:"link_reference",b:"\\[",e:"\\]:",eB:!0,eE:!0,starts:{cN:"link_url",e:"$"}}]}]}});hljs.registerLanguage("diff",function(){return{aliases:["patch"],c:[{cN:"chunk",r:10,v:[{b:/^\@\@ +\-\d+,\d+ +\+\d+,\d+ +\@\@$/},{b:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{b:/^\-\-\- +\d+,\d+ +\-\-\-\-$/}]},{cN:"header",v:[{b:/Index: /,e:/$/},{b:/=====/,e:/=====$/},{b:/^\-\-\-/,e:/$/},{b:/^\*{3} /,e:/$/},{b:/^\+\+\+/,e:/$/},{b:/\*{5}/,e:/\*{5}$/}]},{cN:"addition",b:"^\\+",e:"$"},{cN:"deletion",b:"^\\-",e:"$"},{cN:"change",b:"^\\!",e:"$"}]}});hljs.registerLanguage("java",function(e){var a=e.UIR+"(<"+e.UIR+">)?",t="false synchronized int abstract float private char boolean static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private",c="(\\b(0b[01_]+)|\\b0[xX][a-fA-F0-9_]+|(\\b[\\d_]+(\\.[\\d_]*)?|\\.[\\d_]+)([eE][-+]?\\d+)?)[lLfF]?",r={cN:"number",b:c,r:0};return{aliases:["jsp"],k:t,i:/<\//,c:[{cN:"javadoc",b:"/\\*\\*",e:"\\*/",r:0,c:[{cN:"javadoctag",b:"(^|\\s)@[A-Za-z]+"}]},e.CLCM,e.CBCM,e.ASM,e.QSM,{cN:"class",bK:"class interface",e:/[{;=]/,eE:!0,k:"class interface",i:/[:"\[\]]/,c:[{bK:"extends implements"},e.UTM]},{bK:"new throw return",r:0},{cN:"function",b:"("+a+"\\s+)+"+e.UIR+"\\s*\\(",rB:!0,e:/[{;=]/,eE:!0,k:t,c:[{b:e.UIR+"\\s*\\(",rB:!0,r:0,c:[e.UTM]},{cN:"params",b:/\(/,e:/\)/,k:t,r:0,c:[e.ASM,e.QSM,e.CNM,e.CBCM]},e.CLCM,e.CBCM]},r,{cN:"annotation",b:"@[A-Za-z]+"}]}});hljs.registerLanguage("rust",function(e){var t=e.inherit(e.CBCM);return t.c.push("self"),{aliases:["rs"],k:{keyword:"alignof as be box break const continue crate do else enum extern false fn for if impl in let loop match mod mut offsetof once priv proc pub pure ref return self sizeof static struct super trait true type typeof unsafe unsized use virtual while yield int i8 i16 i32 i64 uint u8 u32 u64 float f32 f64 str char bool",built_in:"assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln!"},l:e.IR+"!?",i:""}]}});hljs.registerLanguage("perl",function(e){var t="getpwent getservent quotemeta msgrcv scalar kill dbmclose undef lc ma syswrite tr send umask sysopen shmwrite vec qx utime local oct semctl localtime readpipe do return format read sprintf dbmopen pop getpgrp not getpwnam rewinddir qqfileno qw endprotoent wait sethostent bless s|0 opendir continue each sleep endgrent shutdown dump chomp connect getsockname die socketpair close flock exists index shmgetsub for endpwent redo lstat msgctl setpgrp abs exit select print ref gethostbyaddr unshift fcntl syscall goto getnetbyaddr join gmtime symlink semget splice x|0 getpeername recv log setsockopt cos last reverse gethostbyname getgrnam study formline endhostent times chop length gethostent getnetent pack getprotoent getservbyname rand mkdir pos chmod y|0 substr endnetent printf next open msgsnd readdir use unlink getsockopt getpriority rindex wantarray hex system getservbyport endservent int chr untie rmdir prototype tell listen fork shmread ucfirst setprotoent else sysseek link getgrgid shmctl waitpid unpack getnetbyname reset chdir grep split require caller lcfirst until warn while values shift telldir getpwuid my getprotobynumber delete and sort uc defined srand accept package seekdir getprotobyname semop our rename seek if q|0 chroot sysread setpwent no crypt getc chown sqrt write setnetent setpriority foreach tie sin msgget map stat getlogin unless elsif truncate exec keys glob tied closedirioctl socket readlink eval xor readline binmode setservent eof ord bind alarm pipe atan2 getgrent exp time push setgrent gt lt or ne m|0 break given say state when",r={cN:"subst",b:"[$@]\\{",e:"\\}",k:t},s={b:"->{",e:"}"},n={cN:"variable",v:[{b:/\$\d/},{b:/[\$\%\@](\^\w\b|#\w+(\:\:\w+)*|{\w+}|\w+(\:\:\w*)*)/},{b:/[\$\%\@][^\s\w{]/,r:0}]},o={cN:"comment",b:"^(__END__|__DATA__)",e:"\\n$",r:5},i=[e.BE,r,n],c=[n,e.HCM,o,{cN:"comment",b:"^\\=\\w",e:"\\=cut",eW:!0},s,{cN:"string",c:i,v:[{b:"q[qwxr]?\\s*\\(",e:"\\)",r:5},{b:"q[qwxr]?\\s*\\[",e:"\\]",r:5},{b:"q[qwxr]?\\s*\\{",e:"\\}",r:5},{b:"q[qwxr]?\\s*\\|",e:"\\|",r:5},{b:"q[qwxr]?\\s*\\<",e:"\\>",r:5},{b:"qw\\s+q",e:"q",r:5},{b:"'",e:"'",c:[e.BE]},{b:'"',e:'"'},{b:"`",e:"`",c:[e.BE]},{b:"{\\w+}",c:[],r:0},{b:"-?\\w+\\s*\\=\\>",c:[],r:0}]},{cN:"number",b:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",r:0},{b:"(\\/\\/|"+e.RSR+"|\\b(split|return|print|reverse|grep)\\b)\\s*",k:"split return print reverse grep",r:0,c:[e.HCM,o,{cN:"regexp",b:"(s|tr|y)/(\\\\.|[^/])*/(\\\\.|[^/])*/[a-z]*",r:10},{cN:"regexp",b:"(m|qr)?/",e:"/[a-z]*",c:[e.BE],r:0}]},{cN:"sub",bK:"sub",e:"(\\s*\\(.*?\\))?[;{]",r:5},{cN:"operator",b:"-\\w\\b",r:0}];return r.c=c,s.c=c,{aliases:["pl"],k:t,c:c}});hljs.registerLanguage("makefile",function(e){var a={cN:"variable",b:/\$\(/,e:/\)/,c:[e.BE]};return{aliases:["mk","mak"],c:[e.HCM,{b:/^\w+\s*\W*=/,rB:!0,r:0,starts:{cN:"constant",e:/\s*\W*=/,eE:!0,starts:{e:/$/,r:0,c:[a]}}},{cN:"title",b:/^[\w]+:\s*$/},{cN:"phony",b:/^\.PHONY:/,e:/$/,k:".PHONY",l:/[\.\w]+/},{b:/^\t+/,e:/$/,r:0,c:[e.QSM,a]}]}});hljs.registerLanguage("json",function(e){var t={literal:"true false null"},i=[e.QSM,e.CNM],l={cN:"value",e:",",eW:!0,eE:!0,c:i,k:t},c={b:"{",e:"}",c:[{cN:"attribute",b:'\\s*"',e:'"\\s*:\\s*',eB:!0,eE:!0,c:[e.BE],i:"\\n",starts:l}],i:"\\S"},n={b:"\\[",e:"\\]",c:[e.inherit(l,{cN:null})],i:"\\S"};return i.splice(i.length,0,c,n),{c:i,k:t,i:"\\S"}});hljs.registerLanguage("go",function(e){var t={keyword:"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer",constant:"true false iota nil",typename:"bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune",built_in:"append cap close complex copy imag len make new panic print println real recover delete"};return{aliases:["golang"],k:t,i:"",c:[e.HCM,{cN:"string",c:[e.BE,r],v:[{b:/"/,e:/"/},{b:/'/,e:/'/}]},{cN:"url",b:"([a-z]+):/",e:"\\s",eW:!0,eE:!0,c:[r]},{cN:"regexp",c:[e.BE,r],v:[{b:"\\s\\^",e:"\\s|{|;",rE:!0},{b:"~\\*?\\s+",e:"\\s|{|;",rE:!0},{b:"\\*(\\.[a-z\\-]+)+"},{b:"([a-z\\-]+\\.)+\\*"}]},{cN:"number",b:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{cN:"number",b:"\\b\\d+[kKmMgGdshdwy]*\\b",r:0},r]};return{aliases:["nginxconf"],c:[e.HCM,{b:e.UIR+"\\s",e:";|{",rB:!0,c:[{cN:"title",b:e.UIR,starts:b}],r:0}],i:"[^\\s\\}]"}});hljs.registerLanguage("sql",function(e){var t={cN:"comment",b:"--",e:"$"};return{cI:!0,i:/[<>]/,c:[{cN:"operator",bK:"begin end start commit rollback savepoint lock alter create drop rename call delete do handler insert load replace select truncate update set show pragma grant merge describe use explain help declare prepare execute deallocate savepoint release unlock purge reset change stop analyze cache flush optimize repair kill install uninstall checksum restore check backup",e:/;/,eW:!0,k:{keyword:"abs absolute acos action add adddate addtime aes_decrypt aes_encrypt after aggregate all allocate alter analyze and any are as asc ascii asin assertion at atan atan2 atn2 authorization authors avg backup before begin benchmark between bin binlog bit_and bit_count bit_length bit_or bit_xor both by cache call cascade cascaded case cast catalog ceil ceiling chain change changed char_length character_length charindex charset check checksum checksum_agg choose close coalesce coercibility collate collation collationproperty column columns columns_updated commit compress concat concat_ws concurrent connect connection connection_id consistent constraint constraints continue contributors conv convert convert_tz corresponding cos cot count count_big crc32 create cross cume_dist curdate current current_date current_time current_timestamp current_user cursor curtime data database databases datalength date_add date_format date_sub dateadd datediff datefromparts datename datepart datetime2fromparts datetimeoffsetfromparts day dayname dayofmonth dayofweek dayofyear deallocate declare decode default deferrable deferred degrees delayed delete des_decrypt des_encrypt des_key_file desc describe descriptor diagnostics difference disconnect distinct distinctrow div do domain double drop dumpfile each else elt enclosed encode encrypt end end-exec engine engines eomonth errors escape escaped event eventdata events except exception exec execute exists exp explain export_set extended external extract fast fetch field fields find_in_set first first_value floor flush for force foreign format found found_rows from from_base64 from_days from_unixtime full function get get_format get_lock getdate getutcdate global go goto grant grants greatest group group_concat grouping grouping_id gtid_subset gtid_subtract handler having help hex high_priority hosts hour ident_current ident_incr ident_seed identified identity if ifnull ignore iif ilike immediate in index indicator inet6_aton inet6_ntoa inet_aton inet_ntoa infile initially inner innodb input insert install instr intersect into is is_free_lock is_ipv4 is_ipv4_compat is_ipv4_mapped is_not is_not_null is_used_lock isdate isnull isolation join key kill language last last_day last_insert_id last_value lcase lead leading least leaves left len lenght level like limit lines ln load load_file local localtime localtimestamp locate lock log log10 log2 logfile logs low_priority lower lpad ltrim make_set makedate maketime master master_pos_wait match matched max md5 medium merge microsecond mid min minute mod mode module month monthname mutex name_const names national natural nchar next no no_write_to_binlog not now nullif nvarchar oct octet_length of old_password on only open optimize option optionally or ord order outer outfile output pad parse partial partition password patindex percent_rank percentile_cont percentile_disc period_add period_diff pi plugin position pow power pragma precision prepare preserve primary prior privileges procedure procedure_analyze processlist profile profiles public publishingservername purge quarter query quick quote quotename radians rand read references regexp relative relaylog release release_lock rename repair repeat replace replicate reset restore restrict return returns reverse revoke right rlike rollback rollup round row row_count rows rpad rtrim savepoint schema scroll sec_to_time second section select serializable server session session_user set sha sha1 sha2 share show sign sin size slave sleep smalldatetimefromparts snapshot some soname soundex sounds_like space sql sql_big_result sql_buffer_result sql_cache sql_calc_found_rows sql_no_cache sql_small_result sql_variant_property sqlstate sqrt square start starting status std stddev stddev_pop stddev_samp stdev stdevp stop str str_to_date straight_join strcmp string stuff subdate substr substring subtime subtring_index sum switchoffset sysdate sysdatetime sysdatetimeoffset system_user sysutcdatetime table tables tablespace tan temporary terminated tertiary_weights then time time_format time_to_sec timediff timefromparts timestamp timestampadd timestampdiff timezone_hour timezone_minute to to_base64 to_days to_seconds todatetimeoffset trailing transaction translation trigger trigger_nestlevel triggers trim truncate try_cast try_convert try_parse ucase uncompress uncompressed_length unhex unicode uninstall union unique unix_timestamp unknown unlock update upgrade upped upper usage use user user_resources using utc_date utc_time utc_timestamp uuid uuid_short validate_password_strength value values var var_pop var_samp variables variance varp version view warnings week weekday weekofyear weight_string when whenever where with work write xml xor year yearweek zon",literal:"true false null",built_in:"array bigint binary bit blob boolean char character date dec decimal float int integer interval number numeric real serial smallint varchar varying int8 serial8 text"},c:[{cN:"string",b:"'",e:"'",c:[e.BE,{b:"''"}]},{cN:"string",b:'"',e:'"',c:[e.BE,{b:'""'}]},{cN:"string",b:"`",e:"`",c:[e.BE]},e.CNM,e.CBCM,t]},e.CBCM,t]}});hljs.registerLanguage("xml",function(){var t="[A-Za-z0-9\\._:-]+",e={b:/<\?(php)?(?!\w)/,e:/\?>/,sL:"php",subLanguageMode:"continuous"},c={eW:!0,i:/]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},{cN:"comment",b:"",r:10},{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{title:"style"},c:[c],starts:{e:"",rE:!0,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[c],starts:{e:"",rE:!0,sL:"javascript"}},e,{cN:"pi",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"",c:[{cN:"title",b:/[^ \/><\n\t]+/,r:0},c]}]}});hljs.registerLanguage("php",function(e){var c={cN:"variable",b:"\\$+[a-zA-Z_-ÿ][a-zA-Z0-9_-ÿ]*"},i={cN:"preprocessor",b:/<\?(php)?|\?>/},a={cN:"string",c:[e.BE,i],v:[{b:'b"',e:'"'},{b:"b'",e:"'"},e.inherit(e.ASM,{i:null}),e.inherit(e.QSM,{i:null})]},n={v:[e.BNM,e.CNM]};return{aliases:["php3","php4","php5","php6"],cI:!0,k:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception default die require __FUNCTION__ enddeclare final try switch continue endfor endif declare unset true false trait goto instanceof insteadof __DIR__ __NAMESPACE__ yield finally",c:[e.CLCM,e.HCM,{cN:"comment",b:"/\\*",e:"\\*/",c:[{cN:"phpdoc",b:"\\s@[A-Za-z]+"},i]},{cN:"comment",b:"__halt_compiler.+?;",eW:!0,k:"__halt_compiler",l:e.UIR},{cN:"string",b:"<<<['\"]?\\w+['\"]?$",e:"^\\w+;",c:[e.BE]},i,c,{b:/->+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{cN:"function",bK:"function",e:/[;{]/,eE:!0,i:"\\$|\\[|%",c:[e.UTM,{cN:"params",b:"\\(",e:"\\)",c:["self",c,e.CBCM,a,n]}]},{cN:"class",bK:"class interface",e:"{",eE:!0,i:/[:\(\$"]/,c:[{bK:"extends implements"},e.UTM]},{bK:"namespace",e:";",i:/[\.']/,c:[e.UTM]},{bK:"use",e:";",c:[e.UTM]},{b:"=>"},a,n]}}); \ No newline at end of file diff --git a/frontend/static/js/vendor/htmx.min.js b/frontend/static/js/vendor/htmx.min.js new file mode 100644 index 0000000..556de48 --- /dev/null +++ b/frontend/static/js/vendor/htmx.min.js @@ -0,0 +1 @@ +(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var W={onLoad:t,process:mt,on:X,off:F,trigger:Q,ajax:or,find:R,findAll:O,closest:N,values:function(e,t){var r=jt(e,t||"post");return r.values},remove:q,addClass:L,removeClass:T,toggleClass:H,takeClass:A,defineExtension:dr,removeExtension:vr,logAll:C,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false},parseInterval:v,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){return new WebSocket(e,[])},version:"1.8.4"};var r={addTriggerHandler:ft,bodyContains:te,canAccessLocalStorage:E,filterValues:zt,hasAttribute:o,getAttributeValue:G,getClosestMatch:h,getExpressionVars:rr,getHeaders:_t,getInputValues:jt,getInternalData:Z,getSwapSpecification:Gt,getTriggerSpecs:Xe,getTarget:oe,makeFragment:g,mergeObjects:re,makeSettleInfo:Zt,oobSwap:_,selectAndSwap:Oe,settleImmediately:At,shouldCancel:Ve,triggerEvent:Q,triggerErrorEvent:Y,withExtensions:wt};var n=["get","post","put","delete","patch"];var i=n.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function v(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function f(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function G(e,t){return f(e,t)||f(e,"data-"+t)}function u(e){return e.parentElement}function J(){return document}function h(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function a(e,t,r){var n=G(t,r);var i=G(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function $(t,r){var n=null;h(t,function(e){return n=a(t,e,r)});if(n!=="unset"){return n}}function d(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function s(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function l(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=J().createDocumentFragment()}return i}function g(e){if(W.config.useTemplateFragments){var t=l("",0);return t.querySelector("template").content}else{var r=s(e);switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return l(""+e+"
",1);case"col":return l(""+e+"
",2);case"tr":return l(""+e+"
",2);case"td":case"th":return l(""+e+"
",3);case"script":return l("
"+e+"
",1);default:return l(e,0)}}}function ee(e){if(e){e()}}function p(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function m(e){return p(e,"Function")}function x(e){return p(e,"Object")}function Z(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function y(e){var t=[];if(e){for(var r=0;r=0}function te(e){if(e.getRootNode&&e.getRootNode()instanceof ShadowRoot){return J().body.contains(e.getRootNode().host)}else{return J().body.contains(e)}}function w(e){return e.trim().split(/\s+/)}function re(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function S(e){try{return JSON.parse(e)}catch(e){St(e);return null}}function E(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function e(e){return Qt(J().body,function(){return eval(e)})}function t(t){var e=W.on("htmx:load",function(e){t(e.detail.elt)});return e}function C(){W.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function R(e,t){if(t){return e.querySelector(t)}else{return R(J(),e)}}function O(e,t){if(t){return e.querySelectorAll(t)}else{return O(J(),e)}}function q(e,t){e=D(e);if(t){setTimeout(function(){q(e)},t)}else{e.parentElement.removeChild(e)}}function L(e,t,r){e=D(e);if(r){setTimeout(function(){L(e,t)},r)}else{e.classList&&e.classList.add(t)}}function T(e,t,r){e=D(e);if(r){setTimeout(function(){T(e,t)},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function H(e,t){e=D(e);e.classList.toggle(t)}function A(e,t){e=D(e);K(e.parentElement.children,function(e){T(e,t)});L(e,t)}function N(e,t){e=D(e);if(e.closest){return e.closest(t)}else{do{if(e==null||d(e,t)){return e}}while(e=e&&u(e))}}function I(e,t){if(t.indexOf("closest ")===0){return[N(e,t.substr(8))]}else if(t.indexOf("find ")===0){return[R(e,t.substr(5))]}else if(t.indexOf("next ")===0){return[k(e,t.substr(5))]}else if(t.indexOf("previous ")===0){return[M(e,t.substr(9))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else{return J().querySelectorAll(t)}}var k=function(e,t){var r=J().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ne(e,t){if(t){return I(e,t)[0]}else{return I(J().body,e)[0]}}function D(e){if(p(e,"String")){return R(e)}else{return e}}function P(e,t,r){if(m(t)){return{target:J().body,event:e,listener:t}}else{return{target:D(e),event:t,listener:r}}}function X(t,r,n){pr(function(){var e=P(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=m(r);return e?r:n}function F(t,r,n){pr(function(){var e=P(t,r,n);e.target.removeEventListener(e.event,e.listener)});return m(r)?r:n}var ie=J().createElement("output");function j(e,t){var r=$(e,t);if(r){if(r==="this"){return[ae(e,t)]}else{var n=I(e,r);if(n.length===0){St('The selector "'+r+'" on '+t+" returned no matches!");return[ie]}else{return n}}}}function ae(e,t){return h(e,function(e){return G(e,t)!=null})}function oe(e){var t=$(e,"hx-target");if(t){if(t==="this"){return ae(e,"hx-target")}else{return ne(e,t)}}else{var r=Z(e);if(r.boosted){return J().body}else{return e}}}function B(e){var t=W.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=J().querySelectorAll(t);if(r){K(r,function(e){var t;var r=i.cloneNode(true);t=J().createDocumentFragment();t.appendChild(r);if(!V(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!Q(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Ce(o,e,e,t,a)}K(a.elts,function(e){Q(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);Y(J().body,"htmx:oobErrorNoTarget",{content:i})}return e}function z(e,t,r){var n=$(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var t=n.querySelector(e.tagName+"[id='"+e.id+"']");if(t&&t!==n){var r=e.cloneNode();U(e,t);i.tasks.push(function(){U(e,r)})}}})}function ue(e){return function(){T(e,W.config.addedClass);mt(e);ht(e);fe(e);Q(e,"htmx:load")}}function fe(e){var t="[autofocus]";var r=d(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function ce(e,t,r,n){le(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;L(i,W.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(ue(i))}}}function he(e,t){var r=0;while(r-1){var t=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var r=t.match(/]*>|>)([\s\S]*?)<\/title>/im);if(r){return r[2]}}}function Oe(e,t,r,n,i){i.title=Re(n);var a=g(n);if(a){z(r,a,i);a=Ee(r,a);se(a);return Ce(e,r,t,a,i)}}function qe(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=S(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!x(o)){o={value:o}}Q(r,a,o)}}}else{Q(r,n,[])}}var Le=/\s/;var Te=/[\s,]/;var He=/[_$a-zA-Z]/;var Ae=/[_$a-zA-Z0-9]/;var Ne=['"',"'","/"];var Ie=/[^\s]/;function ke(e){var t=[];var r=0;while(r0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=Qt(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){Y(J().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if(Me(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function c(e,t){var r="";while(e.length>0&&!e[0].match(t)){r+=e.shift()}return r}var Pe="input, textarea, select";function Xe(e){var t=G(e,"hx-trigger");var r=[];if(t){var n=ke(t);do{c(n,Ie);var f=n.length;var i=c(n,/[,\[\s]/);if(i!==""){if(i==="every"){var a={trigger:"every"};c(n,Ie);a.pollInterval=v(c(n,/[,\[\s]/));c(n,Ie);var o=De(e,n,"event");if(o){a.eventFilter=o}r.push(a)}else if(i.indexOf("sse:")===0){r.push({trigger:"sse",sseEvent:i.substr(4)})}else{var s={trigger:i};var o=De(e,n,"event");if(o){s.eventFilter=o}while(n.length>0&&n[0]!==","){c(n,Ie);var l=n.shift();if(l==="changed"){s.changed=true}else if(l==="once"){s.once=true}else if(l==="consume"){s.consume=true}else if(l==="delay"&&n[0]===":"){n.shift();s.delay=v(c(n,Te))}else if(l==="from"&&n[0]===":"){n.shift();var u=c(n,Te);if(u==="closest"||u==="find"||u==="next"||u==="previous"){n.shift();u+=" "+c(n,Te)}s.from=u}else if(l==="target"&&n[0]===":"){n.shift();s.target=c(n,Te)}else if(l==="throttle"&&n[0]===":"){n.shift();s.throttle=v(c(n,Te))}else if(l==="queue"&&n[0]===":"){n.shift();s.queue=c(n,Te)}else if((l==="root"||l==="threshold")&&n[0]===":"){n.shift();s[l]=c(n,Te)}else{Y(e,"htmx:syntax:error",{token:n.shift()})}}r.push(s)}}if(n.length===f){Y(e,"htmx:syntax:error",{token:n.shift()})}c(n,Ie)}while(n[0]===","&&n.shift())}if(r.length>0){return r}else if(d(e,"form")){return[{trigger:"submit"}]}else if(d(e,'input[type="button"]')){return[{trigger:"click"}]}else if(d(e,Pe)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function Fe(e){Z(e).cancelled=true}function je(e,t,r){var n=Z(e);n.timeout=setTimeout(function(){if(te(e)&&n.cancelled!==true){if(!ze(r,yt("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}je(e,t,r)}},r.pollInterval)}function Be(e){return location.hostname===e.hostname&&f(e,"href")&&f(e,"href").indexOf("#")!==0}function Ue(t,r,e){if(t.tagName==="A"&&Be(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=f(t,"href")}else{var a=f(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=f(t,"action")}e.forEach(function(e){We(t,function(e){lr(n,i,t,e)},r,e,true)})}}function Ve(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(d(t,'input[type="submit"], button')&&N(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function _e(e,t){return Z(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function ze(e,t){var r=e.eventFilter;if(r){try{return r(t)!==true}catch(e){Y(J().body,"htmx:eventFilter:error",{error:e,source:r.source});return true}}return false}function We(a,o,e,s,l){var t;if(s.from){t=I(a,s.from)}else{t=[a]}K(t,function(n){var i=function(e){if(!te(a)){n.removeEventListener(s.trigger,i);return}if(_e(a,e)){return}if(l||Ve(e,a)){e.preventDefault()}if(ze(s,e)){return}var t=Z(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}var r=Z(a);if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!d(e.target,s.target)){return}}if(s.once){if(r.triggeredOnce){return}else{r.triggeredOnce=true}}if(s.changed){if(r.lastValue===a.value){return}else{r.lastValue=a.value}}if(r.delayed){clearTimeout(r.delayed)}if(r.throttle){return}if(s.throttle){if(!r.throttle){o(a,e);r.throttle=setTimeout(function(){r.throttle=null},s.throttle)}}else if(s.delay){r.delayed=setTimeout(function(){o(a,e)},s.delay)}else{o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var Ge=false;var Je=null;function $e(){if(!Je){Je=function(){Ge=true};window.addEventListener("scroll",Je);setInterval(function(){if(Ge){Ge=false;K(J().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){Ze(e)})}},200)}}function Ze(t){if(!o(t,"data-hx-revealed")&&b(t)){t.setAttribute("data-hx-revealed","true");var e=Z(t);if(e.initHash){Q(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){Q(t,"revealed")},{once:true})}}}function Ke(e,t,r){var n=w(r);for(var i=0;i=0){var t=tt(n);setTimeout(function(){Ye(s,r,n+1)},t)}};t.onopen=function(e){n=0};Z(s).webSocket=t;t.addEventListener("message",function(e){if(Qe(s)){return}var t=e.data;wt(s,function(e){t=e.transformResponse(t,null,s)});var r=Zt(s);var n=g(t);var i=y(n.children);for(var a=0;a0){Q(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(Ve(e,u)){e.preventDefault()}})}else{Y(u,"htmx:noWebSocketSourceError")}}function tt(e){var t=W.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}St('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function rt(e,t,r){var n=w(r);for(var i=0;iW.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){Y(J().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Ot(e){if(!E()){return null}var t=S(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){Q(J().body,"htmx:historyCacheMissLoad",o);var e=g(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Ct();var r=Zt(t);var n=Re(this.response);if(n){var i=R("title");if(i){i.innerHTML=n}else{window.document.title=n}}Se(t,e,r);At(r.tasks);Et=a;Q(J().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{Y(J().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function It(e){Lt();e=e||location.pathname+location.search;var t=Ot(e);if(t){var r=g(t.content);var n=Ct();var i=Zt(n);Se(n,r,i);At(i.tasks);document.title=t.title;window.scrollTo(0,t.scroll);Et=e;Q(J().body,"htmx:historyRestore",{path:e,item:t})}else{if(W.config.refreshOnHistoryMiss){window.location.reload(true)}else{Nt(e)}}}function kt(e){var t=j(e,"hx-indicator");if(t==null){t=[e]}K(t,function(e){var t=Z(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,W.config.requestClass)});return t}function Mt(e){K(e,function(e){var t=Z(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,W.config.requestClass)}})}function Dt(e,t){for(var r=0;r=0}function Gt(e,t){var r=t?t:$(e,"hx-swap");var n={swapStyle:Z(e).boosted?"innerHTML":W.config.defaultSwapStyle,swapDelay:W.config.defaultSwapDelay,settleDelay:W.config.defaultSettleDelay};if(Z(e).boosted&&!Wt(e)){n["show"]="top"}if(r){var i=w(r);if(i.length>0){n["swapStyle"]=i[0];for(var a=1;a0?l.join(":"):null;n["scroll"]=f;n["scrollTarget"]=u}if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var u=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=u}if(o.indexOf("focus-scroll:")===0){var d=o.substr("focus-scroll:".length);n["focusScroll"]=d=="true"}}}}return n}function Jt(e){return $(e,"hx-encoding")==="multipart/form-data"||d(e,"form")&&f(e,"enctype")==="multipart/form-data"}function $t(t,r,n){var i=null;wt(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(Jt(r)){return Vt(n)}else{return Ut(n)}}}function Zt(e){return{tasks:[],elts:[e]}}function Kt(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=ne(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=ne(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:W.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:W.config.scrollBehavior})}}}function Yt(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=G(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=Qt(e,function(){return Function("return ("+a+")")()},{})}else{s=S(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return Yt(u(e),t,r,n)}function Qt(e,t,r){if(W.config.allowEval){return t()}else{Y(e,"htmx:evalDisallowedError");return r}}function er(e,t){return Yt(e,"hx-vars",true,t)}function tr(e,t){return Yt(e,"hx-vals",false,t)}function rr(e){return re(er(e),tr(e))}function nr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function ir(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){Y(J().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function ar(e,t){return e.getAllResponseHeaders().match(t)}function or(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||p(r,"String")){return lr(e,t,null,null,{targetOverride:D(r),returnPromise:true})}else{return lr(e,t,D(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:D(r.target),swapOverride:r.swap,returnPromise:true})}}else{return lr(e,t,null,null,{returnPromise:true})}}function sr(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function lr(e,t,n,r,i,f){var c=null;var h=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var d=new Promise(function(e,t){c=e;h=t})}if(n==null){n=J().body}var v=i.handler||fr;if(!te(n)){return}var g=i.targetOverride||oe(n);if(g==null||g==ie){Y(n,"htmx:targetError",{target:G(n,"hx-target")});return}if(!f){var p=function(){return lr(e,t,n,r,i,true)};var m={target:g,elt:n,path:t,verb:e,triggeringEvent:r,etc:i,issueRequest:p};if(Q(n,"htmx:confirm",m)===false){return}}var x=n;var a=Z(n);var y=$(n,"hx-sync");var b=null;var w=false;if(y){var S=y.split(":");var E=S[0].trim();if(E==="this"){x=ae(n,"hx-sync")}else{x=ne(n,E)}y=(S[1]||"drop").trim();a=Z(x);if(y==="drop"&&a.xhr&&a.abortable!==true){return}else if(y==="abort"){if(a.xhr){return}else{w=true}}else if(y==="replace"){Q(x,"htmx:abort")}else if(y.indexOf("queue")===0){var C=y.split(" ");b=(C[1]||"last").trim()}}if(a.xhr){if(a.abortable){Q(x,"htmx:abort")}else{if(b==null){if(r){var R=Z(r);if(R&&R.triggerSpec&&R.triggerSpec.queue){b=R.triggerSpec.queue}}if(b==null){b="last"}}if(a.queuedRequests==null){a.queuedRequests=[]}if(b==="first"&&a.queuedRequests.length===0){a.queuedRequests.push(function(){lr(e,t,n,r,i)})}else if(b==="all"){a.queuedRequests.push(function(){lr(e,t,n,r,i)})}else if(b==="last"){a.queuedRequests=[];a.queuedRequests.push(function(){lr(e,t,n,r,i)})}return}}var o=new XMLHttpRequest;a.xhr=o;a.abortable=w;var s=function(){a.xhr=null;a.abortable=false;if(a.queuedRequests!=null&&a.queuedRequests.length>0){var e=a.queuedRequests.shift();e()}};var O=$(n,"hx-prompt");if(O){var q=prompt(O);if(q===null||!Q(n,"htmx:prompt",{prompt:q,target:g})){ee(c);s();return d}}var L=$(n,"hx-confirm");if(L){if(!confirm(L)){ee(c);s();return d}}var T=_t(n,g,q);if(i.headers){T=re(T,i.headers)}var H=jt(n,e);var A=H.errors;var N=H.values;if(i.values){N=re(N,i.values)}var I=rr(n);var k=re(N,I);var M=zt(k,n);if(e!=="get"&&!Jt(n)){T["Content-Type"]="application/x-www-form-urlencoded"}if(t==null||t===""){t=J().location.href}var D=Yt(n,"hx-request");var P=Z(n).boosted;var l={boosted:P,parameters:M,unfilteredParameters:k,headers:T,target:g,verb:e,errors:A,withCredentials:i.credentials||D.credentials||W.config.withCredentials,timeout:i.timeout||D.timeout||W.config.timeout,path:t,triggeringEvent:r};if(!Q(n,"htmx:configRequest",l)){ee(c);s();return d}t=l.path;e=l.verb;T=l.headers;M=l.parameters;A=l.errors;if(A&&A.length>0){Q(n,"htmx:validation:halted",l);ee(c);s();return d}var X=t.split("#");var F=X[0];var j=X[1];var B=null;if(e==="get"){B=F;var U=Object.keys(M).length!==0;if(U){if(B.indexOf("?")<0){B+="?"}else{B+="&"}B+=Ut(M);if(j){B+="#"+j}}o.open("GET",B,true)}else{o.open(e.toUpperCase(),t,true)}o.overrideMimeType("text/html");o.withCredentials=l.withCredentials;o.timeout=l.timeout;if(D.noHeaders){}else{for(var V in T){if(T.hasOwnProperty(V)){var _=T[V];nr(o,V,_)}}}var u={xhr:o,target:g,requestConfig:l,etc:i,boosted:P,pathInfo:{requestPath:t,finalRequestPath:B||t,anchor:j}};o.onload=function(){try{var e=sr(n);u.pathInfo.responsePath=ir(o);v(n,u);Mt(z);Q(n,"htmx:afterRequest",u);Q(n,"htmx:afterOnLoad",u);if(!te(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(te(r)){t=r}}if(t){Q(t,"htmx:afterRequest",u);Q(t,"htmx:afterOnLoad",u)}}ee(c);s()}catch(e){Y(n,"htmx:onLoadError",re({error:e},u));throw e}};o.onerror=function(){Mt(z);Y(n,"htmx:afterRequest",u);Y(n,"htmx:sendError",u);ee(h);s()};o.onabort=function(){Mt(z);Y(n,"htmx:afterRequest",u);Y(n,"htmx:sendAbort",u);ee(h);s()};o.ontimeout=function(){Mt(z);Y(n,"htmx:afterRequest",u);Y(n,"htmx:timeout",u);ee(h);s()};if(!Q(n,"htmx:beforeRequest",u)){ee(c);s();return d}var z=kt(n);K(["loadstart","loadend","progress","abort"],function(t){K([o,o.upload],function(e){e.addEventListener(t,function(e){Q(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});Q(n,"htmx:beforeSend",u);o.send(e==="get"?null:$t(o,n,M));return d}function ur(e,t){var r=t.xhr;var n=null;var i=null;if(ar(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(ar(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(ar(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=$(e,"hx-push-url");var f=$(e,"hx-replace-url");var c=Z(e).boosted;var l=null;var u=null;if(s){l="push";u=s}else if(f){l="replace";u=f}else if(c){l="push";u=o||a}if(u){if(u==="false"){return{}}if(u==="true"){u=o||a}if(t.pathInfo.anchor&&u.indexOf("#")===-1){u=u+"#"+t.pathInfo.anchor}return{type:l,path:u}}else{return{}}}function fr(s,l){var u=l.xhr;var f=l.target;var n=l.etc;if(!Q(s,"htmx:beforeOnLoad",l))return;if(ar(u,/HX-Trigger:/i)){qe(u,"HX-Trigger",s)}if(ar(u,/HX-Location:/i)){Lt();var e=u.getResponseHeader("HX-Location");var c;if(e.indexOf("{")===0){c=S(e);e=c["path"];delete c["path"]}or("GET",e,c).then(function(){Tt(e)});return}if(ar(u,/HX-Redirect:/i)){location.href=u.getResponseHeader("HX-Redirect");return}if(ar(u,/HX-Refresh:/i)){if("true"===u.getResponseHeader("HX-Refresh")){location.reload();return}}if(ar(u,/HX-Retarget:/i)){l.target=J().querySelector(u.getResponseHeader("HX-Retarget"))}var h=ur(s,l);var i=u.status>=200&&u.status<400&&u.status!==204;var d=u.response;var t=u.status>=400;var r=re({shouldSwap:i,serverResponse:d,isError:t},l);if(!Q(f,"htmx:beforeSwap",r))return;f=r.target;d=r.serverResponse;t=r.isError;l.failed=t;l.successful=!t;if(r.shouldSwap){if(u.status===286){Fe(s)}wt(s,function(e){d=e.transformResponse(d,u,s)});if(h.type){Lt()}var a=n.swapOverride;if(ar(u,/HX-Reswap:/i)){a=u.getResponseHeader("HX-Reswap")}var c=Gt(s,a);f.classList.add(W.config.swappingClass);var o=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var n=Zt(f);Oe(c.swapStyle,f,s,d,n);if(t.elt&&!te(t.elt)&&t.elt.id){var r=document.getElementById(t.elt.id);var i={preventScroll:c.focusScroll!==undefined?!c.focusScroll:!W.config.defaultFocusScroll};if(r){if(t.start&&r.setSelectionRange){r.setSelectionRange(t.start,t.end)}r.focus(i)}}f.classList.remove(W.config.swappingClass);K(n.elts,function(e){if(e.classList){e.classList.add(W.config.settlingClass)}Q(e,"htmx:afterSwap",l)});if(ar(u,/HX-Trigger-After-Swap:/i)){var a=s;if(!te(s)){a=J().body}qe(u,"HX-Trigger-After-Swap",a)}var o=function(){K(n.tasks,function(e){e.call()});K(n.elts,function(e){if(e.classList){e.classList.remove(W.config.settlingClass)}Q(e,"htmx:afterSettle",l)});if(h.type){if(h.type==="push"){Tt(h.path);Q(J().body,"htmx:pushedIntoHistory",{path:h.path})}else{Ht(h.path);Q(J().body,"htmx:replacedInHistory",{path:h.path})}}if(l.pathInfo.anchor){var e=R("#"+l.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title){var t=R("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}Kt(n.elts,c);if(ar(u,/HX-Trigger-After-Settle:/i)){var r=s;if(!te(s)){r=J().body}qe(u,"HX-Trigger-After-Settle",r)}};if(c.settleDelay>0){setTimeout(o,c.settleDelay)}else{o()}}catch(e){Y(s,"htmx:swapError",l);throw e}};if(c.swapDelay>0){setTimeout(o,c.swapDelay)}else{o()}}if(t){Y(s,"htmx:responseError",re({error:"Response Status Error Code "+u.status+" from "+l.pathInfo.requestPath},l))}}var cr={};function hr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function dr(e,t){if(t.init){t.init(r)}cr[e]=re(hr(),t)}function vr(e){delete cr[e]}function gr(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=G(e,"hx-ext");if(t){K(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=cr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return gr(u(e),r,n)}function pr(e){if(J().readyState!=="loading"){e()}else{J().addEventListener("DOMContentLoaded",e)}}function mr(){if(W.config.includeIndicatorStyles!==false){J().head.insertAdjacentHTML("beforeend","")}}function xr(){var e=J().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function yr(){var e=xr();if(e){W.config=re(W.config,e)}}pr(function(){yr();mr();var e=J().body;mt(e);var t=J().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=Z(t);if(r&&r.xhr){r.xhr.abort()}});window.onpopstate=function(e){if(e.state&&e.state.htmx){It();K(t,function(e){Q(e,"htmx:restored",{document:J(),triggerEvent:Q})})}};setTimeout(function(){Q(e,"htmx:load",{})},0)});return W}()}); \ No newline at end of file diff --git a/frontend/static/js/vendor/lightense.min.js b/frontend/static/js/vendor/lightense.min.js new file mode 100644 index 0000000..6d4823c --- /dev/null +++ b/frontend/static/js/vendor/lightense.min.js @@ -0,0 +1,2 @@ +/*! lightense-images v1.0.17 | © Tunghsiao Liu | MIT */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.Lightense=t():e.Lightense=t()}(this,(function(){return e={352:e=>{function t(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function n(e){for(var n=1;nu?d-u:d-t.padding,p=l>u?l-u:l-t.padding,f=n/i,b=g/p;r.scaleFactor=n=r.offset&&g()}function f(e){e.preventDefault(),27===e.keyCode&&g()}return function(i){var o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};e=a(i),r=n(n({},t),o),l(),u(),c(e)}}();e.exports=o}},t={},function n(r){var i=t[r];if(void 0!==i)return i.exports;var o=t[r]={exports:{}};return e[r](o,o.exports,n),o.exports}(352);var e,t})); \ No newline at end of file diff --git a/frontend/static/js/vendor/tweemoji.min.js b/frontend/static/js/vendor/tweemoji.min.js new file mode 100644 index 0000000..a37dee5 --- /dev/null +++ b/frontend/static/js/vendor/tweemoji.min.js @@ -0,0 +1,2 @@ +/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ +var twemoji=function(){"use strict";var twemoji={base:"https://twemoji.maxcdn.com/v/14.0.2/",ext:".png",size:"72x72",className:"emoji",convert:{fromCodePoint:fromCodePoint,toCodePoint:toCodePoint},onerror:function onerror(){if(this.parentNode){this.parentNode.replaceChild(createText(this.alt,false),this)}},parse:parse,replace:replace,test:test},escaper={"&":"&","<":"<",">":">","'":"'",'"':"""},re=/(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[©®\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef0-\udef6]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedd-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude74\ude78-\ude7c\ude80-\ude86\ude90-\udeac\udeb0-\udeba\udec0-\udec2\uded0-\uded9\udee0-\udee7]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g,UFE0Fg=/\uFE0F/g,U200D=String.fromCharCode(8205),rescaper=/[&<>'"]/g,shouldntBeParsed=/^(?:iframe|noframes|noscript|script|select|style|textarea)$/,fromCharCode=String.fromCharCode;return twemoji;function createText(text,clean){return document.createTextNode(clean?text.replace(UFE0Fg,""):text)}function escapeHTML(s){return s.replace(rescaper,replacer)}function defaultImageSrcGenerator(icon,options){return"".concat(options.base,options.size,"/",icon,options.ext)}function grabAllTextNodes(node,allText){var childNodes=node.childNodes,length=childNodes.length,subnode,nodeType;while(length--){subnode=childNodes[length];nodeType=subnode.nodeType;if(nodeType===3){allText.push(subnode)}else if(nodeType===1&&!("ownerSVGElement"in subnode)&&!shouldntBeParsed.test(subnode.nodeName.toLowerCase())){grabAllTextNodes(subnode,allText)}}return allText}function grabTheRightIcon(rawText){return toCodePoint(rawText.indexOf(U200D)<0?rawText.replace(UFE0Fg,""):rawText)}function parseNode(node,options){var allText=grabAllTextNodes(node,[]),length=allText.length,attrib,attrname,modified,fragment,subnode,text,match,i,index,img,rawText,iconId,src;while(length--){modified=false;fragment=document.createDocumentFragment();subnode=allText[length];text=subnode.nodeValue;i=0;while(match=re.exec(text)){index=match.index;if(index!==i){fragment.appendChild(createText(text.slice(i,index),true))}rawText=match[0];iconId=grabTheRightIcon(rawText);i=index+rawText.length;src=options.callback(iconId,options);if(iconId&&src){img=new Image;img.onerror=options.onerror;img.setAttribute("draggable","false");attrib=options.attributes(rawText,iconId);for(attrname in attrib){if(attrib.hasOwnProperty(attrname)&&attrname.indexOf("on")!==0&&!img.hasAttribute(attrname)){img.setAttribute(attrname,attrib[attrname])}}img.className=options.className;img.alt=rawText;img.src=src;modified=true;fragment.appendChild(img)}if(!img)fragment.appendChild(createText(rawText,false));img=null}if(modified){if(i")}return ret})}function replacer(m){return escaper[m]}function returnNull(){return null}function toSizeSquaredAsset(value){return typeof value==="number"?value+"x"+value:value}function fromCodePoint(codepoint){var code=typeof codepoint==="string"?parseInt(codepoint,16):codepoint;if(code<65536){return fromCharCode(code)}code-=65536;return fromCharCode(55296+(code>>10),56320+(code&1023))}function parse(what,how){if(!how||typeof how==="function"){how={callback:how}}return(typeof what==="string"?parseString:parseNode)(what,{callback:how.callback||defaultImageSrcGenerator,attributes:typeof how.attributes==="function"?how.attributes:returnNull,base:typeof how.base==="string"?how.base:twemoji.base,ext:how.ext||twemoji.ext,size:how.folder||toSizeSquaredAsset(how.size||twemoji.size),className:how.className||twemoji.className,onerror:how.onerror||twemoji.onerror})}function replace(text,callback){return String(text).replace(re,callback)}function test(text){re.lastIndex=0;var result=re.test(text);re.lastIndex=0;return result}function toCodePoint(unicodeSurrogates,sep){var r=[],c=0,p=0,i=0;while(i{html}" + return html + + +def send_vas3k_email(recipient, subject, html, **kwargs): + prepared_html = prepare_letter(html, base_url=f"https://{settings.APP_HOST}") + return send_mail( + subject=subject, + html_message=prepared_html, + message=re.sub(r"<[^>]+>", "", prepared_html), + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[recipient], + **kwargs + ) diff --git a/inside/views.py b/inside/views.py new file mode 100644 index 0000000..d561632 --- /dev/null +++ b/inside/views.py @@ -0,0 +1,85 @@ +from django.shortcuts import render +from django.template import loader + +from inside.models import Subscriber +from inside.senders.email import send_vas3k_email + + +def donate(request): + return render(request, "donate.html") + + +def subscribe(request): + if request.method != "POST": + return render(request, "subscribe.html") + + antispam = request.POST.get("name") + if antispam: + return render(request, "error.html", { + "title": "Антиспам", + "message": "Антиспам проверка не пройдена. " + "Попробуйте обновить страницу и ввести email еще раз" + }) + + email = request.POST.get("email") + if not email or "@" not in email or "." not in email: + return render(request, "error.html", { + "title": "Хммм", + "message": "Это не выглядит как валидный имейл..." + }) + + subscriber, is_created = Subscriber.objects.get_or_create( + email=email, + defaults=dict( + secret_hash=Subscriber.generate_secret(email), + ) + ) + + if is_created: + opt_in_template = loader.get_template("emails/opt_in.html") + send_vas3k_email( + recipient=subscriber.email, + subject=f"Подтверждение подписки", + html=opt_in_template.render({ + "email": subscriber.email, + "secret_hash": subscriber.secret_hash + }), + ) + + if is_created or not subscriber.is_confirmed: + return render(request, "message.html", { + "title": "Нужно подтвердить почту", + "message": "Письмо с подтверждением улетело вам на почту. " + "Откройте его и нажмите на кнопку, чтобы подписаться. " + "Это обязательно, иначе ничего приходить не будет. " + "Если никаких писем нет — проверьте «спам» или попробуйте другой адрес." + }) + else: + return render(request, "message.html", { + "title": "Вы уже подписаны", + "message": "Но всё равно спасибо, что проверили :)" + }) + + +def confirm(request, secret_hash): + subscriber = Subscriber.objects.filter(secret_hash=secret_hash).update(is_confirmed=True) + + if subscriber: + return render(request, "message.html", { + "title": "Ура! Вы подписаны", + "message": "Теперь вы будете получать на почту мои уведомления по почте" + }) + else: + return render(request, "error.html", { + "title": "Неизвестный адрес", + "message": "Указанный адрес нам не известен. Подпишитесь еще раз" + }) + + +def unsubscribe(request, secret_hash): + Subscriber.objects.filter(secret_hash=secret_hash).delete() + + return render(request, "message.html", { + "title": "Вы отписались", + "message": "Я удалил вашу почту из базы и больше ничего вам не пришлю" + }) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..c613cf0 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vas3k_blog.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..41498fd --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" + + def ready(self): + # register signals here + from notifications.signals.comments import create_comment # NOQA diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/notifications/signals/__init__.py b/notifications/signals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/signals/comments.py b/notifications/signals/comments.py new file mode 100644 index 0000000..ac4fd3c --- /dev/null +++ b/notifications/signals/comments.py @@ -0,0 +1,29 @@ +import telegram +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +from comments.models import Comment +from notifications.telegram.bot import bot + + +@receiver(post_save, sender=Comment) +def create_comment(sender, instance, created, **kwargs): + comment = instance + post = comment.post + + link = f"https://{settings.APP_HOST}/{post.type}/{post.slug}/" + + if comment.block: + link += f"#block-{comment.block}-{comment.id}" + else: + link += f"#comment-{comment.id}" + + full_text = f"💬 {comment.author_name}{post.title}:\n\n{comment.text[:2000]}" + + bot.send_message( + chat_id=settings.TELEGRAM_MAIN_CHAT_ID, + text=full_text, + parse_mode=telegram.ParseMode.HTML, + disable_web_page_preview=True + ) diff --git a/notifications/telegram/__init__.py b/notifications/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/telegram/bot.py b/notifications/telegram/bot.py new file mode 100644 index 0000000..b114426 --- /dev/null +++ b/notifications/telegram/bot.py @@ -0,0 +1,8 @@ +import logging + +import telegram +from django.conf import settings + +log = logging.getLogger() + +bot = telegram.Bot(token=settings.TELEGRAM_TOKEN) if settings.TELEGRAM_TOKEN else None diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..f8513d4 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,745 @@ +[[package]] +name = "anyio" +version = "3.6.2" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16,<0.22)"] + +[[package]] +name = "apscheduler" +version = "3.6.3" +description = "In-process task scheduler with Cron-like capabilities" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pytz = "*" +six = ">=1.4.0" +tzlocal = ">=1.2" + +[package.extras] +asyncio = ["trollius"] +doc = ["sphinx", "sphinx-rtd-theme"] +gevent = ["gevent"] +mongodb = ["pymongo (>=2.8)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=0.8)"] +testing = ["pytest", "pytest-cov", "pytest-tornado5", "mock", "pytest-asyncio (<0.6)", "pytest-asyncio"] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "asgiref" +version = "3.6.0" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] + +[[package]] +name = "attrs" +version = "22.2.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +cov = ["attrs", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] +dev = ["attrs"] +docs = ["furo", "sphinx", "myst-parser", "zope.interface", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["attrs", "zope.interface"] +tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] +tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] + +[[package]] +name = "beautifulsoup4" +version = "4.11.1" +description = "Screen-scraping library" +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "cachetools" +version = "4.2.2" +description = "Extensible memoizing collections and decorators" +category = "main" +optional = false +python-versions = "~=3.5" + +[[package]] +name = "certifi" +version = "2022.12.7" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "cffi" +version = "1.15.1" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "cryptography" +version = "39.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1,!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "ruff"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + +[[package]] +name = "cssselect" +version = "1.2.0" +description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "cssutils" +version = "2.6.0" +description = "A CSS Cascading Style Sheets library for Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "cssselect", "jaraco.test (>=5.1)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "lxml", "importlib-resources"] + +[[package]] +name = "django" +version = "4.1.5" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.8" + +[package.dependencies] +asgiref = ">=3.5.2,<4" +sqlparse = ">=0.2.2" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-debug-toolbar" +version = "3.8.1" +description = "A configurable set of panels that display various debug information about the current request/response." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +django = ">=3.2.4" +sqlparse = ">=0.2" + +[[package]] +name = "exceptiongroup" +version = "1.1.0" +description = "Backport of PEP 654 (exception groups)" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "httptools" +version = "0.5.0" +description = "A collection of framework independent HTTP protocol utils." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +test = ["Cython (>=0.29.24,<0.30.0)"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "lxml" +version = "4.9.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["beautifulsoup4"] +source = ["Cython (>=0.29.7)"] + +[[package]] +name = "maxminddb" +version = "2.2.0" +description = "Reader for the MaxMind DB format" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "maxminddb-geolite2" +version = "2018.703" +description = "Provides access to the geolite2 database. This product includes GeoLite2 data created by MaxMind, available from http://www.maxmind.com/" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +maxminddb = "*" + +[[package]] +name = "mistune" +version = "3.0.0rc4" +description = "A sane Markdown parser with useful plugins and renderers" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pillow" +version = "9.4.0" +description = "Python Imaging Library (Fork)" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] + +[[package]] +name = "pluggy" +version = "1.0.0" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "premailer" +version = "3.10.0" +description = "Turns CSS blocks into style attributes" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +cachetools = "*" +cssselect = "*" +cssutils = "*" +lxml = "*" +requests = "*" + +[package.extras] +dev = ["therapist", "tox", "twine", "black", "flake8", "wheel"] +test = ["nose", "mock"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.5" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pyjwt" +version = "2.6.0" +description = "JSON Web Token implementation in Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface", "cryptography (>=3.4.0)", "pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)", "pre-commit"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["pytest (>=6.0.0,<7.0.0)", "coverage[toml] (==5.0.4)"] + +[[package]] +name = "pytest" +version = "7.2.0" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] + +[[package]] +name = "python-dotenv" +version = "0.21.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-slugify" +version = "7.0.0" +description = "A Python slugify application that also handles Unicode" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +text-unidecode = ">=1.3" + +[package.extras] +unidecode = ["Unidecode (>=1.1.1)"] + +[[package]] +name = "python-telegram-bot" +version = "13.15" +description = "We have made you a wrapper you can't refuse" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +APScheduler = "3.6.3" +cachetools = "4.2.2" +certifi = "*" +pytz = ">=2018.6" +tornado = "6.1" + +[package.extras] +json = ["ujson"] +passport = ["cryptography (!=3.4,!=3.4.1,!=3.4.2,!=3.4.3)"] +socks = ["pysocks"] + +[[package]] +name = "pytz" +version = "2022.7" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pytz-deprecation-shim" +version = "0.1.0.post0" +description = "Shims to make deprecation of pytz easier" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +tzdata = {version = "*", markers = "python_version >= \"3.6\""} + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "sentry-sdk" +version = "1.12.1" +description = "Python client for Sentry (https://sentry.io)" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +certifi = "*" +urllib3 = {version = ">=1.26.11", markers = "python_version >= \"3.6\""} + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +chalice = ["chalice (>=1.16.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["flask (>=0.11)", "blinker (>=1.1)"] +httpx = ["httpx (>=0.16.0)"] +opentelemetry = ["opentelemetry-distro (>=0.350b0)"] +pure_eval = ["pure-eval", "executing", "asttokens"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["quart (>=0.16.1)", "blinker (>=1.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +tornado = ["tornado (>=5)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "soupsieve" +version = "2.3.2.post1" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "sqlparse" +version = "0.4.3" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "text-unidecode" +version = "1.3" +description = "The most basic Text::Unidecode port" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "tornado" +version = "6.1" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +category = "main" +optional = false +python-versions = ">= 3.5" + +[[package]] +name = "tzdata" +version = "2022.7" +description = "Provider of IANA time zone data" +category = "main" +optional = false +python-versions = ">=2" + +[[package]] +name = "tzlocal" +version = "4.2" +description = "tzinfo object for the local timezone" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pytz-deprecation-shim = "*" +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"] +test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"] + +[[package]] +name = "urllib3" +version = "1.26.13" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "uvicorn" +version = "0.20.0" +description = "The lightning-fast ASGI server." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.17.0" +description = "Fast implementation of asyncio event loop on top of libuv" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +dev = ["Cython (>=0.29.32,<0.30.0)", "pytest (>=3.6.0)", "Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=22.0.0,<22.1.0)", "mypy (>=0.800)", "aiohttp"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)"] +test = ["flake8 (>=3.9.2,<3.10.0)", "psutil", "pycodestyle (>=2.7.0,<2.8.0)", "pyOpenSSL (>=22.0.0,<22.1.0)", "mypy (>=0.800)", "Cython (>=0.29.32,<0.30.0)", "aiohttp"] + +[[package]] +name = "watchfiles" +version = "0.18.1" +description = "Simple, modern and high performance file watching and code reload in python." +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "10.4" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +category = "main" +optional = false +python-versions = ">=3.7" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "3aa71cf2f09ffd6d788b35fdcb946394589eddab3f0909433d4ac4e8b65035b1" + +[metadata.files] +anyio = [] +apscheduler = [] +asgiref = [] +attrs = [] +beautifulsoup4 = [] +cachetools = [] +certifi = [] +cffi = [] +charset-normalizer = [] +click = [] +colorama = [] +cryptography = [] +cssselect = [] +cssutils = [] +django = [] +django-debug-toolbar = [] +exceptiongroup = [] +gunicorn = [] +h11 = [] +httptools = [] +idna = [] +iniconfig = [] +lxml = [] +maxminddb = [] +maxminddb-geolite2 = [] +mistune = [] +packaging = [] +pillow = [] +pluggy = [] +premailer = [] +psycopg2-binary = [] +pycparser = [] +pyjwt = [] +pytest = [] +python-dotenv = [] +python-slugify = [] +python-telegram-bot = [] +pytz = [] +pytz-deprecation-shim = [] +pyyaml = [] +requests = [] +sentry-sdk = [] +six = [] +sniffio = [] +soupsieve = [] +sqlparse = [] +text-unidecode = [] +tomli = [] +tornado = [] +tzdata = [] +tzlocal = [] +urllib3 = [] +uvicorn = [] +uvloop = [] +watchfiles = [] +websockets = [] diff --git a/posts/__init__.py b/posts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/admin.py b/posts/admin.py new file mode 100644 index 0000000..fef2872 --- /dev/null +++ b/posts/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin + +from posts.models import Post + + +class PostAdmin(admin.ModelAdmin): + list_display = ( + "title", "slug", "type", "created_at", + "published_at", "comment_count", "view_count", + "is_visible" + ) + + +admin.site.register(Post, PostAdmin) diff --git a/posts/apps.py b/posts/apps.py new file mode 100644 index 0000000..81782a2 --- /dev/null +++ b/posts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PostsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "posts" diff --git a/posts/forms.py b/posts/forms.py new file mode 100644 index 0000000..8386bfc --- /dev/null +++ b/posts/forms.py @@ -0,0 +1,43 @@ +from django import forms +from django.forms import ModelForm + +from posts.models import Post + + +class PostEditForm(ModelForm): + title = forms.CharField( + label="Заголовок", + required=True, + ) + + subtitle = forms.CharField( + label="Подзаголовок", + required=False, + ) + + image = forms.URLField( + label="Картинка", + required=False, + ) + + text = forms.CharField( + label="Текст", + min_length=0, + max_length=100000, + required=True, + widget=forms.Textarea( + attrs={ + "id": "post-editor", + } + ), + ) + + class Meta: + model = Post + fields = [ + "title", + "subtitle", + "image", + "text", + ] + diff --git a/posts/management/__init__.py b/posts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/management/commands/__init__.py b/posts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/management/commands/migrate_old_posts.py b/posts/management/commands/migrate_old_posts.py new file mode 100644 index 0000000..98bd914 --- /dev/null +++ b/posts/management/commands/migrate_old_posts.py @@ -0,0 +1,89 @@ +import logging +import json + +from django.core.management import BaseCommand +from django.db import connections +from django.utils.html import strip_tags + +from posts.models import Post +from vas3k_blog.posts import POST_TYPES + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Migrate posts from old database to the new one" + + def handle(self, *args, **options): + with connections["old"].cursor() as cursor: + cursor.execute("select * from stories order by is_visible desc") + for row in dictfetchall(cursor): + if row["type"] not in POST_TYPES: + self.stdout.write(f"Skipping: {row['type']} {row['slug']}") + continue + + if row["type"] == "blog" and row["slug"].isnumeric() and int(row["slug"]) < 70: + continue + + self.stdout.write(f"DT: {row['created_at']}") + + row_data = json.loads(row["data"] or "{}") if row["data"] else {} + post, _ = Post.objects.update_or_create( + slug=parse_slug(row), + defaults=dict( + type=row["type"], + author=row["author"], + url=row_data.get("url") if row_data else None, + title=row["title"], + subtitle=row["subtitle"], + image=row["image"], + og_title=row["title"], + og_image=row["preview_image"] or row["image"], + og_description=row["preview_text"], + announce_text=row["preview_text"], + text=parse_text(row), + html_cache=row["text_cache"], + data=row_data, + created_at=row["created_at"], + published_at=row["created_at"], + updated_at=row["created_at"], + word_count=parse_word_count(row), + comment_count=row["comments_count"], + view_count=row["views_count"], + is_raw_html=bool(not row["text"] and row["html"]), + is_visible=row["is_visible"], + is_members_only=row["is_members_only"], + is_commentable=row["is_commentable"], + is_visible_on_home_page=row["is_featured"], + ) + ) + self.stdout.write(f"Post {post.slug} updated...") + + self.stdout.write("Done 🥙") + + +def dictfetchall(cursor): + columns = [col[0] for col in cursor.description] + return [ + dict(zip(columns, row)) + for row in cursor.fetchall() + ] + + +def parse_slug(row): + return row["slug"] if row["type"] != "gallery" else f"{row['type']}_{row['slug']}" + + +def parse_text(row): + text = row["text"] + if text: + if not text.strip().startswith("[[[") and not text.startswith("= num_pages - 1: + end_page = num_pages + 1 + page_numbers = [n for n in range(start_page, end_page) if 0 < n <= num_pages] + + return { + "items": items, + "page_numbers": page_numbers, + "show_first": 1 not in page_numbers, + "show_last": num_pages not in page_numbers, + "num_pages": num_pages + } diff --git a/posts/templatetags/posts.py b/posts/templatetags/posts.py new file mode 100644 index 0000000..adab6d8 --- /dev/null +++ b/posts/templatetags/posts.py @@ -0,0 +1,72 @@ +import re + +from bs4 import BeautifulSoup +from django import template +from django.conf import settings +from django.template import loader +from django.utils.safestring import mark_safe + +register = template.Library() + +from common.markdown.markdown import markdown_email, markdown_text + +block_placeholder_template = loader.get_template("club/block_placeholder.html") +clicker_template = loader.get_template("clickers/clicker.html") +inline_comments_template = loader.get_template("comments/inline-comment-list.html") + + +@register.simple_tag(takes_context=True) +def show_post(context, post): + if post.is_raw_html: + html = post.text or post.html_cache + else: + if not post.html_cache or settings.DEBUG: + new_html = markdown_text(post.text) + if new_html != post.html_cache: + # to not flood into history + post.html_cache = new_html + post.save() + html = post.html_cache or "" + + html = re.sub(r"\[commentable (.+?)\]", lambda match: commentable(context, match.group(1)), html) + html = re.sub(r"\[clicker (.+?)\]", lambda match: clicker(context, match.group(1), match.group(1)), html) + + html = replace_host_if_mirror(context.request, html) + + return mark_safe(html) + + # # remove extra blocks for unauthorized users + # if settings.EXTRA_BLOCK_CLASS in text and not context["me"]: + # soup = BeautifulSoup(text, "html.parser") + # for block in soup.select("." + settings.EXTRA_BLOCK_CLASS): + # block.string = "" + # block["class"] = block.get("class", []) + ["block-extra-placeholder"] + # block.append(BeautifulSoup(block_placeholder_template.render({"story": post}), "html.parser")) + # text = str(soup) + + +def clicker(context, block, text=None): + clicker = context["clickers"].get(block) or {} + + return clicker_template.render({ + **context.flatten(), + "clicker": text or "", + "block": block, + "votes": clicker.get("total") or 0, + "is_voted": block in context["user_votes"], + }) + + +def commentable(context, block): + return inline_comments_template.render({ + **context.flatten(), + "username": context["cookies"].get("username") or "", + "block": block, + "block_comments": [c for c in context["comments"] if str(c.block) == str(block)], + }) + + +def replace_host_if_mirror(request, text): + if request.get_host() in settings.MIRRORS: + text = text.replace(settings.APP_HOST, request.get_host()) + return text diff --git a/posts/templatetags/text_filters.py b/posts/templatetags/text_filters.py new file mode 100755 index 0000000..3d65117 --- /dev/null +++ b/posts/templatetags/text_filters.py @@ -0,0 +1,57 @@ +from django import template +from django.utils.html import urlize +from django.utils.safestring import mark_safe + +from common.markdown.markdown import markdown_comment + +register = template.Library() + + +@register.filter(is_safe=True) +def nl2p(text): + if not text: + return "" + text = text.replace("\n\n", "

") + text = text.replace("\r\n\r\n", "

") + return text + + +@register.filter(is_safe=True) +def markdown(text): + return mark_safe(markdown_comment(text)) + + +@register.filter +def cool_number(value, num_decimals=1): + """ + Django template filter to convert regular numbers to a cool format (ie: 2K, 434.4K, 33M...) + """ + int_value = int(value or 0) + formatted_number = '{{:.{}f}}'.format(num_decimals) + if int_value < 1000: + return str(int_value) + elif int_value < 1000000: + return formatted_number.format(int_value / 1000.0).rstrip('0.') + 'K' + else: + return formatted_number.format(int_value / 1000000.0).rstrip('0.') + 'M' + + +@register.filter +def smart_urlize(value, target="_blank"): + # TODO: this + return mark_safe(urlize(value)) + + +@register.filter +def rupluralize(value, arg="дурак,дурака,дураков"): + args = arg.split(",") + number = abs(int(value)) + a = number % 10 + b = number % 100 + + if (a == 1) and (b != 11): + return args[0] + elif (a >= 2) and (a <= 4) and ((b < 10) or (b >= 20)): + return args[1] + else: + return args[2] diff --git a/posts/views.py b/posts/views.py new file mode 100644 index 0000000..079756b --- /dev/null +++ b/posts/views.py @@ -0,0 +1,159 @@ +from django.db.models import F +from django.http import Http404, HttpResponseForbidden +from django.shortcuts import render, redirect, get_object_or_404 + +from comments.models import Comment +from posts.forms import PostEditForm +from posts.models import Post +from posts.renderers import render_list, render_list_all, render_post +from vas3k_blog.posts import INDEX_PAGE_BEST_POSTS + + +def index(request): + # select latest post + top_post = Post.visible_objects()\ + .filter(is_visible_on_home_page=True)\ + .order_by("-created_at")\ + .first() + + # blog posts + blog_posts = Post.visible_objects()\ + .filter(type="blog", is_visible_on_home_page=True)\ + .exclude(id=top_post.id if top_post else None)\ + .order_by("-created_at")[:3] + + # travel posts + latest_world_story = Post.visible_objects()\ + .filter(type="world", is_visible_on_home_page=True)\ + .exclude(id=top_post.id if top_post else None)\ + .order_by("-created_at")\ + .first() + top_world_posts = Post.visible_objects()\ + .filter(type="world", is_visible_on_home_page=True)\ + .exclude(id__in=[ + top_post.id if top_post else None, + latest_world_story.id if latest_world_story else None + ])\ + .order_by("-view_count")[:7] + world_posts = [latest_world_story] + list(top_world_posts) + + # featured posts + best_posts = Post.visible_objects()\ + .filter(slug__in=INDEX_PAGE_BEST_POSTS)\ + .order_by("-created_at")[:10] + + # notes + notes_posts = Post.visible_objects()\ + .filter(type="notes", is_visible_on_home_page=True)\ + .order_by("-created_at")[:11] + + return render(request, "index.html", { + "blocks": [ + { + "template": "index/main.html", + "post": top_post + }, + { + "title": "", + "template": "index/posts3.html", + "posts": blog_posts + }, + { + "title": "Обо мне", + "template": "index/about.html", + "posts": [] + }, + { + "title": "Заметки", + "url": "/notes/", + "template": "index/posts4.html", + "posts": notes_posts + }, + { + "title": "Отвратительные путешествия", + "template": "index/posts3.html", + "url": "/world/", + "posts": world_posts + }, + { + "title": "Нетленки", + "template": "index/posts2.html", + "posts": best_posts + }, + { + "title": "Проекты", + "template": "index/projects.html", + "posts": [] + } + ] + }) + + +def list_posts(request, post_type="all"): + posts = Post.visible_objects().select_related() + + if post_type and post_type != "all": + posts = posts.filter(type=post_type) + if not posts: + raise Http404() + + return render_list(request, post_type, posts) + else: + return render_list_all(request, posts) + + +def show_post(request, post_type, post_slug): + post = get_object_or_404(Post, slug=post_slug) + + # post_type can be changed + if post.type != post_type: + return redirect("show_post", post.type, post.slug) + + # drafts are visible only to admins + if not post.is_visible: + # if not request.me or not request.me.is_admin: + raise Http404() + + Post.objects.filter(id=post.id)\ + .update(view_count=F("view_count") + 1) + + # don't show private posts into public + if post.is_members_only: + if not request.user.is_authenticated: + return render(request, "users/post_access_denied.html", { + "post": post + }) + + if post.url: + return redirect(post.url) + + comments = Comment.visible_objects()\ + .filter(post=post)\ + .order_by("created_at") + + return render_post(request, post, { + "post": post, + "comments": comments, + }) + + +def edit_post(request, post_type, post_slug): + if not request.user.is_authenticated: + return redirect("login") + + if not request.user.is_superuser: + return HttpResponseForbidden() + + post = get_object_or_404(Post, type=post_type, slug=post_slug) + + if request.method == "POST": + form = PostEditForm(request.POST, instance=post) + if form.is_valid(): + form.save() + return redirect("show_post", post_type=post.type, post_slug=post.slug) + else: + form = PostEditForm(instance=post) + + return render(request, "posts/edit.html", { + "form": form, + }) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..074c0cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[tool.poetry] +name = "vas3k_blog" +version = "0.1.0" +description = "" +authors = ["Vasily Zubarev "] + +[tool.poetry.dependencies] +python = "^3.10" +Django = "^4.1.4" +python-slugify = "^7.0.0" +psycopg2-binary = "^2.9.5" +beautifulsoup4 = "^4.11.1" +mistune = "3.0.0rc4" +maxminddb-geolite2 = "^2018.703" +python-telegram-bot = "^13.15" +requests = "^2.28.1" +Pillow = "^9.3.0" +django-debug-toolbar = "^3.8.1" +premailer = "^3.10.0" +lxml = "^4.9.2" +PyJWT = "^2.6.0" +cryptography = "^39.0.0" +gunicorn = "^20.1.0" +uvicorn = {extras = ["standard"], version = "^0.20.0"} +sentry-sdk = "^1.12.1" + +[tool.poetry.dev-dependencies] +pytest = "^7.2" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/rss/__init__.py b/rss/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rss/apps.py b/rss/apps.py new file mode 100644 index 0000000..5ea0256 --- /dev/null +++ b/rss/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RssConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "rss" diff --git a/rss/feeds.py b/rss/feeds.py new file mode 100755 index 0000000..f36179d --- /dev/null +++ b/rss/feeds.py @@ -0,0 +1,65 @@ +from django.contrib.syndication.views import Feed +from django.template.defaultfilters import truncatewords, truncatechars +from django.utils.html import strip_tags + +from posts.models import Post +from django.conf import settings + + +class FullFeed(Feed): + title = "Вастрик.ру" + link = "/rss/" + description = settings.DESCRIPTION + hide_members_posts = True + + def items(self): + items = Post.visible_objects().\ + filter(is_visible_on_home_page=True).\ + order_by("-created_at").\ + select_related()[:30] + return items + + def item_title(self, item): + return item.title + + def author_name(self): + return "Вастрик" + + def item_copyright(self): + return "vas3k.ru" + + def item_pubdate(self, item): + return item.created_at + + def item_description(self, item): + url = item.get_absolute_url() + + result = "" + if item.image: + result += f"

" + + if item.og_description: + result += item.og_description + else: + result += truncatechars(strip_tags(item.html_cache or item.text or ""), 400) + + return result + + +class PrivateFeed(FullFeed): + title = "Вастрик.ру: Секретный фид" + link = "/rss/private/" + hide_members_posts = False + + +class PublicFeed(FullFeed): + title = "Вастрик.ру: Только публичные посты" + link = "/rss/public/" + hide_members_posts = True + + def items(self): + items = Post.visible_objects().\ + filter(is_visible_on_home_page=True, is_members_only=False).\ + order_by("-created_at").\ + select_related()[:20] + return items diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 0000000..267e900 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from users.models import User + +admin.site.register(User) diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 0000000..88f7b17 --- /dev/null +++ b/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" diff --git a/users/avatars.py b/users/avatars.py new file mode 100644 index 0000000..4074c09 --- /dev/null +++ b/users/avatars.py @@ -0,0 +1,31 @@ +AVATARS = [ + "https://i.vas3k.blog/a4b94e98b4d438b9d124695984b0e42a92fa5924f10d940aa487276bf0032ea9.png", + "https://i.vas3k.blog/4b00e57bc57225f5c462f342db2191803552189303651192eaf5022d57474f53.png", + "https://i.vas3k.blog/072ca5a83dd5537e00fa313e45213e981f7f9eec0f44ce32b4e621d56cedbd10.png", + "https://i.vas3k.blog/b250ad1b03e690d3dfdb69f418bbd601350c430f103ccb936691a396deea69f5.png", + "https://i.vas3k.blog/6d1e26896bb88fee36c8d789acbdd9c952f69e882865ec8157e06593339633aa.jpg", + "https://i.vas3k.blog/b64ba1f5eea4928a759f89a59ff4f97d457cd7d060e43f949c06f8416edd71e6.png", + "https://i.vas3k.blog/4106ecd6ea7f22bb94bfcc33210a9405fe8564e7898179710f193ca00a711aa1.png", + "https://i.vas3k.blog/739a0247edf1a3d314666a813d6ca96f576ed070f7ff846ea04244be96b945f8.png", + "https://i.vas3k.blog/d87826aa135b601da5e6ba51b7d3a70f31535c509ee0d97611abf0a02873e42c.png", + "https://i.vas3k.blog/25ead3a066e38b4d9c42093f4807d86096d00f2c755b1201cbf3bcc9c11a94c5.png", + "https://i.vas3k.blog/c11c67c8531c3edffcc350056a5d767c1aa6ea1a0e1f0d801747473c68131035.png", + "https://i.vas3k.blog/73b73f480341f919a5589133f83efc1fcfc895a2039e731f45bf3dd4411320ec.png", + "https://i.vas3k.blog/8c30f3eb1c182869ade5ccc93bbd4065df571848ff05a023624922fac31de983.png", + "https://i.vas3k.blog/b45e38bedfa891160192d6c2571af4014937908076009d5d6d60ba805dde8587.png", + "https://i.vas3k.blog/7b4c8825f8cb65b2bdc48848b0e3f24b8c1eff5e84d2353723881e4034ad5924.png", + "https://i.vas3k.blog/49f27f263ad6f7cbd75ef39c92b6789f420b1f978bbd56e419c05212119eb0ea.png", + "https://i.vas3k.blog/f9580a140f4a4595ce648ac778efd4010cb0ad04dca7a1f1858e42fc673b71a8.png", + "https://i.vas3k.blog/0820a58675e472844f77051acbb339ef9f0fc56f51de8db2d5733ef694a6040e.png", + "https://i.vas3k.blog/8f72335aa0b1bb2fa15dfebb7a761706eebf59dc6aee20bba4b452678a8fcb3a.jpg", + "https://i.vas3k.blog/9a56989754f1dc0679c18c85091a37619dc72b083fb0296907c4a69196b47445.png", + "https://i.vas3k.blog/1aa372c948870d30e9280d48a27070885dbc9a32aab8ef1730623c3c7f6333a3.png", + "https://i.vas3k.blog/b6547c04012ac8374ff6f12f53648a09488f0827cea2d4a57d1946e213129ba3.jpg", + "https://i.vas3k.blog/1598d72453d3316c8cc4b77e4afc0930412055adeab40ffe86c169c28d85ded4.png", + "https://i.vas3k.blog/1a22b66df55210fae258fe5878d369f7b83dfa7fa837c9e5a808ae350aa8db84.jpg", + "https://i.vas3k.blog/2551d18acb8672333d1665ca884248d278dd98526c18ca3dbfc7a976ee4d0098.jpg", + "https://i.vas3k.blog/a5e61ab4c5beabd0776f93b6c6f6d0cca98a7a8012f3b35ce2a5b1708bef3b7a.jpg", + "https://i.vas3k.blog/17f84171af49df9c7eca97638ddcbb7e6d91a299dc9b2e2c9213c933749c2647.jpg", + "https://i.vas3k.blog/f00d93381857b0e5c8d3c933a32d66f3b9e9f3375f5ac17024d384bbc3ac4071.png", + "https://i.vas3k.blog/7b7562ed1fd20b3d2ea0c5a0d7e9a4e109624954fc23dc29edbbfff230e1cb21.png", +] diff --git a/users/forms.py b/users/forms.py new file mode 100644 index 0000000..ee04496 --- /dev/null +++ b/users/forms.py @@ -0,0 +1,34 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.forms import ModelForm + +from users.avatars import AVATARS +from users.models import User + + +class UserEditForm(ModelForm): + username = forms.CharField( + label="Отображаемое имя", + required=True, + max_length=32 + ) + + avatar = forms.ChoiceField( + label="Новый моднейший аватар", + choices=[(avatar, avatar) for avatar in AVATARS], + widget=forms.RadioSelect, + required=False, + ) + + class Meta: + model = User + fields = [ + "username", + "avatar", + ] + + def clean_avatar(self): + avatar = self.cleaned_data["avatar"] + if avatar not in AVATARS: + return self.instance.avatar + return avatar diff --git a/users/management/__init__.py b/users/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/management/commands/__init__.py b/users/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/management/commands/migrate_old_users.py b/users/management/commands/migrate_old_users.py new file mode 100644 index 0000000..683776d --- /dev/null +++ b/users/management/commands/migrate_old_users.py @@ -0,0 +1,38 @@ +import logging + +from django.core.management import BaseCommand +from django.db import connections + +from users.models import User + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Migrate users from old database to the new one" + + def handle(self, *args, **options): + with connections["old"].cursor() as cursor: + cursor.execute("select * from users where email is not null") + for row in dictfetchall(cursor): + user, _ = User.objects.update_or_create( + email=row["email"], + defaults=dict( + username=row["name"], + patreon_id=row["platform_id"] if row["platform_id"].isnumeric() else None, + vas3k_club_slug=row["platform_id"] if not row["platform_id"].isnumeric() else None, + avatar=row["avatar"], + ) + ) + self.stdout.write(f"User {user.username} updated...") + + self.stdout.write("Done 🥙") + + +def dictfetchall(cursor): + columns = [col[0] for col in cursor.description] + return [ + dict(zip(columns, row)) + for row in cursor.fetchall() + ] + diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..8dc9a99 --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,101 @@ +# Generated by Django 4.1.4 on 2023-01-06 17:54 + +import django.contrib.postgres.fields +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("slug", models.CharField(max_length=32, unique=True)), + ("email", models.EmailField(max_length=254, unique=True)), + ("full_name", models.CharField(max_length=128)), + ("avatar", models.URLField(blank=True, null=True)), + ("secret_hash", models.CharField(max_length=24, unique=True)), + ("city", models.CharField(max_length=128, null=True)), + ("country", models.CharField(max_length=128, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("last_activity_at", models.DateTimeField(auto_now=True)), + ( + "patreon_id", + models.CharField(max_length=128, null=True, unique=True), + ), + ("telegram_id", models.CharField(max_length=128, null=True)), + ("telegram_data", models.JSONField(null=True)), + ("is_email_verified", models.BooleanField(default=False)), + ("is_email_unsubscribed", models.BooleanField(default=False)), + ("is_banned_until", models.DateTimeField(null=True)), + ( + "roles", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("admin", "Админ")], max_length=32 + ), + default=list, + size=None, + ), + ), + ("deleted_at", models.DateTimeField(null=True)), + ( + "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", + ), + ), + ( + "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", + ), + ), + ], + options={ + "db_table": "users", + }, + ), + ] diff --git a/users/migrations/0002_rename_full_name_user_username.py b/users/migrations/0002_rename_full_name_user_username.py new file mode 100644 index 0000000..daceabb --- /dev/null +++ b/users/migrations/0002_rename_full_name_user_username.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.4 on 2023-01-06 18:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="user", + old_name="full_name", + new_name="username", + ), + ] diff --git a/users/migrations/0003_remove_user_is_banned_until_and_more.py b/users/migrations/0003_remove_user_is_banned_until_and_more.py new file mode 100644 index 0000000..d21964c --- /dev/null +++ b/users/migrations/0003_remove_user_is_banned_until_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.1.4 on 2023-01-06 18:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0002_rename_full_name_user_username"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="is_banned_until", + ), + migrations.RemoveField( + model_name="user", + name="is_email_unsubscribed", + ), + migrations.RemoveField( + model_name="user", + name="is_email_verified", + ), + migrations.RemoveField( + model_name="user", + name="patreon_id", + ), + migrations.RemoveField( + model_name="user", + name="slug", + ), + migrations.RemoveField( + model_name="user", + name="telegram_data", + ), + migrations.RemoveField( + model_name="user", + name="telegram_id", + ), + migrations.AddField( + model_name="user", + name="is_banned", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="user", + name="is_staff", + field=models.BooleanField(default=False), + ), + ] diff --git a/users/migrations/0004_remove_user_roles_alter_user_is_superuser.py b/users/migrations/0004_remove_user_roles_alter_user_is_superuser.py new file mode 100644 index 0000000..858e14c --- /dev/null +++ b/users/migrations/0004_remove_user_roles_alter_user_is_superuser.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.4 on 2023-01-06 18:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0003_remove_user_is_banned_until_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="roles", + ), + migrations.AlterField( + model_name="user", + name="is_superuser", + field=models.BooleanField(default=False), + ), + ] diff --git a/users/migrations/0005_user_patreon_id_user_telegram_id_and_more.py b/users/migrations/0005_user_patreon_id_user_telegram_id_and_more.py new file mode 100644 index 0000000..8fffa51 --- /dev/null +++ b/users/migrations/0005_user_patreon_id_user_telegram_id_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.1.4 on 2023-01-08 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0004_remove_user_roles_alter_user_is_superuser"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="patreon_id", + field=models.CharField(blank=True, db_index=True, max_length=64, null=True), + ), + migrations.AddField( + model_name="user", + name="telegram_id", + field=models.CharField(blank=True, db_index=True, max_length=64, null=True), + ), + migrations.AddField( + model_name="user", + name="vas3k_club_slug", + field=models.CharField( + blank=True, db_index=True, max_length=128, null=True + ), + ), + ] diff --git a/users/migrations/__init__.py b/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..872edd7 --- /dev/null +++ b/users/models.py @@ -0,0 +1,104 @@ +import random +from datetime import datetime, timedelta +from uuid import uuid4 + +from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager +from django.contrib.auth.models import PermissionsMixin +from django.db import models + +from users.avatars import AVATARS +from utils.strings import random_string + + +class UserAccountManager(BaseUserManager): + def create_superuser(self, username, email, password, **other_fields): + other_fields.setdefault("is_staff", True) + other_fields.setdefault("is_superuser", True) + user = self.create_user( + username=username, + email=email, + password=password, + **other_fields + ) + user.set_password(password) + user.save() + return user + + def create_user(self, username, email, password=None, **other_fields): + if not email: + raise ValueError("Email address is required!") + + email = self.normalize_email(email) + if password is not None: + user = self.model( + username=username, + email=email, + password=password, + **other_fields + ) + user.save() + else: + user = self.model( + username=username, + email=email, + password=password, + **other_fields + ) + user.set_unusable_password() + user.save() + + return user + + +class User(AbstractBaseUser, PermissionsMixin): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + + email = models.EmailField(unique=True) + username = models.CharField(max_length=128, null=False) + avatar = models.URLField(null=True, blank=True) + secret_hash = models.CharField(max_length=24, unique=True) + + city = models.CharField(max_length=128, null=True) + country = models.CharField(max_length=128, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_activity_at = models.DateTimeField(auto_now=True) + + vas3k_club_slug = models.CharField(max_length=128, db_index=True, null=True, blank=True) + patreon_id = models.CharField(max_length=64, db_index=True, null=True, blank=True) + telegram_id = models.CharField(max_length=64, db_index=True, null=True, blank=True) + + is_staff = models.BooleanField(default=False) + is_superuser = models.BooleanField(default=False) + is_banned = models.DateTimeField(null=True) + deleted_at = models.DateTimeField(null=True) + + objects = UserAccountManager() + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + class Meta: + db_table = "users" + + def save(self, *args, **kwargs): + if not self.secret_hash: + self.secret_hash = random_string(length=18) + + if not self.avatar: + self.avatar = self.get_avatar() + + self.updated_at = datetime.utcnow() + self.last_activity_at = datetime.utcnow() + return super().save(*args, **kwargs) + + def update_last_activity(self): + now = datetime.utcnow() + if self.last_activity_at < now - timedelta(minutes=5): + return User.objects.filter(id=self.id).update(last_activity_at=now) + + def get_avatar(self): + if not self.avatar: + return random.choice(AVATARS) + return self.avatar diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..ff793e9 --- /dev/null +++ b/users/views.py @@ -0,0 +1,32 @@ +from django.http import HttpResponse +from django.shortcuts import render, redirect + +from users.forms import UserEditForm + + +def profile(request): + if not request.user.is_authenticated: + return redirect("login") + + if request.method == "POST": + form = UserEditForm(request.POST, instance=request.user) + if form.is_valid(): + form.save() + return redirect("profile") + else: + form = UserEditForm(instance=request.user) + + return render(request, "users/profile.html", { + "form": form, + }) + + +def robots(request): + lines = [ + "User-agent: *", + "Host: https://vas3k.blog", + "Disallow: /clickers/", + "Disallow: /auth/", + "Clean-param: comment_order&goto /", + ] + return HttpResponse("\n".join(lines), content_type="text/plain") diff --git a/utils/request.py b/utils/request.py new file mode 100644 index 0000000..eb903d9 --- /dev/null +++ b/utils/request.py @@ -0,0 +1,8 @@ +def parse_ip_address(request): + ipaddress = request.META.get("HTTP_X_REAL_IP") \ + or request.META.get("HTTP_X_FORWARDED_FOR") \ + or request.environ.get("REMOTE_ADDR") or "" + + if "," in ipaddress: # multiple ips in the header + ipaddress = ipaddress.split(",", 1)[0] + return ipaddress diff --git a/utils/slug.py b/utils/slug.py new file mode 100644 index 0000000..23bd1a7 --- /dev/null +++ b/utils/slug.py @@ -0,0 +1,17 @@ +from uuid import uuid4 + +from slugify import slugify + +from utils.strings import random_number + + +def generate_unique_slug(model, name, separator="-"): + attempts = 5 + while attempts > 0: + slug = slugify(name[:30], separator=separator) + is_exists = model.objects.filter(slug__iexact=slug).exists() + if not is_exists: + return slug + attempts -= 1 + name += random_number(length=2) + return str(uuid4()) diff --git a/utils/strings.py b/utils/strings.py new file mode 100644 index 0000000..0bc5d8b --- /dev/null +++ b/utils/strings.py @@ -0,0 +1,17 @@ +import random +import string + + +def random_hash(length: int = 16): + letters = string.ascii_letters + string.digits + r"!#$*+./:<=>?@[]()^_~" + return "".join(random.choice(letters) for i in range(length)) + + +def random_string(length: int = 10): + letters = string.ascii_letters + string.digits + return "".join(random.choice(letters) for i in range(length)) + + +def random_number(length: int = 10): + letters = string.digits + return "".join(random.choice(letters) for i in range(length)) diff --git a/utils/wait_for_postgres.py b/utils/wait_for_postgres.py new file mode 100644 index 0000000..3a6d071 --- /dev/null +++ b/utils/wait_for_postgres.py @@ -0,0 +1,16 @@ +from datetime import datetime, timedelta +import random +import socket +import time + +if __name__ == "__main__": + started_at = datetime.utcnow() + while datetime.utcnow() < started_at + timedelta(minutes=5): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.connect(("postgres", 5432)) + print("Postgres had started") + break + except socket.error: + print("Waiting for postgres") + time.sleep(0.5 + (random.randint(0, 100) / 1000)) diff --git a/vas3k_blog/__init__.py b/vas3k_blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vas3k_blog/asgi.py b/vas3k_blog/asgi.py new file mode 100644 index 0000000..5caddd7 --- /dev/null +++ b/vas3k_blog/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for vas3k_blog project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vas3k_blog.settings") + +application = get_asgi_application() diff --git a/vas3k_blog/context_processors.py b/vas3k_blog/context_processors.py new file mode 100644 index 0000000..872e16b --- /dev/null +++ b/vas3k_blog/context_processors.py @@ -0,0 +1,13 @@ +from django.conf import settings + + +def settings_processor(request): + return { + "settings": settings + } + + +def cookies_processor(request): + return { + "cookies": request.COOKIES + } diff --git a/vas3k_blog/exceptions.py b/vas3k_blog/exceptions.py new file mode 100644 index 0000000..ce20bd3 --- /dev/null +++ b/vas3k_blog/exceptions.py @@ -0,0 +1,83 @@ +class BlogException(Exception): + default_code = "error" + default_title = "Что-то пошло не так" + default_message = "Никто не знает что произошло :(" + + def __init__(self, code=None, title=None, message=None, data=None): + self.code = code or self.default_code + self.title = title or self.default_title + self.message = message or self.default_message + self.data = data or {} + + +class BadRequest(BlogException): + default_code = "bad-request" + default_title = "Неправильный параметр запроса" + default_message = "Что-то сломалось" + + +class NotFound(BlogException): + default_code = "not-found" + default_title = "Не найдено" + default_message = "" + + +class AccessDenied(BlogException): + default_code = "access-forbidden" + default_title = "Вам сюда нельзя" + default_message = "Атата" + + +class RateLimitException(BlogException): + default_code = "rate-limit" + default_title = "Вы создали слишком много постов или комментов сегодня" + default_message = "Пожалуйста, остановитесь" + + +class ContentDuplicated(BlogException): + default_code = "duplicated-content" + default_title = "Обнаружен дубликат!" + default_message = "Кажется, вы пытаетесь опубликовать то же самое повторно. " \ + "Проверьте всё ли в порядке." + + +class InsufficientFunds(BlogException): + default_code = "insufficient-funds" + default_title = "Недостаточно средств" + + +class URLParsingException(BlogException): + default_code = "url-parser-exception" + default_title = "Не удалось распарсить URL" + default_message = "" + + +class InvalidCode(BlogException): + default_code = "invalid-code" + default_title = "Вы ввели неправильный код" + default_message = "Введите или запросите его еще раз. Через несколько неправильных попыток коды удаляются" + + +class ApiInsufficientFunds(BlogException): + default_code = "api-insufficient-funds" + default_title = "Недостаточно средств" + + +class ApiException(BlogException): + default_message = None + + +class ApiBadRequest(BlogException): + default_code = "bad-request" + default_title = "Неправильный параметр запроса" + + +class ApiAuthRequired(ApiException): + default_code = "api-authn-required" + default_title = "Auth Required" + + +class ApiAccessDenied(ApiException): + default_code = "api-access-denied" + default_title = "Access Denied" + diff --git a/vas3k_blog/posts.py b/vas3k_blog/posts.py new file mode 100644 index 0000000..a65925d --- /dev/null +++ b/vas3k_blog/posts.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass + +DEFAULT_LIST_ITEMS_PER_PAGE = 30 + + +@dataclass +class PostTypeConfig: + name: str = "Посты" + list_items_per_page: int = DEFAULT_LIST_ITEMS_PER_PAGE + card_template: str = "posts/cards/horizontal.html", + list_template: str = "posts/lists/layout.html", + show_template: str = "posts/full/layout.html" + + +POST_TYPES: dict[str, PostTypeConfig] = { + "blog": PostTypeConfig( + name="Блог", + list_items_per_page=30, + card_template="posts/cards/horizontal.html", + list_template="posts/lists/blog.html", + show_template="posts/full/blog.html", + ), + "notes": PostTypeConfig( + name="Заметки", + list_items_per_page=50, + card_template="posts/cards/vertical.html", + list_template="posts/lists/notes.html", + show_template="posts/full/notes.html", + ), + "world": PostTypeConfig( + name="Путешествия", + list_items_per_page=30, + card_template="posts/cards/horizontal.html", + list_template="posts/lists/blog.html", + show_template="posts/full/blog.html", + ), + "challenge": PostTypeConfig( + name="Поисковые челленджи", + list_items_per_page=30, + card_template="posts/cards/horizontal.html", + list_template="posts/lists/blog.html", + show_template="posts/full/legacy/challenge.html", + ), + "gallery": PostTypeConfig( + name="Галлерея", + list_items_per_page=30, + card_template="posts/cards/vertical.html", + list_template="posts/lists/blog.html", + show_template="posts/full/legacy/gallery.html", + ), + "inside": PostTypeConfig( + name="Вастрик.Инсайд", + list_items_per_page=30, + card_template="posts/cards/vertical.html", + list_template="posts/lists/notes.html", + show_template="posts/full/notes.html", + ), + "365": PostTypeConfig( + name="Заметки", + list_items_per_page=50, + card_template="posts/cards/vertical.html", + list_template="posts/lists/notes.html", + show_template="posts/full/notes.html", + ), +} + + +def post_config_by_type(post_type): + if post_type in POST_TYPES: + return POST_TYPES[post_type] + else: + return PostTypeConfig() + + +INDEX_PAGE_BEST_POSTS = [ + "quantum_computing", + "computational_photography", + "machine_learning", + "team", + "touchbar", + "blockchain", +] diff --git a/vas3k_blog/settings.py b/vas3k_blog/settings.py new file mode 100644 index 0000000..f0d45bd --- /dev/null +++ b/vas3k_blog/settings.py @@ -0,0 +1,244 @@ +""" +Django settings for vas3k_blog project. + +Generated by 'django-admin startproject' using Django 4.1.4. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" +import os +from datetime import timedelta +from pathlib import Path +from random import randint + +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration +from sentry_sdk.integrations.redis import RedisIntegration + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("SECRET_KEY") or "wow so secret" +DEBUG = (os.getenv("DEBUG") != "false") + +ALLOWED_HOSTS = ["0.0.0.0", "127.0.0.1", "vas3k.blog", "vas3k.ru"] +INTERNAL_IPS = ["127.0.0.1"] + +ADMINS = [ + ("vas3k", "me@vas3k.ru"), +] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.humanize", + "django.contrib.sitemaps", + "authn.apps.AuthnConfig", + "users.apps.UsersConfig", + "posts.apps.PostsConfig", + "comments.apps.CommentsConfig", + "rss.apps.RssConfig", + "inside.apps.InsideConfig", + "clickers.apps.ClickersConfig", + "notifications.apps.NotificationsConfig", +] + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "vas3k_blog.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [ + BASE_DIR / "frontend/html", + ], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.messages.context_processors.messages", + "django.contrib.auth.context_processors.auth", + "vas3k_blog.context_processors.settings_processor", + "vas3k_blog.context_processors.cookies_processor", + ], + }, + }, +] + +WSGI_APPLICATION = "vas3k_blog.wsgi.application" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler" + }, + }, + "loggers": { + "": { # "catch all" loggers by referencing it with the empty string + "handlers": ["console"], + "level": "DEBUG", + }, + }, +} + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": os.getenv("POSTGRES_DB") or "vas3k_blog", + "USER": os.getenv("POSTGRES_USER") or "postgres", + "PASSWORD": os.getenv("POSTGRES_PASSWORD") or "", + "HOST": os.getenv("POSTGRES_HOST") or "localhost", + "PORT": os.getenv("POSTGRES_PORT") or 5432, + } +} + +if DEBUG: + DATABASES.update({ + "old": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": "vas3k", + "USER": "vas3k", + "PASSWORD": "", + "HOST": "127.0.0.1", + "PORT": "5432", + } + }) + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.authn.password_validation.CommonPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = "ru" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = "static/" + +STATICFILES_DIRS = [ + BASE_DIR / "frontend/static", +] + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Auth + +CLUB_AUTH_URL = "https://vas3k.club/auth/external/" + +PATREON_AUTH_URL = "https://www.patreon.com/oauth2/authorize" +PATREON_TOKEN_URL = "https://www.patreon.com/api/oauth2/token" +PATREON_USER_URL = "https://www.patreon.com/api/oauth2/v2/identity" +PATREON_CLIENT_ID = os.getenv("PATREON_CLIENT_ID") +PATREON_CLIENT_SECRET = os.getenv("PATREON_CLIENT_SECRET") +PATREON_SCOPE = "identity identity[email]" + +JWT_ALGORITHM = "RS256" +JWT_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAvEDEGKL0b+okI6QBBMiu +3GOHOG/Ml4KJ13tWyPnl5yGswf9rUGOLo0T0dXxSwxp/6g1ZeYqDR7jckuP6A3Rv +DPdKYc44eG3YB/bO2Yeq57Kx1rxvFvWZap2jTyu2wbALmmeg0ne3wkXPExTy/EQ4 +LDft8nraSJuW7c+qrah+F94qKGVNvilf20V5S186iGpft2j/UAl9s81kzZKBwk7M +B+u4jSH8E3KHZVb28CVNOpnYYcLBNLsjGwZk6qbiuq1PEq4AZ5TN3EdoVP9nbIGY +BZAMwoNxP4YQN+mDRa6BU2Mhy+c9ea+fuCKRxNi3+nYjF00D28fErFFcA+BEe4A1 +Hhq25PsVfUgOYvpv1F/ImPJBl8q728DEzDcj1QzL0flbPUMBV6Bsq+l2X3OdrVtQ +GXiwJfJRWIVRVDuJzdH+Te2bvuxk2d0Sq/H3uzXYd/IQU5Jw0ZZRTKs+Rzdpb8ui +eoDmq2uz6Q2WH2gPwyuVlRfatJOHCUDjd6dE93lA0ibyJmzxo/G35ns8sZoZaJrW +rVdFROm3nmAIATC/ui9Ex+tfuOkScYJ5OV1H1qXBckzRVwfOHF0IiJQP4EblLlvv +6CEL2VBz0D2+gE4K4sez6YSn3yTg9TkWGhXWCJ7vomfwIfHIdZsItqay156jMPaV +c+Ha7cw3U+n6KI4idHLiwa0CAwEAAQ== +-----END PUBLIC KEY-----""" +JWT_EXP_TIMEDELTA = timedelta(days=120) + +# Email + +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = os.getenv("EMAIL_HOST", "email-smtp.eu-central-1.amazonaws.com") +EMAIL_PORT = os.getenv("EMAIL_PORT", 587) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = True + +DEFAULT_FROM_EMAIL = "Вастрик " + +# Telegram + +TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN") +TELEGRAM_MAIN_CHAT_ID = os.getenv("TELEGRAM_MAIN_CHAT_ID") + +# App specific + +AUTH_USER_MODEL = "users.User" + +SESSION_COOKIE_AGE = 300 * 24 * 60 * 60 # 300 days +SENTRY_DSN = os.getenv("SENTRY_DSN") + +APP_HOST = "vas3k.blog" +MIRRORS = ["vas3k.ru"] + +AUTHOR = "@vas3k" +TITLE = "Вастрик" +DESCRIPTION = "Авторский блог о выживании в мире технологий и происходящем вокруг киберпанке" + +PAID_BLOCK_CLASS = "block-paid" +EXTRA_BLOCK_CLASS = "block-extra" + +STYLES_HASH = str(randint(1, 10000)) + +MAX_COMMENTS_PER_24H = 50 + +if SENTRY_DSN and not DEBUG: + # activate sentry on production + sentry_sdk.init(dsn=SENTRY_DSN, integrations=[ + DjangoIntegration(), + RedisIntegration(), + ]) + +if DEBUG: + INSTALLED_APPS += ["debug_toolbar"] + MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE diff --git a/vas3k_blog/urls.py b/vas3k_blog/urls.py new file mode 100644 index 0000000..aa91f87 --- /dev/null +++ b/vas3k_blog/urls.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.contrib import admin +from django.urls import path, include +from django.views.generic import RedirectView + +from clickers.views import click_comment, click_block +from comments.views import delete_comment, create_comment +from inside.views import donate, subscribe, confirm, unsubscribe +from posts.views import index, show_post, list_posts, edit_post +from rss.feeds import FullFeed, PublicFeed, PrivateFeed +from users.views import profile, robots +from authn.views import log_in, log_out, login_club, login_patreon, club_callback, patreon_callback + +urlpatterns = [ + path("", index, name="index"), + + path(r"godmode/", admin.site.urls), + + path(r"login/", log_in, name="login"), + path(r"logout/", log_out, name="logout"), + path(r"auth/login/club/", login_club, name="login_club"), + path(r"auth/login/patreon/", login_patreon, name="login_patreon"), + path(r"auth/club_callback/", club_callback, name="club_callback"), + path(r"auth/patreon_callback/", patreon_callback, name="patreon_callback"), + path(r"profile/", profile, name="profile"), + + path(r"donate/", donate, name="donate"), + path(r"subscribe/", subscribe, name="subscribe"), + path(r"subscribe/confirm//", confirm, name="subscribe_confirm"), + path(r"unsubscribe//", unsubscribe, name="unsubscribe"), + + path(r"rss/", FullFeed(), name="rss.full"), + path(r"rss/public/", PublicFeed(), name="rss.public"), + path(r"rss/private/", PrivateFeed(), name="rss.private"), + path(r"rss/blog/", FullFeed(), name="rss.blog"), # legacy + + path(r"clickers/comments//", click_comment, name="click_comment"), + path(r"clickers/blocks///", click_block, name="click_block"), + + path(r"comments/create/", create_comment, name="create_comment"), + path(r"comments//delete/", delete_comment, name="delete_comment"), + + path("robots.txt", robots, name="robots"), + + path(r"//", show_post, name="show_post"), + path(r"//edit/", edit_post, name="edit_post"), + path( + r"///", + RedirectView.as_view(url="/%(post_type)s/%(post_slug)s#%(block)s", permanent=True), + name="show_post_block" + ), + # path(r"//thepub/", show_post, name="story_epub"), + path(r"/", list_posts, name="list_posts"), +] + +if settings.DEBUG: + import debug_toolbar + urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns diff --git a/vas3k_blog/wsgi.py b/vas3k_blog/wsgi.py new file mode 100644 index 0000000..93aabd3 --- /dev/null +++ b/vas3k_blog/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for vas3k_blog project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vas3k_blog.settings") + +application = get_wsgi_application()