MVP
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
91
boards.yml
Normal 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
0
boards/__init__.py
Normal file
5
boards/apps.py
Normal file
5
boards/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BoardsConfig(AppConfig):
|
||||
name = 'boards'
|
||||
7
boards/context_processors.py
Normal file
7
boards/context_processors.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def settings_processor(request):
|
||||
return {
|
||||
"settings": settings
|
||||
}
|
||||
6
boards/icons.py
Normal file
6
boards/icons.py
Normal 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",
|
||||
}
|
||||
72
boards/migrations/0001_initial.py
Normal file
72
boards/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
32
boards/migrations/0002_auto_20191214_2056.py
Normal file
32
boards/migrations/0002_auto_20191214_2056.py
Normal 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),
|
||||
),
|
||||
]
|
||||
23
boards/migrations/0003_auto_20191214_2100.py
Normal file
23
boards/migrations/0003_auto_20191214_2100.py
Normal 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),
|
||||
),
|
||||
]
|
||||
61
boards/migrations/0004_auto_20191214_2251.py
Normal file
61
boards/migrations/0004_auto_20191214_2251.py
Normal 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,
|
||||
),
|
||||
]
|
||||
24
boards/migrations/0005_auto_20191214_2318.py
Normal file
24
boards/migrations/0005_auto_20191214_2318.py
Normal 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,
|
||||
),
|
||||
]
|
||||
23
boards/migrations/0006_auto_20191214_2332.py
Normal file
23
boards/migrations/0006_auto_20191214_2332.py
Normal 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',
|
||||
),
|
||||
]
|
||||
22
boards/migrations/0007_auto_20191215_1143.py
Normal file
22
boards/migrations/0007_auto_20191215_1143.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
boards/migrations/0008_boardfeed_icon.py
Normal file
18
boards/migrations/0008_boardfeed_icon.py
Normal 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),
|
||||
),
|
||||
]
|
||||
27
boards/migrations/0009_auto_20200104_1321.py
Normal file
27
boards/migrations/0009_auto_20200104_1321.py
Normal 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),
|
||||
),
|
||||
]
|
||||
23
boards/migrations/0010_auto_20200104_1344.py
Normal file
23
boards/migrations/0010_auto_20200104_1344.py
Normal 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),
|
||||
),
|
||||
]
|
||||
43
boards/migrations/0011_auto_20200104_1348.py
Normal file
43
boards/migrations/0011_auto_20200104_1348.py
Normal 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),
|
||||
),
|
||||
]
|
||||
24
boards/migrations/0012_auto_20200104_1404.py
Normal file
24
boards/migrations/0012_auto_20200104_1404.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
boards/migrations/0013_auto_20200104_1411.py
Normal file
18
boards/migrations/0013_auto_20200104_1411.py
Normal 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),
|
||||
),
|
||||
]
|
||||
19
boards/migrations/0014_board_curator_title.py
Normal file
19
boards/migrations/0014_board_curator_title.py
Normal 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,
|
||||
),
|
||||
]
|
||||
18
boards/migrations/0015_board_curator_footer.py
Normal file
18
boards/migrations/0015_board_curator_footer.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
boards/migrations/0016_boardfeed_comment.py
Normal file
18
boards/migrations/0016_boardfeed_comment.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
boards/migrations/__init__.py
Normal file
0
boards/migrations/__init__.py
Normal file
172
boards/models.py
Normal file
172
boards/models.py
Normal 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)
|
||||
0
boards/templatetags/__init__.py
Executable file
0
boards/templatetags/__init__.py
Executable file
48
boards/templatetags/text_filters.py
Executable file
48
boards/templatetags/text_filters.py
Executable 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
21
boards/views.py
Normal 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
BIN
db.sqlite3
Normal file
Binary file not shown.
0
infomate/__init__.py
Normal file
0
infomate/__init__.py
Normal file
16
infomate/asgi.py
Normal file
16
infomate/asgi.py
Normal 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
105
infomate/settings.py
Normal 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
24
infomate/urls.py
Normal 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
16
infomate/wsgi.py
Normal 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
21
manage.py
Executable 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
8
requirements.txt
Normal 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
184
scripts/initialize.py
Normal 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
163
scripts/update.py
Normal 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
95
static/css/base.css
Normal 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
251
static/css/components.css
Normal 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
76
static/css/layout.css
Normal 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
349
static/css/normalize.css
vendored
Normal 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
41
static/css/theme.css
Normal 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
23
static/js/main.js
Normal 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
90
templates/board.html
Normal 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 %}
|
||||
7
templates/common/css.html
Normal file
7
templates/common/css.html
Normal 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">
|
||||
7
templates/common/favicon.html
Normal file
7
templates/common/favicon.html
Normal 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
2
templates/common/js.html
Normal 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>
|
||||
16
templates/common/meta.html
Normal file
16
templates/common/meta.html
Normal 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
19
templates/index.html
Normal 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
39
templates/layout.html
Normal 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" %}">👩🎤 {{ 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>
|
||||
Reference in New Issue
Block a user