initial commit...
This commit is contained in:
commit
c5b202a42f
|
@ -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=""
|
|
@ -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
|
|
@ -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"]
|
|
@ -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
|
|
@ -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` помогут вам разобраться что и как.
|
|
@ -0,0 +1,3 @@
|
|||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthnConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "authn"
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
class PatreonException(Exception):
|
||||
pass
|
|
@ -0,0 +1,3 @@
|
|||
from django.db import models
|
||||
|
||||
# Create your models here.
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
|
@ -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} не найден. "
|
||||
"<a href=\"https://vas3k.club\">Попробуйте</a> войти в свой "
|
||||
"аккаунт и потом авторизоваться здесь снова."
|
||||
})
|
||||
|
||||
if club_profile["user"]["payment_status"] != "active":
|
||||
return render(request, "error.html", {
|
||||
"message": "Ваша подписка истекла. "
|
||||
"<a href=\"https://vas3k.club\">Продлите</a> её здесь."
|
||||
})
|
||||
|
||||
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": "Надо быть активным патроном, чтобы комментировать на сайте.<br>"
|
||||
"<a href=\"https://www.patreon.com/join/vas3k\">Станьте им здесь!</a>"
|
||||
})
|
||||
|
||||
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)
|
|
@ -0,0 +1,5 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from clickers.models import Clicker
|
||||
|
||||
admin.site.register(Clicker)
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ClickersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "clickers"
|
|
@ -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()
|
||||
]
|
|
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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"
|
|
@ -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")
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "comments"
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
]
|
|
@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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(),
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -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]
|
|
@ -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("☠️ Комментарий удален")
|
File diff suppressed because it is too large
Load Diff
|
@ -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"<!--BUNDLE\[(.*?)\]-->"
|
||||
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("<!--PAGEBREAK-->"))
|
|
@ -0,0 +1,3 @@
|
|||
from geolite2 import geolite2
|
||||
|
||||
geoip = geolite2.reader()
|
|
@ -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
|
|
@ -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 <p></p>
|
||||
return super().paragraph(text)
|
||||
|
||||
def heading(self, text, level, **attrs):
|
||||
anchor = slugify(text[:24])
|
||||
return f"<div class=\"header-{level}\" id=\"{anchor}\"><a href=\"#{anchor}\">{text}</a></div>\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'<a href="{self.safe_url(url)}">{text or url}</a>'
|
||||
|
||||
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'<a href="{mistune.escape(url)}">{mistune.escape(url)}</a>'
|
||||
|
||||
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'<img src="{mistune.escape(src)}" alt="{mistune.escape(title)}">'
|
||||
caption = f"<figcaption>{title}</figcaption>" if title else ""
|
||||
return f'<figure>{image_tag}{caption}</figure>'
|
||||
|
||||
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'<span class="ratio-16-9">'
|
||||
f'<iframe loading="lazy" src="https://www.youtube.com/embed/{mistune.escape(youtube_match.group(1) or "")}'
|
||||
f'?{playlist}autoplay=0&controls=1&showinfo=1&vq=hd1080"'
|
||||
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"'
|
||||
f'allowfullscreen></iframe>'
|
||||
f"</span>"
|
||||
)
|
||||
caption = f"<figcaption>{mistune.escape(title)}</figcaption>" if title else ""
|
||||
return f"<figure>{video_tag}{caption}</figure>"
|
||||
|
||||
def video(self, src, alt="", title=None):
|
||||
video_tag = (
|
||||
f'<video src="{mistune.escape(src)}" controls autoplay loop muted playsinline>{mistune.escape(alt)}</video>'
|
||||
)
|
||||
caption = f"<figcaption>{mistune.escape(title)}</figcaption>" if title else ""
|
||||
return f"<figure>{video_tag}{caption}</figure>"
|
||||
|
||||
def tweet(self, src, alt="", title=None):
|
||||
tweet_match = TWITTER_RE.match(src)
|
||||
twitter_tag = f'<blockquote class="twitter-tweet" tw-align-center>' \
|
||||
f'<a href="{tweet_match.group(1)}"></a></blockquote><br>' \
|
||||
f'<a href="{src}" target="_blank">{src}</a>'
|
||||
return twitter_tag
|
|
@ -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"""<img src="{src}" alt="{alt}" width="600" border="0"><br>{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'<a href="{mistune.escape(src)}"><span class="ratio-16-9 video-preview" ' \
|
||||
f'style="background-image: url(\'https://img.youtube.com/vi/{mistune.escape(youtube_id)}/0.jpg\');">' \
|
||||
f'</span></a><br>{mistune.escape(title or "")}'
|
||||
|
||||
def video(self, src, alt="", title=None):
|
||||
return f'<video src="{mistune.escape(src)}" controls autoplay loop muted playsinline>{alt}</video><br>{title or ""}'
|
||||
|
||||
def tweet(self, src, alt="", title=None):
|
||||
return f'<a href="{mistune.escape(src)}">{mistune.escape(src)}</a><br>{mistune.escape(title or "")}'
|
||||
|
||||
def heading(self, text, level, **attrs):
|
||||
tag = f"h{level}"
|
||||
return f"<{tag}>{text}</{tag}>\n"
|
|
@ -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)
|
|
@ -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'
|
|
@ -0,0 +1,32 @@
|
|||
__all__ = ["cite_block"]
|
||||
|
||||
from common.markdown.plugins.utils import parse_classes
|
||||
|
||||
CITE_BLOCK_PATTERN = r'^\% (?P<cite_block_text>[\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'<div class="block-cite">{text}</div>\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)
|
|
@ -0,0 +1,37 @@
|
|||
__all__ = ["media_block"]
|
||||
|
||||
from common.markdown.plugins.utils import parse_classes_and_ids
|
||||
|
||||
MEDIA_BLOCK_PATTERN = r'\{{3}(?P<media_block_classes>[^\s]+)?(?P<media_block_text>[\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("<p>", "").replace("</p>", "") # dirty hack to fix some browsers
|
||||
classes, ids = parse_classes_and_ids(attrs.get("classes") or "")
|
||||
return f'<div class="block-media {" ".join(classes)}" id="{" ".join(ids)}">{text}</div>\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)
|
|
@ -0,0 +1,34 @@
|
|||
__all__ = ["spoiler"]
|
||||
|
||||
SPOILER_BLOCK_PATTERN = r'\[\?(?P<spoiler_text>.+?)\?\]'
|
||||
|
||||
|
||||
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"<span class=\"block-spoiler\">" \
|
||||
f"<span class=\"block-spoiler-button\">?</span>" \
|
||||
f"<span class=\"block-spoiler-text\">{text}</span>" \
|
||||
f"</span>\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)
|
|
@ -0,0 +1,42 @@
|
|||
__all__ = ["text_block"]
|
||||
|
||||
from common.markdown.plugins.utils import parse_classes_and_ids
|
||||
|
||||
TEXT_BLOCK_PATTERN = r'\[{3}(?P<text_block_classes>[^\s]+)?(?P<text_block_text>[\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'<div class="block-text {" ".join(classes)}" id="{" ".join(ids)}">' \
|
||||
f'{text}' \
|
||||
f'<br><br>[commentable {block_counter}]</div>\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)
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
"]+"
|
||||
)
|
|
@ -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"
|
|
@ -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"
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
<div class="clicker {% if is_voted %}status_voted{% endif %} clicker-updater">
|
||||
<form action="/clickers/click/" method="post" accept-charset="utf-8" onsubmit="return ajax_click(event);">
|
||||
<span class="clicker-votes count-updater">{{ votes }}</span>
|
||||
<span class="clicker-text">{{ clicker }}</span>
|
||||
<input type="hidden" name="story_id" value="{{ story.id }}">
|
||||
<input type="hidden" name="block" value="{{ block }}">
|
||||
<input type="hidden" name="author" value="{{ username }}">
|
||||
<input type="submit" class="clicker__button" value="">
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
<div class="block-extra-placeholder-text">
|
||||
🔒 Тут инфа только <a href="{% url "club" %}" target="_blank">для своих</a><br>
|
||||
<a href="{% url "login_patreon" %}?goto={{ request.scheme }}://{{ request.get_host }}{{ story.get_absolute_url }}" class="button members-only-button">Войти через Патреон</a>
|
||||
<a href="{% url "login" %}?goto={{ request.scheme }}://{{ request.get_host }}{{ story.get_absolute_url }}" class="button button-black" style="margin-top: 10px;" target="_blank">Войти через Вастрик.Клуб</a>
|
||||
</div>
|
|
@ -0,0 +1,27 @@
|
|||
<form hx-post="{% url "create_comment" %}"
|
||||
hx-swap="outerHTML"
|
||||
action="{% url "create_comment" %}"
|
||||
method="post"
|
||||
class="comments-form"
|
||||
id="comments-form"
|
||||
accept-charset="utf-8"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="post_slug" value="{{ post.slug }}">
|
||||
|
||||
<div class="comments-form-inputs">
|
||||
<div class="comments-form-user">
|
||||
<span class="avatar comments-form-avatar" style="background-image: url('{{ request.user.get_avatar }}')"></span>
|
||||
</div>
|
||||
|
||||
<textarea cols="30" rows="10" name="text" class="comments-form-textarea" id="comment-text" tabindex="2" minlength="3" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="comments-form-footer">
|
||||
<span>
|
||||
Можно использовать <a href="https://doka.guide/tools/markdown/" target="_blank">Markdown</a>
|
||||
</span>
|
||||
|
||||
<input type="submit" tabindex="3" class="button comments-form-submit" value="Отправить">
|
||||
</div>
|
||||
</form>
|
|
@ -0,0 +1,24 @@
|
|||
{% load text_filters %}
|
||||
{% load comments %}
|
||||
|
||||
<div class="comments">
|
||||
{% if post.is_commentable or comments|length > 0 %}
|
||||
<span class="header-2 comments-title">Комментарии 👇</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="comments-list" id="comments-list">
|
||||
{% for comment in comments|without_inline_comments %}
|
||||
{% include "comments/comment.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if post.is_commentable %}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% include "comments/comment-form.html" %}
|
||||
{% else %}
|
||||
<div class="comments-form-login">
|
||||
<a href="{% url "login" %}" class="button button-big">Войдите, чтобы написать комментарий</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
|
@ -0,0 +1,42 @@
|
|||
{% load comments %}
|
||||
{% load humanize %}
|
||||
<div class="comment" id="comment-{{ comment.id }}">
|
||||
<div class="comment-head">
|
||||
{% if comment.user %}
|
||||
<a href="{% url "profile" comment.user.id %}" class="avatar comment-avatar" style="background-image: url('{{ comment.get_avatar }}');"></a>
|
||||
{% else %}
|
||||
<span class="avatar comment-avatar" style="background-image: url('{{ comment.get_avatar }}');"></span>
|
||||
{% endif %}
|
||||
|
||||
<span class="comment-author" onclick="return nick('{{ comment.author_name }}', '#comment-text');">
|
||||
{{ comment.author_name }}
|
||||
</span>
|
||||
|
||||
<span class="comment-date">{{ comment.natural_created_at }}</span>
|
||||
|
||||
<a href="#comment-{{ comment.id }}" class="comment-id">#</a>
|
||||
|
||||
<div
|
||||
class="button button-inverted comment-rating {% mark_if_voted comment "status-voted" %}"
|
||||
hx-post="{% url "click_comment" comment.id %}"
|
||||
hx-swap="innerHTML"
|
||||
>{{ comment.upvotes }}</div>
|
||||
</div>
|
||||
|
||||
<div class="comment-body">
|
||||
{% show_comment comment %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<div class="comment-footer">
|
||||
<button
|
||||
hx-post="{% url "delete_comment" comment.id %}"
|
||||
hx-target="#comment-{{ comment.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Удаляем?"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -0,0 +1,34 @@
|
|||
{% if request.user.is_authenticated %}
|
||||
<form hx-post="{% url "create_comment" %}"
|
||||
hx-target="#comments-list"
|
||||
hx-swap="beforeend"
|
||||
action="{% url "create_comment" %}"
|
||||
method="post"
|
||||
class="comments-form"
|
||||
id="comments-form"
|
||||
accept-charset="utf-8"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="post_slug" value="{{ post.slug }}">
|
||||
|
||||
<div class="comments-form-inputs">
|
||||
<div class="comments-form-user">
|
||||
<span class="avatar comments-form-avatar" style="background-image: url('{{ request.user.get_avatar }}')"></span>
|
||||
</div>
|
||||
|
||||
<textarea cols="30" rows="10" name="text" class="comments-form-textarea" id="comment-text" tabindex="2" minlength="3" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="comments-form-footer">
|
||||
<span>
|
||||
Можно использовать <a href="https://doka.guide/tools/markdown/" target="_blank">Markdown</a>
|
||||
</span>
|
||||
|
||||
<input type="submit" tabindex="3" class="button comments-form-submit" value="Отправить">
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="comments-form-login">
|
||||
<a href="{% url "login" %}" class="button button-big">Войдите, чтобы написать комментарий</a>
|
||||
</div>
|
||||
{% endif %}
|
|
@ -0,0 +1,18 @@
|
|||
<div class="inline-comments-form">
|
||||
<form
|
||||
hx-post="{% url "create_comment" %}"
|
||||
hx-swap="outerHTML"
|
||||
action="{% url "create_comment" %}"
|
||||
method="post"
|
||||
id="comments-form-{{ block }}"
|
||||
accept-charset="utf-8"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="post_slug" value="{{ post.slug }}">
|
||||
<input type="hidden" name="block" value="{{ block }}">
|
||||
<div class="inline-comments-form-inputs">
|
||||
<textarea rows="2" name="text" class="inline-comments-form-textarea" id="inline-comment-text-{{ block }}" placeholder="Откомментировать прямо здесь..." minlength="3" maxlength="10000" required></textarea>
|
||||
<input type="submit" class="button inline-comments-form-submit" value="↵">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,32 @@
|
|||
{% load comments %}
|
||||
{% load text_filters %}
|
||||
|
||||
<div class="inline-comments" id="block-{{ block }}">
|
||||
<div class="inline-comments-header" onclick="return toggle(event, '.inline-comments-content', 'inline-comments-content-hidden');">
|
||||
{% if block_comments|length > 0 %}
|
||||
{{ block_comments|length }} {{ block_comments|length|rupluralize:"комментарий,комментария,комментариев" }}
|
||||
{% else %}
|
||||
Комментировать
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="inline-comments-content {% if not request.user.is_authenticated %}inline-comments-content-hidden{% endif %}">
|
||||
{% if block_comments %}
|
||||
<div class="inline-comments-list" id="inline-comments-list-{{ block }}">
|
||||
{% for comment in block_comments %}
|
||||
{% include "comments/inline-comment.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if post.is_commentable %}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% include "comments/inline-comment-form.html" %}
|
||||
{% else %}
|
||||
<div class="inline-comments-login">
|
||||
<a href="{% url "login" %}" class="button">Войти и написать</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,31 @@
|
|||
{% load comments %}
|
||||
<div class="inline-comment" id="block-{{ block }}-{{ comment.id }}" data-timestamp="{{ comment.created_at.timestamp | floatformat:"0" }}">
|
||||
<div class="inline-comment-upvotes">
|
||||
<div
|
||||
class="button button-inverted inline-comment-rating {% mark_if_voted comment "status-voted" %}"
|
||||
hx-post="{% url "click_comment" comment.id %}"
|
||||
hx-swap="innerHTML"
|
||||
>{{ comment.upvotes }}</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-comments-author" onclick="return nick('{{ comment.author_name }}', '#inline-comment-text-{{ block }}');">
|
||||
{{ comment.author_name }}
|
||||
</div>
|
||||
|
||||
<div class="inline-comments-body">
|
||||
{% show_comment comment %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_superuser %}
|
||||
<div class="inline-comments-footer">
|
||||
<button
|
||||
hx-post="{% url "delete_comment" comment.id %}"
|
||||
hx-target="#block-{{ block }}-{{ comment.id }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Удаляем?"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
<div class="comments">
|
||||
{% include "comments/comment.html" %}
|
||||
</div>
|
||||
|
||||
{% include "comments/comment-form.html" %}
|
|
@ -0,0 +1,3 @@
|
|||
{% include "comments/inline-comment.html" %}
|
||||
|
||||
{% include "comments/inline-comment-form.html" %}
|
|
@ -0,0 +1,36 @@
|
|||
{% load static %}
|
||||
<section class="contacts">
|
||||
<a href="https://t.me/vas3k_channel" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-telegram"></i><span>Телеграм-канал</span>
|
||||
</a>
|
||||
|
||||
<a href="https://twitter.com/vas3k" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-twitter-square"></i>
|
||||
<span>Твиттер</span>
|
||||
</a>
|
||||
|
||||
<a href="https://mastodon.online/@vas3k" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-mastodon"></i>
|
||||
<span>Мастодон</span>
|
||||
</a>
|
||||
|
||||
<a href="https://www.patreon.com/vas3k" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-patreon"></i>
|
||||
<span>Патреон</span>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/vas3k" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-github"></i>
|
||||
<span>Гитхаб</span>
|
||||
</a>
|
||||
|
||||
<a href="mailto:me@vas3k.ru" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span>me@vas3k.ru</span>
|
||||
</a>
|
||||
|
||||
<a href="https://vas3k.ru/rss/" class="contacts-item" target="_blank" rel="syndication">
|
||||
<i class="fas fa-rss-square"></i>
|
||||
<span>RSS</span>
|
||||
</a>
|
||||
</section>
|
|
@ -0,0 +1,7 @@
|
|||
{% load static %}
|
||||
<link rel="icon" type="image/png" href="{% static "images/favicon_32.png" %}" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="{% static "images/favicon_64.png" %}" sizes="64x64">
|
||||
<link rel="icon" type="image/png" href="{% static "images/favicon_128.png" %}" sizes="128x128">
|
||||
<link rel="shortcut icon" type="image/png" href="{% static "images/favicon_square.png" %}">
|
||||
<link rel="apple-touch-icon" type="image/png" href="{% static "images/favicon_square.png" %}">
|
||||
<link rel="mask-icon" href="{% static "images/favicon_128.png" %}" color="#5954ca">
|
|
@ -0,0 +1,49 @@
|
|||
{% load static %}
|
||||
|
||||
<div class="header h-card" hx-boost="true">
|
||||
<a href="{% url "index" %}" class="header-logo u-url">
|
||||
<img src="{% static "images/logo.png" %}" alt="" class="header-logo-image u-photo">
|
||||
<span class="header-logo-title p-name">Вастрик</span>
|
||||
</a>
|
||||
|
||||
<div class="header-menu">
|
||||
<a href="{% url "list_posts" "all" %}" class="button button-inverted header-menu-item">
|
||||
<span>✏️</span>
|
||||
<span>️Посты</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url "list_posts" "world" %}" class="button button-inverted header-menu-item">
|
||||
<span>🌍</span>
|
||||
<span>Путешествия</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url "subscribe" %}" class="button button-inverted header-menu-item">
|
||||
<span>✅</span>
|
||||
<span>Подписаться</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url "donate" %}" class="button button-inverted header-menu-item">
|
||||
<span>👍</span>
|
||||
<span>Донат</span>
|
||||
</a>
|
||||
|
||||
<div class="button button-inverted header-menu-item header-menu-round" onclick="return toggleHeaderSearch(event, '#header-search');">
|
||||
🔍
|
||||
</div>
|
||||
|
||||
<a href="{% url "profile" %}" class="button button-inverted avatar header-menu-item header-menu-round" {% if request.user.is_authenticated %}style="background-image: url('{{ request.user.avatar }}');" {% endif %}>
|
||||
{% if not request.user.is_authenticated or not request.user.avatar %}👤{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-search header-search-hidden" id="header-search">
|
||||
<div class="header-search-form">
|
||||
<form action="https://www.google.ru/search?q=site%3Avas3k.ru+">
|
||||
<input type="hidden" name="domains" value="vas3k.ru">
|
||||
<input type="hidden" name="sitesearch" value="vas3k.ru">
|
||||
<input type="text" name="q" placeholder="Искать по сайту..." class="header-search-form-input">
|
||||
<button type="submit" class="header-search-form-submit">🔍</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,28 @@
|
|||
<div class="clearfix"></div>
|
||||
{% if items and num_pages > 1 %}
|
||||
<div class="paginator">
|
||||
{% if items.has_previous %}
|
||||
<a href="?page={{ items.previous_page_number }}" class="paginator-page">←</a>
|
||||
{% endif %}
|
||||
|
||||
{% if show_first %}
|
||||
<a href="?page=1" class="paginator-page">1</a>
|
||||
<span class="paginator-dots">...</span>
|
||||
{% endif %}
|
||||
|
||||
{% for page in page_numbers %}
|
||||
<a href="?page={{ page }}" class="paginator-page {% if page == items.number %}paginator-page-active{% endif %}">
|
||||
{{ page }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
{% if show_last %}
|
||||
<span class="paginator-dots">...</span>
|
||||
<a href="?page={{ num_pages }}" class="paginator-page">{{ num_pages }}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if items.has_next %}
|
||||
<a href="?page={{ items.next_page_number }}" class="paginator-page">→</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
|
@ -0,0 +1,22 @@
|
|||
{% load static %}
|
||||
|
||||
<section class="post-footer">
|
||||
{% if story.is_members_only %}
|
||||
<div class="post-members-share">
|
||||
<div class="post-members-share-heart">
|
||||
<i class="fas fa-heart"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Как делиться закрытыми постами?</h3>
|
||||
<ul>
|
||||
<li>👍 Можно цитировать, делать скриншоты, репостить картинки, и давать ссылки на пост в паблике. Это хорошо и привлекает новых читателей</li>
|
||||
<li>🙅♀️ Нельзя воровать весь текст или выкладывать сохранённые страницы. Это плохо и атата</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{% url "donate" %}" class="button button-red button-huuuuge">🍺 Поблагодарить автора</a>
|
||||
{% endif %}
|
||||
|
||||
{% include "common/post_subscribe.html" %}
|
||||
</section>
|
|
@ -0,0 +1,12 @@
|
|||
{% load static %}
|
||||
{% load text_filters %}
|
||||
{% load posts %}
|
||||
|
||||
<section class="post-related">
|
||||
<div class="post-related-title"><span><b>Еще?</b> Тогда вот</span></div>
|
||||
<div class="cards-group cards-group-3x">
|
||||
{% for post in related %}
|
||||
{% include "posts/cards/horizontal.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,38 @@
|
|||
<section class="post-subscribe">
|
||||
<div class="post-subscribe-header">
|
||||
✅ Подписка на Вастрика
|
||||
</div>
|
||||
|
||||
<div class="post-subscribe-description">
|
||||
🚨 Роскомнадзор банит одну соцсеть за другой,
|
||||
поэтому я рекомендую подписаться как по почте (её сложнее заблокировать),
|
||||
так и на вашей любимой площадке. Тогда у нас будет хотя бы два канала для связи.
|
||||
</div>
|
||||
|
||||
<div class="post-subscribe-form">
|
||||
<form action="/subscribe/" method="post">
|
||||
<label class="post-subscribe-form-label" for="inside_email">Ваш электронный адрес:</label>
|
||||
<div class="post-subscribe-form-fields">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="name" style="position: absolute; left: -99999px;">
|
||||
<input type="email" id="inside_email" name="email" placeholder="your@email.com" required="required">
|
||||
<button type="submit" class="button button-red">Подписаться</button>
|
||||
</div>
|
||||
<div class="post-subscribe-form-hint">
|
||||
* никакого спама, только мои уведомления
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="post-subscribe-sub-header">
|
||||
Другие площадки 👇
|
||||
</div>
|
||||
|
||||
<div class="post-subscribe-items">
|
||||
<a href="https://t.me/vas3k_channel" class="button">Телеграм-канал</a>
|
||||
<a href="https://www.patreon.com/vas3k" class="button">Патреон</a>
|
||||
<a href="https://mastodon.online/@vas3k" class="button">Мастодон</a>
|
||||
<a href="https://twitter.com/vas3k" class="button">Твиттер</a>
|
||||
<a href="{% url "rss.full" %}" class="button">RSS</a>
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1 @@
|
|||
<link rel="alternate" type="application/rss+xml" title="Полный фид" href="https://vas3k.ru/rss/"/>
|
|
@ -0,0 +1,14 @@
|
|||
{% load static %}
|
||||
|
||||
<script src="{% static "js/vendor/highlight.pack.js" %}" async></script>
|
||||
<script src="{% static "js/vendor/tweemoji.min.js" %}"></script>
|
||||
<script src="{% static "js/vendor/lightense.min.js" %}"></script>
|
||||
<script src="{% static "js/vendor/htmx.min.js" %}"></script>
|
||||
<script src="{% static "js/main.js" %}?v={{ settings.STYLES_HASH }}"></script>
|
||||
<script src="{% static "js/comments.js" %}?v={{ settings.STYLES_HASH }}"></script>
|
||||
|
||||
<script>
|
||||
window.addEventListener("htmx:configRequest", (event) => {
|
||||
event.detail.headers["X-CSRFToken"] = "{{ csrf_token }}";
|
||||
})
|
||||
</script>
|
|
@ -0,0 +1,13 @@
|
|||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static "css/normalize.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/fonts.css" %}">
|
||||
<link rel="stylesheet" href="{% static "css/theme.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/base.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/layout.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/cards.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/posts.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/comments.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/donates.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<link rel="stylesheet" href="{% static "css/users.css" %}?v={{ settings.STYLES_HASH }}">
|
||||
<script src="https://kit.fontawesome.com/c29b827576.js" crossorigin="anonymous"></script>
|
||||
<link rel="stylesheet" href="{% static "css/highlight/monokai_sublime.css" %}">
|
|
@ -0,0 +1,172 @@
|
|||
{% extends "layout.html" %}
|
||||
{% load static %}
|
||||
{% load text_filters %}
|
||||
|
||||
{% block title %}
|
||||
Донат — {{ settings.TITLE }}
|
||||
{% endblock %}
|
||||
|
||||
{% block meta %}
|
||||
<meta property="og:title" content="Донат — {{ settings.TITLE }}">
|
||||
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}/donate/">
|
||||
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}/static/images/favicon_128.png">
|
||||
<meta property="og:description" content="">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Донат — {{ settings.TITLE }}">
|
||||
<meta name="twitter:description" content="">
|
||||
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}/static/images/favicon_128.png">
|
||||
<meta name="twitter:image:src" content="{{ request.scheme }}://{{ request.get_host }}/static/images/favicon_128.png">
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="block donate">
|
||||
<div class="block-title">
|
||||
👍 Поддержать автора
|
||||
</div>
|
||||
|
||||
<div class="block-description">
|
||||
Независимые авторы в интернете — вымирающая редкость.
|
||||
Они существуют только благодаря людям, которые их поддерживают.
|
||||
Если вам нравится то, что я делаю, занести мне донат — это лучший способ сказать «спасибо».
|
||||
</div>
|
||||
|
||||
<div class="donate-selector">
|
||||
<div class="donate-selector-item" onclick="return showDonateTab(event, '#donate-amount-1');">300<br><small>руб</small></div>
|
||||
<div class="donate-selector-item donate-selector-item-active" onclick="return showDonateTab(event, '#donate-amount-2');">512<br><small>руб</small></div>
|
||||
<div class="donate-selector-item" onclick="return showDonateTab(event, '#donate-amount-3');">1499<br><small>руб</small></div>
|
||||
<div class="donate-selector-item" onclick="return showDonateTab(event, '#donate-amount-4');">2500<br><small>руб</small></div>
|
||||
<div class="donate-selector-item" style="line-height: 40px;" onclick="return showDonateTab(event, '#donate-amount-5');"><small>Любая сумма</small></div>
|
||||
</div>
|
||||
|
||||
<div class="donate-amount" id="donate-amount-1">
|
||||
<img src="{% static "images/donate/server.jpg" %}">
|
||||
<strong>На сервер</strong><br>
|
||||
Мы используем только качественное и высокопроизводительное оборудование!
|
||||
</div>
|
||||
|
||||
<div class="donate-amount donate-amount-active" id="donate-amount-2">
|
||||
<img src="{% static "images/donate/beers.jpg" %}">
|
||||
<strong>512 рублей на пиво</strong><br>
|
||||
Клинические исследования доказали: пиво эффективнее всего генерирует идеи для нового поста в блоге. Не знаю почему так.
|
||||
</div>
|
||||
|
||||
<div class="donate-amount" id="donate-amount-3">
|
||||
<img src="{% static "images/donate/mindset.jpg" %}">
|
||||
<strong>За охуительные идеи</strong><br>
|
||||
Мы всё реже говорим людям, что разделяем их идеи. Потому мы и одиноки.
|
||||
Теперь можно сообщить об этом без слов.
|
||||
</div>
|
||||
|
||||
<div class="donate-amount" id="donate-amount-4">
|
||||
<img src="{% static "images/donate/oleg.jpg" %}">
|
||||
<strong>Олег Юрьевич</strong><br>
|
||||
Да не, никто столько не заносит. Это донат только для сильных и смелых миллионеров по жизни.
|
||||
</div>
|
||||
|
||||
<div class="donate-amount" id="donate-amount-5">
|
||||
<img src="{% static "images/donate/other.jpg" %}">
|
||||
<strong>Побрацки</strong><br>
|
||||
Да все и так поняли, что эти переключатели ни на что не влияют и лишь хитрая психологическая манипуляция.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="block donate-service">
|
||||
<div class="donate-service-description">
|
||||
<strong>На карту Тиньков (карты РФ)</strong><br>
|
||||
Если вы из России, перевод на карту будет проще всего. По клику откроется удобное окошко.
|
||||
|
||||
<br><br>
|
||||
|
||||
<small>
|
||||
Либо по номеру карты: 5536913972058263
|
||||
</small>
|
||||
</div>
|
||||
<div class="donate-service-button">
|
||||
<a href="https://www.tinkoff.ru/rm/zubarev.vasiliy1/KlQ9b64480" class="button" target="_blank">
|
||||
💳 Донатить
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block donate-service">
|
||||
<div class="donate-service-description">
|
||||
<strong>Через Stripe (карты не РФ)</strong><br>
|
||||
|
||||
Позволяет напрямую задонатить мне в Евро с любых зарубежных карт. Поддерживает Google и Apple Pay.
|
||||
|
||||
</div>
|
||||
<div class="donate-service-button">
|
||||
<a href="https://donate.stripe.com/5kA8xA9RGdZscQUeUU" class="button" target="_blank">
|
||||
💶 Донатить
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block donate-service">
|
||||
<div class="donate-service-description">
|
||||
<strong>PayPal</strong><br>
|
||||
Вариант для живущих заграницей и просто у кого там завалялась мелочь на аккаунте.
|
||||
</div>
|
||||
<div class="donate-service-button">
|
||||
<a href="https://paypal.me/vas3kcom" class="button" target="_blank">
|
||||
💰 PayPal.me
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block donate-service">
|
||||
<div class="donate-service-description">
|
||||
<strong>На Патреоне</strong><br>
|
||||
Там можно заносить автоматически и получить ранний доступ и прочие плюшки.
|
||||
</div>
|
||||
<div class="donate-service-button">
|
||||
<a href="https://www.patreon.com/vas3k" class="button" target="_blank">❤️ Стать Патроном</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block donate-service donate-service-column">
|
||||
<div class="donate-service-description">
|
||||
<strong>Криптой</strong>
|
||||
</div>
|
||||
|
||||
<div class="donate-service-selector">
|
||||
<div class="donate-service-selector-item">
|
||||
<span>Биткоин</span>
|
||||
<img src="{% static "images/btc.png" %}" alt="Bitcoin QR code">
|
||||
<small>bc1qu5m46dq4zdmyye738pqe0wjcxtgmdq20qatm6k</small>
|
||||
</div>
|
||||
<div class="donate-service-selector-item">
|
||||
<span>Эфир или USDC/USDT</span>
|
||||
<img src="{% static "images/eth.png" %}" alt="ETH/ERC20 QR code">
|
||||
<small>0x35c0ef9C40568b25520d25235280e73a9bcb0d77</small>
|
||||
</div>
|
||||
<div class="donate-service-selector-item">
|
||||
<span>Polkadot</span>
|
||||
<a href="https://sub.id/13gCScunZLdXDkYKuaDi8QcsTT5nEEgxpiyYLTSHc6iCNW3b" target="_blank">
|
||||
<img src="{% static "images/dot.png" %}" alt="DOT QR code">
|
||||
</a>
|
||||
<small>13gCScunZLdXDkYKuaDi8QcsTT5nEEgxpiyYLTSHc6iCNW3b</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDonateTab(event, tabId) {
|
||||
document.querySelectorAll(".donate-selector-item").forEach(item => {
|
||||
item.classList.remove("donate-selector-item-active");
|
||||
});
|
||||
event.target.classList.add("donate-selector-item-active");
|
||||
|
||||
document.querySelectorAll(".donate-amount").forEach(item => {
|
||||
item.classList.remove("donate-amount-active");
|
||||
});
|
||||
|
||||
let tab = document.querySelector(tabId);
|
||||
tab.classList.add("donate-amount-active");
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -0,0 +1,144 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Вастрик.ру</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<style type="text/css">
|
||||
/* CLIENT-SPECIFIC STYLES */
|
||||
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
||||
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { -ms-interpolation-mode: bicubic; }
|
||||
|
||||
/* RESET STYLES */
|
||||
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
||||
table { border-collapse: collapse !important; }
|
||||
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
||||
|
||||
/* iOS BLUE LINKS */
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
/* GMAIL BLUE LINKS */
|
||||
u + #body a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* SAMSUNG MAIL BLUE LINKS */
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
|
||||
/* These rules adjust styles for desktop devices, keeping the email responsive for users. */
|
||||
/* Some email clients don't properly apply media query-based styles, which is why we go mobile-first. */
|
||||
@media screen and (min-width:600px) {
|
||||
h1 { font-size: 48px !important; line-height: 48px !important; }
|
||||
.intro { font-size: 24px !important; line-height: 36px !important; }
|
||||
}
|
||||
|
||||
/* CUSTOM STYLES */
|
||||
a {
|
||||
color: #4682b4;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
main img {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
font-size: 20px;
|
||||
padding: 10px 20px;
|
||||
border: solid 2px #333;
|
||||
background-color: #333;
|
||||
color: #FFF;
|
||||
border-radius: 5px;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #FFF !important;
|
||||
color: #333 !important;;
|
||||
}
|
||||
|
||||
.button-big {
|
||||
font-size: 32px;
|
||||
padding: 15px 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0 !important; padding: 0 !important;">
|
||||
|
||||
<!-- Some preview text. -->
|
||||
<div style="display: none; max-height: 0; overflow: hidden;">
|
||||
{% block preview %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- This ghost table is used to constrain the width in Outlook. The role attribute is set to presentation to prevent it from being read by screenreaders. -->
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
<table cellspacing="0" cellpadding="0" border="0" width="600" align="center" role="presentation"><tr><td>
|
||||
<![endif]-->
|
||||
<!-- The role and aria-label attributes are added to wrap the email content as an article for screen readers. Some of them will read out the aria-label as the title of the document, so use something like "An email from Your Brand Name" to make it recognizable. -->
|
||||
<!-- Default styling of text is applied to the wrapper div. Be sure to use text that is large enough and has a high contrast with the background color for people with visual impairments. -->
|
||||
<div role="article" aria-label="An email from Your Brand Name" lang="en" style="background-color: white; color: #2b2b2b; font-family: 'Avenir Next', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 18px; font-weight: 400; line-height: 28px; margin: 0 auto; max-width: 600px; padding: 40px 20px 40px 20px;">
|
||||
|
||||
<header>
|
||||
<a href="https://vas3k.blog">
|
||||
<center><img src="https://vas3k.ru/static/images/favicon_128.png" alt="" height="80" width="80"></center>
|
||||
</a>
|
||||
<h1 style="color: #000000; font-size: 32px; font-weight: 800; line-height: 32px; margin: 48px 0; text-align: center;">
|
||||
{% block title %}{% endblock %}<br>
|
||||
<span style="font-size: 24px; font-weight: 600;">{% block subtitle %}{% endblock %}</span>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<!-- Main content section. Main is a useful landmark element. -->
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<br><br><br><br>
|
||||
|
||||
<!-- Footer information. Footer is a useful landmark element. -->
|
||||
<footer>
|
||||
<div style="border-top: solid 1px #EEEEEE; padding: 20px 0 0 0; font-size: 12px; opacity: 0.6;">
|
||||
<a href="https://t.me/vas3k_channel">Телеграм</a> ·
|
||||
<a href="https://twitter.com/vas3k">Твиттер</a> ·
|
||||
<a href="https://www.patreon.com/vas3k">Патреон</a>
|
||||
|
||||
{% block footer %}{% endblock %}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<!--[if (gte mso 9)|(IE)]>
|
||||
</td></tr></table>
|
||||
<![endif]-->
|
||||
</body>
|
||||
</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 %}
|
||||
<p>
|
||||
Привет, Олимпийский!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Это Вастрик. У меня в блоге сегодня вышел новый пост и я, как и обещал, присылаю вам его одними из первых.
|
||||
</p>
|
||||
|
||||
<a href="https://vas3k.blog/{{ post.type }}/{{ post.slug }}/">
|
||||
<img src="{% if post.preview_image %}{{ post.preview_image }}{% else %}{{ post.image }}{% endif %}" alt="{{ post.title }}">
|
||||
</a>
|
||||
|
||||
{% if post.preview_text %}
|
||||
<p>
|
||||
{{ post.preview_text | markdown | safe }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
<a href="https://vas3k.blog/{{ post.type }}/{{ post.slug }}/" class="button button-big">
|
||||
👉 Читать пост
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br><br><br><br><br>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>
|
||||
Я стараюсь слать письма только тем, кто их хочет и читает.
|
||||
Поэтому вот вам большая кнопка, чтобы вы любой момент могли отписаться от моих писем и удалить свой e-mail из базы.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://vas3k.blog/unsubscribe/{{ subscriber.secret_hash }}/" class="button">
|
||||
☠️ Отписаться
|
||||
</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<a href="https://vas3k.blog/unsubscribe/{{ subscriber.secret_hash }}/" style="display: inline-block; float: right;">Отписаться</a>
|
||||
{% endblock %}
|
|
@ -0,0 +1,30 @@
|
|||
{% extends "emails/layout.html" %}
|
||||
|
||||
{% block title %}
|
||||
Подтверждение подписки
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<p>
|
||||
Привет!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Кто-то, возможно даже вы, хочет получать новые посты с блога <a href="https://vas3k.blog">Вастрика</a> на этот адрес.
|
||||
Нажмите сюда, чтобы подтвердить это 👇
|
||||
</p>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="https://vas3k.blog/subscribe/confirm/{{ secret_hash }}/" class="button button-big">
|
||||
👍 Да, я хочу подписаться
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br><br>
|
||||
|
||||
<p>
|
||||
Если вы не знаете о чём идет речь, значит кто-то по ошибке ввёл адрес вашей почты.
|
||||
Просто проигнорируйте это письмо или нажмите <a href="https://vas3k.blog/unsubscribe/{{ secret_hash }}/">сюда</a>, чтобы полностью стереть свой e-mail из базы.
|
||||
В любом случае, без вашего согласия по кнопке выше я ничего слать не буду.
|
||||
</p>
|
||||
{% endblock %}
|
|
@ -0,0 +1,16 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container block error">
|
||||
<div class="block-icon">❌</div>
|
||||
|
||||
{% if title %}
|
||||
<div class="block-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="block-description">
|
||||
{{ message | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block body %}
|
||||
{% for block in blocks %}
|
||||
{% include block.template %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,27 @@
|
|||
<section class="container block index-block-about">
|
||||
<div class="index-block-about-title">
|
||||
<p>
|
||||
Привет 👋 Я Вастрик!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="index-block-about-description">
|
||||
<p>
|
||||
В этом блоге я пишу о технологиях, всратом айти и выживании в творящемся вокруг киберпанке. Делаю это уже больше 10 лет.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Я <a href="https://www.linkedin.com/in/vas3k/" target="_blank">программист</a>, техлид. Живу в <a href="https://vas3k.blog/blog/go_to_berlin/" target="_blank">Берлине</a> 🇩🇪 и нежно люблю его.
|
||||
До этого жил в <a href="https://vas3k.blog/world/what_is_lithuania/" target="_blank">Литве</a> 🇱🇹, а <a href="https://vas3k.blog/world/nskflat/">рос</a> и учился и Новосибирске 🥶
|
||||
</p>
|
||||
|
||||
<p>
|
||||
У меня есть пёс, <a href="https://vas3k.blog/blog/bus_2022/" target="_blank">Бус</a> и <a href="https://vas3k.club" target="_blank">Клуб</a>.
|
||||
Ну и еще мелкие <a href="https://github.com/vas3k" target="_blank">пет-проджекты</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="index-block-about-contacts">
|
||||
{% include "common/contacts.html" %}
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,7 @@
|
|||
<section class="container">
|
||||
<div class="cards-group cards-group-1x">
|
||||
{% with post=block.post %}
|
||||
{% include "posts/cards/aspect.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,17 @@
|
|||
<section class="container">
|
||||
{% if block.title %}
|
||||
<div class="header-1">
|
||||
{% if block.url %}
|
||||
<a href="{{ block.url }}">{{ block.title }}</a>
|
||||
{% else %}
|
||||
{{ block.title }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="cards-group cards-group-2x">
|
||||
{% for post in block.posts %}
|
||||
{% include "posts/cards/horizontal.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,18 @@
|
|||
<section class="container container-width-max">
|
||||
{% if block.title %}
|
||||
<div class="header-1">
|
||||
{% if block.url %}
|
||||
<a href="{{ block.url }}">{{ block.title }}</a>
|
||||
{% else %}
|
||||
{{ block.title }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="cards-group cards-group-3x">
|
||||
{% for post in block.posts %}
|
||||
{% include "posts/cards/horizontal.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<section class="container container-width-max">
|
||||
{% if block.title %}
|
||||
<div class="header-1">
|
||||
{% if block.url %}
|
||||
<a href="{{ block.url }}">{{ block.title }}</a>
|
||||
{% else %}
|
||||
{{ block.title }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if block.subtitle %}
|
||||
<div class="header-2">
|
||||
{{ block.subtitle }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="cards-group cards-group-4x">
|
||||
{% for post in block.posts %}
|
||||
{% include "posts/cards/vertical.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,30 @@
|
|||
<section class="container">
|
||||
{% if block.title %}
|
||||
<div class="header-1">
|
||||
{{ block.title }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="cards-group cards-group-3x cards-group-projects">
|
||||
<a href="http://vas3k.club/" class="card card-post card-vertical" style="background-image: url('https://i.vas3k.ru/235b9705e449cd3c4e0d48ff41fe31196d6cbe359cdbd45e24359ac555fb9a0d.png');">
|
||||
<span class="card-info">
|
||||
<span class="card-title p-name">Вастрик.Клуб</span>
|
||||
<span class="card-subtitle p-summary">Наше уютное закрытое коммьюнити на краю большого интернета</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a href="https://howtoberlin.de/" class="card card-post card-vertical" style="background-image: url('https://i.vas3k.ru/fa05b3c8ef78a2df7d6098a42f110f83a2d4e50439134dabafb892a1a062cfdc.png');">
|
||||
<span class="card-info">
|
||||
<span class="card-title p-name">How to Berlin</span>
|
||||
<span class="card-subtitle p-summary">Помогаем переехать в Берлин и не сойти с ума</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a href="https://infomate.club/" class="card card-post card-vertical" style="background-image: url('https://i.vas3k.ru/47b9b0180d8eb1a9ee13ed45a0328a11da46661a15a4f813ebe10f106ca559a9.png');">
|
||||
<span class="card-info">
|
||||
<span class="card-title p-name">Infomate</span>
|
||||
<span class="card-subtitle p-summary">Чтобы оставаться в курсе событий и получать информацию из разных источников</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,69 @@
|
|||
{% load static %}<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>{% block title %}{{ settings.TITLE | safe }}{% endblock %}</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="description" content="{{ settings.DESCRIPTION }}">
|
||||
<meta name="keywords" content="{{ settings.KEYWORDS }}">
|
||||
<meta name="author" content="{{ settings.AUTHOR }}">
|
||||
<meta name="yandex-verification" content="5f66353ad89fbe6f">
|
||||
<meta name="google-site-verification" content="B8zgWa65q_o7zEV-YAA3rmgq4AlT-l37W-2nNbDE6pc">
|
||||
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
{% block meta %}
|
||||
<meta property="og:title" content="{{ settings.TITLE }}">
|
||||
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="{{ request.scheme }}://{{ request.get_host }}/{% static "images/favicon_128.png" %}">
|
||||
<meta property="og:description" content="{{ settings.DESCRIPTION }}">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ settings.TITLE }}">
|
||||
<meta name="twitter:description" content="{{ settings.DESCRIPTION }}">
|
||||
<meta name="twitter:image" content="{{ request.scheme }}://{{ request.get_host }}/{% static "images/favicon_128.png" %}">
|
||||
<meta name="twitter:image:src" content="{{ request.scheme }}://{{ request.get_host }}/{% static "images/favicon_128.png" %}">
|
||||
{% endblock %}
|
||||
{% include "common/rss.html" %}
|
||||
{% include "common/favicon.html" %}
|
||||
{% include "common/styles.html" %}
|
||||
{% include "common/scripts.html" %}
|
||||
{% block css %}{% endblock %}
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}" style="{% block body_styles %}{% endblock %}">
|
||||
{% block menu %}
|
||||
{% include "common/header.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<footer class="footer">
|
||||
{% block footer_contacts %}
|
||||
<div class="contacts">
|
||||
<div>Для связи →</div>
|
||||
<a href="mailto:me@vas3k.ru" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fas fa-envelope"></i>
|
||||
<span>me@vas3k.ru</span>
|
||||
</a>
|
||||
<a href="https://t.me/to_vas3k_bot" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-telegram"></i>
|
||||
<span>Телеграм-бот</span>
|
||||
</a>
|
||||
<a href="https://twitter.com/vas3k" class="contacts-item" target="_blank" rel="me">
|
||||
<i class="fab fa-twitter-square"></i>
|
||||
<span>Твиттер</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<div class="footer-disclaimer">
|
||||
Вы можете использовать цитаты из статей, картинки и скриншоты в своих блогах и презентациях, если поставите ссылку на оригинальный пост.
|
||||
Присылайте ссылки на них мне в личку, мне будет приятно посмотреть как моё странное творчество идет в народ.
|
||||
В коммерческих целях ничего использовать нельзя.
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,15 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container block message">
|
||||
<div class="block-icon">✅</div>
|
||||
|
||||
{% if title %}
|
||||
<div class="block-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="block-description">
|
||||
{{ message | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -0,0 +1,18 @@
|
|||
{% load text_filters %}
|
||||
{% load posts %}
|
||||
<a href="{{ post.get_absolute_url }}" class="card card-post card-horizontal h-entry" style="background-image: url('{{ post.main_image }}');">
|
||||
<img src="{{ post.main_image }}" alt="" class="card-stretch-img u-photo">
|
||||
{% if post.is_members_only %}
|
||||
<span class="card-icon-lock">
|
||||
{% if me %}<i class="fas fa-lock-open"></i>{% else %}<i class="fas fa-lock"></i>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="card-views"><i class="fas fa-eye"></i> {{ post.view_count|cool_number }}</span>
|
||||
<span class="card-info">
|
||||
<span class="card-title p-name" {% if post.data.headline_title_size %}style="font-size: {{ post.data.headline_title_size }};"{% endif %}>{{ post.title }}</span>
|
||||
{% if post.subtitle %}
|
||||
<span class="clearfix10"></span>
|
||||
<span class="card-subtitle p-summary">{{ post.subtitle }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
|
@ -0,0 +1,17 @@
|
|||
{% load text_filters %}
|
||||
{% load posts %}
|
||||
<a href="{{ post.get_absolute_url }}" class="card card-post card-horizontal h-entry" style="background-image: url('{{ post.main_image }}');">
|
||||
{% if post.is_members_only %}
|
||||
<span class="card-icon-lock">
|
||||
{% if me %}<i class="fas fa-lock-open"></i>{% else %}<i class="fas fa-lock"></i>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="card-views"><i class="fas fa-eye"></i> {{ post.view_count|cool_number }}</span>
|
||||
<span class="card-info">
|
||||
<span class="card-title p-name" {% if post.data.headline_title_size %}style="font-size: {{ post.data.headline_title_size }};"{% endif %}>{{ post.title }}</span>
|
||||
{% if post.subtitle %}
|
||||
<span class="clearfix10"></span>
|
||||
<span class="card-subtitle p-summary">{{ post.subtitle }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue