Category pages
This commit is contained in:
parent
a350cc280d
commit
b952d70d92
114 changed files with 1205 additions and 177 deletions
|
|
@ -0,0 +1,10 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 11:28
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0004_auto_20190714_1501")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="user", name="task_interval")]
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 11:53
|
||||
|
||||
import django.db.models.deletion
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0005_remove_user_task_interval")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="task",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="django_celery_beat.PeriodicTask",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 11:55
|
||||
|
||||
import django.db.models.deletion
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [("accounts", "0006_auto_20191116_1253")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="task",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="django_celery_beat.PeriodicTask",
|
||||
verbose_name="collection task",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import json
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import UserManager as DjangoUserManager
|
||||
from django.db import models
|
||||
|
|
@ -9,7 +12,7 @@ from django_celery_beat.models import IntervalSchedule, PeriodicTask
|
|||
|
||||
|
||||
class UserManager(DjangoUserManager):
|
||||
def _create_user(self, email, password, **extra_fields):
|
||||
def _create_user(self, email, password, **extra_fields) -> get_user_model:
|
||||
"""
|
||||
Create and save a user with the given username, email, and password.
|
||||
"""
|
||||
|
|
@ -21,12 +24,14 @@ class UserManager(DjangoUserManager):
|
|||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
def create_user(self, email: str, password=None, **extra_fields) -> get_user_model:
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email, password, **extra_fields):
|
||||
def create_superuser(
|
||||
self, email: str, password: str, **extra_fields
|
||||
) -> get_user_model:
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
|
||||
|
|
@ -42,10 +47,12 @@ class User(AbstractUser):
|
|||
email = models.EmailField(_("email address"), unique=True)
|
||||
|
||||
task = models.OneToOneField(
|
||||
PeriodicTask, _("collection task"), null=True, blank=True, editable=False
|
||||
)
|
||||
task_interval = models.ForeignKey(
|
||||
IntervalSchedule, _("collection schedule"), null=True, blank=True
|
||||
PeriodicTask,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name="collection task",
|
||||
)
|
||||
|
||||
username = None
|
||||
|
|
@ -55,35 +62,24 @@ class User(AbstractUser):
|
|||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._original_interval = self.task_interval
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._original_interval != self.task_interval:
|
||||
if self.task_interval and self.task:
|
||||
self.task.interval = self.task_interval
|
||||
self.task.enabled = True
|
||||
self.task.save()
|
||||
|
||||
elif not self.task_interval and self.task:
|
||||
self.task.enabled = False
|
||||
self.task.save()
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if not self.task:
|
||||
self.task_interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
task_interval, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=1, period=IntervalSchedule.HOURS
|
||||
)
|
||||
|
||||
self.task, _ = PeriodicTask.objects.get_or_create(
|
||||
enabled=True,
|
||||
interval=self.task_interval,
|
||||
interval=task_interval,
|
||||
name=f"{self.email}-collection-task",
|
||||
task="newsreader.news.collection.tasks",
|
||||
task="newsreader.news.collection.tasks.collect",
|
||||
args=json.dumps([self.pk]),
|
||||
kwargs={},
|
||||
)
|
||||
|
||||
self._original_interval = self.task_interval
|
||||
self.save()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
def delete(self, *args, **kwargs) -> Iterable:
|
||||
self.task.delete()
|
||||
return super().delete(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -7,35 +7,16 @@ from newsreader.accounts.models import User
|
|||
|
||||
class UserTestCase(TestCase):
|
||||
def test_task_is_created(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None)
|
||||
|
||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
||||
expected_interval = IntervalSchedule.objects.get(
|
||||
every=1, period=IntervalSchedule.HOURS
|
||||
)
|
||||
|
||||
self.assertEquals(task.interval, expected_interval)
|
||||
user.refresh_from_db()
|
||||
|
||||
self.assertEquals(task, user.task)
|
||||
self.assertEquals(PeriodicTask.objects.count(), 1)
|
||||
|
||||
def test_task_is_updated(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None)
|
||||
def test_task_is_deleted(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None)
|
||||
user.delete()
|
||||
|
||||
new_interval = IntervalSchedule.objects.create(
|
||||
every=2, period=IntervalSchedule.HOURS
|
||||
)
|
||||
user.task_interval = new_interval
|
||||
user.save()
|
||||
|
||||
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
||||
|
||||
self.assertEquals(task.interval, new_interval)
|
||||
|
||||
def test_task_is_disabled(self):
|
||||
user = User.objects.create(email="durp@burp.nl", task=None, task_interval=None)
|
||||
|
||||
user.task_interval = None
|
||||
user.save()
|
||||
|
||||
task = PeriodicTask.objects.get(name=f"{user.email}-collection-task")
|
||||
|
||||
self.assertEquals(task.enabled, False)
|
||||
self.assertEquals(PeriodicTask.objects.count(), 0)
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ STATIC_URL = "/static/"
|
|||
|
||||
STATICFILES_DIRS = ["src/newsreader/static/icons"]
|
||||
|
||||
# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
# Third party settings
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
|
|
|
|||
13
src/newsreader/js/components/Card.js
Normal file
13
src/newsreader/js/components/Card.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
const Card = props => {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card__header">{props.header}</div>
|
||||
<div className="card__content">{props.content}</div>
|
||||
<div className="card__footer">{props.footer}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
13
src/newsreader/js/components/Messages.js
Normal file
13
src/newsreader/js/components/Messages.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
const Messages = props => {
|
||||
const messages = props.messages.map(message => {
|
||||
return (
|
||||
<li className={`messages__item message__item--${message.type}`}>{message.text}</li>
|
||||
);
|
||||
});
|
||||
|
||||
return <ul className="list messages">{messages}</ul>;
|
||||
};
|
||||
|
||||
export default Messages;
|
||||
7
src/newsreader/js/components/Modal.js
Normal file
7
src/newsreader/js/components/Modal.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
const Modal = props => {
|
||||
return <div className="modal">{props.content}</div>;
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
106
src/newsreader/js/pages/categories/App.js
Normal file
106
src/newsreader/js/pages/categories/App.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import React from 'react';
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
import Card from '../../components/Card.js';
|
||||
import CategoryCard from './components/CategoryCard.js';
|
||||
import CategoryModal from './components/CategoryModal.js';
|
||||
import Messages from '../../components/Messages.js';
|
||||
|
||||
class App extends React.Component {
|
||||
selectCategory = ::this.selectCategory;
|
||||
deselectCategory = ::this.deselectCategory;
|
||||
deleteCategory = ::this.deleteCategory;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.token = Cookies.get('csrftoken');
|
||||
this.state = {
|
||||
categories: props.categories,
|
||||
selectedCategoryId: null,
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
selectCategory(categoryId) {
|
||||
this.setState({ selectedCategoryId: categoryId });
|
||||
}
|
||||
|
||||
deselectCategory() {
|
||||
this.setState({ selectedCategoryId: null });
|
||||
}
|
||||
|
||||
deleteCategory(categoryId) {
|
||||
const url = `/api/categories/${categoryId}/`;
|
||||
const options = {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRFToken': this.token,
|
||||
},
|
||||
};
|
||||
|
||||
fetch(url, options).then(response => {
|
||||
if (response.ok) {
|
||||
const categories = this.state.categories.filter(category => {
|
||||
return category.pk != categoryId;
|
||||
});
|
||||
|
||||
return this.setState({
|
||||
categories: categories,
|
||||
selectedCategoryId: null,
|
||||
message: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
type: 'error',
|
||||
text: 'Unable to remove category, try again later',
|
||||
};
|
||||
return this.setState({ selectedCategoryId: null, message: message });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { categories } = this.state;
|
||||
const cards = categories.map(category => {
|
||||
return (
|
||||
<CategoryCard
|
||||
key={category.pk}
|
||||
category={category}
|
||||
showDialog={this.selectCategory}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const selectedCategory = categories.find(category => {
|
||||
return category.pk === this.state.selectedCategoryId;
|
||||
});
|
||||
|
||||
const pageHeader = (
|
||||
<>
|
||||
<h1 className="h1">Categories</h1>
|
||||
<a className="link button button--confirm" href="/categories/create/">
|
||||
Create category
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.message && <Messages messages={[this.state.message]} />}
|
||||
<Card header={pageHeader} />
|
||||
{cards}
|
||||
{selectedCategory && (
|
||||
<CategoryModal
|
||||
category={selectedCategory}
|
||||
handleCancel={this.deselectCategory}
|
||||
handleDelete={this.deleteCategory}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
|
||||
import Card from '../../../components/Card.js';
|
||||
|
||||
const CategoryCard = props => {
|
||||
const { category } = props;
|
||||
|
||||
const categoryRules = category.rules.map(rule => {
|
||||
const faviconUrl = rule.favicon ? rule.favicon : '/static/favicon-placeholder.svg';
|
||||
|
||||
return (
|
||||
<li key={rule.pk} className="list__item">
|
||||
<img className="favicon" src={`${faviconUrl}`} />
|
||||
{rule.name}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const cardHeader = (
|
||||
<>
|
||||
<h2 className="h2">{category.name}</h2>
|
||||
<span>
|
||||
<small className="small">{category.created}</small>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cardContent = <>{category.rules && <ul className="list">{categoryRules}</ul>}</>;
|
||||
const cardFooter = (
|
||||
<>
|
||||
<a className="link button button--primary" href={`/categories/${category.pk}/`}>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
id="category-delete"
|
||||
className="button button--error"
|
||||
onClick={() => props.showDialog(category.pk)}
|
||||
data-id={`${category.pk}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return <Card header={cardHeader} content={cardContent} footer={cardFooter} />;
|
||||
};
|
||||
|
||||
export default CategoryCard;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
|
||||
import Modal from '../../../components/Modal.js';
|
||||
|
||||
const CategoryModal = props => {
|
||||
const content = (
|
||||
<div className="category-modal">
|
||||
<div className="category-modal__header">
|
||||
<h1 className="h1 category-modal__title">Delete category</h1>
|
||||
</div>
|
||||
|
||||
<div className="category-modal__content">
|
||||
<p className="p">Are you sure you want to delete {props.category.name}?</p>
|
||||
<small className="small">
|
||||
Collection rules coupled to this category will not be deleted but will have no
|
||||
category
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="category-modal__footer">
|
||||
<button className="button button--confirm" onClick={props.handleCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="button button--error"
|
||||
onClick={() => props.handleDelete(props.category.pk)}
|
||||
>
|
||||
Delete category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <Modal content={content} />;
|
||||
};
|
||||
|
||||
export default CategoryModal;
|
||||
12
src/newsreader/js/pages/categories/index.js
Normal file
12
src/newsreader/js/pages/categories/index.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import App from './App.js';
|
||||
|
||||
const dataScript = document.getElementById('categories-data');
|
||||
const categories = JSON.parse(dataScript.textContent);
|
||||
|
||||
ReactDOM.render(
|
||||
<App categories={categories} />,
|
||||
document.getElementsByClassName('content')[0]
|
||||
);
|
||||
|
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
|||
import Cookies from 'js-cookie';
|
||||
|
||||
import { unSelectPost, markPostRead } from '../actions/posts.js';
|
||||
import { formatDatetime } from '../../utils.js';
|
||||
import { formatDatetime } from '../../../utils.js';
|
||||
|
||||
class PostModal extends React.Component {
|
||||
readTimer = null;
|
||||
|
|
@ -5,7 +5,7 @@ import { isEqual } from 'lodash';
|
|||
import { fetchPostsByRule, fetchPostsByCategory } from '../../actions/posts.js';
|
||||
import { filterPosts } from './filters.js';
|
||||
|
||||
import LoadingIndicator from '../../../components/LoadingIndicator.js';
|
||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||
import RuleItem from './RuleItem.js';
|
||||
|
||||
class FeedList extends React.Component {
|
||||
|
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
|||
|
||||
import { selectPost } from '../../actions/posts.js';
|
||||
|
||||
import { formatDatetime } from '../../../utils.js';
|
||||
import { formatDatetime } from '../../../../utils.js';
|
||||
|
||||
class PostItem extends React.Component {
|
||||
render() {
|
||||
|
|
@ -4,7 +4,7 @@ import { isEqual } from 'lodash';
|
|||
|
||||
import { filterCategories, filterRules } from './filters.js';
|
||||
|
||||
import LoadingIndicator from '../../../components/LoadingIndicator.js';
|
||||
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
|
||||
import CategoryItem from './CategoryItem.js';
|
||||
import ReadButton from './ReadButton.js';
|
||||
|
||||
|
|
@ -10,7 +10,6 @@ def collect(user_pk):
|
|||
try:
|
||||
user = User.objects.get(pk=user_pk)
|
||||
except ObjectDoesNotExist:
|
||||
# TODO remove this task
|
||||
return
|
||||
|
||||
rules = user.rules.all()
|
||||
|
|
|
|||
37
src/newsreader/news/core/forms.py
Normal file
37
src/newsreader/news/core/forms.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from django import forms
|
||||
|
||||
from newsreader.news.collection.models import CollectionRule
|
||||
from newsreader.news.core.models import Category
|
||||
|
||||
|
||||
class CategoryForm(forms.ModelForm):
|
||||
rules = forms.ModelMultipleChoiceField(
|
||||
queryset=CollectionRule.objects.all(),
|
||||
required=False,
|
||||
widget=forms.widgets.CheckboxSelectMultiple,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop("user")
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.user:
|
||||
self.fields["rules"].queryset = CollectionRule.objects.filter(user=self.user)
|
||||
|
||||
def save(self, commit=True) -> Category:
|
||||
instance = super().save(commit=False)
|
||||
instance.user = self.user
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
|
||||
rule_ids = self.cleaned_data.get("rules", [])
|
||||
instance.rules.set(rule_ids)
|
||||
|
||||
return instance
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = ("name", "rules")
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 2.2.6 on 2019-11-16 12:15
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("core", "0003_post_read"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="category", name="name", field=models.CharField(max_length=50)
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="category", unique_together={("name", "user")}
|
||||
),
|
||||
]
|
||||
|
|
@ -26,12 +26,17 @@ class Post(TimeStampedModel):
|
|||
|
||||
|
||||
class Category(TimeStampedModel):
|
||||
name = models.CharField(max_length=50, unique=True) # TODO remove unique value
|
||||
name = models.CharField(max_length=50)
|
||||
user = models.ForeignKey("accounts.User", _("Owner"), related_name="categories")
|
||||
|
||||
@property
|
||||
def rule_ids(self):
|
||||
return self.rules.values_list("pk", flat=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Category")
|
||||
verbose_name_plural = _("Categories")
|
||||
unique_together = ("name", "user")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
|
|||
38
src/newsreader/news/core/templates/core/categories.html
Normal file
38
src/newsreader/news/core/templates/core/categories.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block head %}
|
||||
<link href="{% static 'core/dist/css/categories.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script id="categories-data">
|
||||
[
|
||||
{% for category in categories %}
|
||||
{% if not forloop.first %}, {% endif %}
|
||||
{
|
||||
"pk": {{ category.pk }},
|
||||
"name": "{{ category.name }}",
|
||||
"created": "{{ category.created }}",
|
||||
"rules" : [
|
||||
{% for rule in category.rules.all %}
|
||||
{% if not forloop.first %}, {% endif %}
|
||||
{
|
||||
"pk": {{ rule.pk }},
|
||||
"name": "{{ rule.name }}",
|
||||
"favicon": {% if rule.favicon %}"{{ rule.favicon }}"{% else %}null{% endif %},
|
||||
"created": "{{ rule.created }}"
|
||||
}
|
||||
{% endfor %}
|
||||
]
|
||||
}
|
||||
{% endfor %}
|
||||
]
|
||||
</script>
|
||||
<script src="{% static 'core/dist/js/categories.js' %}"></script>
|
||||
{% endblock %}
|
||||
18
src/newsreader/news/core/templates/core/category-create.html
Normal file
18
src/newsreader/news/core/templates/core/category-create.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{% extends "core/category.html" %}
|
||||
|
||||
{% block form-header %}
|
||||
<h1 class="h1">Create a category</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block name-input %}
|
||||
<input class="input category-form__input" type="text" name="name" required />
|
||||
{% endblock %}
|
||||
|
||||
{% block rule-input %}
|
||||
<input class="input category-form__input" type="checkbox" name="rules"
|
||||
value="{{ rule.pk }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block confirm-button %}
|
||||
<button class="button button--confirm">Create category</button>
|
||||
{% endblock %}
|
||||
20
src/newsreader/news/core/templates/core/category-update.html
Normal file
20
src/newsreader/news/core/templates/core/category-update.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "core/category.html" %}
|
||||
|
||||
{% block form-header %}
|
||||
<h1 class="h1">Update category</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block name-input %}
|
||||
<input class="input category-form__input" type="text" name="name"
|
||||
value="{{ category.name }}" required />
|
||||
{% endblock %}
|
||||
|
||||
{% block rule-input %}
|
||||
<input class="input category-form__input" type="checkbox" name="rules"
|
||||
{% if rule.pk in category.rule_ids %}checked{% endif %}
|
||||
value="{{ rule.pk }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block confirm-button %}
|
||||
<button class="button button--confirm">Save category</button>
|
||||
{% endblock %}
|
||||
54
src/newsreader/news/core/templates/core/category.html
Normal file
54
src/newsreader/news/core/templates/core/category.html
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block head %}
|
||||
<link href="{% static 'core/dist/css/category.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="content">
|
||||
<form class="form category-form" method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
<div class="form__header">
|
||||
{% block form-header %}{% endblock %}
|
||||
</div>
|
||||
<section class="section form__section category-form__section">
|
||||
<fieldset class="form__fieldset category-form__fieldset">
|
||||
<label class="label category-form__label" for="name">Name</label>
|
||||
{% block name-input %}{% endblock %}
|
||||
{{ form.name.errors }}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="section form__section category-form__section">
|
||||
<fieldset class="form__fieldset category-form__fieldset">
|
||||
<label class="label category-form__label" for="rules">Collection rules</label>
|
||||
<small class="small help-text">
|
||||
Note that existing assigned rules will be reassigned to this category
|
||||
</small>
|
||||
<ul class="list checkbox-list">
|
||||
{% for rule in rules %}
|
||||
<li class="list__item checkbox-list__item">
|
||||
{% block rule-input %}{% endblock %}
|
||||
<img class="favicon"
|
||||
src="{% if rule.favicon %}{{ rule.favicon }}{% else %}/static/favicon-placeholder.svg{% endif %}" />
|
||||
<span>{{ rule.name }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ form.rules.errors }}
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="section form__section category-form__section">
|
||||
<fieldset class="form__fieldset category-form__fieldset">
|
||||
<a class="link button button--cancel" href="{% url 'categories' %}">Cancel</a>
|
||||
{% block confirm-button %}{% endblock %}
|
||||
</fieldset>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
{% load static %}
|
||||
|
||||
{% block head %}
|
||||
<link href="{% static 'core/dist/css/core.css' %}" rel="stylesheet" />
|
||||
<link href="{% static 'core/dist/css/homepage.css' %}" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||
from newsreader.news.core.models import Category
|
||||
from newsreader.news.core.tests.factories import CategoryFactory
|
||||
|
||||
|
||||
class CategoryCreateViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(password="test")
|
||||
self.client.login(email=self.user.email, password="test")
|
||||
|
||||
self.url = reverse("category-create")
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_creation(self):
|
||||
rules = CollectionRuleFactory.create_batch(size=4, user=self.user)
|
||||
|
||||
data = {"name": "new-category", "rules": [rule.pk for rule in rules]}
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertEquals(response.status_code, 302)
|
||||
|
||||
category = Category.objects.get(name="new-category")
|
||||
|
||||
self.assertCountEqual(category.rule_ids, [rule.pk for rule in rules])
|
||||
|
||||
def test_collection_rules_only_from_user(self):
|
||||
other_user = UserFactory()
|
||||
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
for rule in other_rules:
|
||||
self.assertNotContains(response, rule.name)
|
||||
|
||||
def test_creation_with_other_user_rules(self):
|
||||
other_user = UserFactory()
|
||||
other_rules = CollectionRuleFactory.create_batch(
|
||||
size=4, user=other_user, category=None
|
||||
)
|
||||
|
||||
user_rules = CollectionRuleFactory.create_batch(
|
||||
size=3, user=self.user, category=None
|
||||
)
|
||||
|
||||
data = {"name": "new-category", "rules": [rule.pk for rule in other_rules]}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertContains(response, "not one of the available choices")
|
||||
self.assertEquals(Category.objects.count(), 0)
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from newsreader.accounts.tests.factories import UserFactory
|
||||
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||
from newsreader.news.core.tests.factories import CategoryFactory
|
||||
|
||||
|
||||
class CategoryUpdateViewTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = UserFactory(password="test")
|
||||
self.client.login(email=self.user.email, password="test")
|
||||
|
||||
self.category = CategoryFactory(name="category", user=self.user)
|
||||
self.url = reverse("category-update", args=[self.category.pk])
|
||||
|
||||
def test_simple(self):
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEquals(response.status_code, 200)
|
||||
|
||||
def test_name_change(self):
|
||||
data = {"name": "durp"}
|
||||
self.client.post(self.url, data)
|
||||
|
||||
self.category.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.category.name, "durp")
|
||||
|
||||
def test_add_collection_rules(self):
|
||||
rules = CollectionRuleFactory.create_batch(size=4, user=self.user)
|
||||
data = {"name": self.category.name, "rules": [rule.pk for rule in rules]}
|
||||
|
||||
self.client.post(self.url, data)
|
||||
|
||||
self.category.refresh_from_db()
|
||||
|
||||
# this actually checks for sequence contents too
|
||||
# see https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertCountEqual
|
||||
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in rules])
|
||||
|
||||
def test_collection_rule_change(self):
|
||||
other_rules = CollectionRuleFactory.create_batch(size=4, user=self.user)
|
||||
other_category = CategoryFactory(user=self.user)
|
||||
other_category.rules.set([*other_rules])
|
||||
|
||||
current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user)
|
||||
self.category.rules.set([*current_rules])
|
||||
|
||||
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
|
||||
|
||||
data = {"name": self.category.name, "rules": [rule.pk for rule in other_rules]}
|
||||
|
||||
self.client.post(self.url, data)
|
||||
|
||||
self.category.refresh_from_db()
|
||||
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in other_rules])
|
||||
|
||||
other_category.refresh_from_db()
|
||||
self.assertCountEqual(other_category.rule_ids, [])
|
||||
|
||||
def test_collection_rule_removal(self):
|
||||
current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user)
|
||||
self.category.rules.set([*current_rules])
|
||||
|
||||
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
|
||||
|
||||
data = {"name": "durp"}
|
||||
self.client.post(self.url, data)
|
||||
|
||||
self.category.refresh_from_db()
|
||||
|
||||
self.assertEquals(self.category.rules.count(), 0)
|
||||
|
||||
def test_collection_rules_only_from_user(self):
|
||||
other_user = UserFactory()
|
||||
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
|
||||
|
||||
response = self.client.get(self.url)
|
||||
|
||||
for rule in other_rules:
|
||||
self.assertNotContains(response, rule.name)
|
||||
|
||||
def test_update_category_of_other_user(self):
|
||||
other_user = UserFactory()
|
||||
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
|
||||
|
||||
other_category = CategoryFactory(name="other category", user=other_user)
|
||||
other_category.rules.set([*other_rules])
|
||||
|
||||
data = {"name": "durp"}
|
||||
other_url = reverse("category-update", args=[other_category.pk])
|
||||
response = self.client.post(other_url, data)
|
||||
|
||||
self.assertEquals(response.status_code, 404)
|
||||
|
||||
other_category.refresh_from_db()
|
||||
|
||||
self.assertEquals(other_category.name, "other category")
|
||||
self.assertEquals(other_category.rules.count(), 4)
|
||||
|
||||
def test_category_update_with_other_user_rules(self):
|
||||
other_user = UserFactory()
|
||||
other_rules = CollectionRuleFactory.create_batch(size=4, user=other_user)
|
||||
other_category = CategoryFactory(user=other_user)
|
||||
other_category.rules.set([*other_rules])
|
||||
|
||||
current_rules = CollectionRuleFactory.create_batch(size=3, user=self.user)
|
||||
self.category.rules.set([*current_rules])
|
||||
|
||||
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
|
||||
|
||||
data = {"name": self.category.name, "rules": [rule.pk for rule in other_rules]}
|
||||
|
||||
response = self.client.post(self.url, data)
|
||||
|
||||
self.assertContains(response, "not one of the available choices")
|
||||
|
||||
self.category.refresh_from_db()
|
||||
self.assertCountEqual(self.category.rule_ids, [rule.pk for rule in current_rules])
|
||||
|
||||
other_category.refresh_from_db()
|
||||
self.assertCountEqual(other_category.rule_ids, [rule.pk for rule in other_rules])
|
||||
|
|
@ -10,10 +10,28 @@ from newsreader.news.core.endpoints import (
|
|||
NestedPostCategoryView,
|
||||
NestedRuleCategoryView,
|
||||
)
|
||||
from newsreader.news.core.views import NewsView
|
||||
from newsreader.news.core.views import (
|
||||
CategoryCreateView,
|
||||
CategoryListView,
|
||||
CategoryUpdateView,
|
||||
NewsView,
|
||||
)
|
||||
|
||||
|
||||
index_page = login_required(NewsView.as_view())
|
||||
urlpatterns = [
|
||||
path("", login_required(NewsView.as_view()), name="index"),
|
||||
path("categories/", login_required(CategoryListView.as_view()), name="categories"),
|
||||
path(
|
||||
"categories/<int:pk>/",
|
||||
login_required(CategoryUpdateView.as_view()),
|
||||
name="category-update",
|
||||
),
|
||||
path(
|
||||
"categories/create/",
|
||||
login_required(CategoryCreateView.as_view()),
|
||||
name="category-create",
|
||||
),
|
||||
]
|
||||
|
||||
endpoints = [
|
||||
path("posts/", ListPostView.as_view(), name="posts-list"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
from typing import Dict
|
||||
from typing import Dict, Iterable
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.views.generic.edit import CreateView, UpdateView
|
||||
from django.views.generic.list import ListView
|
||||
|
||||
from newsreader.news.collection.models import CollectionRule
|
||||
from newsreader.news.core.forms import CategoryForm
|
||||
from newsreader.news.core.models import Category
|
||||
|
||||
|
||||
class NewsView(TemplateView):
|
||||
|
|
@ -23,3 +30,43 @@ class NewsView(TemplateView):
|
|||
|
||||
context.update(categories=categories, rules=rules)
|
||||
return context
|
||||
|
||||
|
||||
class CategoryViewMixin:
|
||||
queryset = Category.objects.prefetch_related("rules").order_by("name")
|
||||
|
||||
def get_queryset(self) -> Iterable:
|
||||
user = self.request.user
|
||||
return self.queryset.filter(user=user)
|
||||
|
||||
|
||||
class CategoryDetailMixin:
|
||||
success_url = reverse_lazy("categories")
|
||||
form_class = CategoryForm
|
||||
|
||||
def get_context_data(self, **kwargs) -> Dict:
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
|
||||
rules = CollectionRule.objects.filter(user=self.request.user).order_by("name")
|
||||
context_data["rules"] = rules
|
||||
|
||||
return context_data
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["user"] = self.request.user
|
||||
return kwargs
|
||||
|
||||
|
||||
class CategoryListView(CategoryViewMixin, ListView):
|
||||
template_name = "core/categories.html"
|
||||
context_object_name = "categories"
|
||||
|
||||
|
||||
class CategoryUpdateView(CategoryViewMixin, CategoryDetailMixin, UpdateView):
|
||||
template_name = "core/category-update.html"
|
||||
context_object_name = "category"
|
||||
|
||||
|
||||
class CategoryCreateView(CategoryViewMixin, CategoryDetailMixin, CreateView):
|
||||
template_name = "core/category-create.html"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
padding: 0;
|
||||
background-color: $gainsboro;
|
||||
|
||||
font-family: $default-font;
|
||||
|
||||
& * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
.button {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 10px 50px;
|
||||
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
|
||||
font-family: $button-font;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
color: $white;
|
||||
background-color: $confirm-green;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten($confirm-green, +5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/newsreader/scss/components/card/_card.scss
Normal file
36
src/newsreader/scss/components/card/_card.scss
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
|
||||
width: 50%;
|
||||
border-radius: 2px;
|
||||
|
||||
background-color: $white;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 15px 0;
|
||||
|
||||
border-bottom: 2px $border-gray solid;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
& .favicon {
|
||||
height: 30px;
|
||||
margin: 0 20px 0 0;
|
||||
}
|
||||
}
|
||||
1
src/newsreader/scss/components/card/index.scss
Normal file
1
src/newsreader/scss/components/card/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "card";
|
||||
5
src/newsreader/scss/components/content/_content.scss
Normal file
5
src/newsreader/scss/components/content/_content.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
1
src/newsreader/scss/components/content/index.scss
Normal file
1
src/newsreader/scss/components/content/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "content";
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
%error {
|
||||
background-color: $error-red;
|
||||
color: $white;
|
||||
list-style: none;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.errorlist {
|
||||
@extend %error;
|
||||
}
|
||||
3
src/newsreader/scss/components/errorlist/;
Normal file
3
src/newsreader/scss/components/errorlist/;
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.errorlist {
|
||||
|
||||
}
|
||||
20
src/newsreader/scss/components/errorlist/_errorlist.scss
Normal file
20
src/newsreader/scss/components/errorlist/_errorlist.scss
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.errorlist {
|
||||
@extend .list;
|
||||
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
|
||||
background-color: $error-red;
|
||||
color: $white;
|
||||
|
||||
list-style: disc;
|
||||
list-style-position: inside;
|
||||
|
||||
& li {
|
||||
margin: 15px 0 0 0;
|
||||
|
||||
&:first-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1 @@
|
|||
@import "error";
|
||||
@import "errorlist";
|
||||
|
|
@ -2,12 +2,27 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 70%;
|
||||
font-family: $form-font;
|
||||
|
||||
background-color: $white;
|
||||
|
||||
&__fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 15px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
& .favicon {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
@import "./body/index";
|
||||
@import "./button/index";
|
||||
@import "./form/index";
|
||||
@import "./main/index";
|
||||
@import "./navbar/index";
|
||||
@import "./error/index";
|
||||
@import "./loading-indicator/index";
|
||||
@import "./modal/index";
|
||||
@import "./card/index";
|
||||
@import "./list/index";
|
||||
@import "./content/index";
|
||||
@import "./messages/index";
|
||||
@import "./section/index";
|
||||
@import "./errorlist/index";
|
||||
|
|
|
|||
16
src/newsreader/scss/components/list/_list.scss
Normal file
16
src/newsreader/scss/components/list/_list.scss
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.list {
|
||||
padding: 0 10px;
|
||||
margin: 0;
|
||||
|
||||
list-style: none;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
|
||||
& > * {
|
||||
margin: 0 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/newsreader/scss/components/list/index.scss
Normal file
1
src/newsreader/scss/components/list/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "list";
|
||||
16
src/newsreader/scss/components/messages/_messages.scss
Normal file
16
src/newsreader/scss/components/messages/_messages.scss
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
margin: 5px 0 20px 0;
|
||||
padding: 15px 0;
|
||||
|
||||
color: $white;
|
||||
background-color: $error-red;
|
||||
|
||||
&__item {
|
||||
padding: 0 30px;
|
||||
}
|
||||
}
|
||||
1
src/newsreader/scss/components/messages/index.scss
Normal file
1
src/newsreader/scss/components/messages/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "messages";
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
position: fixed;
|
||||
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
margin: 0 0 5px 0;
|
||||
padding: 15px 0;
|
||||
width: 100%;
|
||||
|
||||
|
|
|
|||
6
src/newsreader/scss/components/section/_section.scss
Normal file
6
src/newsreader/scss/components/section/_section.scss
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border: none;
|
||||
}
|
||||
1
src/newsreader/scss/components/section/index.scss
Normal file
1
src/newsreader/scss/components/section/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "section";
|
||||
46
src/newsreader/scss/elements/button/_button.scss
Normal file
46
src/newsreader/scss/elements/button/_button.scss
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.button {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 10px 50px;
|
||||
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
|
||||
font-family: $button-font;
|
||||
font-size: 16px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--success, &--confirm {
|
||||
color: $white;
|
||||
background-color: $confirm-green;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten($confirm-green, +5%);
|
||||
}
|
||||
}
|
||||
|
||||
&--error, &--cancel {
|
||||
color: $white;
|
||||
background-color: $error-red;
|
||||
|
||||
&:hover {
|
||||
background-color: lighten($error-red, +5%);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&--primary {
|
||||
color: $white;
|
||||
background-color: darken($azureish-white, +20%);
|
||||
|
||||
&:hover {
|
||||
background-color: $azureish-white;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/newsreader/scss/elements/h1/_h1.scss
Normal file
3
src/newsreader/scss/elements/h1/_h1.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.h1 {
|
||||
font-family: $header-font;
|
||||
}
|
||||
1
src/newsreader/scss/elements/h1/index.scss
Normal file
1
src/newsreader/scss/elements/h1/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "h1";
|
||||
3
src/newsreader/scss/elements/h2/_h2.scss
Normal file
3
src/newsreader/scss/elements/h2/_h2.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.h2 {
|
||||
font-family: $header-font;
|
||||
}
|
||||
1
src/newsreader/scss/elements/h2/index.scss
Normal file
1
src/newsreader/scss/elements/h2/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "h2";
|
||||
3
src/newsreader/scss/elements/h3/_h3.scss
Normal file
3
src/newsreader/scss/elements/h3/_h3.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.h3 {
|
||||
font-family: $header-font;
|
||||
}
|
||||
1
src/newsreader/scss/elements/h3/index.scss
Normal file
1
src/newsreader/scss/elements/h3/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "h3";
|
||||
5
src/newsreader/scss/elements/help-text/_help-text.scss
Normal file
5
src/newsreader/scss/elements/help-text/_help-text.scss
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.help-text {
|
||||
@extends .small;
|
||||
|
||||
padding: 5px 15px;
|
||||
}
|
||||
1
src/newsreader/scss/elements/help-text/index.scss
Normal file
1
src/newsreader/scss/elements/help-text/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "help-text";
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
@import "./button/index";
|
||||
@import "link/index";
|
||||
@import "h1/index";
|
||||
@import "h2/index";
|
||||
@import "h3/index";
|
||||
@import "small/index";
|
||||
@import "input/index";
|
||||
@import "label/index";
|
||||
@import "help-text/index";
|
||||
10
src/newsreader/scss/elements/input/_input.scss
Normal file
10
src/newsreader/scss/elements/input/_input.scss
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.input {
|
||||
padding: 10px;
|
||||
|
||||
border: 1px $border-gray solid;
|
||||
border-radius: 2px;
|
||||
|
||||
&:focus {
|
||||
border: 1px $focus-blue solid;
|
||||
}
|
||||
}
|
||||
1
src/newsreader/scss/elements/input/index.scss
Normal file
1
src/newsreader/scss/elements/input/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "input";
|
||||
3
src/newsreader/scss/elements/label/_label.scss
Normal file
3
src/newsreader/scss/elements/label/_label.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.label {
|
||||
padding: 10px;
|
||||
}
|
||||
1
src/newsreader/scss/elements/label/index.scss
Normal file
1
src/newsreader/scss/elements/label/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "label";
|
||||
7
src/newsreader/scss/elements/link/_link.scss
Normal file
7
src/newsreader/scss/elements/link/_link.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.link {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
1
src/newsreader/scss/elements/link/index.scss
Normal file
1
src/newsreader/scss/elements/link/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "link";
|
||||
3
src/newsreader/scss/elements/small/_small.scss
Normal file
3
src/newsreader/scss/elements/small/_small.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.small {
|
||||
color: $nickel;
|
||||
}
|
||||
1
src/newsreader/scss/elements/small/index.scss
Normal file
1
src/newsreader/scss/elements/small/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "small";
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.card {
|
||||
&__footer > *:last-child {
|
||||
margin: 0 0 0 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
@import "card";
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
.category-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: center;
|
||||
|
||||
margin: 20px 0;
|
||||
width: 50%;
|
||||
|
||||
border-radius: 2px;
|
||||
background-color: $white;
|
||||
|
||||
&__header {
|
||||
padding: 5px 20px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 10px 30px;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
@import "category-modal";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
@import "card/index";
|
||||
@import "category-modal/index";
|
||||
0
src/newsreader/scss/pages/categories/elements/index.scss
Normal file
0
src/newsreader/scss/pages/categories/elements/index.scss
Normal file
8
src/newsreader/scss/pages/categories/index.scss
Normal file
8
src/newsreader/scss/pages/categories/index.scss
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// General imports
|
||||
@import "../../partials/variables";
|
||||
@import "../../components/index";
|
||||
@import "../../elements/index";
|
||||
|
||||
// Page specific
|
||||
@import "./components/index";
|
||||
@import "./elements/index";
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.category-form {
|
||||
margin: 20px 0;
|
||||
|
||||
&__section:last-child {
|
||||
|
||||
& .category-form__fieldset {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
@import "category-form";
|
||||
1
src/newsreader/scss/pages/category/components/index.scss
Normal file
1
src/newsreader/scss/pages/category/components/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import "category-form/index";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue