This commit is contained in:
Andrew Godwin 2022-11-13 16:14:38 -07:00
parent 5a8b6bb3d0
commit 34b24a0dcb
26 changed files with 397 additions and 138 deletions

View file

@ -1,4 +1,4 @@
from typing import Dict from typing import Dict, Optional
import urlman import urlman
from django.db import models from django.db import models
@ -126,10 +126,14 @@ class Post(StatorModel):
### Local creation ### ### Local creation ###
@classmethod @classmethod
def create_local(cls, author: Identity, content: str) -> "Post": def create_local(
cls, author: Identity, content: str, summary: Optional[str] = None
) -> "Post":
post = cls.objects.create( post = cls.objects.create(
author=author, author=author,
content=content, content=content,
summary=summary or None,
sensitive=bool(summary),
local=True, local=True,
) )
post.object_uri = post.author.actor_uri + f"posts/{post.id}/" post.object_uri = post.author.actor_uri + f"posts/{post.id}/"

View file

View file

@ -0,0 +1,33 @@
import datetime
from django import template
from django.utils import timezone
register = template.Library()
@register.filter
def timedeltashort(value: datetime.datetime):
"""
A more compact version of timesince
"""
if not value:
return ""
# TODO: Handle things in the future properly
delta = timezone.now() - value
seconds = int(delta.total_seconds())
days = delta.days
if seconds < 60:
text = f"{seconds:0n}s"
elif seconds < 60 * 60:
minutes = seconds // 60
text = f"{minutes:0n}m"
elif seconds < 60 * 60 * 24:
hours = seconds // (60 * 60)
text = f"{hours:0n}h"
elif days < 365:
text = f"{days:0n}h"
else:
years = days // 365.25
text = f"{years:0n}y"
return text

View file

@ -1,42 +0,0 @@
from django import forms
from django.shortcuts import redirect
from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator
from django.views.generic import FormView
from activities.models import Post, TimelineEvent
from core.forms import FormHelper
from users.decorators import identity_required
@method_decorator(identity_required, name="dispatch")
class Home(FormView):
template_name = "activities/home.html"
class form_class(forms.Form):
text = forms.CharField()
helper = FormHelper(submit_text="Post")
def get_context_data(self):
context = super().get_context_data()
context.update(
{
"timeline_posts": [
te.subject_post
for te in TimelineEvent.objects.filter(
identity=self.request.identity,
type=TimelineEvent.Types.post,
).order_by("-created")[:100]
],
}
)
return context
def form_valid(self, form):
Post.create_local(
author=self.request.identity,
content=linebreaks_filter(form.cleaned_data["text"]),
)
return redirect(".")

View file

@ -0,0 +1,70 @@
from django import forms
from django.shortcuts import redirect
from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView
from activities.models import Post, TimelineEvent
from users.decorators import identity_required
@method_decorator(identity_required, name="dispatch")
class Home(FormView):
template_name = "activities/home.html"
class form_class(forms.Form):
text = forms.CharField(
widget=forms.Textarea(
attrs={
"placeholder": "What's on your mind?",
},
)
)
content_warning = forms.CharField(
required=False,
widget=forms.TextInput(
attrs={
"placeholder": "Content Warning",
"class": "hidden",
},
),
)
def get_context_data(self):
context = super().get_context_data()
context["timeline_posts"] = [
te.subject_post
for te in TimelineEvent.objects.filter(
identity=self.request.identity,
type=TimelineEvent.Types.post,
)
.select_related("subject_post", "subject_post__author")
.order_by("-created")[:100]
]
context["current_page"] = "home"
return context
def form_valid(self, form):
Post.create_local(
author=self.request.identity,
content=linebreaks_filter(form.cleaned_data["text"]),
summary=form.cleaned_data.get("content_warning"),
)
return redirect(".")
@method_decorator(identity_required, name="dispatch")
class Federated(TemplateView):
template_name = "activities/federated.html"
def get_context_data(self):
context = super().get_context_data()
context["timeline_posts"] = (
Post.objects.filter(visibility=Post.Visibilities.public)
.select_related("author")
.order_by("-created")[:100]
)
context["current_page"] = "federated"
return context

View file

@ -1,11 +0,0 @@
from crispy_forms.helper import FormHelper as BaseFormHelper
from crispy_forms.layout import Submit
class FormHelper(BaseFormHelper):
submit_text = "Submit"
def __init__(self, form=None, submit_text=None):
super().__init__(form)
self.add_input(Submit("submit", submit_text or "Submit"))

View file

@ -1,6 +1,6 @@
from django.views.generic import TemplateView from django.views.generic import TemplateView
from activities.views.home import Home from activities.views.timelines import Home
from users.models import Identity from users.models import Identity

View file

@ -2,7 +2,6 @@ django~=4.1
pyld~=2.0.3 pyld~=2.0.3
pillow~=9.3.0 pillow~=9.3.0
urlman~=2.0.1 urlman~=2.0.1
django-crispy-forms~=1.14
cryptography~=38.0 cryptography~=38.0
httpx~=0.23 httpx~=0.23
pyOpenSSL~=22.1.0 pyOpenSSL~=22.1.0

View file

@ -22,9 +22,6 @@ ignore_missing_imports = True
[mypy-urlman.*] [mypy-urlman.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-crispy_forms.*]
ignore_missing_imports = True
[mypy-cryptography.*] [mypy-cryptography.*]
ignore_missing_imports = True ignore_missing_imports = True

View file

@ -79,6 +79,7 @@ a {
--color-bg-main: #26323c; --color-bg-main: #26323c;
--color-bg-menu: #2e3e4c; --color-bg-menu: #2e3e4c;
--color-bg-box: #1a2631; --color-bg-box: #1a2631;
--color-bg-error: rgb(87, 32, 32);
--color-highlight: #449c8c; --color-highlight: #449c8c;
--color-text-duller: #5f6983; --color-text-duller: #5f6983;
@ -90,13 +91,13 @@ a {
--color-button-main: #444b5d; --color-button-main: #444b5d;
--color-button-main-hover: #515d7c; --color-button-main-hover: #515d7c;
--color-bg3: #444b5d; --color-bg3: #444b5d;
--color-text-error: rgb(155, 111, 111);
} }
body { body {
background-color: var(--color-bg-main); background-color: var(--color-bg-main);
color: var(--color-text-main); color: var(--color-text-main);
font-family: "Raleway", sans-serif; font-family: "Raleway", sans-serif;
font-size: 16px;
} }
main { main {
@ -123,7 +124,7 @@ header .logo {
} }
header .logo:hover { header .logo:hover {
border-bottom: 3px solid rgba(255, 255, 255, 0.2); border-bottom: 3px solid rgba(255, 255, 255, 0.3);
} }
header .logo img { header .logo img {
@ -201,6 +202,33 @@ nav a:hover {
color: var(--color-text-main); color: var(--color-text-main);
} }
/* Left-right columns */
.columns {
display: flex;
}
.left-column {
flex-grow: 1;
width: 300px;
padding: 15px;
}
.right-column {
width: 250px;
background: var(--color-bg-menu);
}
.right-column h2 {
background: var(--color-highlight);
padding: 8px 10px;
font-weight: bold;
font-size: 90%;
text-transform: uppercase;
}
/* Icon menus */
.icon-menu { .icon-menu {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -255,43 +283,111 @@ nav a:hover {
/* Forms */ /* Forms */
form .control-group { form {
margin: 0 0 15px 0; padding: 20px 40px 20px 30px;
} }
form .asteriskField { .right-column form {
display: none; padding: 0;
}
form h1 {
margin: 0 0 10px 0;
}
form p {
color: var(--color-text-main);
margin: 10px 0 15px 0;
}
form .field {
margin: 25px 0 25px 0;
background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
padding: 10px 10px;
}
.right-column form .field {
margin: 0;
background: none;
box-shadow: none;
padding: 0;
} }
form label { form label {
display: block;
text-transform: uppercase; text-transform: uppercase;
font-size: 110%; font-size: 100%;
color: var(--color-text-dull); font-weight: bold;
letter-spacing: 0.05em;
} }
form label.requiredField::after { form label small {
content: " (required)";
font-size: 80%; font-size: 80%;
color: var(--color-text-duller); margin-left: 5px;
color: var(--color-text-dull);
} }
form .help-block { .right-column form label {
color: var(--color-text-error); margin: 5px 10px 5px 10px;
padding: 4px 0 0 0; }
form .help {
color: var(--color-text-dull);
font-size: 90%;
margin: 2px 0 6px 0;
}
form .errorlist {
list-style-type: none;
margin: 0;
padding: 0;
}
form .errorlist li {
color: var(--color-text-main);
background: var(--color-bg-error);
border-radius: 3px;
margin: 5px 0 8px 0;
padding: 3px 7px;
}
form .errorlist li::before {
content: "\f071";
font: var(--fa-font-solid);
margin-right: 7px;
}
form .hidden {
display: none;
} }
form input, form input,
form select { form select,
form textarea {
width: 100%; width: 100%;
padding: 4px 6px; padding: 5px 7px;
background: var(--color-bg-main); background: var(--color-bg-main);
border: 1px solid var(--color-input-border); border: 1px solid var(--color-input-border);
border-radius: 3px; border-radius: 3px;
color: var(--color-text-main); color: var(--color-text-main);
} }
form input:focus { .right-column form.compose input,
.right-column form.compose textarea {
margin: 0 0 10px 0;
border: 0;
font-size: 95%;
border-radius: 0;
background-color: var(--color-bg-box);
}
.right-column form.compose textarea {
height: 150px;
}
form input:focus,
form select:focus,
form textarea:focus {
outline: none; outline: none;
border: 1px solid var(--color-input-border-active); border: 1px solid var(--color-input-border-active);
} }
@ -311,6 +407,48 @@ form input[type=submit]:hover {
background: var(--color-button-main-hover); background: var(--color-button-main-hover);
} }
form .buttons {
text-align: right;
margin: 25px 0 15px 0;
}
.right-column form .buttons {
margin: 5px 10px 5px 0;
}
form button,
form .button {
padding: 5px 10px;
margin: 0 0 0 5px;
border-radius: 5px;
border: 3px solid rgba(255, 255, 255, 0);
cursor: pointer;
font-weight: bold;
background-color: var(--color-highlight);
color: var(--color-text-main);
display: inline-block;
}
form button.toggle,
form .button.toggle {
background: var(--color-bg-main);
}
form button.toggle.enabled,
form .button.toggle.enabled {
background: var(--color-highlight);
}
form button:hover,
form .button:hover {
border: 3px solid rgba(255, 255, 255, 0.3);
}
.right-column form button,
.right-column form .button {
padding: 2px 6px;
}
/* Identities */ /* Identities */
h1.identity { h1.identity {
@ -350,29 +488,26 @@ h1.identity small {
overflow: hidden; overflow: hidden;
} }
.left-column .post {
background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
padding: 10px;
}
.post .icon { .post .icon {
height: 48px; height: 48px;
width: auto; width: auto;
float: left; float: left;
} }
.post .author { .post .handle {
padding-left: 64px; display: block;
} padding: 7px 0 10px 64px;
.post .author a,
.post time a {
color: inherit;
text-decoration: none;
}
.post .author small {
font-weight: normal;
color: var(--color-text-dull);
} }
.post time { .post time {
display: block; display: block;
float: right;
padding-left: 64px; padding-left: 64px;
color: var(--color-text-duller); color: var(--color-text-duller);
} }

1
static/js/hyperscript.min.js vendored Executable file

File diff suppressed because one or more lines are too long

View file

@ -12,7 +12,6 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"crispy_forms",
"core", "core",
"activities", "activities",
"users", "users",

View file

@ -10,7 +10,6 @@ MIDDLEWARE.insert(0, "core.middleware.AlwaysSecureMiddleware")
# Ensure debug features are on # Ensure debug features are on
DEBUG = True DEBUG = True
CRISPY_FAIL_SILENTLY = False
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [

View file

@ -11,7 +11,6 @@ except KeyError:
# Ensure debug features are off # Ensure debug features are off
DEBUG = False DEBUG = False
CRISPY_FAIL_SILENTLY = True
# TODO: Allow better setting of allowed_hosts, if we need to # TODO: Allow better setting of allowed_hosts, if we need to
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]

View file

@ -1,12 +1,15 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from activities.views import timelines
from core import views as core from core import views as core
from stator import views as stator from stator import views as stator
from users.views import activitypub, auth, identity from users.views import activitypub, auth, identity
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
# Activity views
path("federated/", timelines.Federated.as_view()),
# Authentication # Authentication
path("auth/login/", auth.Login.as_view()), path("auth/login/", auth.Login.as_view()),
path("auth/logout/", auth.Logout.as_view()), path("auth/logout/", auth.Logout.as_view()),

View file

@ -0,0 +1,6 @@
<nav>
<a href="/" {% if current_page == "home" %}class="selected"{% endif %}>Home</a>
<a href="/" {% if current_page == "mentions" %}class="selected"{% endif %}>Mentions</a>
<a href="/" {% if current_page == "public" %}class="selected"{% endif %}>Public</a>
<a href="/federated/" {% if current_page == "federated" %}class="selected"{% endif %}>Federated</a>
</nav>

View file

@ -1,4 +1,5 @@
{% load static %} {% load static %}
{% load activity_tags %}
<div class="post"> <div class="post">
{% if post.author.icon_uri %} {% if post.author.icon_uri %}
@ -7,20 +8,20 @@
<img src="{% static "img/unknown-icon-128.png" %}" class="icon"> <img src="{% static "img/unknown-icon-128.png" %}" class="icon">
{% endif %} {% endif %}
<h3 class="author">
<a href="{{ post.author.urls.view }}">
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
</a>
</h3>
<time> <time>
<a href="{{ post.urls.view }}"> <a href="{{ post.urls.view }}">
{% if post.authored %} {% if post.authored %}
{{ post.authored | timesince }} ago {{ post.authored | timedeltashort }}
{% else %} {% else %}
{{ post.created | timesince }} ago {{ post.created | timedeltashort }}
{% endif %} {% endif %}
</a> </a>
</time> </time>
<a href="{{ post.author.urls.view }}" class="handle">
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
</a>
<div class="content"> <div class="content">
{{ post.safe_content }} {{ post.safe_content }}
</div> </div>

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Federated Timeline{% endblock %}
{% block content %}
{% include "activities/_home_menu.html" %}
<section class="columns">
<div class="left-column">
{% for post in timeline_posts %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
</div>
<div class="right-column">
<h2>?</h2>
</div>
</section>
{% endblock %}

View file

@ -1,15 +1,32 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block content %} {% block content %}
{% include "activities/_home_menu.html" %}
{% crispy form form.helper %} <section class="columns">
<div class="left-column">
{% for post in timeline_posts %} {% for post in timeline_posts %}
{% include "activities/_post.html" %} {% include "activities/_post.html" %}
{% empty %} {% empty %}
No posts yet. No posts yet.
{% endfor %} {% endfor %}
</div>
<div class="right-column">
<h2>Compose</h2>
<form action="." method="POST" class="compose">
{% csrf_token %}
{{ form.text }}
{{ form.content_warning }}
<div class="buttons">
<span class="button toggle" _="on click toggle .enabled then toggle .hidden on #id_content_warning">CW</span>
<button>Post</button>
</div>
</form>
</div>
</section>
{% endblock %} {% endblock %}

View file

@ -1,11 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Login{% endblock %} {% block title %}Login{% endblock %}
{% block content %} {% block content %}
<section class="modal identities"> <nav>
<h1>Login</h1> <a href="." class="selected">Login</a>
{% crispy form form.helper %} </nav>
</section> <form action="." method="POST">
{% csrf_token %}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
<div class="buttons">
<button>Login</button>
</div>
</form>
{% endblock %} {% endblock %}

View file

@ -8,6 +8,7 @@
<link rel="stylesheet" href="{% static "css/style.css" %}" type="text/css" media="screen" /> <link rel="stylesheet" href="{% static "css/style.css" %}" type="text/css" media="screen" />
<link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" /> <link rel="stylesheet" href="{% static "fonts/raleway/raleway.css" %}" type="text/css" />
<link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" /> <link rel="stylesheet" href="{% static "fonts/font_awesome/all.min.css" %}" type="text/css" />
<script src="{% static "js/hyperscript.min.js" %}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">

View file

@ -0,0 +1,13 @@
<div class="field">
<label for="{{ field.id_for_label }}">
{{ field.label }}
{% if field.field.required %}<small>(Required)</small>{% endif %}
</label>
{% if field.help_text %}
<p class="help">
{{ field.help_text }}
</p>
{% endif %}
{{ field.errors }}
{{ field }}
</div>

View file

@ -1,13 +1,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Create Identity{% endblock %} {% block title %}Create Identity{% endblock %}
{% block content %} {% block content %}
{% include "identity/_identity_menu.html" %} {% include "identity/_identity_menu.html" %}
<section class="modal identities"> <form action="." method="POST">
<h1>Create Identity</h1> <h1>Create New Identity</h1>
{% crispy form form.helper %} <p>You can have multiple identities - they are totally separate, and share
</section> nothing apart from your login details. Use them for alternates, projects, and more.</p>
{% csrf_token %}
{% for field in form %}
{% include "forms/_field.html" %}
{% endfor %}
<div class="buttons">
<button>Create</button>
</div>
</form>
{% endblock %} {% endblock %}

View file

@ -3,6 +3,10 @@
{% block title %}Welcome{% endblock %} {% block title %}Welcome{% endblock %}
{% block content %} {% block content %}
<nav>
<a href="/" class="selected">Home</a>
</nav>
{% for identity in identities %} {% for identity in identities %}
<a href="{{ identity.urls.view }}">{{ identity }}</a> <a href="{{ identity.urls.view }}">{{ identity }}</a>
{% endfor %} {% endfor %}

View file

@ -1,12 +1,7 @@
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
from core.forms import FormHelper
class Login(LoginView): class Login(LoginView):
class form_class(AuthenticationForm):
helper = FormHelper(submit_text="Login")
template_name = "auth/login.html" template_name = "auth/login.html"

View file

@ -8,7 +8,6 @@ from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
from core.config import Config from core.config import Config
from core.forms import FormHelper
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Domain, Follow, Identity, IdentityStates from users.models import Domain, Follow, Identity, IdentityStates
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -83,28 +82,31 @@ class CreateIdentity(FormView):
template_name = "identity/create.html" template_name = "identity/create.html"
class form_class(forms.Form): class form_class(forms.Form):
username = forms.CharField() username = forms.CharField(
name = forms.CharField() help_text="Must be unique on your domain. Cannot be changed easily. Use only: a-z 0-9 _ -"
)
helper = FormHelper(submit_text="Create") domain = forms.ChoiceField(
help_text="Pick the domain to make this identity on. Cannot be changed later."
)
name = forms.CharField(
help_text="The display name other users see. You can change this easily."
)
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["domain"] = forms.ChoiceField( self.fields["domain"].choices = [
choices=[
(domain.domain, domain.domain) (domain.domain, domain.domain)
for domain in Domain.available_for_user(user) for domain in Domain.available_for_user(user)
] ]
)
def clean_username(self): def clean_username(self):
# Remove any leading @ # Remove any leading @ and force it lowercase
value = self.cleaned_data["username"].lstrip("@") value = self.cleaned_data["username"].lstrip("@").lower()
# Validate it's all ascii characters # Validate it's all ascii characters
for character in value: for character in value:
if character not in string.ascii_letters + string.digits + "_-": if character not in string.ascii_letters + string.digits + "_-":
raise forms.ValidationError( raise forms.ValidationError(
"Only the letters a-z, numbers 0-9, dashes and underscores are allowed." "Only the letters a-z, numbers 0-9, dashes, and underscores are allowed."
) )
return value return value