diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..144c1b8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/boards.yml b/boards.yml new file mode 100644 index 0000000..de3bc0f --- /dev/null +++ b/boards.yml @@ -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 diff --git a/boards/__init__.py b/boards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/boards/apps.py b/boards/apps.py new file mode 100644 index 0000000..bef757d --- /dev/null +++ b/boards/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BoardsConfig(AppConfig): + name = 'boards' diff --git a/boards/context_processors.py b/boards/context_processors.py new file mode 100644 index 0000000..fed4934 --- /dev/null +++ b/boards/context_processors.py @@ -0,0 +1,7 @@ +from django.conf import settings + + +def settings_processor(request): + return { + "settings": settings + } diff --git a/boards/icons.py b/boards/icons.py new file mode 100644 index 0000000..d8676f4 --- /dev/null +++ b/boards/icons.py @@ -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", +} diff --git a/boards/migrations/0001_initial.py b/boards/migrations/0001_initial.py new file mode 100644 index 0000000..b122548 --- /dev/null +++ b/boards/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/boards/migrations/0002_auto_20191214_2056.py b/boards/migrations/0002_auto_20191214_2056.py new file mode 100644 index 0000000..557f15c --- /dev/null +++ b/boards/migrations/0002_auto_20191214_2056.py @@ -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), + ), + ] diff --git a/boards/migrations/0003_auto_20191214_2100.py b/boards/migrations/0003_auto_20191214_2100.py new file mode 100644 index 0000000..2fe492c --- /dev/null +++ b/boards/migrations/0003_auto_20191214_2100.py @@ -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), + ), + ] diff --git a/boards/migrations/0004_auto_20191214_2251.py b/boards/migrations/0004_auto_20191214_2251.py new file mode 100644 index 0000000..c73d1af --- /dev/null +++ b/boards/migrations/0004_auto_20191214_2251.py @@ -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, + ), + ] diff --git a/boards/migrations/0005_auto_20191214_2318.py b/boards/migrations/0005_auto_20191214_2318.py new file mode 100644 index 0000000..e36cd23 --- /dev/null +++ b/boards/migrations/0005_auto_20191214_2318.py @@ -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, + ), + ] diff --git a/boards/migrations/0006_auto_20191214_2332.py b/boards/migrations/0006_auto_20191214_2332.py new file mode 100644 index 0000000..c496756 --- /dev/null +++ b/boards/migrations/0006_auto_20191214_2332.py @@ -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', + ), + ] diff --git a/boards/migrations/0007_auto_20191215_1143.py b/boards/migrations/0007_auto_20191215_1143.py new file mode 100644 index 0000000..89b6181 --- /dev/null +++ b/boards/migrations/0007_auto_20191215_1143.py @@ -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), + ), + ] diff --git a/boards/migrations/0008_boardfeed_icon.py b/boards/migrations/0008_boardfeed_icon.py new file mode 100644 index 0000000..3d1c12f --- /dev/null +++ b/boards/migrations/0008_boardfeed_icon.py @@ -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), + ), + ] diff --git a/boards/migrations/0009_auto_20200104_1321.py b/boards/migrations/0009_auto_20200104_1321.py new file mode 100644 index 0000000..7575290 --- /dev/null +++ b/boards/migrations/0009_auto_20200104_1321.py @@ -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), + ), + ] diff --git a/boards/migrations/0010_auto_20200104_1344.py b/boards/migrations/0010_auto_20200104_1344.py new file mode 100644 index 0000000..86bdf53 --- /dev/null +++ b/boards/migrations/0010_auto_20200104_1344.py @@ -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), + ), + ] diff --git a/boards/migrations/0011_auto_20200104_1348.py b/boards/migrations/0011_auto_20200104_1348.py new file mode 100644 index 0000000..a1921c7 --- /dev/null +++ b/boards/migrations/0011_auto_20200104_1348.py @@ -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), + ), + ] diff --git a/boards/migrations/0012_auto_20200104_1404.py b/boards/migrations/0012_auto_20200104_1404.py new file mode 100644 index 0000000..0dfab18 --- /dev/null +++ b/boards/migrations/0012_auto_20200104_1404.py @@ -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), + ), + ] diff --git a/boards/migrations/0013_auto_20200104_1411.py b/boards/migrations/0013_auto_20200104_1411.py new file mode 100644 index 0000000..fce3107 --- /dev/null +++ b/boards/migrations/0013_auto_20200104_1411.py @@ -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), + ), + ] diff --git a/boards/migrations/0014_board_curator_title.py b/boards/migrations/0014_board_curator_title.py new file mode 100644 index 0000000..def1970 --- /dev/null +++ b/boards/migrations/0014_board_curator_title.py @@ -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, + ), + ] diff --git a/boards/migrations/0015_board_curator_footer.py b/boards/migrations/0015_board_curator_footer.py new file mode 100644 index 0000000..759b5ab --- /dev/null +++ b/boards/migrations/0015_board_curator_footer.py @@ -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), + ), + ] diff --git a/boards/migrations/0016_boardfeed_comment.py b/boards/migrations/0016_boardfeed_comment.py new file mode 100644 index 0000000..1255104 --- /dev/null +++ b/boards/migrations/0016_boardfeed_comment.py @@ -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), + ), + ] diff --git a/boards/migrations/__init__.py b/boards/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/boards/models.py b/boards/models.py new file mode 100644 index 0000000..bc28268 --- /dev/null +++ b/boards/models.py @@ -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""" """ + + return f"""{self.domain} """ + + 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) diff --git a/boards/templatetags/__init__.py b/boards/templatetags/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/boards/templatetags/text_filters.py b/boards/templatetags/text_filters.py new file mode 100755 index 0000000..4d9c670 --- /dev/null +++ b/boards/templatetags/text_filters.py @@ -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] diff --git a/boards/views.py b/boards/views.py new file mode 100644 index 0000000..3e034a2 --- /dev/null +++ b/boards/views.py @@ -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, + }) diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..6c895e5 Binary files /dev/null and b/db.sqlite3 differ diff --git a/infomate/__init__.py b/infomate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infomate/asgi.py b/infomate/asgi.py new file mode 100644 index 0000000..c7cf600 --- /dev/null +++ b/infomate/asgi.py @@ -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() diff --git a/infomate/settings.py b/infomate/settings.py new file mode 100644 index 0000000..5a7e01e --- /dev/null +++ b/infomate/settings.py @@ -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()] + ) diff --git a/infomate/urls.py b/infomate/urls.py new file mode 100644 index 0000000..26dc195 --- /dev/null +++ b/infomate/urls.py @@ -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//", board, name="board"), +] diff --git a/infomate/wsgi.py b/infomate/wsgi.py new file mode 100644 index 0000000..e968c0c --- /dev/null +++ b/infomate/wsgi.py @@ -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() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..755cd21 --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f52d08 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/initialize.py b/scripts/initialize.py new file mode 100644 index 0000000..7aa8117 --- /dev/null +++ b/scripts/initialize.py @@ -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() diff --git a/scripts/update.py b/scripts/update.py new file mode 100644 index 0000000..f9a9dd8 --- /dev/null +++ b/scripts/update.py @@ -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() diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..9ab294e --- /dev/null +++ b/static/css/base.css @@ -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; + } +} diff --git a/static/css/components.css b/static/css/components.css new file mode 100644 index 0000000..a90b62b --- /dev/null +++ b/static/css/components.css @@ -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); + } \ No newline at end of file diff --git a/static/css/layout.css b/static/css/layout.css new file mode 100644 index 0000000..d058633 --- /dev/null +++ b/static/css/layout.css @@ -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; +} \ No newline at end of file diff --git a/static/css/normalize.css b/static/css/normalize.css new file mode 100644 index 0000000..192eb9c --- /dev/null +++ b/static/css/normalize.css @@ -0,0 +1,349 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/static/css/theme.css b/static/css/theme.css new file mode 100644 index 0000000..30cc1bd --- /dev/null +++ b/static/css/theme.css @@ -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; +} diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..6375e4e --- /dev/null +++ b/static/js/main.js @@ -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(); diff --git a/templates/board.html b/templates/board.html new file mode 100644 index 0000000..f3fe21e --- /dev/null +++ b/templates/board.html @@ -0,0 +1,90 @@ +{% extends "layout.html" %} +{% load text_filters %} + +{% block title %}{{ board.board_name }} | {{ block.super }}{% endblock %} + +{% block content %} +
+
+
+
+
{{ board.curator_name }}
+ + {% if board.curator_url %} + + {% endif %} + + {% if board.curator_bio %} +
{{ board.curator_bio|smart_urlize|safe }}
+ {% endif %} +
+
+
+ +
+ {% if not blocks %} +
+ Здесь пока ничего нет...

+ Такое бывает, когда страничка только создана или обновляется.
+ Подождите немного и зайдите снова! +
+ {% endif %} + {% for block in blocks %} +
+ {% if block.name %} +
{{ block.name }}
+ {% else %} +
+ {% endif %} + + {% for feed in feeds %} + {% if feed.block == block %} + {% for column, articles in feed.articles_by_column %} +
+
+ {% if feed.icon %} + {{ feed.name }} + {% endif %} + {{ feed.name }}
+ последний пост {{ feed.natural_last_article_at }} +
+ +
+ {% endfor %} + {% endif %} + {% endfor %} +
+ {% endfor %} +
+ +{% if board %} + +{% endif %} +{% endblock %} diff --git a/templates/common/css.html b/templates/common/css.html new file mode 100644 index 0000000..b4e9bae --- /dev/null +++ b/templates/common/css.html @@ -0,0 +1,7 @@ +{% load static %} + + + + + + diff --git a/templates/common/favicon.html b/templates/common/favicon.html new file mode 100644 index 0000000..589c8b1 --- /dev/null +++ b/templates/common/favicon.html @@ -0,0 +1,7 @@ +{% load staticfiles %} + + + + + + diff --git a/templates/common/js.html b/templates/common/js.html new file mode 100644 index 0000000..4bb84ca --- /dev/null +++ b/templates/common/js.html @@ -0,0 +1,2 @@ + + diff --git a/templates/common/meta.html b/templates/common/meta.html new file mode 100644 index 0000000..4559f0d --- /dev/null +++ b/templates/common/meta.html @@ -0,0 +1,16 @@ + + + + + + +{% block meta_og %} + + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..181dc4d --- /dev/null +++ b/templates/index.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block title %}{{ block.super }} | {{ settings.APP_TITLE }}{% endblock %} + +{% block content %} +
+

{{ settings.APP_TITLE }}

+
+ +
+ {% for board in boards %} + + + {{ board.curator_name }} + {{ board.curator_title }} + + {% endfor %} +
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..b5b4dcb --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,39 @@ + +{% load static %} + + + {% block title %}{{ settings.APP_NAME|safe }}{% endblock %} + {% block meta %} + {% include "common/meta.html" %} + {% endblock %} + {% include "common/favicon.html" %} + {% include "common/css.html" %} + + + +{% block body %} + {% block menu %} + + {% endblock %} + {% block content %} + {% endblock %} +{% endblock %} + +{% block footer %} + +{% endblock %} + +{% include "common/js.html" %} + +