UI/Domains Refactor

Redoes the UI to remove timelines, promote domains, and a lot of other things to support the refactor.
This commit is contained in:
Andrew Godwin 2023-05-03 22:42:37 -06:00 committed by GitHub
parent 7331591432
commit 8f57aa5f37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
135 changed files with 1966 additions and 2381 deletions

View File

@ -18,14 +18,10 @@ jobs:
python-version: ["3.10", "3.11"] python-version: ["3.10", "3.11"]
db: db:
- "postgres://postgres:postgres@localhost/postgres" - "postgres://postgres:postgres@localhost/postgres"
- "sqlite:///takahe.db"
include: include:
- db: "postgres://postgres:postgres@localhost/postgres" - db: "postgres://postgres:postgres@localhost/postgres"
db_name: postgres db_name: postgres
search: true search: true
- db: "sqlite:///takahe.db"
db_name: sqlite
search: false
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2 on 2023-04-29 18:49
import django.contrib.postgres.indexes
import django.contrib.postgres.search
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("activities", "0013_postattachment_author"),
]
operations = [
migrations.AddIndex(
model_name="post",
index=django.contrib.postgres.indexes.GinIndex(
django.contrib.postgres.search.SearchVector(
"content", config="english"
),
name="content_vector_gin",
),
),
]

View File

@ -11,6 +11,7 @@ import httpx
import urlman import urlman
from asgiref.sync import async_to_sync, sync_to_async from asgiref.sync import async_to_sync, sync_to_async
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector
from django.db import models, transaction from django.db import models, transaction
from django.template import loader from django.template import loader
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
@ -312,6 +313,10 @@ class Post(StatorModel):
class Meta: class Meta:
indexes = [ indexes = [
GinIndex(fields=["hashtags"], name="hashtags_gin"), GinIndex(fields=["hashtags"], name="hashtags_gin"),
GinIndex(
SearchVector("content", config="english"),
name="content_vector_gin",
),
models.Index( models.Index(
fields=["visibility", "local", "published"], fields=["visibility", "local", "published"],
name="ix_post_local_public_published", name="ix_post_local_public_published",

View File

@ -72,7 +72,12 @@ class PostService:
def unboost_as(self, identity: Identity): def unboost_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.boost) self.uninteract_as(identity, PostInteraction.Types.boost)
def context(self, identity: Identity | None) -> tuple[list[Post], list[Post]]: def context(
self,
identity: Identity | None,
num_ancestors: int = 10,
num_descendants: int = 50,
) -> tuple[list[Post], list[Post]]:
""" """
Returns ancestor/descendant information. Returns ancestor/descendant information.
@ -82,7 +87,6 @@ class PostService:
If identity is provided, includes mentions/followers-only posts they If identity is provided, includes mentions/followers-only posts they
can see. Otherwise, shows unlisted and above only. can see. Otherwise, shows unlisted and above only.
""" """
num_ancestors = 10
num_descendants = 50 num_descendants = 50
# Retrieve ancestors via parent walk # Retrieve ancestors via parent walk
ancestors: list[Post] = [] ancestors: list[Post] = []

View File

@ -123,6 +123,12 @@ class SearchService:
results.add(hashtag) results.add(hashtag)
return results return results
def search_post_content(self):
"""
Searches for posts on an identity via full text search
"""
return self.identity.posts.filter(content__search=self.query)[:50]
def search_all(self): def search_all(self):
""" """
Returns all possible results for a search Returns all possible results for a search

View File

@ -47,12 +47,15 @@ class TimelineService:
) )
def local(self) -> models.QuerySet[Post]: def local(self) -> models.QuerySet[Post]:
return ( queryset = (
PostService.queryset() PostService.queryset()
.local_public() .local_public()
.filter(author__restriction=Identity.Restriction.none) .filter(author__restriction=Identity.Restriction.none)
.order_by("-id") .order_by("-id")
) )
if self.identity is not None:
queryset = queryset.filter(author__domain=self.identity.domain)
return queryset
def federated(self) -> models.QuerySet[Post]: def federated(self) -> models.QuerySet[Post]:
return ( return (

View File

@ -1,27 +1,17 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import redirect
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.generic import FormView from django.views.generic import FormView
from activities.models import ( from activities.models import Post, PostAttachment, PostAttachmentStates, TimelineEvent
Post,
PostAttachment,
PostAttachmentStates,
PostStates,
TimelineEvent,
)
from core.files import blurhash_image, resize_image from core.files import blurhash_image, resize_image
from core.html import FediverseHtmlParser
from core.models import Config from core.models import Config
from users.decorators import identity_required from users.views.base import IdentityViewMixin
@method_decorator(identity_required, name="dispatch") class Compose(IdentityViewMixin, FormView):
class Compose(FormView):
template_name = "activities/compose.html" template_name = "activities/compose.html"
class form_class(forms.Form): class form_class(forms.Form):
@ -33,6 +23,7 @@ class Compose(FormView):
}, },
) )
) )
visibility = forms.ChoiceField( visibility = forms.ChoiceField(
choices=[ choices=[
(Post.Visibilities.public, "Public"), (Post.Visibilities.public, "Public"),
@ -42,6 +33,7 @@ class Compose(FormView):
(Post.Visibilities.mentioned, "Mentioned Only"), (Post.Visibilities.mentioned, "Mentioned Only"),
], ],
) )
content_warning = forms.CharField( content_warning = forms.CharField(
required=False, required=False,
label=Config.lazy_system_value("content_warning_text"), label=Config.lazy_system_value("content_warning_text"),
@ -52,11 +44,42 @@ class Compose(FormView):
), ),
help_text="Optional - Post will be hidden behind this text until clicked", help_text="Optional - Post will be hidden behind this text until clicked",
) )
reply_to = forms.CharField(widget=forms.HiddenInput(), required=False)
def __init__(self, request, *args, **kwargs): image = forms.ImageField(
required=False,
help_text="Optional - For multiple image uploads and cropping, please use an app",
widget=forms.FileInput(
attrs={
"_": f"""
on change
if me.files[0].size > {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB * 1024 ** 2}
add [@disabled=] to #upload
remove <ul.errorlist/>
make <ul.errorlist/> called errorlist
make <li/> called error
set size_in_mb to (me.files[0].size / 1024 / 1024).toFixed(2)
put 'File must be {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB}MB or less (actual: ' + size_in_mb + 'MB)' into error
put error into errorlist
put errorlist before me
else
remove @disabled from #upload
remove <ul.errorlist/>
end
end
"""
}
),
)
image_caption = forms.CharField(
required=False,
help_text="Provide an image caption for the visually impaired",
)
def __init__(self, identity, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request self.identity = identity
self.fields["text"].widget.attrs[ self.fields["text"].widget.attrs[
"_" "_"
] = rf""" ] = rf"""
@ -83,7 +106,7 @@ class Compose(FormView):
def clean_text(self): def clean_text(self):
text = self.cleaned_data.get("text") text = self.cleaned_data.get("text")
# Check minimum interval # Check minimum interval
last_post = self.request.identity.posts.order_by("-created").first() last_post = self.identity.posts.order_by("-created").first()
if ( if (
last_post last_post
and (timezone.now() - last_post.created).total_seconds() and (timezone.now() - last_post.created).total_seconds()
@ -102,184 +125,75 @@ class Compose(FormView):
) )
return text return text
def clean_image(self):
value = self.cleaned_data.get("image")
if value:
max_mb = settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB
max_bytes = max_mb * 1024 * 1024
if value.size > max_bytes:
# Erase the file from our data to stop trying to show it again
self.files = {}
raise forms.ValidationError(
f"File must be {max_mb}MB or less (actual: {value.size / 1024 ** 2:.2f})"
)
return value
def get_form(self, form_class=None): def get_form(self, form_class=None):
return self.form_class(request=self.request, **self.get_form_kwargs()) return self.form_class(identity=self.identity, **self.get_form_kwargs())
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
if self.post_obj: initial["visibility"] = self.identity.config_identity.default_post_visibility
initial.update(
{
"reply_to": self.reply_to.pk if self.reply_to else "",
"visibility": self.post_obj.visibility,
"text": FediverseHtmlParser(self.post_obj.content).plain_text,
"content_warning": self.post_obj.summary,
}
)
else:
initial[
"visibility"
] = self.request.identity.config_identity.default_post_visibility
if self.reply_to:
initial["reply_to"] = self.reply_to.pk
if self.reply_to.visibility == Post.Visibilities.public:
initial[
"visibility"
] = self.request.identity.config_identity.default_reply_visibility
else:
initial["visibility"] = self.reply_to.visibility
initial["content_warning"] = self.reply_to.summary
# Build a set of mentions for the content to start as
mentioned = {self.reply_to.author}
mentioned.update(self.reply_to.mentions.all())
mentioned.discard(self.request.identity)
initial["text"] = "".join(
f"@{identity.handle} "
for identity in mentioned
if identity.username
)
return initial return initial
def form_valid(self, form): def form_valid(self, form):
# Gather any attachment objects now, they're not in the form proper # See if we need to make an image attachment
attachments = [] attachments = []
if "attachment" in self.request.POST: if form.cleaned_data.get("image"):
attachments = PostAttachment.objects.filter( main_file = resize_image(
pk__in=self.request.POST.getlist("attachment", []) form.cleaned_data["image"],
size=(2000, 2000),
cover=False,
) )
# Dispatch based on edit or not thumbnail_file = resize_image(
if self.post_obj: form.cleaned_data["image"],
self.post_obj.edit_local( size=(400, 225),
content=form.cleaned_data["text"], cover=True,
summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"],
attachments=attachments,
) )
self.post_obj.transition_perform(PostStates.edited) attachment = PostAttachment.objects.create(
else: blurhash=blurhash_image(thumbnail_file),
post = Post.create_local( mimetype="image/webp",
author=self.request.identity, width=main_file.image.width,
content=form.cleaned_data["text"], height=main_file.image.height,
summary=form.cleaned_data.get("content_warning"), name=form.cleaned_data.get("image_caption"),
visibility=form.cleaned_data["visibility"], state=PostAttachmentStates.fetched,
reply_to=self.reply_to, author=self.identity,
attachments=attachments,
) )
# Add their own timeline event for immediate visibility attachment.file.save(
TimelineEvent.add_post(self.request.identity, post) main_file.name,
return redirect("/") main_file,
)
def dispatch(self, request, handle=None, post_id=None, *args, **kwargs): attachment.thumbnail.save(
self.post_obj = None thumbnail_file.name,
if handle and post_id: thumbnail_file,
# Make sure the request identity owns the post! )
if handle != request.identity.handle: attachment.save()
raise PermissionDenied("Post author is not requestor") attachments.append(attachment)
# Create the post
self.post_obj = get_object_or_404(request.identity.posts, pk=post_id) post = Post.create_local(
author=self.identity,
# Grab the reply-to post info now content=form.cleaned_data["text"],
self.reply_to = None summary=form.cleaned_data.get("content_warning"),
reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to") visibility=form.cleaned_data["visibility"],
if reply_to_id: attachments=attachments,
try: )
self.reply_to = Post.objects.get(pk=reply_to_id) # Add their own timeline event for immediate visibility
except Post.DoesNotExist: TimelineEvent.add_post(self.identity, post)
pass messages.success(self.request, "Your post was created.")
# Keep going with normal rendering return redirect(".")
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["reply_to"] = self.reply_to context["identity"] = self.identity
if self.post_obj: context["section"] = "compose"
context["post"] = self.post_obj
return context return context
@method_decorator(identity_required, name="dispatch")
class ImageUpload(FormView):
"""
Handles image upload - returns a new input type hidden to embed in
the main form that references an orphaned PostAttachment
"""
template_name = "activities/_image_upload.html"
class form_class(forms.Form):
image = forms.ImageField(
widget=forms.FileInput(
attrs={
"_": f"""
on change
if me.files[0].size > {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB * 1024 ** 2}
add [@disabled=] to #upload
remove <ul.errorlist/>
make <ul.errorlist/> called errorlist
make <li/> called error
set size_in_mb to (me.files[0].size / 1024 / 1024).toFixed(2)
put 'File must be {settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB}MB or less (actual: ' + size_in_mb + 'MB)' into error
put error into errorlist
put errorlist before me
else
remove @disabled from #upload
remove <ul.errorlist/>
end
end
"""
}
)
)
description = forms.CharField(required=False)
def clean_image(self):
value = self.cleaned_data["image"]
max_mb = settings.SETUP.MEDIA_MAX_IMAGE_FILESIZE_MB
max_bytes = max_mb * 1024 * 1024
if value.size > max_bytes:
# Erase the file from our data to stop trying to show it again
self.files = {}
raise forms.ValidationError(
f"File must be {max_mb}MB or less (actual: {value.size / 1024 ** 2:.2f})"
)
return value
def form_invalid(self, form):
return super().form_invalid(form)
def form_valid(self, form):
# Make a PostAttachment
main_file = resize_image(
form.cleaned_data["image"],
size=(2000, 2000),
cover=False,
)
thumbnail_file = resize_image(
form.cleaned_data["image"],
size=(400, 225),
cover=True,
)
attachment = PostAttachment.objects.create(
blurhash=blurhash_image(thumbnail_file),
mimetype="image/webp",
width=main_file.image.width,
height=main_file.image.height,
name=form.cleaned_data.get("description"),
state=PostAttachmentStates.fetched,
author=self.request.identity,
)
attachment.file.save(
main_file.name,
main_file,
)
attachment.thumbnail.save(
thumbnail_file.name,
thumbnail_file,
)
attachment.save()
# Return the response, with a hidden input plus a note
return render(
self.request, "activities/_image_uploaded.html", {"attachment": attachment}
)

View File

@ -1,26 +0,0 @@
from django.views.generic import ListView
from activities.models import Hashtag
class ExploreTag(ListView):
template_name = "activities/explore_tag.html"
extra_context = {
"current_page": "explore",
"allows_refresh": True,
}
paginate_by = 20
def get_queryset(self):
return (
Hashtag.objects.public()
.filter(
stats__total__gt=0,
)
.order_by("-stats__total")
)[:20]
class Explore(ExploreTag):
pass

View File

@ -1,38 +0,0 @@
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
from django.views.generic import View
from activities.models.hashtag import Hashtag
from users.decorators import identity_required
@method_decorator(identity_required, name="dispatch")
class HashtagFollow(View):
"""
Follows/unfollows a hashtag with the current identity
"""
undo = False
def post(self, request: HttpRequest, hashtag):
hashtag = get_object_or_404(
Hashtag,
pk=hashtag,
)
follow = None
if self.undo:
request.identity.hashtag_follows.filter(hashtag=hashtag).delete()
else:
follow = request.identity.hashtag_follows.get_or_create(hashtag=hashtag)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_hashtag_follow.html",
{
"hashtag": hashtag,
"follow": follow,
},
)
return redirect(hashtag.urls.view)

View File

@ -1,15 +1,13 @@
from django.core.exceptions import PermissionDenied
from django.http import Http404, JsonResponse from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers from django.views.decorators.vary import vary_on_headers
from django.views.generic import TemplateView, View from django.views.generic import TemplateView
from activities.models import Post, PostInteraction, PostStates from activities.models import Post, PostStates
from activities.services import PostService from activities.services import PostService
from core.decorators import cache_page_by_ap_json from core.decorators import cache_page_by_ap_json
from core.ld import canonicalise from core.ld import canonicalise
from users.decorators import identity_required
from users.models import Identity from users.models import Identity
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -19,7 +17,6 @@ from users.shortcuts import by_handle_or_404
) )
@method_decorator(vary_on_headers("Accept"), name="dispatch") @method_decorator(vary_on_headers("Accept"), name="dispatch")
class Individual(TemplateView): class Individual(TemplateView):
template_name = "activities/post.html" template_name = "activities/post.html"
identity: Identity identity: Identity
@ -32,7 +29,7 @@ class Individual(TemplateView):
self.post_obj = get_object_or_404( self.post_obj = get_object_or_404(
PostService.queryset() PostService.queryset()
.filter(author=self.identity) .filter(author=self.identity)
.visible_to(request.identity, include_replies=True), .unlisted(include_replies=True),
pk=post_id, pk=post_id,
) )
if self.post_obj.state in [PostStates.deleted, PostStates.deleted_fanned_out]: if self.post_obj.state in [PostStates.deleted, PostStates.deleted_fanned_out]:
@ -49,20 +46,17 @@ class Individual(TemplateView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
ancestors, descendants = PostService(self.post_obj).context( ancestors, descendants = PostService(self.post_obj).context(
self.request.identity identity=None, num_ancestors=2
) )
context.update( context.update(
{ {
"identity": self.identity, "identity": self.identity,
"post": self.post_obj, "post": self.post_obj,
"interactions": PostInteraction.get_post_interactions(
[self.post_obj] + ancestors + descendants,
self.request.identity,
),
"link_original": True, "link_original": True,
"ancestors": ancestors, "ancestors": ancestors,
"descendants": descendants, "descendants": descendants,
"public_styling": True,
} }
) )
@ -76,128 +70,3 @@ class Individual(TemplateView):
canonicalise(self.post_obj.to_ap(), include_security=True), canonicalise(self.post_obj.to_ap(), include_security=True),
content_type="application/activity+json", content_type="application/activity+json",
) )
@method_decorator(identity_required, name="dispatch")
class Like(View):
"""
Adds/removes a like from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
service = PostService(post)
if self.undo:
service.unlike_as(request.identity)
else:
service.like_as(request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_like.html",
{
"post": post,
"interactions": {"like": set() if self.undo else {post.pk}},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
class Boost(View):
"""
Adds/removes a boost from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
service = PostService(post)
if self.undo:
service.unboost_as(request.identity)
else:
service.boost_as(request.identity)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_boost.html",
{
"post": post,
"interactions": {"boost": set() if self.undo else {post.pk}},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
class Bookmark(View):
"""
Adds/removes a bookmark from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
if self.undo:
request.identity.bookmarks.filter(post=post).delete()
else:
request.identity.bookmarks.get_or_create(post=post)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_bookmark.html",
{
"post": post,
"bookmarks": set() if self.undo else {post.pk},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
class Delete(TemplateView):
"""
Deletes a post
"""
template_name = "activities/post_delete.html"
def dispatch(self, request, handle, post_id):
# Make sure the request identity owns the post!
if handle != request.identity.handle:
raise PermissionDenied("Post author is not requestor")
self.identity = by_handle_or_404(self.request, handle, local=False)
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
return super().dispatch(request)
def get_context_data(self):
return {"post": self.post_obj}
def post(self, request):
PostService(self.post_obj).delete()
return redirect("/")

View File

@ -1,22 +0,0 @@
from django import forms
from django.views.generic import FormView
from activities.services import SearchService
class Search(FormView):
template_name = "activities/search.html"
class form_class(forms.Form):
query = forms.CharField(
help_text="Search for:\nA user by @username@domain or their profile URL\nA hashtag by #tagname\nA post by its URL",
widget=forms.TextInput(attrs={"type": "search", "autofocus": "autofocus"}),
)
def form_valid(self, form):
searcher = SearchService(form.cleaned_data["query"], self.request.identity)
# Render results
context = self.get_context_data(form=form)
context["results"] = searcher.search_all()
return self.render_to_response(context)

View File

@ -1,53 +1,35 @@
from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import ListView, TemplateView from django.views.generic import ListView, TemplateView
from activities.models import Hashtag, PostInteraction, TimelineEvent from activities.models import Hashtag, TimelineEvent
from activities.services import TimelineService from activities.services import TimelineService
from core.decorators import cache_page from core.decorators import cache_page
from users.decorators import identity_required from users.models import Identity
from users.models import Bookmark, HashtagFollow from users.views.base import IdentityViewMixin
from .compose import Compose
@method_decorator(identity_required, name="dispatch") @method_decorator(login_required, name="dispatch")
class Home(TemplateView): class Home(TemplateView):
"""
Homepage for logged-in users - shows identities primarily.
"""
template_name = "activities/home.html" template_name = "activities/home.html"
form_class = Compose.form_class
def get_form(self, form_class=None):
return self.form_class(request=self.request, **self.get_form_kwargs())
def get_context_data(self): def get_context_data(self):
events = TimelineService(self.request.identity).home() return {
paginator = Paginator(events, 25) "identities": Identity.objects.filter(
page_number = self.request.GET.get("page") users__pk=self.request.user.pk
event_page = paginator.get_page(page_number) ).order_by("created"),
context = {
"interactions": PostInteraction.get_event_interactions(
event_page,
self.request.identity,
),
"bookmarks": Bookmark.for_identity(
self.request.identity, event_page, "subject_post_id"
),
"current_page": "home",
"allows_refresh": True,
"page_obj": event_page,
"form": self.form_class(request=self.request),
} }
return context
@method_decorator( @method_decorator(
cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch" cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch"
) )
class Tag(ListView): class Tag(ListView):
template_name = "activities/tag.html" template_name = "activities/tag.html"
extra_context = { extra_context = {
"current_page": "tag", "current_page": "tag",
@ -64,77 +46,15 @@ class Tag(ListView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
return TimelineService(self.request.identity).hashtag(self.hashtag) return TimelineService(None).hashtag(self.hashtag)
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["hashtag"] = self.hashtag context["hashtag"] = self.hashtag
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
context["follow"] = HashtagFollow.maybe_get(
self.request.identity,
self.hashtag,
)
return context return context
@method_decorator( class Notifications(IdentityViewMixin, ListView):
cache_page("cache_timeout_page_timeline", public_only=True), name="dispatch"
)
class Local(ListView):
template_name = "activities/local.html"
extra_context = {
"current_page": "local",
"allows_refresh": True,
}
paginate_by = 25
def get_queryset(self):
return TimelineService(self.request.identity).local()
def get_context_data(self):
context = super().get_context_data()
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context
@method_decorator(identity_required, name="dispatch")
class Federated(ListView):
template_name = "activities/federated.html"
extra_context = {
"current_page": "federated",
"allows_refresh": True,
}
paginate_by = 25
def get_queryset(self):
return TimelineService(self.request.identity).federated()
def get_context_data(self):
context = super().get_context_data()
context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context
@method_decorator(identity_required, name="dispatch")
class Notifications(ListView):
template_name = "activities/notifications.html" template_name = "activities/notifications.html"
extra_context = { extra_context = {
"current_page": "notifications", "current_page": "notifications",
@ -146,7 +66,6 @@ class Notifications(ListView):
"boosted": TimelineEvent.Types.boosted, "boosted": TimelineEvent.Types.boosted,
"mentioned": TimelineEvent.Types.mentioned, "mentioned": TimelineEvent.Types.mentioned,
"liked": TimelineEvent.Types.liked, "liked": TimelineEvent.Types.liked,
"identity_created": TimelineEvent.Types.identity_created,
} }
def get_queryset(self): def get_queryset(self):
@ -164,7 +83,7 @@ class Notifications(ListView):
for type_name, type in self.notification_types.items(): for type_name, type in self.notification_types.items():
if notification_options.get(type_name, True): if notification_options.get(type_name, True):
types.append(type) types.append(type)
return TimelineService(self.request.identity).notifications(types) return TimelineService(self.identity).notifications(types)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -185,12 +104,6 @@ class Notifications(ListView):
events.append(event) events.append(event)
# Retrieve what kinds of things to show # Retrieve what kinds of things to show
context["events"] = events context["events"] = events
context["identity"] = self.identity
context["notification_options"] = self.request.session["notification_options"] context["notification_options"] = self.request.session["notification_options"]
context["interactions"] = PostInteraction.get_event_interactions(
context["page_obj"],
self.request.identity,
)
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"], "subject_post_id"
)
return context return context

View File

@ -6,8 +6,7 @@ from django.http import JsonResponse
def identity_required(function): def identity_required(function):
""" """
API version of the identity_required decorator that just makes sure the Makes sure the token is tied to an identity, not an app only.
token is tied to one, not an app only.
""" """
@wraps(function) @wraps(function)

View File

@ -1,3 +1,5 @@
import secrets
from django.db import models from django.db import models
@ -17,3 +19,23 @@ class Application(models.Model):
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
@classmethod
def create(
cls,
client_name: str,
redirect_uris: str,
website: str | None,
scopes: str | None = None,
):
client_id = "tk-" + secrets.token_urlsafe(16)
client_secret = secrets.token_urlsafe(40)
return cls.objects.create(
name=client_name,
website=website,
client_id=client_id,
client_secret=client_secret,
redirect_uris=redirect_uris,
scopes=scopes or "read",
)

View File

@ -1,3 +1,4 @@
import urlman
from django.db import models from django.db import models
@ -37,6 +38,9 @@ class Token(models.Model):
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
revoked = models.DateTimeField(blank=True, null=True) revoked = models.DateTimeField(blank=True, null=True)
class urls(urlman.Urls):
edit = "/@{self.identity.handle}/settings/tokens/{self.id}/"
def has_scope(self, scope: str): def has_scope(self, scope: str):
""" """
Returns if this token has the given scope. Returns if this token has the given scope.

View File

@ -1,5 +1,3 @@
import secrets
from hatchway import QueryOrBody, api_view from hatchway import QueryOrBody, api_view
from .. import schemas from .. import schemas
@ -14,14 +12,10 @@ def add_app(
scopes: QueryOrBody[None | str] = None, scopes: QueryOrBody[None | str] = None,
website: QueryOrBody[None | str] = None, website: QueryOrBody[None | str] = None,
) -> schemas.Application: ) -> schemas.Application:
client_id = "tk-" + secrets.token_urlsafe(16) application = Application.create(
client_secret = secrets.token_urlsafe(40) client_name=client_name,
application = Application.objects.create(
name=client_name,
website=website, website=website,
client_id=client_id,
client_secret=client_secret,
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
scopes=scopes or "read", scopes=scopes,
) )
return schemas.Application.from_orm(application) return schemas.Application.from_orm(application)

View File

@ -4,9 +4,6 @@ from core.models import Config
def config_context(request): def config_context(request):
return { return {
"config": Config.system, "config": Config.system,
"config_identity": (
request.identity.config_identity if request.identity else None
),
"top_section": request.path.strip("/").split("/")[0], "top_section": request.path.strip("/").split("/")[0],
"opengraph_defaults": { "opengraph_defaults": {
"og:site_name": Config.system.site_name, "og:site_name": Config.system.site_name,

View File

@ -20,16 +20,6 @@ def vary_by_ap_json(request, *args, **kwargs) -> str:
return "not_ap" return "not_ap"
def vary_by_identity(request, *args, **kwargs) -> str:
"""
Return a cache usable string token that is different based upon the
request.identity
"""
if request.identity:
return f"ident{request.identity.pk}"
return "identNone"
def cache_page( def cache_page(
timeout: int | str = "cache_timeout_page_default", timeout: int | str = "cache_timeout_page_default",
*, *,

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2 on 2023-04-29 18:49
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0016_hashtagfollow"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0001_initial"),
]
operations = [
migrations.AlterUniqueTogether(
name="config",
unique_together=set(),
),
migrations.AddField(
model_name="config",
name="domain",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="configs",
to="users.domain",
),
),
migrations.AlterUniqueTogether(
name="config",
unique_together={("key", "user", "identity", "domain")},
),
]

View File

@ -43,6 +43,14 @@ class Config(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
domain = models.ForeignKey(
"users.domain",
blank=True,
null=True,
related_name="configs",
on_delete=models.CASCADE,
)
json = models.JSONField(blank=True, null=True) json = models.JSONField(blank=True, null=True)
image = models.ImageField( image = models.ImageField(
blank=True, blank=True,
@ -52,7 +60,7 @@ class Config(models.Model):
class Meta: class Meta:
unique_together = [ unique_together = [
("key", "user", "identity"), ("key", "user", "identity", "domain"),
] ]
system: ClassVar["Config.ConfigOptions"] # type: ignore system: ClassVar["Config.ConfigOptions"] # type: ignore
@ -86,7 +94,7 @@ class Config(models.Model):
""" """
return cls.load_values( return cls.load_values(
cls.SystemOptions, cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True}, {"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -96,7 +104,7 @@ class Config(models.Model):
""" """
return await sync_to_async(cls.load_values)( return await sync_to_async(cls.load_values)(
cls.SystemOptions, cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True}, {"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -106,7 +114,7 @@ class Config(models.Model):
""" """
return cls.load_values( return cls.load_values(
cls.UserOptions, cls.UserOptions,
{"identity__isnull": True, "user": user}, {"identity__isnull": True, "user": user, "domain__isnull": True},
) )
@classmethod @classmethod
@ -116,7 +124,7 @@ class Config(models.Model):
""" """
return await sync_to_async(cls.load_values)( return await sync_to_async(cls.load_values)(
cls.UserOptions, cls.UserOptions,
{"identity__isnull": True, "user": user}, {"identity__isnull": True, "user": user, "domain__isnull": True},
) )
@classmethod @classmethod
@ -126,7 +134,7 @@ class Config(models.Model):
""" """
return cls.load_values( return cls.load_values(
cls.IdentityOptions, cls.IdentityOptions,
{"identity": identity, "user__isnull": True}, {"identity": identity, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -136,7 +144,27 @@ class Config(models.Model):
""" """
return await sync_to_async(cls.load_values)( return await sync_to_async(cls.load_values)(
cls.IdentityOptions, cls.IdentityOptions,
{"identity": identity, "user__isnull": True}, {"identity": identity, "user__isnull": True, "domain__isnull": True},
)
@classmethod
def load_domain(cls, domain):
"""
Loads an domain config options object
"""
return cls.load_values(
cls.DomainOptions,
{"domain": domain, "user__isnull": True, "identity__isnull": True},
)
@classmethod
async def aload_domain(cls, domain):
"""
Async loads an domain config options object
"""
return await sync_to_async(cls.load_values)(
cls.DomainOptions,
{"domain": domain, "user__isnull": True, "identity__isnull": True},
) )
@classmethod @classmethod
@ -170,7 +198,7 @@ class Config(models.Model):
key, key,
value, value,
cls.SystemOptions, cls.SystemOptions,
{"identity__isnull": True, "user__isnull": True}, {"identity__isnull": True, "user__isnull": True, "domain__isnull": True},
) )
@classmethod @classmethod
@ -179,7 +207,7 @@ class Config(models.Model):
key, key,
value, value,
cls.UserOptions, cls.UserOptions,
{"identity__isnull": True, "user": user}, {"identity__isnull": True, "user": user, "domain__isnull": True},
) )
@classmethod @classmethod
@ -188,7 +216,16 @@ class Config(models.Model):
key, key,
value, value,
cls.IdentityOptions, cls.IdentityOptions,
{"identity": identity, "user__isnull": True}, {"identity": identity, "user__isnull": True, "domain__isnull": True},
)
@classmethod
def set_domain(cls, domain, key, value):
cls.set_value(
key,
value,
cls.DomainOptions,
{"domain": domain, "user__isnull": True, "identity__isnull": True},
) )
class SystemOptions(pydantic.BaseModel): class SystemOptions(pydantic.BaseModel):
@ -210,6 +247,7 @@ class Config(models.Model):
policy_terms: str = "" policy_terms: str = ""
policy_privacy: str = "" policy_privacy: str = ""
policy_rules: str = "" policy_rules: str = ""
policy_issues: str = ""
signup_allowed: bool = True signup_allowed: bool = True
signup_text: str = "" signup_text: str = ""
@ -239,20 +277,23 @@ class Config(models.Model):
custom_head: str | None custom_head: str | None
class UserOptions(pydantic.BaseModel): class UserOptions(pydantic.BaseModel):
light_theme: bool = False
pass
class IdentityOptions(pydantic.BaseModel): class IdentityOptions(pydantic.BaseModel):
toot_mode: bool = False toot_mode: bool = False
default_post_visibility: int = 0 # Post.Visibilities.public default_post_visibility: int = 0 # Post.Visibilities.public
default_reply_visibility: int = 1 # Post.Visibilities.unlisted
visible_follows: bool = True visible_follows: bool = True
light_theme: bool = False search_enabled: bool = True
# wellness Options # Wellness Options
visible_reaction_counts: bool = True visible_reaction_counts: bool = True
expand_linked_cws: bool = True expand_linked_cws: bool = True
infinite_scroll: bool = True
custom_css: str | None class DomainOptions(pydantic.BaseModel):
site_name: str = ""
site_icon: UploadedImage | None = None
hide_login: bool = False
custom_css: str = ""
single_user: str = ""

View File

@ -1,14 +1,11 @@
import json
from typing import ClassVar from typing import ClassVar
import markdown_it import markdown_it
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.templatetags.static import static
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.views.decorators.cache import cache_control
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from django.views.static import serve from django.views.static import serve
@ -21,17 +18,18 @@ from core.models import Config
def homepage(request): def homepage(request):
if request.user.is_authenticated: if request.user.is_authenticated:
return Home.as_view()(request) return Home.as_view()(request)
elif request.domain.config_domain.single_user:
return redirect(f"/@{request.domain.config_domain.single_user}/")
else: else:
return About.as_view()(request) return About.as_view()(request)
@method_decorator(cache_page(public_only=True), name="dispatch") @method_decorator(cache_page(public_only=True), name="dispatch")
class About(TemplateView): class About(TemplateView):
template_name = "about.html" template_name = "about.html"
def get_context_data(self): def get_context_data(self):
service = TimelineService(self.request.identity) service = TimelineService(None)
return { return {
"current_page": "about", "current_page": "about",
"content": mark_safe( "content": mark_safe(
@ -87,46 +85,6 @@ class RobotsTxt(TemplateView):
} }
@method_decorator(cache_control(max_age=60 * 15), name="dispatch")
class AppManifest(StaticContentView):
"""
Serves a PWA manifest file. This is a view as we want to drive some
items from settings.
NOTE: If this view changes to need runtime Config, it should change from
StaticContentView to View, otherwise the settings will only get
picked up during boot time.
"""
content_type = "application/json"
def get_static_content(self) -> str | bytes:
return json.dumps(
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"name": "Takahē",
"short_name": "Takahē",
"start_url": "/",
"display": "standalone",
"background_color": "#26323c",
"theme_color": "#26323c",
"description": "An ActivityPub server",
"icons": [
{
"src": static("img/icon-128.png"),
"sizes": "128x128",
"type": "image/png",
},
{
"src": static("img/icon-1024.png"),
"sizes": "1024x1024",
"type": "image/png",
},
],
}
)
class FlatPage(TemplateView): class FlatPage(TemplateView):
""" """
Serves a "flat page" from a config option, Serves a "flat page" from a config option,

102
docs/releases/0.9.rst Normal file
View File

@ -0,0 +1,102 @@
0.9
===
*Not yet released*
This release is a large overhaul Takahē that removes all timeline UI elements
in the web interface in favour of apps, while reworking the remaining pages
to be a pleasant profile viewing, post viewing, and settings experience.
We've also started on our path of making individual domains much more
customisable; you can now theme them individually, the Local timeline is now
domain-specific, and domains can be set to serve single user profiles.
This release's major changes:
* The Home, Notifications, Local and Federated timelines have been removed
from the web UI. They still function for apps.
* The ability to like, boost, bookmark and reply to posts has been removed from
the web UI. They still function for apps.
* The web Compose tool has been considerably simplified and relegated to a new
"tools" section; most users should now use an app for composing posts.
* The Follows page is now in settings and is view-only.
* Identity profiles and individual post pages are now considerably simplified
and have no sidebar.
* A Search feature is now available for posts from a single identity on its
profile page; users can turn this on or off in their identity's profile
settings.
* Domains can now have their own site name, site icon, and custom CSS
* Domains can be set to a "single user mode" where they redirect to a user
profile, rather than showing their own homepage.
* Added an Authorized Apps identity settings screen, that allows seeing what apps you've
authorized, revocation of app access, and generating your own personal API
tokens.
* Added a Delete Profile settings screen that allows self-serve identity deletion.
* The logged-in homepage now shows a list of identities to select from as well
as a set of recommended apps to use for timeline interfaces.
* We have totally dropped our alpha-quality SQLite support; it just doesn't have
sufficient full-text-search and JSON operator support, unfortunately.
There are many minor changes to support the new direction; important ones include:
* The dark/light mode toggle is now a User (login) setting, not an Identity setting
* Identity selection is no longer part of a session - now, multiple identity
settings pages can be opened at once.
* The ability for users to add their own custom CSS has been removed, as it
was potentially confusing with our upcoming profile customization work (it
only ever applied to your own session, and with timelines gone, it no longer
makes much sense!)
* API pagination has been further improved, specifically for Elk compatibility
* Server admins can now add a "Report a Problem" footer link with either
hosted content or an external link.
This is a large change in direction, and we hope that it will match the way
that people use Takahē and its multi-domain support far better. For more
discussion and rationale on the change, see `Andrew's blog post about it <https://aeracode.org/2023/04/29/refactor-treat/>`_.
Our future plans include stability and polish in order to get us to a 1.0 release,
as well as allowing users to customize their profiles more, account import
support, and protocol enhancements like automatic fetching of replies for
non-local posts. If you're curious about what we're up to, or have an idea,
we're very happy to chat about it in our Discord!
If you'd like to help with code, design, other areas, see
:doc:`/contributing` to see how to get in touch.
You can download images from `Docker Hub <https://hub.docker.com/r/jointakahe/takahe>`_,
or use the image name ``jointakahe/takahe:0.9``.
Upgrade Notes
-------------
Despite the large refactor to the UI, Takahē's internals are not significantly
changed, and this upgrade is operationally like any other minor release.
Migrations
~~~~~~~~~~
There are new database migrations; they are backwards-compatible, so please
apply them before restarting your webservers and stator processes.
One of the migrations involves adding a large search index and may take some time to
process (on the order of minutes) if you have a large database.
You may wish to bring your site down into
a maintenance mode before applying it to reduce the chance of lock conflicts
slowing things down, or causing request timeouts.

View File

@ -9,7 +9,7 @@ django-hatchway~=0.5.1
django-htmx~=1.13.0 django-htmx~=1.13.0
django-oauth-toolkit~=2.2.0 django-oauth-toolkit~=2.2.0
django-storages[google,boto3]~=1.13.1 django-storages[google,boto3]~=1.13.1
django~=4.1 django~=4.1.0
email-validator~=1.3.0 email-validator~=1.3.0
gunicorn~=20.1.0 gunicorn~=20.1.0
httpx~=0.23 httpx~=0.23

View File

@ -82,13 +82,14 @@ td a {
/* Base template styling */ /* Base template styling */
:root { :root {
--color-highlight: #449c8c;
--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-bg-error: rgb(87, 32, 32);
--color-highlight: #449c8c;
--color-delete: #8b2821; --color-delete: #8b2821;
--color-header-menu: rgba(0, 0, 0, 0.5); --color-header-menu: rgba(255, 255, 255, 0.8);
--color-main-shadow: rgba(0, 0, 0, 0.6); --color-main-shadow: rgba(0, 0, 0, 0.6);
--size-main-shadow: 50px; --size-main-shadow: 50px;
@ -96,40 +97,36 @@ td a {
--color-text-dull: #99a; --color-text-dull: #99a;
--color-text-main: #fff; --color-text-main: #fff;
--color-text-link: var(--color-highlight); --color-text-link: var(--color-highlight);
--color-text-in-highlight: #fff; --color-text-in-highlight: var(--color-text-main);
--color-input-background: #26323c; --color-input-background: var(--color-bg-main);
--color-input-border: #000; --color-input-border: #000;
--color-input-border-active: #444b5d; --color-input-border-active: #444b5d;
--color-button-secondary: #2e3e4c; --color-button-secondary: #2e3e4c;
--color-button-disabled: #7c9c97; --color-button-disabled: #7c9c97;
--sm-header-height: 50px; --width-sidebar-small: 200px;
--sm-sidebar-width: 50px; --width-sidebar-medium: 250px;
--md-sidebar-width: 250px;
--md-header-height: 50px;
--emoji-height: 1.1em; --emoji-height: 1.1em;
} }
body.light-theme { body.theme-light {
--color-bg-main: #dfe3e7; --color-bg-main: #d4dee7;
--color-bg-menu: #cfd6dd; --color-bg-menu: #c0ccd8;
--color-bg-box: #f2f5f8; --color-bg-box: #f0f3f5;
--color-bg-error: rgb(219, 144, 144); --color-bg-error: rgb(219, 144, 144);
--color-highlight: #449c8c;
--color-delete: #884743; --color-delete: #884743;
--color-header-menu: rgba(0, 0, 0, 0.1); --color-header-menu: rgba(0, 0, 0, 0.7);
--color-main-shadow: rgba(0, 0, 0, 0.1); --color-main-shadow: rgba(0, 0, 0, 0.1);
--size-main-shadow: 20px; --size-main-shadow: 20px;
--color-text-duller: #3a3b3d; --color-text-duller: #4f5157;
--color-text-dull: rgb(44, 44, 48); --color-text-dull: rgb(62, 62, 68);
--color-text-main: rgb(0, 0, 0); --color-text-main: rgb(0, 0, 0);
--color-text-link: var(--color-highlight); --color-text-link: var(--color-highlight);
--color-input-background: #fff; --color-input-background: var(--color-bg-main);
--color-input-border: rgb(109, 109, 109); --color-input-border: rgb(109, 109, 109);
--color-input-border-active: #464646; --color-input-border-active: #464646;
--color-button-secondary: #5c6770; --color-button-secondary: #5c6770;
@ -145,15 +142,14 @@ body {
} }
main { main {
width: 1100px; width: 800px;
margin: 20px auto; margin: 20px auto;
box-shadow: 0 0 var(--size-main-shadow) var(--color-main-shadow); box-shadow: none;
border-radius: 5px; border-radius: 5px;
} }
.no-sidebar main { body.wide main {
box-shadow: none; width: 1100px;
max-width: 800px;
} }
footer { footer {
@ -172,10 +168,8 @@ footer a {
header { header {
display: flex; display: flex;
height: 50px; height: 42px;
} margin: -20px 0 20px 0;
.no-sidebar header {
justify-content: center; justify-content: center;
} }
@ -184,11 +178,11 @@ header .logo {
font-family: "Raleway"; font-family: "Raleway";
font-weight: bold; font-weight: bold;
background: var(--color-highlight); background: var(--color-highlight);
border-radius: 5px 0 0 0; border-radius: 0 0 5px 5px;
text-transform: lowercase; text-transform: lowercase;
padding: 10px 11px 9px 10px; padding: 6px 8px 5px 7px;
height: 50px; height: 42px;
font-size: 130%; font-size: 120%;
color: var(--color-text-in-highlight); color: var(--color-text-in-highlight);
border-bottom: 3px solid rgba(0, 0, 0, 0); border-bottom: 3px solid rgba(0, 0, 0, 0);
z-index: 10; z-index: 10;
@ -196,10 +190,6 @@ header .logo {
white-space: nowrap; white-space: nowrap;
} }
.no-sidebar header .logo {
border-radius: 5px;
}
header .logo:hover { header .logo:hover {
border-bottom: 3px solid rgba(255, 255, 255, 0.3); border-bottom: 3px solid rgba(255, 255, 255, 0.3);
} }
@ -211,31 +201,25 @@ header .logo img {
} }
header menu { header menu {
flex-grow: 1; flex-grow: 0;
display: flex; display: flex;
list-style-type: none; list-style-type: none;
justify-content: flex-start; justify-content: flex-start;
z-index: 10; z-index: 10;
} }
.no-sidebar header menu {
flex-grow: 0;
}
header menu a { header menu a {
padding: 10px 20px 4px 20px; padding: 6px 10px 4px 10px;
color: var(--color-text-main); color: var(--color-header-menu);
line-height: 30px; line-height: 30px;
border-bottom: 3px solid rgba(0, 0, 0, 0); border-bottom: 3px solid rgba(0, 0, 0, 0);
margin: 0 2px;
text-align: center;
} }
.no-sidebar header menu a { header menu a.logo {
margin: 0 10px; width: auto;
} margin-right: 7px;
body.has-banner header menu a {
background: rgba(0, 0, 0, 0.5);
border-right: 0;
} }
header menu a:hover, header menu a:hover,
@ -243,10 +227,10 @@ header menu a.selected {
border-bottom: 3px solid var(--color-highlight); border-bottom: 3px solid var(--color-highlight);
} }
.no-sidebar header menu a:hover:not(.logo) { header menu a:hover:not(.logo) {
border-bottom: 3px solid rgba(0, 0, 0, 0); border-bottom: 3px solid rgba(0, 0, 0, 0);
background-color: var(--color-bg-menu); background-color: var(--color-bg-menu);
border-radius: 5px; border-radius: 0 0 5px 5px;
} }
header menu a i { header menu a i {
@ -279,26 +263,6 @@ header menu .gap {
flex-grow: 1; flex-grow: 1;
} }
header menu a.identity {
border-right: 0;
text-align: right;
padding-right: 10px;
background: var(--color-bg-menu) !important;
border-radius: 0 5px 0 0;
width: 250px;
}
header menu a.identity i {
display: inline-block;
vertical-align: middle;
padding: 0 7px 2px 0;
}
header menu a.identity a.view-profile {
display: inline-block;
margin-right: 20px;
}
header menu a img { header menu a img {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
@ -313,7 +277,8 @@ header menu a small {
} }
nav { nav {
padding: 10px 10px 20px 0; padding: 10px 0px 20px 5px;
border-radius: 5px;
} }
nav hr { nav hr {
@ -334,23 +299,23 @@ nav h3:first-child {
nav a { nav a {
display: block; display: block;
color: var(--color-text-dull); color: var(--color-text-dull);
padding: 7px 18px 7px 13px; padding: 7px 18px 7px 10px;
border-left: 3px solid transparent; border-right: 3px solid transparent;
} }
nav a.selected { nav a.selected {
color: var(--color-text-main); color: var(--color-text-main);
background: var(--color-bg-main); background: var(--color-bg-main);
border-radius: 0 5px 5px 0; border-radius: 5px 0 0 5px;
} }
nav a:hover { nav a:hover {
color: var(--color-text-main); color: var(--color-text-main);
border-left: 3px solid var(--color-highlight); border-right: 3px solid var(--color-highlight);
} }
nav a.selected:hover { nav a.selected:hover {
border-left: 3px solid transparent; border-right: 3px solid transparent;
} }
nav a.danger { nav a.danger {
@ -368,48 +333,54 @@ nav a i {
display: inline-block; display: inline-block;
} }
nav .identity-banner {
margin: 5px 0 10px 7px;
}
nav .identity-banner img.icon {
max-width: 32px;
max-height: 32px;
}
nav .identity-banner .avatar-link {
padding: 4px;
}
nav .identity-banner .handle {
word-wrap: break-word;
}
nav .identity-banner div.link {
color: var(--color-text-main);
}
nav .identity-banner a,
nav .identity-banner a:hover {
border-right: none;
}
/* Left-right columns */ /* Left-right columns */
.columns { .settings {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
margin-top: 20px;
} }
.left-column { .settings .settings-content {
flex-grow: 1; flex-grow: 1;
width: 300px; width: 300px;
max-width: 900px; max-width: 900px;
padding: 15px; padding: 0 15px 15px 15px;
} }
.left-column h1 { .settings nav {
margin: 0 0 10px 0; width: var(--width-sidebar-medium);
}
.left-column h1 small {
font-size: 60%;
color: var(--color-text-dull);
display: block;
margin: -10px 0 0 0;
padding: 0;
}
.left-column h2 {
margin: 10px 0 10px 0;
}
.left-column h3 {
margin: 10px 0 0 0;
}
.right-column {
width: var(--md-sidebar-width);
background: var(--color-bg-menu); background: var(--color-bg-menu);
border-radius: 0 0 5px 0;
} }
.right-column h2 { .settings nav h2 {
background: var(--color-highlight); background: var(--color-highlight);
color: var(--color-text-in-highlight); color: var(--color-text-in-highlight);
padding: 8px 10px; padding: 8px 10px;
@ -418,12 +389,6 @@ nav a i {
text-transform: uppercase; text-transform: uppercase;
} }
.right-column footer {
padding: 0 10px 20px 10px;
font-size: 90%;
text-align: left;
}
img.emoji { img.emoji {
height: var(--emoji-height); height: var(--emoji-height);
vertical-align: baseline; vertical-align: baseline;
@ -433,27 +398,51 @@ img.emoji {
content: "…"; content: "…";
} }
/* Generic markdown styling and sections */ /* Generic styling and sections */
.no-sidebar section { section {
max-width: 700px;
background: var(--color-bg-box); background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1); box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
margin: 25px auto 45px auto; margin: 0 auto 45px auto;
padding: 5px 15px; padding: 5px 15px;
} }
.no-sidebar section:last-of-type { section:first-of-type {
margin-bottom: 10px; margin-top: 30px;
} }
.no-sidebar section.shell { section.invisible {
background: none; background: none;
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
} }
.no-sidebar #main-content>h1 { section h1.above {
position: relative;
top: -35px;
left: -15px;
font-weight: bold;
text-transform: uppercase;
font-size: 120%;
color: var(--color-text-main);
margin-bottom: -20px;
}
section p {
margin: 5px 0 10px 0;
}
section:last-of-type {
margin-bottom: 10px;
}
section.shell {
background: none;
box-shadow: none;
padding: 0;
}
#main-content>h1 {
max-width: 700px; max-width: 700px;
margin: 25px auto 5px auto; margin: 25px auto 5px auto;
font-weight: bold; font-weight: bold;
@ -462,7 +451,7 @@ img.emoji {
color: var(--color-text-main); color: var(--color-text-main);
} }
.no-sidebar h1+section { h1+section {
margin-top: 0; margin-top: 0;
} }
@ -493,9 +482,7 @@ p.authorization-code {
.icon-menu .option { .icon-menu .option {
display: block; display: block;
margin: 0 0 20px 0; margin: 0 0 10px 0;
background: var(--color-bg-box);
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
padding: 10px 20px; padding: 10px 20px;
@ -596,6 +583,40 @@ p.authorization-code {
color: var(--color-text-dull); color: var(--color-text-dull);
} }
/* Icon/app listings */
.flex-icons {
display: flex;
list-style-type: none;
margin: 20px 0 10px 0;
padding: 0;
flex-wrap: wrap;
justify-content: space-between;
}
.flex-icons a {
display: inline-block;
width: 200px;
margin: 0 0 15px 0;
}
.flex-icons a img {
max-width: 64px;
max-height: 64px;
float: left;
}
.flex-icons a h2 {
margin: 7px 0 0 72px;
font-size: 110%;
}
.flex-icons a i {
margin: 0 0 0 72px;
font-size: 90%;
display: block;
}
/* Item tables */ /* Item tables */
table.items { table.items {
@ -685,7 +706,7 @@ table.items td.actions a.danger:hover {
/* Forms */ /* Forms */
.no-sidebar form { section form {
max-width: 500px; max-width: 500px;
margin: 40px auto; margin: 40px auto;
} }
@ -716,29 +737,11 @@ form.inline {
div.follow { div.follow {
float: right; float: right;
margin: 20px 0 0 0; margin: 30px 0 0 0;
font-size: 16px; font-size: 16px;
text-align: center; text-align: center;
} }
div.follow-hashtag {
margin: 0;
}
.follow.has-reverse {
margin-top: 0;
}
.follow .reverse-follow {
display: block;
margin: 0 0 5px 0;
}
div.follow button,
div.follow .button {
margin: 0;
}
div.follow .actions { div.follow .actions {
/* display: flex; */ /* display: flex; */
position: relative; position: relative;
@ -748,69 +751,8 @@ div.follow .actions {
align-content: center; align-content: center;
} }
div.follow .actions a { .follow .actions button {
border-radius: 4px;
min-width: 40px;
text-align: center; text-align: center;
cursor: pointer;
}
div.follow .actions menu {
display: none;
background-color: var(--color-bg-menu);
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
position: absolute;
right: 0;
top: 43px;
}
div.follow .actions menu.enabled {
display: block;
min-width: 160px;
z-index: 10;
}
div.follow .actions menu a {
text-align: left;
display: block;
font-size: 15px;
padding: 4px 10px;
color: var(--color-text-dull);
}
.follow .actions menu button {
background: none !important;
border: none;
cursor: pointer;
text-align: left;
display: block;
font-size: 15px;
padding: 4px 10px;
color: var(--color-text-dull);
}
.follow .actions menu button i {
margin-right: 4px;
width: 16px;
}
.follow .actions button:hover {
color: var(--color-text-main);
}
.follow .actions menu a i {
margin-right: 4px;
width: 16px;
}
div.follow .actions a:hover {
color: var(--color-text-main);
}
div.follow .actions a.active {
color: var(--color-text-link);
} }
form.inline-menu { form.inline-menu {
@ -1118,12 +1060,9 @@ button i:first-child,
padding: 2px 6px; padding: 2px 6px;
} }
form .field.multi-option { form .multi-option {
margin-bottom: 10px;
}
form .field.multi-option {
margin-bottom: 10px; margin-bottom: 10px;
display: block;
} }
form .option.option-row { form .option.option-row {
@ -1156,6 +1095,25 @@ blockquote {
border-left: 2px solid var(--color-bg-menu); border-left: 2px solid var(--color-bg-menu);
} }
.secret .label {
background-color: var(--color-bg-menu);
padding: 3px 7px;
border-radius: 3px;
cursor: pointer;
}
.secret.visible .label {
display: none;
}
.secret .value {
display: none;
}
.secret.visible .value {
display: inline;
}
/* Logged out homepage */ /* Logged out homepage */
@ -1174,42 +1132,48 @@ blockquote {
/* Identities */ /* Identities */
h1.identity { section.identity {
margin: 0 0 20px 0; overflow: hidden;
margin-bottom: 10px;
} }
h1.identity .banner { section.identity .banner {
width: 100%; width: 100%;
height: 200px; height: 200px;
object-fit: cover; object-fit: cover;
display: block; display: block;
width: calc(100% + 30px); width: calc(100% + 30px);
margin: -65px -15px 20px -15px; margin: -5px -15px 0px -15px;
border-radius: 5px 0 0 0; border-radius: 5px 0 0 0;
} }
h1.identity .icon { section.identity .icon {
width: 80px; width: 80px;
height: 80px; height: 80px;
float: left; float: left;
margin: 0 20px 0 0; margin: 15px 20px 15px 0;
cursor: pointer; cursor: pointer;
} }
h1.identity .emoji { section.identity .emoji {
height: var(--emoji-height); height: var(--emoji-height);
} }
h1.identity small { section.identity h1 {
margin: 25px 0 0 0;
}
section.identity small {
display: block; display: block;
font-size: 60%; font-size: 100%;
font-weight: normal; font-weight: normal;
color: var(--color-text-dull); color: var(--color-text-dull);
margin: -5px 0 0 0; margin: -5px 0 0 0;
} }
.bio { section.identity .bio {
margin: 0 0 20px 0; clear: left;
margin: 0 0 10px 0;
} }
.bio .emoji { .bio .emoji {
@ -1220,14 +1184,19 @@ h1.identity small {
margin: 0 0 10px 0; margin: 0 0 10px 0;
} }
.identity-metadata {
margin-bottom: 10px;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.identity-metadata .metadata-pair { .identity-metadata .metadata-pair {
display: block; display: inline-block;
margin: 0px 0 10px 0; width: 300px;
background: var(--color-bg-box); margin: 0px 0 5px 0;
box-shadow: 5px 5px 10px rgba(0, 0, 0, 0.1);
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
padding: 10px 20px;
border: 2px solid rgba(255, 255, 255, 0); border: 2px solid rgba(255, 255, 255, 0);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
@ -1235,24 +1204,18 @@ h1.identity small {
.identity-metadata .metadata-pair .metadata-name { .identity-metadata .metadata-pair .metadata-name {
display: inline-block; display: inline-block;
min-width: 80px; min-width: 90px;
margin-right: 15px; margin-right: 15px;
text-align: right; text-align: right;
color: var(--color-text-dull); color: var(--color-text-dull);
} }
.identity-metadata .metadata-pair .metadata-name::after {
padding-left: 3px;
color: var(--color-text-dull);
}
.system-note { .system-note {
background: var(--color-bg-menu); background: var(--color-bg-menu);
color: var(--color-text-dull); color: var(--color-text-dull);
border-radius: 3px; border-radius: 3px;
padding: 5px 8px; padding: 5px 8px;
margin: 15px 0; margin-bottom: 20px;
} }
.system-note a { .system-note a {
@ -1321,13 +1284,12 @@ table.metadata td .emoji {
} }
.view-options { .view-options {
margin: 0 0 10px 0px; margin-bottom: 10px;
padding: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
} background: none;
box-shadow: none;
.view-options.follows {
margin: 0 0 20px 0px;
} }
.view-options a:not(.button) { .view-options a:not(.button) {
@ -1358,17 +1320,23 @@ table.metadata td .emoji {
min-width: 16px; min-width: 16px;
} }
/* Announcements */ /* Announcements/Flash messages */
.announcement { .announcement,
.message {
background-color: var(--color-highlight); background-color: var(--color-highlight);
border-radius: 5px; border-radius: 5px;
margin: 0 0 20px 0; margin: 10px 0 0 0;
padding: 5px 30px 5px 8px; padding: 5px 30px 5px 8px;
position: relative; position: relative;
} }
.announcement .dismiss { .message {
background-color: var(--color-bg-menu);
}
.announcement .dismiss,
.message .dismiss {
position: absolute; position: absolute;
top: 5px; top: 5px;
right: 10px; right: 10px;
@ -1405,6 +1373,11 @@ table.metadata td .emoji {
} }
.identity-banner img.icon {
max-width: 64px;
max-height: 64px;
}
/* Posts */ /* Posts */
@ -1577,7 +1550,7 @@ form .post {
.post .actions { .post .actions {
display: flex; display: flex;
position: relative; position: relative;
justify-content: space-between; justify-content: right;
padding: 8px 0 0 0; padding: 8px 0 0 0;
align-items: center; align-items: center;
align-content: center; align-content: center;
@ -1593,6 +1566,12 @@ form .post {
color: var(--color-text-dull); color: var(--color-text-dull);
} }
.post .actions a.no-action:hover {
background-color: transparent;
cursor: default;
color: var(--color-text-dull);
}
.post .actions a:hover { .post .actions a:hover {
background-color: var(--color-bg-main); background-color: var(--color-bg-main);
} }
@ -1750,52 +1729,30 @@ form .post {
color: var(--color-text-dull); color: var(--color-text-dull);
} }
@media (max-width: 1100px) { @media (max-width: 1120px),
main { (display-mode: standalone) {
max-width: 900px;
body.wide main {
width: 100%;
padding: 0 10px;
} }
} }
@media (max-width: 920px), @media (max-width: 850px),
(display-mode: standalone) { (display-mode: standalone) {
.left-column {
margin: var(--md-header-height) var(--md-sidebar-width) 0 0;
}
.right-column {
width: var(--md-sidebar-width);
position: fixed;
height: 100%;
right: 0;
top: var(--md-header-height);
overflow-y: auto;
padding-bottom: 60px;
}
.right-column nav {
padding: 0;
}
body:not(.no-sidebar) header {
height: var(--md-header-height);
position: fixed;
width: 100%;
z-index: 9;
}
body:not(.no-sidebar) header menu a {
background: var(--color-header-menu);
}
main { main {
width: 100%; width: 100%;
margin: 0; margin: 20px auto 20px auto;
box-shadow: none; box-shadow: none;
border-radius: 0; border-radius: 0;
padding: 0 10px;
} }
.no-sidebar main { .settings nav {
margin: 20px auto 20px auto; width: var(--width-sidebar-small);
} }
.post .attachments a.image img { .post .attachments a.image img {
@ -1805,72 +1762,6 @@ form .post {
@media (max-width: 750px) { @media (max-width: 750px) {
header {
height: var(--sm-header-height);
}
header menu a.identity {
width: var(--sm-sidebar-width);
padding: 10px 10px 0 0;
font-size: 0;
}
header menu a.identity i {
font-size: 22px;
}
#main-content {
padding: 8px;
}
.left-column {
margin: var(--sm-header-height) var(--sm-sidebar-width) 0 0;
}
.right-column {
width: var(--sm-sidebar-width);
top: var(--sm-header-height);
}
.right-column nav {
padding-right: 0;
}
.right-column nav hr {
display: block;
color: var(--color-text-dull);
margin: 20px 10px;
}
.right-column nav a {
padding: 10px 0 10px 10px;
}
.right-column nav a i {
font-size: 22px;
}
.right-column nav a .fa-solid {
display: flex;
justify-content: center;
}
.right-column nav a span {
display: none;
}
.right-column h3 {
display: none;
}
.right-column h2,
.right-column .compose {
display: none;
}
.right-column footer {
display: none;
}
.post { .post {
margin-bottom: 15px; margin-bottom: 15px;
@ -1881,6 +1772,10 @@ form .post {
@media (max-width: 550px) { @media (max-width: 550px) {
main {
padding: 0;
}
.post .content, .post .content,
.post .summary, .post .summary,
.post .edited, .post .edited,

42
static/img/apps/elk.svg Executable file
View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="250"
height="250"
fill="none"
version="1.1"
id="svg30"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs34" />
<mask
id="a"
width="240"
height="234"
x="4"
y="1"
maskUnits="userSpaceOnUse"
style="mask-type:alpha">
<path
fill="#D9D9D9"
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
id="path19" />
</mask>
<g
mask="url(#a)"
id="g28"
transform="matrix(0.90923731,0,0,1.0049564,13.520015,-3.1040835)">
<path
fill="#ea9e44"
d="m 116.94,88.1 c -13.344,1.552 -20.436,-2.019 -24.706,10.71 0,0 14.336,21.655 52.54,21.112 -2.135,8.848 -1.144,15.368 -1.144,23.207 0,26.079 -20.589,48.821 -65.961,48.821 -23.03,0 -51.015,4.191 -72.367,15.911 -15.175,8.305 -27.048,20.336 -32.302,37.023 l 5.956,8.461 11.4,0.155 v 47.889 l -13.91,21.966 3.998,63.645 H -6.364 L -5.22,335.773 C 1.338,331.892 16.36,321.802 29.171,306.279 46.557,285.4 59.902,255.052 44.193,217.486 l 11.744,-5.045 c 12.887,30.814 8.388,57.514 -2.898,79.013 21.58,-0.698 40.11,-2.095 55.819,-4.734 l -3.584,-43.698 12.659,-1.087 L 129.98,387 h 13.116 l 2.212,-94.459 c 10.447,-4.502 34.239,-21.034 45.372,-78.47 1.372,-6.986 2.135,-12.885 2.516,-17.93 1.754,-12.806 2.745,-27.243 3.051,-43.698 l -18.683,-5.976 h 57.42 l 5.567,-12.807 c -5.414,0.233 -11.896,-2.639 -11.896,-2.639 l 1.297,-6.209 H 242 L 176.801,90.428 c -7.244,2.794 -14.87,6.442 -20.208,10.866 -4.27,-3.105 -19.063,-12.807 -39.653,-13.195 z"
id="path22" />
<path
fill="#c16929"
d="M 6.217,24.493 18.494,21 c 5.948,21.577 13.345,33.375 22.648,39.352 8.388,5.099 19.75,5.239 31.799,4.579 C 69.433,63.767 66.154,62.137 63.104,59.886 56.317,54.841 50.522,46.458 46.175,31.246 l 12.201,-3.649 c 3.279,11.488 7.092,18.085 12.201,21.888 5.11,3.726 11.286,4.657 18.606,5.433 13.726,1.553 30.884,2.174 52.312,12.264 2.898,1.086 5.872,2.483 8.769,4.036 -0.381,-0.776 -0.762,-1.553 -1.296,-2.406 -3.66,-5.822 -10.828,-11.953 -24.097,-16.92 l 4.27,-12.109 c 21.581,7.917 30.121,19.171 33.553,28.097 3.965,10.168 1.525,18.124 1.525,18.124 -3.05,1.009 -6.1,2.406 -9.608,3.492 -6.634,-4.579 -12.887,-8.033 -18.835,-10.75 C 113.814,70.442 92.31,76.108 73.246,77.893 58.91,79.213 45.794,78.591 34.432,71.295 23.222,64.155 13.385,50.495 6.217,24.493 Z"
id="path24" />
<path
fill="#c16929"
d="M 90.098,45.294 C 87.582,39.55 86.057,32.487 86.743,23.794 l 12.659,0.932 c -0.763,10.555 2.897,17.696 7.015,22.353 -5.338,-0.931 -10.447,-1.04 -16.319,-1.785 z m 80.069,-1.32 8.312,-9.702 c 21.58,19.094 8.159,46.415 8.159,46.415 l -11.819,-1.32 c -0.382,-6.24 -1.144,-17.836 -6.635,-24.371 3.584,1.84 6.635,3.865 9.99,6.908 0,-5.666 -1.754,-12.341 -8.007,-17.93 z"
id="path26" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
static/img/apps/ivory.webp Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
static/img/apps/tusky.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -5,6 +5,7 @@ import signal
import time import time
import traceback import traceback
import uuid import uuid
from collections.abc import Callable
from asgiref.sync import async_to_sync, sync_to_async from asgiref.sync import async_to_sync, sync_to_async
from django.conf import settings from django.conf import settings
@ -21,7 +22,7 @@ class LoopingTask:
copy running at a time. copy running at a time.
""" """
def __init__(self, callable): def __init__(self, callable: Callable):
self.callable = callable self.callable = callable
self.task: asyncio.Task | None = None self.task: asyncio.Task | None = None

View File

@ -1 +1 @@
__version__ = "0.8.0" __version__ = "0.9.0-dev"

View File

@ -194,6 +194,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.postgres",
"corsheaders", "corsheaders",
"django_htmx", "django_htmx",
"hatchway", "hatchway",
@ -220,7 +221,7 @@ MIDDLEWARE = [
"core.middleware.HeadersMiddleware", "core.middleware.HeadersMiddleware",
"core.middleware.ConfigLoadingMiddleware", "core.middleware.ConfigLoadingMiddleware",
"api.middleware.ApiTokenMiddleware", "api.middleware.ApiTokenMiddleware",
"users.middleware.IdentityMiddleware", "users.middleware.DomainMiddleware",
] ]
ROOT_URLCONF = "takahe.urls" ROOT_URLCONF = "takahe.urls"

View File

@ -2,49 +2,18 @@ from django.conf import settings as djsettings
from django.contrib import admin as djadmin from django.contrib import admin as djadmin
from django.urls import include, path, re_path from django.urls import include, path, re_path
from activities.views import ( from activities.views import compose, debug, posts, timelines
compose,
debug,
explore,
follows,
hashtags,
posts,
search,
timelines,
)
from api.views import oauth from api.views import oauth
from core import views as core from core import views as core
from mediaproxy import views as mediaproxy from mediaproxy import views as mediaproxy
from stator import views as stator from stator import views as stator
from users.views import ( from users.views import activitypub, admin, announcements, auth, identity, settings
activitypub,
admin,
announcements,
auth,
identity,
report,
settings,
)
urlpatterns = [ urlpatterns = [
path("", core.homepage), path("", core.homepage),
path("robots.txt", core.RobotsTxt.as_view()), path("robots.txt", core.RobotsTxt.as_view()),
path("manifest.json", core.AppManifest.as_view()),
# Activity views # Activity views
path("notifications/", timelines.Notifications.as_view(), name="notifications"),
path("local/", timelines.Local.as_view(), name="local"),
path("federated/", timelines.Federated.as_view(), name="federated"),
path("search/", search.Search.as_view(), name="search"),
path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"), path("tags/<hashtag>/", timelines.Tag.as_view(), name="tag"),
path("tags/<hashtag>/follow/", hashtags.HashtagFollow.as_view()),
path("tags/<hashtag>/unfollow/", hashtags.HashtagFollow.as_view(undo=True)),
path("explore/", explore.Explore.as_view(), name="explore"),
path("explore/tags/", explore.ExploreTag.as_view(), name="explore-tag"),
path(
"follows/",
follows.Follows.as_view(),
name="follows",
),
# Settings views # Settings views
path( path(
"settings/", "settings/",
@ -56,31 +25,66 @@ urlpatterns = [
settings.SecurityPage.as_view(), settings.SecurityPage.as_view(),
name="settings_security", name="settings_security",
), ),
path(
"settings/profile/",
settings.ProfilePage.as_view(),
name="settings_profile",
),
path( path(
"settings/interface/", "settings/interface/",
settings.InterfacePage.as_view(), settings.InterfacePage.as_view(),
name="settings_interface", name="settings_interface",
), ),
path( path(
"settings/import_export/", "@<handle>/settings/",
settings.SettingsRoot.as_view(),
name="settings",
),
path(
"@<handle>/settings/profile/",
settings.ProfilePage.as_view(),
name="settings_profile",
),
path(
"@<handle>/settings/posting/",
settings.PostingPage.as_view(),
name="settings_posting",
),
path(
"@<handle>/settings/follows/",
settings.FollowsPage.as_view(),
name="settings_follows",
),
path(
"@<handle>/settings/import_export/",
settings.ImportExportPage.as_view(), settings.ImportExportPage.as_view(),
name="settings_import_export", name="settings_import_export",
), ),
path( path(
"settings/import_export/following.csv", "@<handle>/settings/import_export/following.csv",
settings.CsvFollowing.as_view(), settings.CsvFollowing.as_view(),
name="settings_export_following_csv", name="settings_export_following_csv",
), ),
path( path(
"settings/import_export/followers.csv", "@<handle>/settings/import_export/followers.csv",
settings.CsvFollowers.as_view(), settings.CsvFollowers.as_view(),
name="settings_export_followers_csv", name="settings_export_followers_csv",
), ),
path(
"@<handle>/settings/tokens/",
settings.TokensRoot.as_view(),
name="settings_tokens",
),
path(
"@<handle>/settings/tokens/create/",
settings.TokenCreate.as_view(),
name="settings_token_create",
),
path(
"@<handle>/settings/tokens/<pk>/",
settings.TokenEdit.as_view(),
name="settings_token_edit",
),
path(
"@<handle>/settings/delete/",
settings.DeleteIdentity.as_view(),
name="settings_delete",
),
path( path(
"admin/", "admin/",
admin.AdminRoot.as_view(), admin.AdminRoot.as_view(),
@ -236,30 +240,18 @@ urlpatterns = [
path("@<handle>/", identity.ViewIdentity.as_view()), path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/inbox/", activitypub.Inbox.as_view()), path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/outbox/", activitypub.Outbox.as_view()), path("@<handle>/outbox/", activitypub.Outbox.as_view()),
path("@<handle>/action/", identity.ActionIdentity.as_view()),
path("@<handle>/rss/", identity.IdentityFeed()), path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/report/", report.SubmitReport.as_view()),
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)), path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)), path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
path("@<handle>/search/", identity.IdentitySearch.as_view()),
path(
"@<handle>/notifications/",
timelines.Notifications.as_view(),
name="notifications",
),
# Posts # Posts
path("compose/", compose.Compose.as_view(), name="compose"), path("@<handle>/compose/", compose.Compose.as_view(), name="compose"),
path(
"compose/image_upload/",
compose.ImageUpload.as_view(),
name="compose_image_upload",
),
path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()), path("@<handle>/posts/<int:post_id>/", posts.Individual.as_view()),
path("@<handle>/posts/<int:post_id>/like/", posts.Like.as_view()),
path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/bookmark/", posts.Bookmark.as_view()),
path(
"@<handle>/posts/<int:post_id>/unbookmark/", posts.Bookmark.as_view(undo=True)
),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/report/", report.SubmitReport.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
# Authentication # Authentication
path("auth/login/", auth.Login.as_view(), name="login"), path("auth/login/", auth.Login.as_view(), name="login"),
path("auth/logout/", auth.Logout.as_view(), name="logout"), path("auth/logout/", auth.Logout.as_view(), name="logout"),
@ -267,26 +259,29 @@ urlpatterns = [
path("auth/signup/<token>/", auth.Signup.as_view(), name="signup"), path("auth/signup/<token>/", auth.Signup.as_view(), name="signup"),
path("auth/reset/", auth.TriggerReset.as_view(), name="trigger_reset"), path("auth/reset/", auth.TriggerReset.as_view(), name="trigger_reset"),
path("auth/reset/<token>/", auth.PerformReset.as_view(), name="password_reset"), path("auth/reset/<token>/", auth.PerformReset.as_view(), name="password_reset"),
# Identity selection # Identity handling
path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view(), name="identity_select"),
path("identity/create/", identity.CreateIdentity.as_view(), name="identity_create"), path("identity/create/", identity.CreateIdentity.as_view(), name="identity_create"),
# Flat pages # Flat pages
path("about/", core.About.as_view(), name="about"), path("about/", core.About.as_view(), name="about"),
path( path(
"pages/privacy/", "pages/privacy/",
core.FlatPage.as_view(title="Privacy Policy", config_option="policy_privacy"), core.FlatPage.as_view(title="Privacy Policy", config_option="policy_privacy"),
name="privacy", name="policy_privacy",
), ),
path( path(
"pages/terms/", "pages/terms/",
core.FlatPage.as_view(title="Terms of Service", config_option="policy_terms"), core.FlatPage.as_view(title="Terms of Service", config_option="policy_terms"),
name="terms", name="policy_terms",
), ),
path( path(
"pages/rules/", "pages/rules/",
core.FlatPage.as_view(title="Server Rules", config_option="policy_rules"), core.FlatPage.as_view(title="Server Rules", config_option="policy_rules"),
name="rules", name="policy_rules",
),
path(
"pages/issues/",
core.FlatPage.as_view(title="Report a Problem", config_option="policy_issues"),
name="policy_issues",
), ),
# Annoucements # Annoucements
path("announcements/<id>/dismiss/", announcements.AnnouncementDismiss.as_view()), path("announcements/<id>/dismiss/", announcements.AnnouncementDismiss.as_view()),

View File

@ -4,3 +4,13 @@
{{ announcement.html }} {{ announcement.html }}
</div> </div>
{% endfor %} {% endfor %}
{% if messages %}
<ul class="messages">
{% for message in messages %}
<div class="message {{ message.tags }}">
<a class="dismiss" title="Dismiss" _="on click remove closest <div/>"><i class="fa-solid fa-xmark"></i></a>
{{ message }}
</div>
{% endfor %}
</ul>
{% endif %}

View File

@ -1,7 +1,8 @@
<footer> <footer>
{% if config.site_about %}<a href="{% url "about" %}">About</a>{% endif %} {% if config.site_about %}<a href="{% url "about" %}">About</a>{% endif %}
{% if config.policy_rules %}<a href="{% url "rules" %}">Server&nbsp;Rules</a>{% endif %} {% if config.policy_rules %}<a href="{% url "policy_rules" %}">Server&nbsp;Rules</a>{% endif %}
{% if config.policy_terms %}<a href="{% url "terms" %}">Terms&nbsp;of&nbsp;Service</a>{% endif %} {% if config.policy_terms %}<a href="{% url "policy_terms" %}">Terms&nbsp;of&nbsp;Service</a>{% endif %}
{% if config.policy_privacy %}<a href="{% url "privacy" %}">Privacy&nbsp;Policy</a>{% endif %} {% if config.policy_privacy %}<a href="{% url "policy_privacy" %}">Privacy&nbsp;Policy</a>{% endif %}
{% if config.policy_issues %}<a href="{% url "policy_issues" %}">Report a Problem</a>{% endif %}
<a href="https://jointakahe.org">Takahē&nbsp;{{ config.version }}</a> <a href="https://jointakahe.org">Takahē&nbsp;{{ config.version }}</a>
</footer> </footer>

View File

@ -1,9 +0,0 @@
{% if post.pk in bookmarks %}
<a title="Unbookmark" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unbookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-bookmark"></i>
</a>
{% else %}
<a title="Bookmark" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_bookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-regular fa-bookmark"></i>
</a>
{% endif %}

View File

@ -1,11 +0,0 @@
{% if post.pk in interactions.boost %}
<a title="Unboost" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unboost }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts }}</span>
</a>
{% else %}
<a title="Boost" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_boost }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts }}</span>
</a>
{% endif %}

View File

@ -1,11 +0,0 @@
<a class="option" href="{{ hashtag.urls.timeline }}">
<i class="fa-solid fa-hashtag"></i>
<span class="handle">
{{ hashtag.display_name }}
</span>
{% if not hide_stats %}
<span>
Post count: {{ hashtag.stats.total }}
</span>
{% endif %}
</a>

View File

@ -1,9 +0,0 @@
{% if follow %}
<button title="Unfollow" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.unfollow }}" hx-swap="outerHTML" tabindex="0">
Unfollow
</button>
{% else %}
<button title="Follow" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ hashtag.urls.follow }}" hx-swap="outerHTML" tabindex="0">
Follow
</button>
{% endif %}

View File

@ -1,15 +0,0 @@
<form
hx-encoding='multipart/form-data'
hx-post='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on htmx:xhr:progress(loaded, total)
set #attachmentProgress.value to (loaded/total)*100">
{% csrf_token %}
{% include "forms/_field.html" with field=form.image %}
{% include "forms/_field.html" with field=form.description %}
<div class="buttons">
<button id="upload" _="on click show #attachmentProgress with display:block then hide me">Upload</button>
<progress id="attachmentProgress" value="0" max="100"></progress>
</div>
</form>

View File

@ -1,19 +0,0 @@
<div class="uploaded-image">
<input type="hidden" name="attachment" value="{{ attachment.pk }}">
<img src="{{ attachment.thumbnail_url.relative }}">
<p>
{{ attachment.name|default:"(no description)" }}
</p>
<div class="buttons">
<button class="button delete left" _="on click remove closest .uploaded-image">Remove</button>
</div>
</div>
{% if request.htmx %}
<button class="add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML"
_="on load if length of <.uploaded-image/> > 3 then hide me">
Add Image
</button>
{% endif %}

View File

@ -1,11 +0,0 @@
{% if post.pk in interactions.like %}
<a title="Unlike" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unlike }}" hx-swap="outerHTML" role="menuitem" tabindex="0">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes }}</span>
</a>
{% else %}
<a title="Like" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_like }}" hx-swap="outerHTML" role="menuitem" tabindex="0">
<i class="fa-solid fa-star"></i>
<span class="like-count">{{ post.stats_with_defaults.likes }}</span>
</a>
{% endif %}

View File

@ -1,86 +0,0 @@
<nav>
<a href="/" {% if current_page == "home" %}class="selected"{% endif %} title="Home">
<i class="fa-solid fa-home"></i>
<span>Home</span>
</a>
{% if request.user.is_authenticated %}
<a href="{% url "notifications" %}" {% if current_page == "notifications" %}class="selected"{% endif %} title="Notifications">
<i class="fa-solid fa-at"></i>
<span>Notifications</span>
</a>
{% comment %}
Not sure we want to show this quite yet
<a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
<i class="fa-solid fa-hashtag"></i>
<span>Explore</span>
</a>
{% endcomment %}
<a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local">
<i class="fa-solid fa-city"></i>
<span>Local</span>
</a>
<a href="{% url "federated" %}" {% if current_page == "federated" %}class="selected"{% endif %} title="Federated">
<i class="fa-solid fa-globe"></i>
<span>Federated</span>
</a>
<a href="{% url "follows" %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
<i class="fa-solid fa-arrow-right-arrow-left"></i>
<span>Follows</span>
</a>
<hr>
<h3></h3>
<a href="{% url "compose" %}" {% if top_section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-feather"></i>
<span>Compose</span>
</a>
<a href="{% url "search" %}" {% if top_section == "search" %}class="selected"{% endif %} title="Search">
<i class="fa-solid fa-search"></i>
<span>Search</span>
</a>
{% if current_page == "tag" %}
<a href="{% url "tag" hashtag.hashtag %}" class="selected" title="Tag {{ hashtag.display_name }}">
<i class="fa-solid fa-hashtag"></i>
<span>{{ hashtag.display_name }}</span>
</a>
{% endif %}
<a href="{% url "settings" %}" {% if top_section == "settings" %}class="selected"{% endif %} title="Settings">
<i class="fa-solid fa-gear"></i>
<span>Settings</span>
</a>
<a href="{% url "identity_select" %}" title="Select Identity">
<i class="fa-solid fa-users-viewfinder"></i>
<span>Select Identity</span>
</a>
{% else %}
<a href="{% url "local" %}" {% if current_page == "local" %}class="selected"{% endif %} title="Local Posts">
<i class="fa-solid fa-city"></i>
<span>Local Posts</span>
</a>
<a href="{% url "explore" %}" {% if current_page == "explore" %}class="selected"{% endif %} title="Explore">
<i class="fa-solid fa-hashtag"></i>
<span>Explore</span>
</a>
<h3></h3>
{% if config.signup_allowed %}
<a href="{% url "signup" %}" {% if current_page == "signup" %}class="selected"{% endif %} title="Create Account">
<i class="fa-solid fa-user-plus"></i>
<span>Create Account</span>
</a>
{% endif %}
{% endif %}
</nav>
{% if current_page == "home" %}
<h2>Compose</h2>
<form action="{% url "compose" %}" method="POST" class="compose">
{% csrf_token %}
{{ form.text }}
{{ form.content_warning }}
<input type="hidden" name="visibility" value="{{ config_identity.default_post_visibility }}">
<div class="buttons">
<span id="character-counter">{{ config.post_length }}</span>
<button class="toggle" _="on click or keyup[key is 'Enter'] toggle .enabled then toggle .hidden on #id_content_warning then halt">CW</button>
<button id="post-button">{% if config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button>
</div>
</form>
{% endif %}

View File

@ -27,12 +27,8 @@
</div> </div>
{% if post.summary %} {% if post.summary %}
{% if config_identity.expand_linked_cws %} <div class="summary" _="on click or keyup[key is 'Enter'] toggle .enabled on <.{{ post.summary_class }} .summary/> then toggle .hidden on <.{{ post.summary_class }} .content/> then halt" tabindex="0">
<div class="summary" _="on click or keyup[key is 'Enter'] toggle .enabled on <.{{ post.summary_class }} .summary/> then toggle .hidden on <.{{ post.summary_class }} .content/> then halt" tabindex="0"> {{ post.summary }}
{% else %}
<div class="summary" _="on click or keyup[key is 'Enter'] toggle .enabled then toggle .hidden on the next .content then halt" tabindex="0">
{% endif %}
{{ post.summary }}
</div> </div>
{% endif %} {% endif %}
@ -77,41 +73,37 @@
</div> </div>
{% endif %} {% endif %}
{% if request.identity %} <div class="actions">
<div class="actions" role="menubar"> <a title="Replies" href="{% if not post.local and post.url %}{{ post.url }}{% else %}{{ post.urls.view }}{% endif %}">
{% include "activities/_reply.html" %} <i class="fa-solid fa-reply"></i>
{% include "activities/_like.html" %} <span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
{% include "activities/_boost.html" %} </a>
{% include "activities/_bookmark.html" %} <a title="Likes" class="no-action">
<a title="Menu" class="menu" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" role="menuitem" aria-haspopup="menu" tabindex="0"> <i class="fa-solid fa-star"></i>
<i class="fa-solid fa-bars"></i> <span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
</a>
<a title="Boosts" class="no-action">
<i class="fa-solid fa-retweet"></i>
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>
</a>
<a title="Menu" class="menu" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" role="menuitem" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i>
</a>
<menu>
<a href="{{ post.urls.view }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies
</a> </a>
<menu> {% if not post.local and post.url %}
<a href="{{ post.urls.view }}" role="menuitem"> <a href="{{ post.url }}" role="menuitem">
<i class="fa-solid fa-comment"></i> View Post &amp; Replies <i class="fa-solid fa-arrow-up-right-from-square"></i> See Original
</a> </a>
<a href="{{ post.urls.action_report }}" role="menuitem"> {% endif %}
<i class="fa-solid fa-flag"></i> Report {% if request.user.admin %}
<a href="{{ post.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View In Admin
</a> </a>
{% if post.author == request.identity %} {% endif %}
<a href="{{ post.urls.action_edit }}" role="menuitem"> </menu>
<i class="fa-solid fa-pen-to-square"></i> Edit </div>
</a>
<a href="{{ post.urls.action_delete }}" role="menuitem">
<i class="fa-solid fa-trash"></i> Delete
</a>
{% elif not post.local and post.url %}
<a href="{{ post.url }}" role="menuitem">
<i class="fa-solid fa-arrow-up-right-from-square"></i> See Original
</a>
{% endif %}
{% if request.user.admin %}
<a href="{{ post.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View In Admin
</a>
{% endif %}
</menu>
</div>
{% endif %}
</div> </div>

View File

@ -1,5 +0,0 @@
<a title="Reply" href="{{ post.urls.action_reply }}" role="menuitem">
<i class="fa-solid fa-reply"></i>
<span class="like-count">{{ post.stats_with_defaults.replies }}</span>
</a>

View File

@ -1,41 +1,26 @@
{% extends "base.html" %} {% extends "settings/base.html" %}
{% block title %}Compose{% endblock %} {% block title %}Compose{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<legend>Content</legend> <legend>Compose</legend>
{% if reply_to %} <p><i>For more advanced posting options, like editing and multiple image uploads, please use an app.</i></p>
<label>Replying to</label>
{% include "activities/_mini_post.html" with post=reply_to %}
{% endif %}
{{ form.reply_to }}
{{ form.id }} {{ form.id }}
{% include "forms/_field.html" with field=form.text %} {% include "forms/_field.html" with field=form.text %}
{% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %} {% include "forms/_field.html" with field=form.visibility %}
{% include "forms/_field.html" with field=form.content_warning %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Images</legend> <legend>Image</legend>
{% if post %} {% include "forms/_field.html" with field=form.image %}
{% for attachment in post.attachments.all %} {% include "forms/_field.html" with field=form.image_caption %}
{% include "activities/_image_uploaded.html" %}
{% endfor %}
{% endif %}
{% if not post or post.attachments.count < 4 %}
<button class="add-image"
hx-get='{% url "compose_image_upload" %}'
hx-target="this"
hx-swap="outerHTML">
Add Image
</button>
{% endif %}
</fieldset> </fieldset>
<div class="buttons"> <div class="buttons">
<span id="character-counter">{{ config.post_length }}</span> <span id="character-counter">{{ config.post_length }}</span>
<button id="post-button">{% if post %}Save Edits{% elif config_identity.toot_mode %}Toot!{% else %}Post{% endif %}</button> <button id="post-button">{% if post %}Save Edits{% else %}Post{% endif %}</button>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -2,8 +2,6 @@
{% block title %}Debug JSON{% endblock %} {% block title %}Debug JSON{% endblock %}
{% block body_class %}no-sidebar{% endblock %}
{% block content %} {% block content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}

View File

@ -1,16 +0,0 @@
{% extends "base.html" %}
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %}
<div class="timeline-name">Explore Trending Tags</div>
<section class="icon-menu">
{% for hashtag in page_obj %}
{% include "activities/_hashtag.html" %}
{% empty %}
No tags are trending yet.
{% endfor %}
</section>
{% endblock %}

View File

@ -1,3 +0,0 @@
{% extends "activities/local.html" %}
{% block title %}Federated Timeline{% endblock %}

View File

@ -1,60 +0,0 @@
{% extends "base.html" %}
{% load activity_tags %}
{% block subtitle %}Follows{% endblock %}
{% block content %}
<div class="view-options">
{% if inbound %}
<a href=".">Following ({{ num_outbound }})</a>
<a href="." class="selected">Followers ({{ num_inbound }})</a>
{% else %}
<a href=".?inbound=true" class="selected">Following ({{ num_outbound }})</a>
<a href=".?inbound=true">Followers ({{ num_inbound }})</a>
{% endif %}
</div>
<section class="icon-menu">
{% for identity in page_obj %}
<a class="option " href="{{ identity.urls.view }}">
<div class="option-content">
{% include "identity/_identity_banner.html" with identity=identity link_avatar=False link_handle=False %}
</div>
<div class="option-actions">
{% if identity.id in outbound_ids %}
<span class="pill">Following</span>
{% endif %}
{% if identity.id in inbound_ids %}
<span class="pill">Follows You</span>
{% endif %}
{% if inbound %}
<form action="{{ identity.urls.action }}" method="POST" class="follow">
{% csrf_token %}
{% if identity.id in outbound_ids %}
<input type="hidden" name="action" value="unfollow">
<button class="destructive">Unfollow</button>
{% else %}
<input type="hidden" name="action" value="follow">
<button>Follow</button>
{% endif %}
</form>
{% endif %}
<time>{{ identity.follow_date | timedeltashort }} ago</time>
</div>
</a>
{% empty %}
<p class="option empty">You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</p>
{% endfor %}
</section>
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}{% if inbound %}&amp;inbound=true{% endif %}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}{% if inbound %}&amp;inbound=true{% endif %}">Next Page</a>
{% endif %}
</div>
{% endblock %}

View File

@ -1,37 +1,52 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load activity_tags %} {% load activity_tags %}
{% load static %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block content %} {% block content %}
{% if page_obj.number == 1 %} <section class="icon-menu">
{% include "_announcements.html" %} <h1 class="above">Identities</h1>
{% endif %} {% for identity in identities %}
{% for event in page_obj %} <a class="option" href="{{ identity.urls.view }}">
{% if event.type == "post" %} <img src="{{ identity.local_icon_url.relative }}">
{% include "activities/_post.html" with post=event.subject_post %} <span class="handle">
{% elif event.type == "boost" %} {{ identity.html_name_or_handle }}
<div class="boost-banner"> <small>@{{ identity.handle }}</small>
<a href="{{ event.subject_identity.urls.view }}"> </span>
{{ event.subject_identity.html_name_or_handle }} <button class="right secondary" _="on click go to url {{ identity.urls.view }} then halt">View</button>
</a> boosted <button class="right secondary" _="on click go to url {{ identity.urls.settings }} then halt">Settings</button>
<time> </a>
{{ event.subject_post_interaction.published | timedeltashort }} ago {% empty %}
</time> <p class="option empty">You have no identities.</p>
</div> {% endfor %}
{% include "activities/_post.html" with post=event.subject_post %} <a href="{% url "identity_create" %}" class="option new">
{% endif %} <i class="fa-solid fa-plus"></i> Create a new identity
{% empty %} </a>
Nothing to show yet. </section>
{% endfor %} <section>
<h1 class="above">Apps</h1>
<div class="pagination"> <p>
{% if page_obj.has_previous and not request.htmx %} To see your timelines, compose and edit messages, and follow people,
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a> you will need to use a Mastodon-compatible app. Our favourites
{% endif %} are listed below, but there's lots more options out there!
</p>
{% if page_obj.has_next %} <div class="flex-icons">
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a> <a href="https://elk.zone/">
{% endif %} <img src="{% static "img/apps/elk.svg" %}" alt="Elk logo">
</div> <h2>Elk</h2>
<i>Web/Mobile (Free)</i>
</a>
<a href="https://tusky.app/">
<img src="{% static "img/apps/tusky.png" %}" alt="Tusky logo">
<h2>Tusky</h2>
<i>Android (Free)</i>
</a>
<a href="https://tapbots.com/ivory/">
<img src="{% static "img/apps/ivory.webp" %}" alt="Ivory logo">
<h2>Ivory</h2>
<i>iOS (Paid)</i>
</a>
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Local Timeline{% endblock %}
{% block content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
{% for post in page_obj %}
{% include "activities/_post.html" with feedindex=forloop.counter %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous and not request.htmx %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a>
{% endif %}
</div>
{% endblock %}

View File

@ -1,55 +1,38 @@
{% extends "base.html" %} {% extends "settings/base.html" %}
{% block title %}Notifications{% endblock %} {% block title %}Notifications{% endblock %}
{% block content %} {% block settings_content %}
{% if page_obj.number == 1 %}
{% include "_announcements.html" %}
{% endif %}
<div class="view-options"> <section class="invisible">
{% if notification_options.followed %} <div class="view-options">
<a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a> {% if notification_options.followed %}
{% else %} <a href=".?followed=false" class="selected"><i class="fa-solid fa-check"></i> Followers</a>
<a href=".?followed=true"><i class="fa-solid fa-xmark"></i> Followers</a>
{% endif %}
{% if notification_options.boosted %}
<a href=".?boosted=false" class="selected"><i class="fa-solid fa-check"></i> Boosts</a>
{% else %}
<a href=".?boosted=true"><i class="fa-solid fa-xmark"></i> Boosts</a>
{% endif %}
{% if notification_options.liked %}
<a href=".?liked=false" class="selected"><i class="fa-solid fa-check"></i> Likes</a>
{% else %}
<a href=".?liked=true"><i class="fa-solid fa-xmark"></i> Likes</a>
{% endif %}
{% if notification_options.mentioned %}
<a href=".?mentioned=false" class="selected"><i class="fa-solid fa-check"></i> Mentions</a>
{% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %}
{% if request.user.admin %}
{% if notification_options.identity_created %}
<a href=".?identity_created=false" class="selected"><i class="fa-solid fa-check"></i> New Identities</a>
{% else %} {% else %}
<a href=".?identity_created=true"><i class="fa-solid fa-xmark"></i> New Identities</a> <a href=".?followed=true"><i class="fa-solid fa-xmark"></i> Followers</a>
{% endif %} {% endif %}
{% endif %} {% if notification_options.boosted %}
</div> <a href=".?boosted=false" class="selected"><i class="fa-solid fa-check"></i> Boosts</a>
{% else %}
<a href=".?boosted=true"><i class="fa-solid fa-xmark"></i> Boosts</a>
{% endif %}
{% if notification_options.liked %}
<a href=".?liked=false" class="selected"><i class="fa-solid fa-check"></i> Likes</a>
{% else %}
<a href=".?liked=true"><i class="fa-solid fa-xmark"></i> Likes</a>
{% endif %}
{% if notification_options.mentioned %}
<a href=".?mentioned=false" class="selected"><i class="fa-solid fa-check"></i> Mentions</a>
{% else %}
<a href=".?mentioned=true"><i class="fa-solid fa-xmark"></i> Mentions</a>
{% endif %}
</div>
{% for event in events %} {% for event in events %}
{% include "activities/_event.html" %} {% include "activities/_event.html" %}
{% empty %} {% empty %}
No notifications yet. No notifications yet.
{% endfor %} {% endfor %}
<div class="pagination"> </section>
{% if page_obj.has_previous and not request.htmx %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -7,11 +7,13 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% for ancestor in ancestors reversed %} <section class="invisible">
{% include "activities/_post.html" with post=ancestor reply=True link_original=False %} {% for ancestor in ancestors reversed %}
{% endfor %} {% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
{% include "activities/_post.html" %} {% endfor %}
{% for descendant in descendants %} {% include "activities/_post.html" %}
{% include "activities/_post.html" with post=descendant reply=True link_original=False %} {% for descendant in descendants %}
{% endfor %} {% include "activities/_post.html" with post=descendant reply=True link_original=False %}
{% endfor %}
</section>
{% endblock %} {% endblock %}

View File

@ -1,48 +0,0 @@
{% extends "base.html" %}
{% block title %}Search{% endblock %}
{% block content %}
<form action="." method="POST">
{% csrf_token %}
<fieldset>
{% include "forms/_field.html" with field=form.query %}
</fieldset>
<div class="buttons">
<button>Search</button>
</div>
</form>
{% if results.identities %}
<h2>People</h2>
{% for identity in results.identities %}
{% include "activities/_identity.html" %}
{% endfor %}
{% endif %}
{% if results.posts %}
<h2>Posts</h2>
<section class="icon-menu">
{% for post in results.posts %}
{% include "activities/_post.html" %}
{% endfor %}
</section>
{% endif %}
{% if results.hashtags %}
<h2>Hashtags</h2>
<section class="icon-menu">
{% for hashtag in results.hashtags %}
{% include "activities/_hashtag.html" with hide_stats=True %}
{% endfor %}
</section>
{% endif %}
{% if results and not results.identities and not results.hashtags and not results.posts %}
<h2>No results</h2>
<p>
We could not find anything matching your query.
</p>
<p>
If you're trying to find a post or profile on another server,
try again in a few moments - if the other end is overloaded, it
can take some time to fetch the details.
</p>
{% endif %}
{% endblock %}

View File

@ -3,27 +3,26 @@
{% block title %}#{{ hashtag.display_name }} Timeline{% endblock %} {% block title %}#{{ hashtag.display_name }} Timeline{% endblock %}
{% block content %} {% block content %}
<div class="timeline-name"> <section class="invisible">
<div class="inline follow follow-hashtag"> <div class="timeline-name">
{% include "activities/_hashtag_follow.html" %} <div class="hashtag">
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }}
</div>
</div> </div>
<div class="hashtag"> {% for post in page_obj %}
<i class="fa-solid fa-hashtag"></i>{{ hashtag.display_name }} {% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}">Next Page</a>
{% endif %}
</div> </div>
</div> </section>
{% for post in page_obj %}
{% include "activities/_post.html" %}
{% empty %}
No posts yet.
{% endfor %}
<div class="pagination">
{% if page_obj.has_previous %}
<a class="button" href=".?page={{ page_obj.previous_page_number }}">Previous Page</a>
{% endif %}
{% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }} {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}">Next Page</a>
{% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,61 @@
<nav>
{% if request.user.moderator or request.user.admin %}
<h3>Moderation</h3>
<a href="{% url "admin_identities" %}" {% if section == "identities" %}class="selected"{% endif %} title="Identities">
<i class="fa-solid fa-id-card"></i>
<span>Identities</span>
</a>
<a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %} title="Invites">
<i class="fa-solid fa-envelope"></i>
<span>Invites</span>
</a>
<a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
<i class="fa-solid fa-hashtag"></i>
<span>Hashtags</span>
</a>
<a href="{% url "admin_emoji" %}" {% if section == "emoji" %}class="selected"{% endif %} title="Emoji">
<i class="fa-solid fa-icons"></i>
<span>Emoji</span>
</a>
<a href="{% url "admin_reports" %}" {% if section == "reports" %}class="selected"{% endif %} title="Reports">
<i class="fa-solid fa-flag"></i>
<span>Reports</span>
</a>
{% endif %}
{% if request.user.admin %}
<hr>
<h3>Administration</h3>
<a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic">
<i class="fa-solid fa-book"></i>
<span>Basic</span>
</a>
<a href="{% url "admin_policies" %}" {% if section == "policies" %}class="selected"{% endif %} title="Policies">
<i class="fa-solid fa-file-lines"></i>
<span>Policies</span>
</a>
<a href="{% url "admin_announcements" %}" {% if section == "announcements" %}class="selected"{% endif %} title="Announcements">
<i class="fa-solid fa-bullhorn"></i>
<span>Announcements</span>
</a>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i>
<span>Domains</span>
</a>
<a href="{% url "admin_federation" %}" {% if section == "federation" %}class="selected"{% endif %} title="Federation">
<i class="fa-solid fa-diagram-project"></i>
<span>Federation</span>
</a>
<a href="{% url "admin_users" %}" {% if section == "users" %}class="selected"{% endif %} title="Users">
<i class="fa-solid fa-users"></i>
<span>Users</span>
</a>
<a href="{% url "admin_stator" %}" {% if section == "stator" %}class="selected"{% endif %} title="Stator">
<i class="fa-solid fa-clock-rotate-left"></i>
<span>Stator</span>
</a>
<a href="/djadmin" title="Django Admin" class="danger">
<i class="fa-solid fa-gear"></i>
<span>Django Admin</span>
</a>
{% endif %}
</nav>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Create Announcement{% endblock %} {% block subtitle %}Create Announcement{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -2,7 +2,7 @@
{% block title %}Delete Announcement - Admin{% endblock %} {% block title %}Delete Announcement - Admin{% endblock %}
{% block content %} {% block settings_content %}
<h1>Confirm Delete</h1> <h1>Confirm Delete</h1>
<section> <section>
<form action="." method="POST"> <form action="." method="POST">

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Announcement #{{ announcement.pk }}{% endblock %} {% block subtitle %}Announcement #{{ announcement.pk }}{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Announcements{% endblock %} {% block subtitle %}Announcements{% endblock %}
{% block content %} {% block settings_content %}
<div class="view-options"> <div class="view-options">
<a href="{% url "admin_announcement_create" %}" class="button"><i class="fa-solid fa-plus"></i> Create</a> <a href="{% url "admin_announcement_create" %}" class="button"><i class="fa-solid fa-plus"></i> Create</a>
</div> </div>

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}{% block subtitle %}{% endblock %} - Administration{% endblock %}
{% block body_class %}wide{% endblock %}
{% block content %}
<div class="settings">
{% include "admin/_menu.html" %}
<div class="settings-content">
{% block settings_content %}
{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block title %}Add Domain - Admin{% endblock %} {% block title %}Add Domain - Admin{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
<h1>Add A Domain</h1> <h1>Add A Domain</h1>
<p> <p>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block title %}Delete {{ domain.domain }} - Admin{% endblock %} {% block title %}Delete {{ domain.domain }} - Admin{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}{{ domain.domain }}{% endblock %} {% block subtitle %}{{ domain.domain }}{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<legend>Domain Details</legend> <legend>Domain Details</legend>
@ -16,6 +16,14 @@
{% include "forms/_field.html" with field=form.default %} {% include "forms/_field.html" with field=form.default %}
{% include "forms/_field.html" with field=form.users %} {% include "forms/_field.html" with field=form.users %}
</fieldset> </fieldset>
<fieldset>
<legend>Appearance</legend>
{% include "forms/_field.html" with field=form.site_name %}
{% include "forms/_field.html" with field=form.site_icon %}
{% include "forms/_field.html" with field=form.hide_login %}
{% include "forms/_field.html" with field=form.single_user %}
{% include "forms/_field.html" with field=form.custom_css %}
</fieldset>
<fieldset> <fieldset>
<legend>Admin Notes</legend> <legend>Admin Notes</legend>
{% include "forms/_field.html" with field=form.notes %} {% include "forms/_field.html" with field=form.notes %}

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Domains{% endblock %} {% block subtitle %}Domains{% endblock %}
{% block content %} {% block settings_content %}
<div class="view-options"> <div class="view-options">
<span class="spacer"></span> <span class="spacer"></span>
<a href="{% url "admin_domains_create" %}" class="button"><i class="fa-solid fa-plus"></i> Add Domain</a> <a href="{% url "admin_domains_create" %}" class="button"><i class="fa-solid fa-plus"></i> Add Domain</a>

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% load activity_tags %} {% load activity_tags %}
{% block subtitle %}Emoji{% endblock %} {% block subtitle %}Emoji{% endblock %}
{% block content %} {% block settings_content %}
<form action="." class="search"> <form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by shortcode or domain"> <input type="search" name="query" value="{{ query }}" placeholder="Search by shortcode or domain">
{% if local_only %} {% if local_only %}

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}{{ emoji.shortcode }}{% endblock %} {% block subtitle %}{{ emoji.shortcode }}{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data"> <form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% load activity_tags %} {% load activity_tags %}
{% block subtitle %}Federation{% endblock %} {% block subtitle %}Federation{% endblock %}
{% block content %} {% block settings_content %}
<form action="." class="search"> <form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by domain"> <input type="search" name="query" value="{{ query }}" placeholder="Search by domain">
<button><i class="fa-solid fa-search"></i></button> <button><i class="fa-solid fa-search"></i></button>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}{{ domain.domain }}{% endblock %} {% block subtitle %}{{ domain.domain }}{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<h1>{{ domain }}</h1> <h1>{{ domain }}</h1>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}{{ hashtag.hashtag }}{% endblock %} {% block subtitle %}{{ hashtag.hashtag }}{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Hashtags{% endblock %} {% block subtitle %}Hashtags{% endblock %}
{% block content %} {% block settings_content %}
<table class="items"> <table class="items">
{% for hashtag in page_obj %} {% for hashtag in page_obj %}
<tr> <tr>

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% load activity_tags %} {% load activity_tags %}
{% block subtitle %}Identities{% endblock %} {% block subtitle %}Identities{% endblock %}
{% block content %} {% block settings_content %}
<form action="." class="search"> <form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by name/username"> <input type="search" name="query" value="{{ query }}" placeholder="Search by name/username">
{% if local_only %} {% if local_only %}

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}{{ identity.name_or_handle }}{% endblock %} {% block subtitle %}{{ identity.name_or_handle }}{% endblock %}
{% block content %} {% block settings_content %}
<h1>{{ identity.html_name_or_handle }} <small>{{ identity.handle }}</small></h1> <h1>{{ identity.html_name_or_handle }} <small>{{ identity.handle }}</small></h1>
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Create Invite{% endblock %} {% block subtitle %}Create Invite{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}View Invite{% endblock %} {% block subtitle %}View Invite{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% load activity_tags %} {% load activity_tags %}
{% block subtitle %}Invites{% endblock %} {% block subtitle %}Invites{% endblock %}
{% block content %} {% block settings_content %}
<div class="view-options"> <div class="view-options">
<span class="spacer"></span> <span class="spacer"></span>
<a href="{% url "admin_invite_create" %}" class="button">Create New</a> <a href="{% url "admin_invite_create" %}" class="button">Create New</a>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Report {{ report.pk }}{% endblock %} {% block subtitle %}Report {{ report.pk }}{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% load activity_tags %} {% load activity_tags %}
{% block subtitle %}Reports{% endblock %} {% block subtitle %}Reports{% endblock %}
{% block content %} {% block settings_content %}
<div class="view-options"> <div class="view-options">
{% if all %} {% if all %}
<a href="." class="selected"><i class="fa-solid fa-check"></i> Show Resolved</a> <a href="." class="selected"><i class="fa-solid fa-check"></i> Show Resolved</a>

View File

@ -0,0 +1,20 @@
{% extends "admin/base_main.html" %}
{% block subtitle %}{{ section.title }}{% endblock %}
{% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %}
{% for title, fields in fieldsets.items %}
<fieldset>
<legend>{{ title }}</legend>
{% for field in fields %}
{% include "forms/_field.html" %}
{% endfor %}
</fieldset>
{% endfor %}
<div class="buttons">
<button>Save</button>
</div>
</form>
{% endblock %}

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}Stator{% endblock %} {% block subtitle %}Stator{% endblock %}
{% block content %} {% block settings_content %}
{% for model, stats in model_stats.items %} {% for model, stats in model_stats.items %}
<fieldset> <fieldset>
<legend>{{ model }}</legend> <legend>{{ model }}</legend>

View File

@ -1,8 +1,8 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% block subtitle %}{{ editing_user.email }}{% endblock %} {% block subtitle %}{{ editing_user.email }}{% endblock %}
{% block content %} {% block settings_content %}
<h1>{{ editing_user.email }}</h1> <h1>{{ editing_user.email }}</h1>
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}

View File

@ -1,9 +1,9 @@
{% extends "settings/base.html" %} {% extends "admin/base_main.html" %}
{% load activity_tags %} {% load activity_tags %}
{% block subtitle %}Users{% endblock %} {% block subtitle %}Users{% endblock %}
{% block content %} {% block settings_content %}
<form action="." class="search"> <form action="." class="search">
<input type="search" name="query" value="{{ query }}" placeholder="Search by email"> <input type="search" name="query" value="{{ query }}" placeholder="Search by email">
<button><i class="fa-solid fa-search"></i></button> <button><i class="fa-solid fa-search"></i></button>

View File

@ -8,7 +8,6 @@
<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" />
<link rel="manifest" href="/manifest.json" />
<link rel="shortcut icon" href="{{ config.site_icon }}"> <link rel="shortcut icon" href="{{ config.site_icon }}">
<script src="{% static "js/hyperscript.min.js" %}"></script> <script src="{% static "js/hyperscript.min.js" %}"></script>
<script src="{% static "js/htmx.min.js" %}"></script> <script src="{% static "js/htmx.min.js" %}"></script>
@ -18,14 +17,15 @@
--color-highlight: {{ config.highlight_color }}; --color-highlight: {{ config.highlight_color }};
--color-text-link: {{ config.highlight_color }}; --color-text-link: {{ config.highlight_color }};
} }
{% if not config_identity.visible_reaction_counts %}
.like-count {
display: none;
}
{% endif %}
</style> </style>
{% if config_identity.custom_css %} {% if identity and public_styling %}
<style>{{ config_identity.custom_css|safe }}</style> {% if identity.domain.config_domain.custom_css %}
<style>{{ identity.domain.config_domain.custom_css|safe }}</style>
{% endif %}
{% else %}
{% if request.domain.config_domain.custom_css %}
<style>{{ request.domain.config_domain.custom_css|safe }}</style>
{% endif %}
{% endif %} {% endif %}
{% block opengraph %} {% block opengraph %}
{% include "_opengraph.html" with opengraph_local=opengraph_defaults %} {% include "_opengraph.html" with opengraph_local=opengraph_defaults %}
@ -33,67 +33,51 @@
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
{% block custom_head %}{% if config.custom_head %}{{ config.custom_head|safe }}{% endif %}{% endblock %} {% block custom_head %}{% if config.custom_head %}{{ config.custom_head|safe }}{% endif %}{% endblock %}
</head> </head>
<body class="{% if config_identity.light_theme %}light-theme {% endif %}{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'> <body class="{% if user.config_user.light_theme %}theme-light {% endif %}{% block body_class %}{% endblock %}" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<a id="skip-to-main" class="screenreader-text" href="#main-content">Skip to Content</a> <a id="skip-to-main" class="screenreader-text" href="#main-content">Skip to Content</a>
<a id="skip-to-nav" class="screenreader-text" href="#side-navigation">Skip to Navigation</a> <a id="skip-to-nav" class="screenreader-text" href="#side-navigation">Skip to Navigation</a>
<main> <main>
{% block body_main %} {% block body_main %}
<header> <header>
<a class="logo" href="/">
<img src="{{ config.site_icon }}" width="32">
{{ config.site_name }}
</a>
<menu> <menu>
{% if user.is_authenticated %} <a class="logo" href="/">
<a href="{% url "compose" %}" title="Compose" role="menuitem" {% if top_section == "compose" %}class="selected"{% endif %}> {% if identity and public_styling %}
<i class="fa-solid fa-feather"></i> <img src="{{ identity.domain.config_domain.site_icon|default:config.site_icon }}" width="32">
</a> {{ identity.domain.config_domain.site_name|default:config.site_name }}
<a href="{% url "search" %}" title="Search" role="menuitem" class="search {% if top_section == "search" %}selected{% endif %}"> {% else %}
<i class="fa-solid fa-search"></i> <img src="{{ request.domain.config_domain.site_icon|default:config.site_icon }}" width="32">
</a> {{ request.domain.config_domain.site_name|default:config.site_name }}
{% if allows_refresh %}
<a href="." title="Refresh" role="menuitem" hx-get="." hx-select=".left-column" hx-target=".left-column" hx-swap="outerHTML" hx-trigger="click, every 120s[isAtTopOfPage()]">
<i class="fa-solid fa-rotate"></i>
</a>
{% endif %} {% endif %}
<div class="gap"></div> </a>
<a href="{{ request.identity.urls.view }}" role="menuitem" class="identity"> {% if user.is_authenticated %}
{% if not request.identity %} <a href="/" title="My Account"><i class="fa-solid fa-user"></i></a>
No Identity {% if identity %}
<img src="{% static "img/unknown-icon-128.png" %}" title="No identity selected"> <a href="{{ identity.urls.settings }}" title="Settings"><i class="fa-solid fa-gear"></i></a>
{% else %} {% else %}
{{ request.identity.username }} <a href="/settings" title="Settings"><i class="fa-solid fa-gear"></i></a>
<img src="{{ request.identity.local_icon_url.relative }}" title="{{ request.identity.handle }}"> {% endif %}
{% endif %} {% if user.admin %}
</a> <a href="/admin" title="Administration"><i class="fa-solid fa-screwdriver-wrench"></i></a>
{% else %} {% endif %}
<div class="gap"></div> {% elif not request.domain.config_domain.hide_login %}
<a href="{% url "login" %}" role="menuitem" class="identity"><i class="fa-solid fa-right-to-bracket"></i> Login</a> <a href="{% url "login" %}" title="Login"><i class="fa-solid fa-right-to-bracket"></i></a>
{% endif %} {% endif %}
</menu> </menu>
</header> </header>
{% block full_content %} {% block announcements %}
{% include "activities/_image_viewer.html" %} {% include "_announcements.html" %}
{% block pre_content %} {% endblock %}
{% endblock %}
<div class="columns"> {% include "activities/_image_viewer.html" %}
<div class="left-column" id="main-content">
{% block content %} {% block content %}
{% endblock %}
</div>
<div class="right-column" id="side-navigation">
{% block right_content %}
{% include "activities/_menu.html" %}
{% endblock %}
{% include "_footer.html" %}
</div>
</div>
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
</main> </main>
{% block footer %} {% block footer %}
{% include "_footer.html" %}
{% endblock %} {% endblock %}
</body> </body>

View File

@ -1,38 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body_class %}no-sidebar{% endblock %}
{% block opengraph %} {% block opengraph %}
{# Error pages don't have the context loaded, so disable opengraph to keep it from spewing errors #} {# Error pages don't have the context loaded, so disable opengraph to keep it from spewing errors #}
{% endblock %} {% endblock %}
{% block body_main %}
<header>
<menu>
{% if not request.user.is_authenticated and current_page == "about" and config.signup_allowed %}
<a href="{% url "signup" %}">Sign Up</a>
{% else %}
<a href="/">Home</a>
{% endif %}
<a class="logo" href="/">
<img src="{{ config.site_icon }}" width="32">
{{ config.site_name }}
</a>
{% if request.user.is_authenticated %}
<a href="javascript:history.back()">Back</a>
{% else %}
<a href="{% url "login" %}">Login</a>
{% endif %}
</menu>
</header>
<div id="main-content">
{% include "activities/_image_viewer.html" %}
{% block content %}
{% endblock %}
</div>
{% endblock %}
{% block footer %}
{% include "_footer.html" %}
{% endblock %}

View File

@ -28,55 +28,65 @@
{# fmt:on #} {# fmt:on #}
</script> </script>
{% include "forms/_field.html" %} <div class="field">
<div class="label-input">
<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|safe|linebreaksbr }}
</p>
{% endif %}
{{ field.errors }}
{{ field }}
<div class="field multi-option"> <div class="multi-option">
<section class="icon-menu"> {# fmt:off #}
{# fmt:off #} <span id="new_{{ field.name }}" style="display: none"
<span id="new_{{ field.name }}" stlye="display: none" _="on load
_="on load get the (value of #id_{{ field.name }}) as Object
get the (value of #id_{{ field.name }}) as Object set items to it
set items to it for item in items
for item in items set f to {{ field.name }}.addEmptyField()
set f to {{ field.name }}.addEmptyField() set one to the first <input.{{ field.name }}-{{ name_one }}/> in f then
set one to the first <input.{{ field.name }}-{{ name_one }}/> in f then set one@value to item.{{ name_one }}
set one@value to item.{{ name_one }}
set two to the first <input.{{ field.name }}-{{ name_two }}/> in f then set two to the first <input.{{ field.name }}-{{ name_two }}/> in f then
set two@value to item.{{ name_two }} set two@value to item.{{ name_two }}
end end
get the (@data-min-empty of #id_{{ field.name }}) get the (@data-min-empty of #id_{{ field.name }})
set min_empty to it set min_empty to it
if items.length < min_empty then if items.length < min_empty then
repeat (min_empty - items.length) times repeat (min_empty - items.length) times
call {{ field.name }}.addEmptyField() call {{ field.name }}.addEmptyField()
end end
"></span> "></span>
{# fmt:on #} {# fmt:on #}
<div class="option"> <div class="option">
<span class="option-field"> <span class="option-field">
<button class="fa-solid fa-add" title="Add Row" _="on click {{ field.name }}.addEmptyField() then halt"></button> <button class="fa-solid fa-add" title="Add Row" _="on click {{ field.name }}.addEmptyField() then halt"></button>
</span> </span>
</div> </div>
<div id="blank_{{ field.name }}" class="option option-row hidden"> <div id="blank_{{ field.name }}" class="option option-row hidden">
<span class="option-field"> <span class="option-field">
<input type=text class="{{ field.name }}-{{ name_one }}" name="{{ field.name }}_{{ name_one }}" value=""> <input type=text class="{{ field.name }}-{{ name_one }}" name="{{ field.name }}_{{ name_one }}" value="">
</span> </span>
<span class="option-field"> <span class="option-field">
<input type=text class="{{ field.name }}-{{ name_two }}" name="{{ field.name }}_{{ name_two }}" value=""> <input type=text class="{{ field.name }}-{{ name_two }}" name="{{ field.name }}_{{ name_two }}" value="">
</span> </span>
<div class="right"> <div class="right">
<button class="fa-solid fa-trash delete" title="Delete Row" <button class="fa-solid fa-trash delete" title="Delete Row"
_="on click remove (closest parent .option) _="on click remove (closest parent .option)
then {{ field.name }}.collect{{ field.name|title }}Fields() then {{ field.name }}.collect{{ field.name|title }}Fields()
then halt" /> then halt" />
</div>
</div> </div>
</div> </div>
</div>
</section>
</div> </div>
{% endwith %} {% endwith %}

View File

@ -24,7 +24,7 @@
<a href="{{ identity.urls.view }}" class="handle"> <a href="{{ identity.urls.view }}" class="handle">
{% endif %} {% endif %}
<div class="link">{{ identity.html_name_or_handle }}</div> <div class="link">{{ identity.html_name_or_handle }}</div>
<small>@{{ identity.handle }}</small> <small>@{{ identity.username }}@{{ identity.domain_id }}</small>
{% if link_handle is False %} {% if link_handle is False %}
</div> </div>
{% else %} {% else %}

View File

@ -1,22 +0,0 @@
<nav>
<a href="{% url "identity_select" %}" {% if identities %}class="selected"{% endif %} title="Select Identity">
<i class="fa-solid fa-users-viewfinder"></i>
<span>Select Identity</span>
</a>
<a href="{% url "identity_create" %}" {% if form %}class="selected"{% endif %} title="Create Identity">
<i class="fa-solid fa-plus"></i>
<span>Create Identity</span>
</a>
<a href="{% url "logout" %}" title="Logout">
<i class="fa-solid fa-right-from-bracket"></i>
<span>Logout</span>
</a>
{% if request.user.admin %}
<hr>
<h3>Administration</h3>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains">
<i class="fa-solid fa-globe"></i>
<span>Domains</span>
</a>
{% endif %}
</nav>

View File

@ -0,0 +1,10 @@
<section class="view-options">
<a href="{{ identity.urls.view }}" {% if not section %}class="selected"{% endif %}><strong>{{ post_count }}</strong> Posts</a>
{% if identity.local and identity.config_identity.visible_follows %}
<a href="{{ identity.urls.following }}" {% if not inbound and section == "follows" %}class="selected"{% endif %}><strong>{{ following_count }}</strong> Following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and section == "follows" %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> Follower{{ followers_count|pluralize }}</a>
{% endif %}
{% if identity.local and identity.config_identity.search_enabled %}
<a href="{{ identity.urls.search }}" {% if section == "search" %}class="selected"{% endif %}>Search</a>
{% endif %}
</section>

View File

@ -1,78 +0,0 @@
<div class="inline follow {% if inbound_follow %}has-reverse{% endif %}">
<div class="actions" role="menubar">
{% if request.identity == identity %}
<a href="{% url "settings_profile" %}" class="button" title="Edit Profile">
<i class="fa-solid fa-user-edit"></i> Edit
</a>
{% elif not inbound_block %}
{% if inbound_follow or outbound_mute %}
<span class="reverse-follow">
{% if inbound_follow %}Follows You{% endif %}{% if inbound_follow and outbound_mute %},{% endif %}
{% if outbound_mute %}Muted{% endif %}
</span>
{% endif %}
<form action="{{ identity.urls.action }}" method="POST" class="inline-menu">
{% csrf_token %}
{% if outbound_block %}
<input type="hidden" name="action" value="unblock">
<button class="destructive" title="Unblock"><i class="fa-solid fa-ban"></i>
Unblock
</button>
{% elif outbound_follow %}
<input type="hidden" name="action" value="unfollow">
<button class="destructive" title="Unfollow"><i class="fa-solid fa-user-minus"></i>
{% if outbound_follow.pending %}Follow Pending{% else %}Unfollow{% endif %}
</button>
{% else %}
<input type="hidden" name="action" value="follow">
<button><i class="fa-solid fa-user-plus"></i> Follow</button>
{% endif %}
</form>
{% endif %}
<a title="Menu" class="menu button" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i>
</a>
<menu>
{% if outbound_follow %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
{% if outbound_follow.boosts %}
<input type="hidden" name="action" value="hide_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Hide boosts</button>
{% else %}
<input type="hidden" name="action" value="show_boosts">
<button role="menuitem"><i class="fa-solid fa-retweet"></i> Show boosts</button>
{% endif %}
</form>
{% endif %}
{% if request.identity != identity and not outbound_block %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="block">
<button role="menuitem"><i class="fa-solid fa-ban"></i> Block user</button>
</form>
{% if outbound_mute %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="unmute">
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Unmute user</button>
</form>
{% else %}
<form action="{{ identity.urls.action }}" method="POST" class="inline">
{% csrf_token %}
<input type="hidden" name="action" value="mute">
<button role="menuitem"><i class="fa-solid fa-comment-slash"></i> Mute user</button>
</form>
{% endif %}
{% endif %}
{% if request.user.admin %}
<a href="{{ identity.urls.admin_edit }}" role="menuitem">
<i class="fa-solid fa-user-gear"></i> View in Admin
</a>
<a href="{{ identity.urls.djadmin_edit }}" role="menuitem">
<i class="fa-solid fa-gear"></i> View in djadmin
</a>
{% endif %}
</menu>
</div>
</div>

View File

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %}
{% block right_content %}
{% include "identity/_menu.html" %}
{% endblock %}

View File

@ -1,12 +1,15 @@
{% extends "identity/base.html" %} {% extends "base.html" %}
{% block title %}Create Identity{% endblock %} {% block title %}Create Identity{% endblock %}
{% block content %} {% block content %}
<form action="." method="POST"> <form action="." method="POST">
<h1>Create New Identity</h1> <h1>Create New Identity</h1>
<p>You can have multiple identities - they are totally separate, and share <p>
nothing apart from your login details. Use them for alternates, projects, and more.</p> You can have multiple identities - they are totally separate, and share
nothing apart from your login details. They can also be shared among multiple
users, so you can use them for company or project accounts.
</p>
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<legend>Identity Details</legend> <legend>Identity Details</legend>

View File

@ -1,6 +1,6 @@
{% extends "identity/view.html" %} {% extends "identity/view.html" %}
{% block title %}{% if self.inbound %}Followers{% else %}Following{% endif %} - {{ identity }}{% endblock %} {% block title %}{% if inbound %}Followers{% else %}Following{% endif %} - {{ identity }}{% endblock %}
{% block subcontent %} {% block subcontent %}
@ -8,7 +8,7 @@
{% include "activities/_identity.html" %} {% include "activities/_identity.html" %}
{% empty %} {% empty %}
<span class="empty"> <span class="empty">
This person has no {% if self.inbound %}followers{% else %}follows{% endif %} yet. This person has no {% if inbound %}followers{% else %}follows{% endif %} yet.
</span> </span>
{% endfor %} {% endfor %}

View File

@ -0,0 +1,23 @@
{% extends "identity/view.html" %}
{% block title %}Search - {{ identity }}{% endblock %}
{% block subcontent %}
<form action="." method="post">
{% csrf_token %}
<fieldset>
{% include "forms/_field.html" with field=form.query %}
</fieldset>
<div class="buttons">
<button>Search</button>
</div>
</form>
{% for post in results %}
{% include "activities/_post.html" %}
{% empty %}
<p class="empty">No posts were found that match your search.</p>
{% endfor %}
{% endblock %}

View File

@ -1,23 +0,0 @@
{% extends "identity/base.html" %}
{% block title %}Select Identity{% endblock %}
{% block content %}
<section class="icon-menu">
{% for identity in identities %}
<a class="option" href="{{ identity.urls.activate }}">
<img src="{{ identity.local_icon_url.relative }}">
<span class="handle">
{{ identity.html_name_or_handle }}
<small>@{{ identity.handle }}</small>
</span>
<button class="right secondary" _="on click go to url {{ identity.urls.view }} then halt">View</button>
</a>
{% empty %}
<p class="option empty">You have no identities.</p>
{% endfor %}
<a href="{% url "identity_create" %}" class="option new">
<i class="fa-solid fa-plus"></i> Create a new identity
</a>
</section>
{% endblock %}

View File

@ -12,88 +12,80 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block body_class %}has-banner{% endblock %}
{% block content %} {% block content %}
{% if not request.htmx %} <section class="identity">
<h1 class="identity"> {% if identity.local_image_url %}
{% if identity.local_image_url %} <img src="{{ identity.local_image_url.relative }}" class="banner">
<img src="{{ identity.local_image_url.relative }}" class="banner"> {% endif %}
{% endif %}
<span <span
_="on click halt the event then call imageviewer.show(me)" _="on click halt the event then call imageviewer.show(me)"
>
<img src="{{ identity.local_icon_url.relative }}" class="icon"
data-original-url="{{ identity.local_icon_url.relative }}"
alt="Profile image for {{ identity.name }}"
> >
<img src="{{ identity.local_icon_url.relative }}" class="icon" </span>
data-original-url="{{ identity.local_icon_url.relative }}"
alt="Profile image for {{ identity.name }}"
>
</span>
{% if request.identity %}{% include "identity/_view_menu.html" %}{% endif %} <div class="inline follow">
<div class="actions" role="menubar">
{{ identity.html_name_or_handle }} {% if request.user in identity.users.all %}
<small> <a href="{% url "settings_profile" handle=identity.handle %}" class="button" title="Edit Profile">
@{{ identity.handle }} <i class="fa-solid fa-user-edit"></i> Settings
<a title="Copy handle" </a>
class="copy" {% endif %}
tabindex="0" {% if request.user.admin or request.user.moderator %}
_="on click or keyup[key is 'Enter'] <a href="{% url "settings_profile" handle=identity.handle %}" class="button danger" title="View in Admin">
writeText('@{{ identity.handle }}') into the navigator's clipboard <i class="fa-solid fa-screwdriver-wrench"></i> Admin
then add .copied </a>
wait 2s
then remove .copied">
<i class="fa-solid fa-copy"></i>
</a>
</small>
</h1>
{% endif %}
{% if inbound_block %}
<p class="system-note">
This user has blocked you.
</p>
{% else %}
{% if not request.htmx %}
{% if identity.summary %}
<div class="bio">
{{ identity.safe_summary }}
</div>
{% endif %}
{% if identity.metadata %}
<div class="identity-metadata">
{% for entry in identity.safe_metadata %}
<div class="metadata-pair">
<span class="metadata-name">{{ entry.name }}</span>
<span class="metadata-value">{{ entry.value }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<div class="view-options follows">
<a href="{{ identity.urls.view }}" {% if not follows_page %}class="selected"{% endif %}><strong>{{ post_count }}</strong> posts</a>
{% if identity.local and identity.config_identity.visible_follows %}
<a href="{{ identity.urls.following }}" {% if not inbound and follows_page %}class="selected"{% endif %}><strong>{{ following_count }}</strong> following</a>
<a href="{{ identity.urls.followers }}" {% if inbound and follows_page %}class="selected"{% endif %}><strong>{{ followers_count }}</strong> follower{{ followers_count|pluralize }}</a>
{% endif %} {% endif %}
</div> </div>
</div>
{% if not identity.local %} <h1>{{ identity.html_name_or_handle }}</h1>
{% if identity.outdated and not identity.name %} <small>
<p class="system-note"> @{{ identity.handle }}
The system is still fetching this profile. Refresh to see updates. <a title="Copy handle"
</p> class="copy"
{% else %} tabindex="0"
<p class="system-note"> _="on click or keyup[key is 'Enter']
This is a member of another server. writeText('@{{ identity.handle }}') into the navigator's clipboard
<a href="{{ identity.profile_uri|default:identity.actor_uri }}">See their original profile ➔</a> then add .copied
</p> wait 2s
{% endif %} then remove .copied">
{% endif %} <i class="fa-solid fa-copy"></i>
</a>
</small>
{% if identity.summary %}
<div class="bio">
{{ identity.safe_summary }}
</div>
{% endif %} {% endif %}
</section>
<section class="invisible identity-metadata">
{% if identity.metadata %}
{% for entry in identity.safe_metadata %}
<div class="metadata-pair">
<span class="metadata-name">{{ entry.name }}</span>
<span class="metadata-value">{{ entry.value }}</span>
</div>
{% endfor %}
{% endif %}
</section>
{% if not identity.local %}
<section class="system-note">
{% if identity.outdated and not identity.name %}
The system is still fetching this profile. Refresh to see updates.
{% else %}
This is a member of another server.
<a href="{{ identity.profile_uri|default:identity.actor_uri }}">See their original profile ➔</a>
{% endif %}
</section>
{% else %}
{% include "identity/_tabs.html" %}
{% block subcontent %} {% block subcontent %}
@ -104,10 +96,10 @@
{% if identity.local %} {% if identity.local %}
No posts yet. No posts yet.
{% else %} {% else %}
No posts have been received/retrieved by this server yet. No posts have been received by this server yet.
{% if identity.profile_uri %} {% if identity.profile_uri %}
You might find historical posts at You may find historical posts at
<a href="{{ identity.profile_uri }}">their original profile ➔</a> <a href="{{ identity.profile_uri }}">their original profile ➔</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -120,7 +112,7 @@
{% endif %} {% endif %}
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML" {% if config_identity.infinite_scroll %}hx-trigger="revealed"{% endif %}>Next Page</a> <a class="button" href=".?page={{ page_obj.next_page_number }}" hx-get=".?page={{ page_obj.next_page_number }}" hx-select=".left-column > *:not(.view-options)" hx-target=".pagination" hx-swap="outerHTML">Next Page</a>
{% endif %} {% endif %}
</div> </div>

View File

@ -1,85 +1,62 @@
<nav> <nav>
<h3>Identity</h3> {% if identity %}
<a href="{% url "settings_profile" %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile"> {% include "identity/_identity_banner.html" %}
<i class="fa-solid fa-user"></i> <h3>Identity Settings</h3>
<span>Profile</span> <a href="{% url "settings_profile" handle=identity.handle %}" {% if section == "profile" %}class="selected"{% endif %} title="Profile">
</a> <i class="fa-solid fa-user"></i>
<a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface"> <span>Profile</span>
<i class="fa-solid fa-display"></i> </a>
<span>Interface</span> <a href="{% url "settings_posting" handle=identity.handle %}" {% if section == "posting" %}class="selected"{% endif %} title="Posting">
</a> <i class="fa-solid fa-message"></i>
<a href="{% url "settings_import_export" %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface"> <span>Posting</span>
<i class="fa-solid fa-cloud-arrow-up"></i> </a>
<span>Import/Export</span> <a href="{% url "settings_import_export" handle=identity.handle %}" {% if section == "importexport" %}class="selected"{% endif %} title="Interface">
</a> <i class="fa-solid fa-cloud-arrow-up"></i>
<hr> <span>Import/Export</span>
<h3>Account</h3> </a>
<a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security"> <a href="{% url "settings_tokens" handle=identity.handle %}" {% if section == "tokens" %}class="selected"{% endif %} title="Authorized Apps">
<i class="fa-solid fa-key"></i> <i class="fa-solid fa-window-restore"></i>
<span>Login &amp; Security</span> <span>Authorized Apps</span>
</a> </a>
<a href="{% url "logout" %}"> <a href="{% url "settings_delete" handle=identity.handle %}" {% if section == "delete" %}class="selected"{% endif %} title="Delete Identity">
<i class="fa-solid fa-right-from-bracket" title="Logout"></i> <i class="fa-solid fa-user-slash"></i>
<span>Logout</span> <span>Delete Identity</span>
</a> </a>
{% if request.user.moderator or request.user.admin %}
<hr> <hr>
<h3>Moderation</h3> <h3>Tools</h3>
<a href="{% url "admin_identities" %}" {% if section == "identities" %}class="selected"{% endif %} title="Identities"> <a href="{% url "settings_follows" handle=identity.handle %}" {% if section == "follows" %}class="selected"{% endif %} title="Follows">
<i class="fa-solid fa-id-card"></i> <i class="fa-solid fa-arrow-right-arrow-left"></i>
<span>Identities</span> <span>Follows</span>
</a> </a>
<a href="{% url "admin_invites" %}" {% if section == "invites" %}class="selected"{% endif %} title="Invites"> <a href="{% url "compose" handle=identity.handle %}" {% if section == "compose" %}class="selected"{% endif %} title="Compose">
<i class="fa-solid fa-envelope"></i> <i class="fa-solid fa-pen-to-square"></i>
<span>Invites</span> <span>Compose</span>
</a> </a>
<a href="{% url "admin_hashtags" %}" {% if section == "hashtags" %}class="selected"{% endif %} title="Hashtags">
<i class="fa-solid fa-hashtag"></i>
<span>Hashtags</span>
</a>
<a href="{% url "admin_emoji" %}" {% if section == "emoji" %}class="selected"{% endif %} title="Emoji">
<i class="fa-solid fa-icons"></i>
<span>Emoji</span>
</a>
<a href="{% url "admin_reports" %}" {% if section == "reports" %}class="selected"{% endif %} title="Reports">
<i class="fa-solid fa-flag"></i>
<span>Reports</span>
</a>
{% endif %}
{% if request.user.admin %}
<hr> <hr>
<h3>Administration</h3> <h3>User Settings</h3>
<a href="{% url "admin_basic" %}" {% if section == "basic" %}class="selected"{% endif %} title="Basic"> <a href="{% url "settings" %}" title="Switch to User Settings">
<i class="fa-solid fa-book"></i> <i class="fa-solid fa-arrow-right"></i>
<span>Basic</span> <span>Go to User Settings</span>
</a> </a>
<a href="{% url "admin_policies" %}" {% if section == "policies" %}class="selected"{% endif %} title="Policies"> {% else %}
<i class="fa-solid fa-file-lines"></i> <h3>Account</h3>
<span>Policies</span> <a href="{% url "settings_security" %}" {% if section == "security" %}class="selected"{% endif %} title="Login &amp; Security">
<i class="fa-solid fa-key"></i>
<span>Login &amp; Security</span>
</a> </a>
<a href="{% url "admin_announcements" %}" {% if section == "announcements" %}class="selected"{% endif %} title="Announcements"> <a href="{% url "settings_interface" %}" {% if section == "interface" %}class="selected"{% endif %} title="Interface">
<i class="fa-solid fa-bullhorn"></i> <i class="fa-solid fa-display"></i>
<span>Announcements</span> <span>Interface</span>
</a> </a>
<a href="{% url "admin_domains" %}" {% if section == "domains" %}class="selected"{% endif %} title="Domains"> <a href="{% url "logout" %}">
<i class="fa-solid fa-globe"></i> <i class="fa-solid fa-right-from-bracket" title="Logout"></i>
<span>Domains</span> <span>Logout</span>
</a> </a>
<a href="{% url "admin_federation" %}" {% if section == "federation" %}class="selected"{% endif %} title="Federation"> <hr>
<i class="fa-solid fa-diagram-project"></i> <h3>Identity Settings</h3>
<span>Federation</span> <a href="/" title="Go to Identity Select">
</a> <i class="fa-solid fa-arrow-right"></i>
<a href="{% url "admin_users" %}" {% if section == "users" %}class="selected"{% endif %} title="Users"> <span>Go to Identity Select</span>
<i class="fa-solid fa-users"></i>
<span>Users</span>
</a>
<a href="{% url "admin_stator" %}" {% if section == "stator" %}class="selected"{% endif %} title="Stator">
<i class="fa-solid fa-clock-rotate-left"></i>
<span>Stator</span>
</a>
<a href="/djadmin" title="Django Admin" class="danger">
<i class="fa-solid fa-gear"></i>
<span>Django Admin</span>
</a> </a>
{% endif %} {% endif %}
</nav> </nav>

View File

@ -2,6 +2,14 @@
{% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %} {% block title %}{% block subtitle %}{% endblock %} - Settings{% endblock %}
{% block right_content %} {% block body_class %}wide{% endblock %}
{% include "settings/_menu.html" %}
{% block content %}
<div class="settings">
{% include "settings/_menu.html" %}
<div class="settings-content">
{% block settings_content %}
{% endblock %}
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "settings/base.html" %}
{% block subtitle %}Token{% endblock %}
{% block settings_content %}
<h1>Deleting {{ identity.handle }}</h1>
<form action="." method="POST">
{% csrf_token %}
<fieldset>
<legend>Confirmation</legend>
<p>
Deleting this account is <i>permanent and irreversible</i>. Once you
start the deletion process, all your posts, interactions and profile
data will be gone, and other servers will be contacted to remove
your data as well.
</p>
<p>
Some other servers in the Fediverse may maintain a copy of your
profile and posts; we do not control them, and cannot speed up
or affect your data's removal. Most data will disappear from
the network after a couple of weeks.
</p>
{% include "forms/_field.html" with field=form.confirmation %}
</fieldset>
<div class="buttons">
<a href="{% url "settings" handle=identity.handle %}" class="button secondary left">Cancel</a>
<button class="danger">Delete {{ identity.handle }}</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "settings/base.html" %}
{% block subtitle %}Follows{% endblock %}
{% block settings_content %}
<div class="view-options">
{% if inbound %}
<a href=".">Following ({{ num_outbound }})</a>
<a href="." class="selected">Followers ({{ num_inbound }})</a>
{% else %}
<a href=".?inbound=true" class="selected">Following ({{ num_outbound }})</a>
<a href=".?inbound=true">Followers ({{ num_inbound }})</a>
{% endif %}
</div>
<table class="items">
{% for other_identity in page_obj %}
<tr>
<td class="icon">
<a href="{{ other_identity.urls.view }}" class="overlay"></a>
<img
src="{{ other_identity.local_icon_url.relative }}"
class="icon"
alt="Avatar for {{ other_identity.name_or_handle }}"
loading="lazy"
data-handle="{{ other_identity.name_or_handle }}"
_="on error set my.src to generate_avatar(@data-handle)"
>
</td>
<td class="name">
<a href="{{ other_identity.urls.view }}" class="overlay"></a>
{{ other_identity.html_name_or_handle }}
<small>@{{ other_identity.handle }}</small>
</td>
<td class="stat">
{% if other_identity.id in outbound_ids %}
<span class="pill">Following</span>
{% endif %}
{% if other_identity.id in inbound_ids %}
<span class="pill">Follows You</span>
{% endif %}
</td>
<td class="actions">
<a href="{{ other_identity.urls.view }}" title="View"><i class="fa-solid fa-eye"></i></a>
</td>
</tr>
{% empty %}
<tr class="empty"><td>You {% if inbound %}have no followers{% else %}are not following anyone{% endif %}.</td></tr>
{% endfor %}
</table>
{% if inbound %}
{% include "admin/_pagination.html" with nouns="follower,followers" %}
{% else %}
{% include "admin/_pagination.html" with nouns="follow,follows" %}
{% endif %}
{% endblock %}

View File

@ -2,7 +2,7 @@
{% block subtitle %}Import/Export{% endblock %} {% block subtitle %}Import/Export{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST" enctype="multipart/form-data"> <form action="." method="POST" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
@ -32,7 +32,7 @@
<small>{{ numbers.outbound_follows }} {{ numbers.outbound_follows|pluralize:"follow,follows" }}</small> <small>{{ numbers.outbound_follows }} {{ numbers.outbound_follows|pluralize:"follow,follows" }}</small>
</td> </td>
<td> <td>
<a href="{% url "settings_export_following_csv" %}">Download CSV</a> <a href="{% url "settings_export_following_csv" handle=identity.handle %}">Download CSV</a>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -41,7 +41,7 @@
<small>{{ numbers.inbound_follows }} {{ numbers.inbound_follows|pluralize:"follower,followers" }}</small> <small>{{ numbers.inbound_follows }} {{ numbers.inbound_follows|pluralize:"follower,followers" }}</small>
</td> </td>
<td> <td>
<a href="{% url "settings_export_followers_csv" %}">Download CSV</a> <a href="{% url "settings_export_followers_csv" handle=identity.handle %}">Download CSV</a>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -2,7 +2,7 @@
{% block subtitle %}Login &amp; Security{% endblock %} {% block subtitle %}Login &amp; Security{% endblock %}
{% block content %} {% block settings_content %}
<form action="." method="POST"> <form action="." method="POST">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>

Some files were not shown because too many files have changed in this diff Show More