This commit is contained in:
vas3k
2020-01-05 12:22:20 +01:00
parent b36864fced
commit 154aab0da3
50 changed files with 2403 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
*.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
# Mr Developer
.mr.developer.cfg
# IDEA
.idea
# Mac OS X
.DS_Store
private_settings.py
local_settings.py
media/images
media/i

91
boards.yml Normal file
View File

@@ -0,0 +1,91 @@
boards:
- blocks:
- feeds:
- columns: 3
name: Hacker News
rss: https://news.ycombinator.com/rss
url: https://news.ycombinator.com
- name: 'Medium: Technology'
rss: https://medium.com/feed/topic/technology
url: https://medium.com/topic/technology
- icon: https://assets.producthunt.com/assets/ph-ios-icon-e1733530a1bfc41080db8161823f1ef262cdbbc933800c0a2a706f70eb9c277a.png
name: Product Hunt
rss: https://www.producthunt.com/feed
url: https://www.producthunt.com
- feeds:
- name: 'Reddit: /r/technology/'
rss: https://www.reddit.com/r/technology.rss
url: https://www.reddit.com/r/technology
- name: ZDNet
rss: https://www.zdnet.com/news/rss.xml
url: https://www.zdnet.com
- name: TechCrunch
rss: http://feeds.feedburner.com/TechCrunch/
url: https://techcrunch.com
- name: The Verge
rss: https://www.theverge.com/rss/index.xml
url: https://www.theverge.com
- name: The Next Web
rss: http://feeds2.feedburner.com/thenextweb
url: https://thenextweb.com
- name: Wired
rss: https://www.wired.com/feed/rss
url: https://www.wired.com
- name: ArsTechnica
rss: http://feeds.arstechnica.com/arstechnica/index/
url: https://arstechnica.com
- name: Slashdot
rss: http://rss.slashdot.org/Slashdot/slashdotMain
url: https://slashdot.org
- name: Engadget
rss: https://www.engadget.com/rss.xml
url: https://www.engadget.com
- name: MIT Technology Review
rss: https://www.technologyreview.com/topnews.rss
url: https://www.technologyreview.com
name: Tech News
- feeds:
- name: Show HN
rss: https://hnrss.org/show
url: https://news.ycombinator.com/show
- name: Starter Story
rss: https://www.starterstory.com/feed?format=rss
url: https://www.starterstory.com
- name: 'Reddit: /r/SideProject'
rss: https://www.reddit.com/r/SideProject.rss
url: https://www.reddit.com/r/SideProject/
name: Make
- feeds:
- name: PeritoBurrito
rss: http://perito-burrito.com/feed
url: https://perito-burrito.com
- name: Vandrouki
rss: https://feeds.feedburner.com/vandroukiru
url: https://vandrouki.ru
- name: Secret Flying
rss: https://www.secretflying.com/feed/
url: https://www.secretflying.com
- name: 'Atlas Obscura: Stories'
rss: https://www.atlasobscura.com/feeds/latest
url: https://www.atlasobscura.com/articles
name: Travel
- feeds:
- name: PetaPixel
rss: https://feedproxy.google.com/PetaPixel
url: https://petapixel.com
- name: DPReview
rss: https://www.dpreview.com/feeds/reviews.xml
url: https://www.dpreview.com
- name: 500px ISO
rss: https://iso.500px.com/feed/
url: https://iso.500px.com
name: Photo
curator:
avatar: https://i.vas3k.ru/eb8.png
bio: Пишу в блог, пишу код, отвратительно путешествую
name: Вастрик
title: О технологиях
footer: Кек
url: https://vas3k.ru
name: Вастрик
slug: vas3k

0
boards/__init__.py Normal file
View File

5
boards/apps.py Normal file
View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class BoardsConfig(AppConfig):
name = 'boards'

View File

@@ -0,0 +1,7 @@
from django.conf import settings
def settings_processor(request):
return {
"settings": settings
}

6
boards/icons.py Normal file
View File

@@ -0,0 +1,6 @@
DOMAIN_ICONS = {
"youtube.com": "fa:fab fa-youtube",
"youtu.be": "fa:fab fa-youtube",
"reddit.com": "fa:fab fa-reddit-alien",
"github.com": "fa:fab fa-github",
}

View File

@@ -0,0 +1,72 @@
# Generated by Django 2.2.8 on 2019-12-14 20:55
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Board',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('slug', models.SlugField(unique=True)),
('name', models.CharField(db_index=True, max_length=120)),
('avatar', models.URLField(null=True)),
('curator_name', models.CharField(max_length=120)),
('curator_url', models.URLField(null=True)),
('curator_bio', models.CharField(max_length=120, null=True)),
('schema', models.TextField(null=True)),
('created_at', models.DateTimeField(db_index=True)),
('updated_at', models.DateTimeField()),
('refreshed_at', models.DateTimeField()),
('frequency', models.FloatField(default=0.0)),
],
options={
'db_table': 'boards',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='BoardFeed',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('url', models.URLField()),
('created_at', models.DateTimeField(db_index=True)),
('refreshed_at', models.DateTimeField()),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feeds', to='boards.Board')),
],
options={
'db_table': 'board_feeds',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Article',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('url', models.URLField(db_index=True)),
('type', models.CharField(max_length=16)),
('domain', models.CharField(max_length=256)),
('title', models.CharField(max_length=256)),
('image', models.URLField(null=True)),
('description', models.TextField(null=True)),
('created_at', models.DateTimeField(db_index=True)),
('updated_at', models.DateTimeField()),
('click_count', models.PositiveIntegerField(default=0)),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='boards.Board')),
('feed', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='articles', to='boards.BoardFeed')),
],
options={
'db_table': 'articles',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 2.2.8 on 2019-12-14 20:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0001_initial'),
]
operations = [
migrations.RemoveField(
model_name='article',
name='click_count',
),
migrations.AlterField(
model_name='article',
name='created_at',
field=models.DateTimeField(auto_created=True, db_index=True),
),
migrations.AlterField(
model_name='board',
name='created_at',
field=models.DateTimeField(auto_created=True, db_index=True),
),
migrations.AlterField(
model_name='boardfeed',
name='created_at',
field=models.DateTimeField(auto_created=True, db_index=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2.8 on 2019-12-14 21:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0002_auto_20191214_2056'),
]
operations = [
migrations.AlterField(
model_name='board',
name='created_at',
field=models.DateTimeField(db_index=True),
),
migrations.AlterField(
model_name='board',
name='refreshed_at',
field=models.DateTimeField(null=True),
),
]

View File

@@ -0,0 +1,61 @@
# Generated by Django 2.2.8 on 2019-12-14 22:51
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('boards', '0003_auto_20191214_2100'),
]
operations = [
migrations.AddField(
model_name='boardfeed',
name='index',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='boardfeed',
name='name',
field=models.CharField(default='', max_length=512),
preserve_default=False,
),
migrations.AlterField(
model_name='article',
name='created_at',
field=models.DateTimeField(db_index=True),
),
migrations.AlterField(
model_name='boardfeed',
name='created_at',
field=models.DateTimeField(db_index=True),
),
migrations.AlterField(
model_name='boardfeed',
name='refreshed_at',
field=models.DateTimeField(null=True),
),
migrations.CreateModel(
name='BoardBlock',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=512, null=True)),
('created_at', models.DateTimeField(db_index=True)),
('index', models.PositiveIntegerField(default=0)),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blocks', to='boards.Board')),
],
options={
'db_table': 'board_blocks',
'ordering': ['index'],
},
),
migrations.AddField(
model_name='boardfeed',
name='block',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='feeds', to='boards.BoardBlock'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 2.2.8 on 2019-12-14 23:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0004_auto_20191214_2251'),
]
operations = [
migrations.RenameField(
model_name='boardfeed',
old_name='url',
new_name='rss_url',
),
migrations.AddField(
model_name='boardfeed',
name='web_url',
field=models.URLField(default='nope'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2.8 on 2019-12-14 23:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('boards', '0005_auto_20191214_2318'),
]
operations = [
migrations.RenameField(
model_name='boardfeed',
old_name='rss_url',
new_name='rss',
),
migrations.RenameField(
model_name='boardfeed',
old_name='web_url',
new_name='url',
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 2.2.8 on 2019-12-15 11:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0006_auto_20191214_2332'),
]
operations = [
migrations.RemoveField(
model_name='board',
name='frequency',
),
migrations.AddField(
model_name='boardfeed',
name='frequency',
field=models.FloatField(default=0.0),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.8 on 2019-12-15 12:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0007_auto_20191215_1143'),
]
operations = [
migrations.AddField(
model_name='boardfeed',
name='icon',
field=models.URLField(null=True),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.2.8 on 2020-01-04 13:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0008_boardfeed_icon'),
]
operations = [
migrations.AlterModelOptions(
name='boardfeed',
options={'ordering': ['index']},
),
migrations.AddField(
model_name='board',
name='is_visible',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='boardfeed',
name='last_article_at',
field=models.DateTimeField(null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2.8 on 2020-01-04 13:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0009_auto_20200104_1321'),
]
operations = [
migrations.AddField(
model_name='boardfeed',
name='articles_per_column',
field=models.SmallIntegerField(default=15),
),
migrations.AddField(
model_name='boardfeed',
name='columns',
field=models.SmallIntegerField(default=1),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 2.2.8 on 2020-01-04 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0010_auto_20200104_1344'),
]
operations = [
migrations.AlterField(
model_name='article',
name='image',
field=models.URLField(max_length=512, null=True),
),
migrations.AlterField(
model_name='article',
name='url',
field=models.URLField(db_index=True, max_length=512),
),
migrations.AlterField(
model_name='board',
name='avatar',
field=models.URLField(max_length=512, null=True),
),
migrations.AlterField(
model_name='boardfeed',
name='icon',
field=models.URLField(max_length=512, null=True),
),
migrations.AlterField(
model_name='boardfeed',
name='rss',
field=models.URLField(max_length=512),
),
migrations.AlterField(
model_name='boardfeed',
name='url',
field=models.URLField(max_length=512),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 2.2.8 on 2020-01-04 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0011_auto_20200104_1348'),
]
operations = [
migrations.AddField(
model_name='article',
name='uniq_id',
field=models.TextField(db_index=True, default='uniq'),
preserve_default=False,
),
migrations.AlterField(
model_name='article',
name='url',
field=models.URLField(max_length=2048),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.8 on 2020-01-04 14:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0012_auto_20200104_1404'),
]
operations = [
migrations.AlterField(
model_name='article',
name='domain',
field=models.CharField(max_length=256, null=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2.8 on 2020-01-05 09:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0013_auto_20200104_1411'),
]
operations = [
migrations.AddField(
model_name='board',
name='curator_title',
field=models.CharField(default='', max_length=120),
preserve_default=False,
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.8 on 2020-01-05 11:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0014_board_curator_title'),
]
operations = [
migrations.AddField(
model_name='board',
name='curator_footer',
field=models.TextField(null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.8 on 2020-01-05 11:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boards', '0015_board_curator_footer'),
]
operations = [
migrations.AddField(
model_name='boardfeed',
name='comment',
field=models.TextField(null=True),
),
]

View File

172
boards/models.py Normal file
View File

@@ -0,0 +1,172 @@
import uuid
from datetime import datetime, timedelta
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.db import models
from slugify import slugify
from boards.icons import DOMAIN_ICONS
class Board(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
slug = models.SlugField(unique=True)
name = models.CharField(max_length=120, db_index=True)
avatar = models.URLField(max_length=512, null=True)
curator_name = models.CharField(max_length=120)
curator_title = models.CharField(max_length=120)
curator_url = models.URLField(null=True)
curator_bio = models.CharField(max_length=120, null=True)
curator_footer = models.TextField(null=True)
schema = models.TextField(null=True)
created_at = models.DateTimeField(db_index=True)
updated_at = models.DateTimeField()
refreshed_at = models.DateTimeField(null=True)
is_visible = models.BooleanField(default=True)
class Meta:
db_table = "boards"
ordering = ["name"]
def save(self, *args, **kwargs):
if not self.created_at:
self.created_at = datetime.utcnow()
if not self.slug:
self.slug = slugify(self.name).lower()
self.updated_at = datetime.utcnow()
return super().save(*args, **kwargs)
def board_name(self):
return self.name or self.curator_name
def natural_refreshed_at(self):
if not self.refreshed_at:
return "updating right now..."
return naturaltime(self.refreshed_at)
class BoardBlock(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
board = models.ForeignKey(Board, related_name="blocks", on_delete=models.CASCADE, db_index=True)
name = models.CharField(max_length=512, null=True)
created_at = models.DateTimeField(db_index=True)
index = models.PositiveIntegerField(default=0)
class Meta:
db_table = "board_blocks"
ordering = ["index"]
def save(self, *args, **kwargs):
if not self.created_at:
self.created_at = datetime.utcnow()
return super().save(*args, **kwargs)
class BoardFeed(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
board = models.ForeignKey(Board, related_name="feeds", on_delete=models.CASCADE, db_index=True)
block = models.ForeignKey(BoardBlock, related_name="feeds", on_delete=models.CASCADE, db_index=True)
name = models.CharField(max_length=512)
comment = models.TextField(null=True)
icon = models.URLField(max_length=512, null=True)
url = models.URLField(max_length=512)
rss = models.URLField(max_length=512)
created_at = models.DateTimeField(db_index=True)
last_article_at = models.DateTimeField(null=True)
refreshed_at = models.DateTimeField(null=True)
frequency = models.FloatField(default=0.0) # per week
columns = models.SmallIntegerField(default=1)
articles_per_column = models.SmallIntegerField(default=15)
index = models.PositiveIntegerField(default=0)
class Meta:
db_table = "board_feeds"
ordering = ["index"]
def save(self, *args, **kwargs):
if not self.created_at:
self.created_at = datetime.utcnow()
self.updated_at = datetime.utcnow()
return super().save(*args, **kwargs)
def last_articles(self):
return self.articles.all()[:15 * self.columns]
def articles_by_column(self):
articles = self.articles.all()[:self.articles_per_column * self.columns]
return [
(column, articles[column * self.articles_per_column:self.articles_per_column * (column + 1)])
for column in range(self.columns)
]
def natural_last_article_at(self):
if not self.last_article_at:
return None
return naturaltime(self.last_article_at)
class Article(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
uniq_id = models.TextField(db_index=True)
board = models.ForeignKey(Board, related_name="articles", on_delete=models.CASCADE, db_index=True)
feed = models.ForeignKey(BoardFeed, related_name="articles", on_delete=models.CASCADE, db_index=True)
url = models.URLField(max_length=2048)
type = models.CharField(max_length=16)
domain = models.CharField(max_length=256, null=True)
title = models.CharField(max_length=256)
image = models.URLField(max_length=512, null=True)
description = models.TextField(null=True)
created_at = models.DateTimeField(db_index=True)
updated_at = models.DateTimeField()
class Meta:
db_table = "articles"
ordering = ["-created_at"]
def save(self, *args, **kwargs):
if not self.created_at:
self.created_at = datetime.utcnow()
self.updated_at = datetime.utcnow()
return super().save(*args, **kwargs)
def icon(self):
article_icon = DOMAIN_ICONS.get(self.domain)
if not article_icon:
return ""
if article_icon.startswith("fa:"):
return f"""<i class="{article_icon[3:]}"></i> """
return f"""<img src="{article_icon}" alt="{self.domain}" class="icon"> """
def natural_created_at(self):
if not self.created_at:
return None
return naturaltime(self.created_at)
def is_fresh(self):
frequency = self.feed.frequency
now = datetime.utcnow()
if frequency <= 1:
# low frequency feed — any post this week is new
return self.created_at > now - timedelta(days=7)
elif frequency <= 20:
# average frequency — mark today posts
return self.created_at > now - timedelta(days=1)
# high frequency - mark 6-hour old posts
return self.created_at > now - timedelta(hours=6)

View File

View File

@@ -0,0 +1,48 @@
import re
from django import template
from django.utils.html import urlize
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def pretty_url(value):
return re.sub(r"https?://(www\.)?", "", value, 1)
@register.filter
def cool_number(value, num_decimals=1):
"""
Django template filter to convert regular numbers to a cool format (ie: 2K, 434.4K, 33M...)
"""
int_value = int(value)
formatted_number = '{{:.{}f}}'.format(num_decimals)
if int_value < 1000:
return str(int_value)
elif int_value < 1000000:
return formatted_number.format(int_value / 1000.0).rstrip('0.') + 'K'
else:
return formatted_number.format(int_value / 1000000.0).rstrip('0.') + 'M'
@register.filter
def smart_urlize(value, target="_blank"):
# TODO: this
return mark_safe(urlize(value))
@register.filter
def rupluralize(value, arg="дурак,дурака,дураков"):
args = arg.split(",")
number = abs(int(value))
a = number % 10
b = number % 100
if (a == 1) and (b != 11):
return args[0]
elif (a >= 2) and (a <= 4) and ((b < 10) or (b >= 20)):
return args[1]
else:
return args[2]

21
boards/views.py Normal file
View File

@@ -0,0 +1,21 @@
from django.shortcuts import render, get_object_or_404
from boards.models import Board, BoardBlock, BoardFeed, Article
def index(request):
boards = Board.objects.filter(is_visible=True).order_by("created_at")
return render(request, "index.html", {
"boards": boards
})
def board(request, board_slug):
board = get_object_or_404(Board, slug=board_slug)
blocks = BoardBlock.objects.filter(board=board)
feeds = BoardFeed.objects.filter(board=board)
return render(request, "board.html", {
"board": board,
"blocks": blocks,
"feeds": feeds,
})

BIN
db.sqlite3 Normal file

Binary file not shown.

0
infomate/__init__.py Normal file
View File

16
infomate/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for infomate project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'infomate.settings')
application = get_asgi_application()

105
infomate/settings.py Normal file
View File

@@ -0,0 +1,105 @@
import os
from datetime import timedelta
from random import random
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
DEBUG = True
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = "wow so secret"
ALLOWED_HOSTS = ["127.0.0.1", "vas3k.ru"]
INSTALLED_APPS = [
"django.contrib.staticfiles",
"django.contrib.humanize",
"boards",
]
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
]
ROOT_URLCONF = "infomate.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"boards.context_processors.settings_processor",
],
},
},
]
WSGI_APPLICATION = "infomate.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": "infomate",
"USER": "postgres", # redefined in private_settings.py
"PASSWORD": "",
"HOST": "127.0.0.1",
"PORT": "5432",
}
}
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "ru-ru"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = False
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "static"),
)
STATIC_URL = "/static/"
CSS_HASH = str(random())
# App settings
APP_NAME = "Infomate"
APP_TITLE = "Читай то, что читают другие"
APP_DESCRIPTION = ""
SENTRY_DSN = None
try:
# poor mans' private settings
from .private_settings import *
except ImportError:
pass
if SENTRY_DSN and not DEBUG:
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration()]
)

24
infomate/urls.py Normal file
View File

@@ -0,0 +1,24 @@
"""infomate URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from boards.views import index, board
urlpatterns = [
path("", index, name="index"),
path("board/<slug:board_slug>/", board, name="board"),
]

16
infomate/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for infomate project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'infomate.settings')
application = get_wsgi_application()

21
manage.py Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'infomate.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
Django==2.2.8
click==7.0
awesome-slugify>=1.6.5
requests==2.22.0
beautifulsoup4==4.6.2
pyyaml==5.2
feedparser==5.2.1
sentry-sdk==0.13.0

184
scripts/initialize.py Normal file
View File

@@ -0,0 +1,184 @@
import os
import sys
import django
BASE_DIR = os.path.join(os.path.dirname(__file__), "..")
sys.path.append(BASE_DIR)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "infomate.settings")
django.setup()
from urllib.parse import urljoin
import click
import requests
import yaml
import feedparser
from bs4 import BeautifulSoup
from boards.models import Board, BoardFeed, BoardBlock
DEFAULT_REQUEST_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36"
}
@click.command()
@click.option('--config', default="boards.yml", help="Boards YAML file")
def initialize(config):
yaml_file = os.path.join(BASE_DIR, config)
with open(yaml_file) as f:
try:
config = yaml.load(f.read(), Loader=yaml.FullLoader)
except yaml.YAMLError as ex:
print(f"Bad YAML file '{yaml_file}': {ex}")
exit(1)
for board_config in config["boards"]:
board_name = board_config.get("name") or board_config["slug"]
print(f"Creating board: {board_name}...")
board, is_created = Board.objects.get_or_create(
slug=board_config["slug"],
defaults=dict(
name=board_name,
avatar=board_config["curator"].get("avatar"),
curator_name=board_config["curator"].get("name"),
curator_title=board_config["curator"].get("title"),
curator_footer=board_config["curator"].get("footer"),
curator_bio=board_config["curator"].get("bio"),
curator_url=board_config["curator"].get("url"),
)
)
if not is_created:
# update existing values
board.name = board_config.get("name") or board_config["slug"]
board.avatar = board_config["curator"].get("avatar")
board.curator_name = board_config["curator"].get("name")
board.curator_title = board_config["curator"].get("title")
board.curator_footer = board_config["curator"].get("footer")
board.curator_bio = board_config["curator"].get("bio")
board.curator_url = board_config["curator"].get("url")
board.save()
for block_index, block_config in enumerate(board_config["blocks"]):
block_name = block_config.get("name") or ""
print(f"\nCreating block: {block_name}...")
block, is_created = BoardBlock.objects.get_or_create(
board=board,
name=block_name,
default=dict(
index=block_index
)
)
if not is_created:
block.index = block_index
block.save()
if not block_config.get("feeds"):
continue
for feed_index, feed_config in enumerate(block_config["feeds"]):
feed_name = feed_config.get("name") or ""
feed_url = feed_config["url"]
print(f"Creating feed: {feed_name}...")
html = load_page_html(feed_url)
icon = feed_config.get("icon")
if not icon:
icon = find_favicon(feed_url, html)
print(f"- found favicon: {icon}")
rss_url = feed_config.get("rss")
if not rss_url:
rss_url = find_rss_feed(feed_url, html)
if not rss_url:
print(f"RSS feed for '{feed_name}' not found. Please specify 'rss' key.")
exit(1)
print(f"- found RSS: {rss_url}")
feed_config["rss"] = rss_url
feed, is_created = BoardFeed.objects.get_or_create(
board=board,
block=block,
url=feed_config["url"],
defaults=dict(
rss=rss_url,
name=feed_name,
comment=feed_config.get("comment"),
icon=icon,
index=feed_index,
columns=feed_config.get("columns") or 1,
)
)
if not is_created:
feed.name = feed_name
feed.comment = feed_config.get("comment")
feed.rss = rss_url
feed.icon = icon
feed.index = feed_index
feed.columns = feed_config.get("columns") or 1
feed.save()
with open(yaml_file, "w") as f:
yaml.dump(config, f, default_flow_style=False, encoding="utf-8", allow_unicode=True)
print("Done ✅")
def load_page_html(url):
return requests.get(
url=url,
headers=DEFAULT_REQUEST_HEADERS,
allow_redirects=True,
timeout=30
).text
def find_rss_feed(url, html):
bs = BeautifulSoup(html, features="lxml")
possible_feeds = set()
feed_urls = bs.findAll("link", rel="alternate")
for feed_url in feed_urls:
t = feed_url.get("type", None)
if t:
if "rss" in t or "xml" in t:
href = feed_url.get("href", None)
if href:
possible_feeds.add(urljoin(url, href))
a_tags = bs.findAll("a")
for a in a_tags:
href = a.get("href", None)
if href:
if "xml" in href or "rss" in href or "feed" in href:
possible_feeds.add(urljoin(url, href))
for feed_url in possible_feeds:
feed = feedparser.parse(feed_url)
if feed.entries:
return feed_url
return None
def find_favicon(url, html):
bs = BeautifulSoup(html, features="lxml")
link_tags = bs.findAll("link")
for link_tag in link_tags:
rel = link_tag.get("rel", None)
if rel and "icon" in rel:
href = link_tag.get("href", None)
if href:
return urljoin(url, href)
return None
if __name__ == '__main__':
initialize()

163
scripts/update.py Normal file
View File

@@ -0,0 +1,163 @@
import os
import sys
import django
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "infomate.settings")
django.setup()
import re
from datetime import timedelta, datetime
from urllib.parse import urlparse
from time import mktime
import threading
import queue
import requests
import click
import feedparser
from bs4 import BeautifulSoup
from boards.models import BoardFeed, Article, Board
DEFAULT_NUM_WORKER_THREADS = 5
DEFAULT_ENTRIES_LIMIT = 100
REFRESH_DELTA = timedelta(hours=1)
queue = queue.Queue()
@click.command()
@click.option('--num-workers', default=DEFAULT_NUM_WORKER_THREADS, help="Number of parser threads")
@click.option('--force', is_flag=True, help="Force to update all existing feeds")
def update(num_workers, force):
never_updated_feeds = BoardFeed.objects.filter(refreshed_at__isnull=True)
if not force:
need_to_update_feeds = BoardFeed.objects.filter(refreshed_at__lte=datetime.utcnow() - REFRESH_DELTA)
else:
need_to_update_feeds = BoardFeed.objects.all()
tasks = []
for feed in list(never_updated_feeds) + list(need_to_update_feeds):
tasks.append({
"id": feed.id,
"board_id": feed.board_id,
"name": feed.name,
"rss": feed.rss
})
threads = []
for i in range(num_workers):
t = threading.Thread(target=worker)
t.start()
threads.append(t)
# put tasks to the queue
for item in tasks:
queue.put(item)
# wait until tasks are done
queue.join()
# update timestamps
Board.objects.all().update(refreshed_at=datetime.utcnow())
# stop workers
for i in range(num_workers):
queue.put(None)
for t in threads:
t.join()
def worker():
while True:
task = queue.get()
if task is None:
break
refresh_feed(task)
queue.task_done()
def refresh_feed(item):
print(f"Updating feed {item['name']}...")
feed = feedparser.parse(item['rss'])
for entry in feed.entries[:DEFAULT_ENTRIES_LIMIT]:
print(f"- article: '{entry.title}' {entry.link}")
article, is_created = Article.objects.get_or_create(
board_id=item["board_id"],
feed_id=item["id"],
uniq_id=entry.id if hasattr(entry, "id") else entry.link,
defaults=dict(
url=entry.link,
created_at=parse_datetime(entry),
updated_at=datetime.utcnow(),
title=entry.title[:256]
)
)
if is_created:
# parse heavy info
real_url = resolve_real_url(entry)
summary, lead_image = parse_text_and_lead_image(entry)
article.url = real_url[:2000]
article.domain = parse_domain(real_url)[:256]
article.description = summary[:1000]
article.image = lead_image[:512]
article.save()
week_ago = datetime.utcnow() - timedelta(days=7)
frequency = Article.objects.filter(feed_id=item["id"], created_at__gte=week_ago).count()
last_article = Article.objects.filter(feed_id=item["id"]).order_by("-created_at").first()
BoardFeed.objects.filter(id=item["id"]).update(
refreshed_at=datetime.utcnow(),
last_article_at=last_article.created_at if last_article else None,
frequency=frequency or 0
)
def resolve_real_url(entry):
url = entry.link
depth = 10
while depth > 0:
depth -= 1
r = requests.head(url)
if 300 < r.status_code < 400:
url = r.headers["location"]
else:
break
return url
def parse_domain(url):
domain = urlparse(url).netloc
if domain.startswith("www."):
domain = domain[4:]
return domain
def parse_datetime(entry):
published_time = entry.get("published_parsed") or entry.get("updated_parsed")
if published_time:
return datetime.fromtimestamp(mktime(published_time))
return datetime.utcnow()
def parse_text_and_lead_image(entry):
bs = BeautifulSoup(entry.summary, features="lxml")
text = re.sub(r"\s\s+", " ", bs.text or "").strip()
img_tags = bs.findAll("img")
for img_tag in img_tags:
src = img_tag.get("src", None)
if src:
return text, src
return text, ""
if __name__ == '__main__':
update()

95
static/css/base.css Normal file
View File

@@ -0,0 +1,95 @@
/* Buttons, switches and other reusable elements */
.avatar {
background-position: 50% 50%;
background-size: cover;
border-radius: 50%;
min-height: 20px;
min-width: 20px;
background-color: #e3e3e3;
box-sizing: border-box;
}
.icon {
width: 14px;
height: 14px;
vertical-align: middle;
opacity: 0.8;
}
.theme-switcher {
display: inline-block;
height: 34px;
position: relative;
width: 60px;
}
.theme-switcher input {
display: none;
}
.theme-switcher .slider {
display: block;
background-color: #000;
bottom: 0;
cursor: pointer;
left: 0;
position: absolute;
right: 0;
top: 0;
transition: .4s;
}
.theme-switcher .slider:before {
background-color: #fff;
bottom: 4px;
content: "";
height: 26px;
left: 4px;
position: absolute;
transition: .4s;
width: 26px;
}
.theme-switcher .slider:after {
content: "🌙";
display: block;
position: absolute;
top: 12px;
right: 5px;
font-size: 13px;
line-height: 13px;
}
.theme-switcher input:checked + .slider:after {
content: "☀️";
right: auto;
left: 7px;
font-size: 16px;
}
.theme-switcher input:checked + .slider {
}
.theme-switcher input:checked + .slider:before {
transform: translateX(26px);
}
.theme-switcher .slider.round {
border-radius: 34px;
}
.theme-switcher .slider.round:before {
border-radius: 50%;
}
@media only screen and (max-width : 570px) {
.hide-on-iphone {
display: none;
}
}
@media only screen and (max-width : 1024px) {
.hide-on-ipad {
display: none;
}
}

251
static/css/components.css Normal file
View File

@@ -0,0 +1,251 @@
/* Main styles */
.menu {
font-size: 29px;
line-height: 70px;
height: 70px;
}
.menu-logo {
text-align: center;
}
.menu-theme-switcher {
text-align: center;
padding: 5px;
box-sizing: border-box;
}
.menu a {
text-decoration: none;
color: var(--link-color);
}
.landing-top {
text-align: center;
min-height: 100px;
}
.landing-boards {
display: flex;
flex-wrap: wrap;
justify-content: center;
font-size: 18px;
}
.landing-boards a {
color: var(--link-color);
}
.landing-board {
display: block;
text-decoration: none;
width: 250px;
height: 300px;
text-align: center;
margin: 30px;
border-radius: 30px;
padding: 40px 20px;
}
.landing-board:hover {
background-color: var(--opposite-bg-color);
color: var(--opposite-text-color);
}
.landing-board-avatar {
display: inline-block;
width: 200px;
height: 200px;
}
.landing-board-name {
display: block;
font-size: 160%;
margin: 15px;
font-weight: bold;
}
.landing-board-title {
display: block;
margin-top: 10px;
}
.header {
/*background-image: linear-gradient(285.46deg, rgb(0, 0, 0) 3.26%, rgb(33, 35, 49) 93.52%);*/
}
.curator {
max-width: 800px;
padding: 0 20px;
box-sizing: border-box;
}
.curator-avatar {
width: 150px;
height: 150px;
}
.curator-info {
margin-left: 20px;
line-height: 1.5em;
}
.curator-name {
font-size: 210%;
font-weight: 600;
padding-top: 10px;
}
.curator-url {
font-size: 120%;
padding-top: 10px;
}
.curator-bio {
font-size: 120%;
padding-top: 20px;
}
.curator-info a {
color: var(--link-color);
}
.board-empty {
padding: 100px 20px;
text-align: center;
}
.block {
margin-bottom: 100px;
}
.block-header {
font-size: 180%;
text-align: center;
border-bottom: solid 2px var(--text-color);
min-height: 40px;
}
.is-block-header-dummy {
border: none;
height: 0;
}
.feed {
padding: 30px 20px 0;
}
.feed-title {
margin-bottom: 20px;
line-height: 1em;
}
.feed-title-hidden {
visibility: hidden;
}
.feed-title img {
width: 32px;
height: 32px;
margin-right: 10px;
vertical-align: middle;
float: left;
}
.feed-title a {
font-size: 130%;
color: var(--link-color);
}
.feed-title small {
font-size: 70%;
opacity: 0.7;
line-height: 200%;
}
.feed-articles {
margin-bottom: 40px;
}
.article {
position: relative;
margin-bottom: 8px;
height: 20px;
}
.article-title {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.article-tooltip {
display: none;
position: absolute;
left: 20px;
padding: 20px;
box-sizing: border-box;
border-radius: 20px;
background-color: var(--opposite-bg-color);
color: var(--opposite-text-color) !important;
z-index: 999;
text-decoration: none;
}
.article:hover > .article-tooltip {
display: block;
}
.article-tooltip-image {
display: block;
max-width: 100%;
max-height: 200px;
margin: 0 auto 20px;
}
.article-tooltip-title {
display: block;
font-weight: bold;
font-size: 90%;
}
.article-tooltip-description {
display: block;
font-weight: normal;
font-size: 70%;
}
.article-tooltip-info {
display: block;
font-weight: normal;
font-size: 70%;
}
.is-article-fresh {
font-weight: 600;
}
.board-footer {
max-width: 500px;
margin: 0 auto;
text-align: center;
padding: 20px;
font-size: 140%;
line-height: 140%;
}
.board-footer .big {
font-size: 200%;
}
.footer {
text-align: center;
padding: 60px;
}
.footer a {
color: var(--link-color);
}

76
static/css/layout.css Normal file
View File

@@ -0,0 +1,76 @@
/* Layouts and grids without any styles */
:root {
--max-content-width: 1300px;
}
.menu {
display: grid;
grid-template-columns: 170px auto 100px;
max-width: var(--max-content-width);
margin: 0 auto;
}
.landing-top {
max-width: var(--max-content-width);
margin: 0 auto;
}
.landing-boards {
max-width: var(--max-content-width);
margin: 0 auto;
}
.header {
display: grid;
min-height: 250px;
}
.curator {
display: grid;
grid-template-columns: 150px auto;
place-self: center;
}
.board {
display: block;
max-width: var(--max-content-width);
margin: 0 auto;
}
.block {
display: grid;
grid-template-columns: 33% 33% 33%;
grid-template-rows: auto auto;
}
.block-header {
grid-column-start: 1;
grid-column-end: 4;
}
@media only screen and (max-width : 570px) {
.block {
grid-template-columns: 100%;
}
.block-header {
grid-column-start: 1;
grid-column-end: 2;
}
}
@media only screen and (max-width : 1024px) {
.block {
grid-template-columns: 50% 50%;
}
.block-header {
grid-column-start: 1;
grid-column-end: 3;
}
}
.footer {
display: block;
}

349
static/css/normalize.css vendored Normal file
View File

@@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

41
static/css/theme.css Normal file
View File

@@ -0,0 +1,41 @@
body {
font-family: 'Nunito', sans-serif;
font-size: 16px;
line-height: 1.3em;
transition: all linear .2s;
}
.light-theme {
--bg-color: #FFF;
--opposite-bg-color: #282c35;
--text-color: #333;
--opposite-text-color: #DDD;
--link-color: #333;
--visited-link-color: #afafaf;
color: var(--text-color);
background-color: var(--bg-color);
}
.dark-theme {
--bg-color: #282c35;
--opposite-bg-color: #FFF;
--text-color: #DDD;
--opposite-text-color: #333;
--link-color: #DDD;
--visited-link-color: #737373;
color: var(--text-color);
background-color: var(--bg-color);
}
a {
color: var(--link-color);
transition: all linear .1s;
}
a:visited {
color: var(--visited-link-color);
}
a:hover {
color: #9b9b9b;
}

23
static/js/main.js Normal file
View File

@@ -0,0 +1,23 @@
function initializeThemeSwitcher() {
const toggleSwitch = document.querySelector('.theme-switcher input[type="checkbox"]');
function switchTheme(e) {
if (e.target.checked) {
document.body.className = "dark-theme";
localStorage.setItem("theme", "dark");
} else {
document.body.className = "light-theme";
localStorage.setItem("theme", "light");
}
}
toggleSwitch.addEventListener("change", switchTheme, false);
const theme = localStorage.getItem("theme");
if (theme === "dark") {
document.body.className = "dark-theme";
toggleSwitch.checked = true;
}
}
initializeThemeSwitcher();

90
templates/board.html Normal file
View File

@@ -0,0 +1,90 @@
{% extends "layout.html" %}
{% load text_filters %}
{% block title %}{{ board.board_name }} | {{ block.super }}{% endblock %}
{% block content %}
<div class="header">
<div class="curator">
<div class="avatar curator-avatar" style="background-image: url('{{ board.avatar }}');"></div>
<div class="curator-info">
<div class="curator-name">{{ board.curator_name }}</div>
{% if board.curator_url %}
<div class="curator-url">
👉 <a href="{{ board.curator_url }}" target="_blank">{{ board.curator_url|pretty_url }}</a>
</div>
{% endif %}
{% if board.curator_bio %}
<div class="curator-bio">{{ board.curator_bio|smart_urlize|safe }}</div>
{% endif %}
</div>
</div>
</div>
<div class="board">
{% if not blocks %}
<div class="board-empty">
Здесь пока ничего нет...<br><br>
Такое бывает, когда страничка только создана или обновляется.<br>
Подождите немного и зайдите снова!
</div>
{% endif %}
{% for block in blocks %}
<div class="block">
{% if block.name %}
<div class="block-header">{{ block.name }}</div>
{% else %}
<div class="block-header is-block-header-dummy"></div>
{% endif %}
{% for feed in feeds %}
{% if feed.block == block %}
{% for column, articles in feed.articles_by_column %}
<div class="feed {% if column > 1 %}hide-on-iphone{% endif %}">
<div class="feed-title {% if column != 0 %}feed-title-hidden{% endif %}">
{% if feed.icon %}
<img src="{{ feed.icon }}" alt="{{ feed.name }}">
{% endif %}
<a href="{{ feed.url }}" target="_blank">{{ feed.name }}</a><br>
<small>последний пост {{ feed.natural_last_article_at }}</small>
</div>
<div class="articles feed-articles">
{% for article in articles %}
<div class="article {% if article.is_fresh %}is-article-fresh{% endif %}">
<div class="article-title">{{ article.icon|safe }}<a href="{{ article.url }}" target="_blank">{{ article.title }}</a></div>
<a href="{{ article.url }}" class="article-tooltip" target="_blank">
{% if article.image %}
<img src="{{ article.image }}" alt="{{ article.title }}" class="article-tooltip-image">
{% endif %}
<span class="article-tooltip-title">{{ article.title|truncatechars:100 }}</span>
{% if article.description and article.description|length > 20 %}
<span class="article-tooltip-description">
{{ article.description|truncatechars:300 }}
</span>
{% endif %}
<span class="article-tooltip-info">{{ article.natural_created_at }}</span>
</a>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% if board %}
<div class="board-footer">
{% if board.curator_footer %}
<span class="big">👆</span> {{ board.curator_footer|smart_urlize|safe }}<br>
{% else %}
<span class="big">👆</span> подборка каналов предоставлена автором<br>
{% endif %}
<br><small>Обновлено {{ board.natural_refreshed_at }}</small>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% load static %}
<link rel="stylesheet" href="/static/css/normalize.css">
<link rel="stylesheet" href="/static/css/theme.css?v={{ settings.CSS_HASH }}">
<link rel="stylesheet" href="/static/css/base.css?v={{ settings.CSS_HASH }}">
<link rel="stylesheet" href="/static/css/layout.css?v={{ settings.CSS_HASH }}">
<link rel="stylesheet" href="/static/css/components.css?v={{ settings.CSS_HASH }}">
<link href="https://fonts.googleapis.com/css?family=Nunito:400,700,900&display=swap&subset=cyrillic" rel="stylesheet">

View File

@@ -0,0 +1,7 @@
{% load staticfiles %}
<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">

2
templates/common/js.html Normal file
View File

@@ -0,0 +1,2 @@
<script src="https://kit.fontawesome.com/c29b827576.js" crossorigin="anonymous"></script>
<script src="/static/js/main.js?v={{ settings.CSS_HASH }}"></script>

View File

@@ -0,0 +1,16 @@
<meta charset="UTF-8"/>
<meta name="description" content="{{ settings.DESCRIPTION }}">
<meta name="keywords" content="{{ settings.KEYWORDS }}">
<meta name="viewport" content="{% block viewport %}width=device-width, height=device-height, initial-scale=1.0{% endblock %}">
<meta name="format-detection" content="telephone=no">
{% block meta_og %}
<meta property="og:title" content="{{ settings.NAME }}">
<meta property="og:url" content="{{ settings.DOMAIN }}">
<meta property="og:type" content="website" />
<meta property="og:description" content="{{ settings.DESCRIPTION }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ settings.NAME }}">
<meta name="twitter:description" content="{{ settings.DESCRIPTION }}">
{% endblock %}

19
templates/index.html Normal file
View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block title %}{{ block.super }} | {{ settings.APP_TITLE }}{% endblock %}
{% block content %}
<div class="landing-top">
<h2>{{ settings.APP_TITLE }}</h2>
</div>
<div class="landing-boards">
{% for board in boards %}
<a class="landing-board" href="{% url "board" board.slug %}">
<span class="avatar landing-board-avatar" style="background-image: url('{{ board.avatar }}');"></span>
<span class="landing-board-name">{{ board.curator_name }}</span>
<span class="landing-board-title">{{ board.curator_title }}</span>
</a>
{% endfor %}
</div>
{% endblock %}

39
templates/layout.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
{% load static %}
<html lang="ru">
<head>
<title>{% block title %}{{ settings.APP_NAME|safe }}{% endblock %}</title>
{% block meta %}
{% include "common/meta.html" %}
{% endblock %}
{% include "common/favicon.html" %}
{% include "common/css.html" %}
</head>
<body class="light-theme">
{% block body %}
{% block menu %}
<div class="menu">
<div class="menu-logo"><a href="{% url "index" %}">👩‍🎤&nbsp;{{ settings.APP_NAME|lower|safe }}</a></div>
<div class="menu-center"></div>
<div class="menu-theme-switcher">
<label class="theme-switcher" for="checkbox">
<input type="checkbox" id="checkbox" />
<span class="slider round"></span>
</label>
</div>
</div>
{% endblock %}
{% block content %}
{% endblock %}
{% endblock %}
{% block footer %}
<div class="footer">
Сделал <a href="https://vas3k.ru">Вастрик</a>
</div>
{% endblock %}
{% include "common/js.html" %}
</body>
</html>