implement guild subscriptions, blocking, aesthetic improvements
This commit is contained in:
parent
c1c005cc4e
commit
05dca27149
23 changed files with 292 additions and 68 deletions
|
|
@ -2,10 +2,13 @@
|
|||
|
||||
## 0.4.0
|
||||
|
||||
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou)
|
||||
- Added user strikes, memberships and user blocks
|
||||
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
|
||||
- Added user blocks
|
||||
- Added user strikes: a strike logs the content of a removed message for future use
|
||||
- Implemented guild subscriptions
|
||||
+ Blocking a user prevents them from seeing your comments, posts (standalone or in feed) and profile
|
||||
- Added ✨color themes✨
|
||||
- Users can now set their display name and biography in `/settings`
|
||||
- Users can now set their display name, biography and color theme in `/settings`
|
||||
|
||||
## 0.3.3
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ AJAX hooks for the website.
|
|||
'''
|
||||
|
||||
import re
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, abort, flash, redirect, request
|
||||
from sqlalchemy import delete, insert, select
|
||||
from .models import Guild, db, User, Post, PostUpvote
|
||||
from .models import Guild, Member, UserBlock, db, User, Post, PostUpvote
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
current_user: User
|
||||
|
||||
bp = Blueprint('ajax', __name__)
|
||||
|
||||
@bp.route('/username_availability/<username>')
|
||||
|
|
@ -71,3 +73,81 @@ def post_upvote(id):
|
|||
db.session.commit()
|
||||
return { 'status': 'ok', 'count': p.upvotes() }
|
||||
|
||||
@bp.route('/@<username>/block', methods=['POST'])
|
||||
@login_required
|
||||
def block_user(username):
|
||||
u = db.session.execute(select(User).where(User.username == username)).scalar()
|
||||
|
||||
if u is None:
|
||||
abort(404)
|
||||
|
||||
is_block = 'reverse' not in request.form
|
||||
is_unblock = request.form.get('reverse') == '1'
|
||||
|
||||
if is_block:
|
||||
if current_user.has_blocked(u):
|
||||
flash(f'{u.handle()} is already blocked')
|
||||
else:
|
||||
db.session.execute(insert(UserBlock).values(
|
||||
actor_id = current_user.id,
|
||||
target_id = u.id
|
||||
))
|
||||
db.session.commit()
|
||||
flash(f'{u.handle()} is now blocked')
|
||||
|
||||
if is_unblock:
|
||||
if not current_user.has_blocked(u):
|
||||
flash('You didn\'t block this user')
|
||||
else:
|
||||
db.session.execute(delete(UserBlock).where(
|
||||
UserBlock.c.actor_id == current_user.id,
|
||||
UserBlock.c.target_id == u.id
|
||||
))
|
||||
db.session.commit()
|
||||
flash(f'Removed block on {u.handle()}')
|
||||
|
||||
return redirect(request.args.get('next', u.url())), 303
|
||||
|
||||
@bp.route('/+<name>/subscribe', methods=['POST'])
|
||||
@login_required
|
||||
def subscribe_guild(name):
|
||||
gu = db.session.execute(select(Guild).where(Guild.name == name)).scalar()
|
||||
|
||||
if gu is None:
|
||||
abort(404)
|
||||
|
||||
is_join = 'reverse' not in request.form
|
||||
is_leave = request.form.get('reverse') == '1'
|
||||
|
||||
membership = db.session.execute(select(Member).where(Member.guild == gu, Member.user_id == current_user.id)).scalar()
|
||||
|
||||
if is_join:
|
||||
if membership is None:
|
||||
membership = db.session.execute(insert(Member).values(
|
||||
guild_id = gu.id,
|
||||
user_id = current_user.id,
|
||||
is_subscribed = True
|
||||
).returning(Member)).scalar()
|
||||
elif membership.is_subscribed == False:
|
||||
membership.is_subscribed = True
|
||||
db.session.add(membership)
|
||||
else:
|
||||
return redirect(gu.url()), 303
|
||||
db.session.commit()
|
||||
flash(f"You are now subscribed to {gu.handle()}")
|
||||
|
||||
if is_leave:
|
||||
if membership is None:
|
||||
return redirect(gu.url()), 303
|
||||
elif membership.is_subscribed == True:
|
||||
membership.is_subscribed = False
|
||||
db.session.add(membership)
|
||||
else:
|
||||
return redirect(gu.url()), 303
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Unsubscribed from {gu.handle()}.")
|
||||
|
||||
return redirect(gu.url()), 303
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
|
||||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import func, select
|
||||
from .models import db, Post, Guild, User
|
||||
from sqlalchemy import and_, distinct, func, select
|
||||
from .models import Comment, Member, db, Post, Guild, User
|
||||
|
||||
current_user: User
|
||||
|
||||
def cuser() -> User:
|
||||
return current_user if current_user.is_authenticated else None
|
||||
|
|
@ -22,13 +24,18 @@ def topic_timeline(gname):
|
|||
|
||||
def user_timeline(user_id):
|
||||
return select(Post).join(User, User.id == Post.author_id).where(
|
||||
Post.visible_by(cuser()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
Post.visible_by(cuser_id()), User.id == user_id, User.not_suspended(), Post.not_removed(), User.has_not_blocked(Post.author_id, cuser_id())
|
||||
).order_by(Post.created_at.desc())
|
||||
|
||||
def top_guilds_query():
|
||||
q_post_count = func.count().label('post_count')
|
||||
qr = select(Guild, q_post_count)\
|
||||
.join(Post, Post.topic_id == Guild.id).group_by(Guild)\
|
||||
.having(q_post_count > 5).order_by(q_post_count.desc())
|
||||
q_post_count = func.count(distinct(Post.id)).label('post_count')
|
||||
q_sub_count = func.count(distinct(Member.id)).label('sub_count')
|
||||
qr = select(Guild, q_post_count, q_sub_count)\
|
||||
.join(Post, Post.topic_id == Guild.id, isouter=True)\
|
||||
.join(Member, and_(Member.guild_id == Guild.id, Member.is_subscribed == True), isouter=True)\
|
||||
.group_by(Guild).having(q_post_count > 5).order_by(q_post_count.desc(), q_sub_count.desc())
|
||||
return qr
|
||||
|
||||
def new_comments(p: Post):
|
||||
return select(Comment).where(Comment.parent_post_id == p.id, Comment.parent_comment_id == None,
|
||||
Comment.not_removed(), User.has_not_blocked(Comment.author_id, cuser_id())).order_by(Comment.created_at.desc())
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import markdown
|
||||
from markupsafe import Markup
|
||||
|
||||
from suou import Snowflake
|
||||
from suou import Siq, Snowflake
|
||||
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
|
||||
|
||||
from . import app
|
||||
|
|
@ -29,6 +29,12 @@ def to_b32l(n):
|
|||
|
||||
app.template_filter('b32l')(to_b32l)
|
||||
|
||||
@app.template_filter()
|
||||
def to_cb32(n):
|
||||
return '0' + Siq.from_bytes(n).to_cb32()
|
||||
|
||||
app.template_filter('cb32')(to_cb32)
|
||||
|
||||
@app.template_filter()
|
||||
def append(text, l: list):
|
||||
l.append(text)
|
||||
|
|
|
|||
|
|
@ -214,10 +214,28 @@ class User(Base):
|
|||
def not_suspended(cls):
|
||||
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
||||
|
||||
def has_blocked(self, other: User | None) -> bool:
|
||||
if other is None or not other.is_authenticated:
|
||||
return False
|
||||
return bool(db.session.execute(select(UserBlock).where(UserBlock.c.actor_id == self.id, UserBlock.c.target_id == other.id)).scalar())
|
||||
|
||||
@not_implemented()
|
||||
def end_friendship(self, other: User):
|
||||
"""
|
||||
Remove any relationship between two users.
|
||||
Executed before block.
|
||||
"""
|
||||
# TODO implement in 0.5
|
||||
...
|
||||
|
||||
def has_subscriber(self, other: User) -> bool:
|
||||
# TODO implement in 0.5
|
||||
return False #bool(db.session.execute(select(Friendship).where(...)).scalar())
|
||||
|
||||
@classmethod
|
||||
def has_not_blocked(cls, actor, target):
|
||||
"""
|
||||
Filter out a content if the author has blocked current user.
|
||||
Filter out a content if the author has blocked current user. Returns a query.
|
||||
|
||||
XXX untested.
|
||||
"""
|
||||
|
|
@ -242,6 +260,8 @@ class User(Base):
|
|||
def strike_count(self) -> int:
|
||||
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
|
||||
|
||||
# UserBlock table is at the top !!
|
||||
|
||||
## END User
|
||||
|
||||
class Guild(Base):
|
||||
|
|
@ -272,9 +292,16 @@ class Guild(Base):
|
|||
def handle(self):
|
||||
return f'+{self.name}'
|
||||
|
||||
def subscriber_count(self):
|
||||
return db.session.execute(select(func.count('*')).select_from(Member).where(Member.guild == self, Member.is_subscribed == True)).scalar()
|
||||
|
||||
# utilities
|
||||
posts = relationship('Post', back_populates='guild')
|
||||
|
||||
def has_subscriber(self, other: User) -> bool:
|
||||
if other is None or not other.is_authenticated:
|
||||
return False
|
||||
return bool(db.session.execute(select(Member).where(Member.user_id == other.id, Member.guild_id == self.id, Member.is_subscribed == True)).scalar())
|
||||
|
||||
Topic = deprecated('renamed to Guild')(Guild)
|
||||
|
||||
|
|
@ -356,9 +383,9 @@ class Post(Base):
|
|||
def url(self):
|
||||
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
|
||||
|
||||
@not_implemented
|
||||
def generate_slug(self):
|
||||
return slugify.slugify(self.title, max_length=64)
|
||||
@not_implemented('slugify is not a dependency as of now')
|
||||
def generate_slug(self) -> str:
|
||||
return "slugify.slugify(self.title, max_length=64)"
|
||||
|
||||
def upvotes(self) -> int:
|
||||
return (db.session.execute(select(func.count('*')).select_from(PostUpvote).where(PostUpvote.c.post_id == self.id, PostUpvote.c.is_downvote == False)).scalar()
|
||||
|
|
@ -397,8 +424,8 @@ class Post(Base):
|
|||
return Post.removed_at == None
|
||||
|
||||
@classmethod
|
||||
def visible_by(cls, user: User):
|
||||
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
|
||||
def visible_by(cls, user_id: int | None):
|
||||
return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
|
||||
|
||||
from flask import Blueprint
|
||||
from flask_restx import Resource, Api
|
||||
from flask import Blueprint, redirect, url_for
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy import select
|
||||
from suou import Snowflake
|
||||
from suou.flask_sqlalchemy import require_auth
|
||||
|
||||
from suou.flask_restx import Api
|
||||
|
||||
from ..models import Post, User, db
|
||||
|
||||
rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
|
||||
rest = Api(rest_bp)
|
||||
|
||||
auth_required = require_auth(User, db)
|
||||
|
||||
@rest.route('/nurupo')
|
||||
class Nurupo(Resource):
|
||||
def get(self):
|
||||
|
|
@ -18,9 +23,20 @@ class Nurupo(Resource):
|
|||
## TODO coverage of REST is still partial, but it's planned
|
||||
## to get complete sooner or later
|
||||
|
||||
## XXX there is a bug in suou.sqlalchemy.auth_required() — apparently, /user/@me does not
|
||||
## redirect, neither is able to get user injected.
|
||||
## Auth-based REST endpoints won't be fully functional until 0.6 in most cases
|
||||
|
||||
@rest.route('/user/@me')
|
||||
class UserInfoMe(Resource):
|
||||
@auth_required(required=True)
|
||||
def get(self, user: User):
|
||||
return redirect(url_for('rest.UserInfo', user.id)), 302
|
||||
|
||||
@rest.route('/user/<b32l:id>')
|
||||
class UserInfo(Resource):
|
||||
def get(self, id: int):
|
||||
## TODO sanizize REST to make blocked users inaccessible
|
||||
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
|
||||
if u is None:
|
||||
return dict(error='User not found'), 404
|
||||
|
|
@ -34,6 +50,7 @@ class UserInfo(Resource):
|
|||
)
|
||||
return dict(users={f'{Snowflake(id):l}': uj})
|
||||
|
||||
|
||||
@rest.route('/post/<b32l:id>')
|
||||
class SinglePost(Resource):
|
||||
def get(self, id: int):
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
--c7-accent: #606080
|
||||
--c8-accent: #aeaac0
|
||||
--c9-accent: #3ae0b8
|
||||
--c10-accent: #a828ba
|
||||
--c10-accent: #8828ea
|
||||
--c11-accent: #1871d8
|
||||
--c12-accent: #885a18
|
||||
--c13-accent: #38a856
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ blockquote
|
|||
padding-right: 1em
|
||||
border-right: 4px solid var(--border)
|
||||
|
||||
.callout
|
||||
color: var(--text-alt)
|
||||
|
||||
.success
|
||||
color: var(--success)
|
||||
|
||||
.error
|
||||
color: var(--error)
|
||||
|
||||
.callout
|
||||
color: var(--text-alt)
|
||||
|
||||
.message-content
|
||||
p
|
||||
margin: 4px 0
|
||||
|
|
|
|||
|
|
@ -103,9 +103,9 @@ header.header
|
|||
// __ aside styles __ //
|
||||
aside.card
|
||||
overflow: hidden
|
||||
> :first-child
|
||||
> :is(h1, h2, h3, h4, h5, h6):first-child
|
||||
background-color: var(--accent)
|
||||
padding: 12px
|
||||
padding: 6px 12px
|
||||
margin: -12px -12px 0 -12px
|
||||
position: relative
|
||||
a
|
||||
|
|
@ -120,6 +120,9 @@ aside.card
|
|||
padding: 12px
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
> p
|
||||
padding: 12px
|
||||
margin: 0
|
||||
|
||||
|
||||
.flash
|
||||
|
|
@ -171,8 +174,6 @@ ul.message-options
|
|||
list-style: none
|
||||
padding: 0
|
||||
font-size: smaller
|
||||
.comment-frame &
|
||||
margin-bottom: -4px
|
||||
|
||||
.post-frame
|
||||
margin-left: 3em
|
||||
|
|
@ -321,12 +322,30 @@ button, [type="submit"], [type="reset"], [type="button"]
|
|||
|
||||
.comment-frame
|
||||
border: 1px solid var(--border)
|
||||
padding: 12px
|
||||
background: var(--background)
|
||||
padding: 12px 12px 6px
|
||||
border-radius: 24px
|
||||
border-start-start-radius: 0
|
||||
min-width: 50%
|
||||
width: 0
|
||||
margin-right: auto
|
||||
margin-inline-end: auto
|
||||
margin-bottom: 12px
|
||||
position: relative
|
||||
&::before
|
||||
content: ''
|
||||
border: 1px solid var(--border)
|
||||
border-inline-end: 0
|
||||
border-bottom: 0
|
||||
background: var(--background)
|
||||
height: 1em
|
||||
width: 1em
|
||||
position: absolute
|
||||
left: calc(-1px - .5em)
|
||||
top: -1px
|
||||
transform: skewX(45deg)
|
||||
li:has(> &)
|
||||
list-style: none
|
||||
|
||||
|
||||
.border-accent
|
||||
border: var(--accent) 1px solid
|
||||
|
|
@ -352,3 +371,8 @@ button.card
|
|||
background-color: var(--accent)
|
||||
color: var(--background)
|
||||
|
||||
.big_icon
|
||||
display: block
|
||||
margin: 12px auto
|
||||
font-size: 36px
|
||||
text-align: center
|
||||
|
|
|
|||
|
|
@ -11,18 +11,18 @@
|
|||
{% endfor %}
|
||||
</head>
|
||||
<body class="admin">
|
||||
<div class="header">
|
||||
<header class="header">
|
||||
<h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content">
|
||||
{% for message in get_flashed_messages() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<footer class="footer">
|
||||
<p><a href="/">Back to {{ app_name }}</a>.</p>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/static/lib.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<ul>
|
||||
{% for strike in strike_list %}
|
||||
<li>
|
||||
<p><strong>#{{ strike.id }}</strong> to {{ strike.user.handle() }}</p>
|
||||
<p><strong>#{{ strike.id | to_cb32 }}</strong> to {{ strike.user.handle() }}</p>
|
||||
<ul class="inline">
|
||||
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
|
||||
<!-- you might not want to see why -->
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
{% from "macros/create.html" import privacy_select with context %}
|
||||
{% from "macros/icon.html" import icon, callout with context %}
|
||||
|
||||
{% block title %}{{ title_tag('Editing: ' + p.title, False) }}{% endblock %}
|
||||
|
||||
|
|
@ -17,9 +18,11 @@
|
|||
<textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd>
|
||||
</div>
|
||||
<div>{{ privacy_select(p.privacy) }}</div>
|
||||
<div><input type="submit" value="Save" /></dd>
|
||||
<div>
|
||||
<input type="submit" value="Save" />
|
||||
</div>
|
||||
</form>
|
||||
<p class="error">{{ icon('delete') }} <a href="/delete/post/{{ p.id | to_b32l }}">Delete post</a></p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
27
freak/templates/macros/button.html
Normal file
27
freak/templates/macros/button.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
|
||||
|
||||
{% from "macros/icon.html" import icon with context%}
|
||||
|
||||
{% macro block_button(target, blocked = False) %}
|
||||
<form method="POST" action="{{ target.url() }}/block">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
{% if blocked %}
|
||||
<input type="hidden" name="reverse" value="1" />
|
||||
<button type="submit" class="card">{{ icon('self') }} Remove block</button>
|
||||
{% else %}
|
||||
<button type="submit" class="card">{{ icon('block') }} Block</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro subscribe_button(target, subbed = False) %}
|
||||
<form method="POST" action="{{ target.url() }}/subscribe">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
{% if subbed %}
|
||||
<input type="hidden" name="reverse" value="1" />
|
||||
<button type="submit" class="card">{{ icon('leave') }} Unsubscribe</button>
|
||||
{% else %}
|
||||
<button type="submit" class="card">{{ icon('join') }} Subscribe</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
|
||||
{% from "macros/icon.html" import icon with context %}
|
||||
|
||||
{% macro checked_if(cond) %}
|
||||
{% if cond -%}
|
||||
checked=""
|
||||
|
|
@ -12,11 +14,11 @@ disabled=""
|
|||
{% endmacro %}
|
||||
|
||||
{% macro privacy_select(value = 0) %}
|
||||
<ul>
|
||||
<li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >Public <small class="faint">(everyone in your profile or public timeline)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >Friends <small class="faint">(only people you follow each other)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >Only you <small class="faint">(nobody else)</small></label></li>
|
||||
<ul class="grid">
|
||||
<li><input type="radio" name="privacy" value="0" id="new__privacy_0" {{ checked_if(value == 0) }} /><label for="new__privacy_0" >{{ icon('topic_travel') }} Public <small class="faint">(everyone in your profile or public timeline)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="1" id="new__privacy_1" {{ checked_if(value == 1) }} /><label for="new__privacy_1" >{{ icon('link_post') }} Unlisted <small class="faint">(everyone in your profile, hide from public timeline)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="2" id="new__privacy_2" {{ checked_if(value == 2) }} /><label for="new__privacy_2" >{{ icon('custom_feed') }} Friends <small class="faint">(only people you follow each other)</small></label></li>
|
||||
<li><input type="radio" name="privacy" value="3" id="new__privacy_3" {{ checked_if(value == 3) }} /><label for="new__privacy_3" >{{ icon('lock') }} Only you <small class="faint">(nobody else)</small></label></li>
|
||||
</ul>
|
||||
{% endmacro %}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,13 @@
|
|||
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro callout(useicon = "spoiler") %}
|
||||
<div class="callout">
|
||||
{% macro callout(useicon = "spoiler", classes = "") %}
|
||||
<div class="callout {{ classes }}">
|
||||
{{ icon(useicon) }}
|
||||
{{ caller() }}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro big_icon(name, fill = False) %}
|
||||
<div class="big_icon">{{ icon(name, fill) }}</div>
|
||||
{% endmacro %}
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
|
||||
{% from "macros/icon.html" import icon with context %}
|
||||
{% from "macros/button.html" import block_button, subscribe_button with context %}
|
||||
|
||||
{% macro nav_guild(gu) %}
|
||||
<aside class="card">
|
||||
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
|
||||
|
|
@ -6,10 +9,13 @@
|
|||
<li><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
|
||||
<li>
|
||||
<strong>{{ gu.posts | count }}</strong> posts -
|
||||
<strong>-</strong> subscribers
|
||||
<strong>{{ gu.subscriber_count() }}</strong> subscribers
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
{% if current_user.is_authenticated %}
|
||||
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro nav_user(user) %}
|
||||
|
|
@ -19,8 +25,21 @@
|
|||
{% if user.biography %}
|
||||
<li>{{ icon('info') }} {{ user.biography }}</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated and current_user.age() >= 18 and user.age() < 18 %}
|
||||
<li class="error">{{ icon('spoiler') }} MINOR</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</aside>
|
||||
{% if user == current_user %}
|
||||
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
|
||||
{% elif current_user.is_authenticated %}
|
||||
{{ block_button(user, current_user.has_blocked(user)) }}
|
||||
{{ subscribe_button(user, user.has_subscriber(current_user)) }}
|
||||
{% else %}
|
||||
<aside class="card">
|
||||
<p><a href="/login">Log in</a> to subscribe and interact with {{ user.handle() }}</p>
|
||||
</aside>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro nav_top_communities(top_communities) %}
|
||||
|
|
|
|||
|
|
@ -40,12 +40,12 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% if current_user.is_administrator and p.report_count() %}
|
||||
{% call callout() %}
|
||||
{% call callout('spoiler', 'error') %}
|
||||
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if p.is_removed %}
|
||||
{% call callout('delete') %}
|
||||
{% call callout('delete', 'error') %}
|
||||
This post has been removed
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
{{ comment_area(p.url()) }}
|
||||
<div class="comment-section">
|
||||
<ul>
|
||||
{% for comment in p.top_level_comments() %}
|
||||
{% for comment in comments %}
|
||||
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
|
||||
{{ single_comment(comment) }}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
||||
{% from "macros/title.html" import title_tag with context %}
|
||||
{% from "macros/icon.html" import icon, callout with context %}
|
||||
{% from "macros/icon.html" import icon, big_icon, callout with context %}
|
||||
{% from "macros/nav.html" import nav_user with context %}
|
||||
|
||||
{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
||||
|
|
@ -16,9 +16,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
{% if user.is_active and not user.has_blocked(current_user) %}
|
||||
{{ nav_user(user) }}
|
||||
{% if user == current_user %}
|
||||
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -40,9 +39,10 @@
|
|||
{% endif %}
|
||||
</ul>
|
||||
{% elif not user.is_active %}
|
||||
{{ big_icon('ban') }}
|
||||
<p class="centered">{{ user.handle() }} is suspended</p>
|
||||
{% else %}
|
||||
<p class="centered">{{ user.handle() }} never posted any content</p>
|
||||
<p class="centered">{{ user.handle() }} has never posted any content</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
<h2>Appearance</h2>
|
||||
<div>
|
||||
<label>Color scheme</label>
|
||||
<ul class="apply-theme">
|
||||
<ul class="apply-theme grid">
|
||||
<li><input type="radio" id="US__color_scheme_dark" name="color_scheme" value="dark" {{ checked_if((current_user.color_theme // 256) == 2) }}><label for="US__color_scheme_dark">Dark</label></li>
|
||||
<li><input type="radio" id="US__color_scheme_light" name="color_scheme" value="light" {{ checked_if((current_user.color_theme // 256) == 1) }}><label for="US__color_scheme_light">Light</label></li>
|
||||
<li><input type="radio" id="US__color_scheme_unset" name="color_scheme" value="unset" {{ checked_if((current_user.color_theme // 256) == 0) }}><label for="US__color_scheme_unset">System</label></li>
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
<div>
|
||||
<label>Color theme</label>
|
||||
<ul class="apply-theme">
|
||||
<ul class="apply-theme grid">
|
||||
{% for color in colors %}
|
||||
<li><input type="radio" id="US__color_theme_{{ color.code }}" name="color_theme" value="{{ color.code }}" {{ checked_if((current_user.color_theme % 256) == color.code) }}><label for="US__color_theme_{{ color.code }}">{{ color.name }}</label></li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
|
||||
from typing import Iterable
|
||||
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
from sqlalchemy import insert, select
|
||||
|
|
@ -6,7 +7,7 @@ from suou import Snowflake
|
|||
|
||||
from ..utils import is_b32l
|
||||
from ..models import Comment, Guild, db, User, Post
|
||||
from ..algorithms import user_timeline
|
||||
from ..algorithms import new_comments, user_timeline
|
||||
|
||||
bp = Blueprint('detail', __name__)
|
||||
|
||||
|
|
@ -64,12 +65,17 @@ def post_detail(id: int):
|
|||
else:
|
||||
abort(404)
|
||||
|
||||
def comments_of(p: Post) -> Iterable[Comment]:
|
||||
## TODO add sort argument
|
||||
return db.paginate(new_comments(p))
|
||||
|
||||
|
||||
@bp.route('/@<username>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||
def user_post_detail(username: str, id: int, slug: str = ''):
|
||||
post: Post | None = db.session.execute(select(Post).join(User, User.id == Post.author_id).where(Post.id == id, User.username == username)).scalar()
|
||||
|
||||
if post is None or (post.is_removed and post.author != current_user):
|
||||
if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user):
|
||||
abort(404)
|
||||
|
||||
if post.slug and slug != post.slug:
|
||||
|
|
@ -78,14 +84,14 @@ def user_post_detail(username: str, id: int, slug: str = ''):
|
|||
if request.method == 'POST':
|
||||
single_post_post_hook(post)
|
||||
|
||||
return render_template('singlepost.html', p=post)
|
||||
return render_template('singlepost.html', p=post, comments=comments_of(post))
|
||||
|
||||
@bp.route('/+<gname>/comments/<b32l:id>/', methods=['GET', 'POST'])
|
||||
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||
def guild_post_detail(gname, id, slug=''):
|
||||
post: Post | None = db.session.execute(select(Post).join(Guild).where(Post.id == id, Guild.name == gname)).scalar()
|
||||
|
||||
if post is None or (post.is_removed and post.author != current_user):
|
||||
if post is None or (post.author and post.author.has_blocked(current_user)) or (post.is_removed and post.author != current_user):
|
||||
abort(404)
|
||||
|
||||
if post.slug and slug != post.slug:
|
||||
|
|
@ -94,7 +100,7 @@ def guild_post_detail(gname, id, slug=''):
|
|||
if request.method == 'POST':
|
||||
single_post_post_hook(post)
|
||||
|
||||
return render_template('singlepost.html', p=post)
|
||||
return render_template('singlepost.html', p=post, comments=comments_of(post))
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ bp = Blueprint('frontpage', __name__)
|
|||
|
||||
@bp.route('/')
|
||||
def homepage():
|
||||
top_communities = [(x[0], x[1], 0) for x in
|
||||
top_communities = [(x[0], x[1], x[2]) for x in
|
||||
db.session.execute(top_guilds_query().limit(10)).fetchall()]
|
||||
|
||||
if current_user and current_user.is_authenticated:
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
|||
"PsycoPG2-binary",
|
||||
"libsass",
|
||||
"setuptools>=78.1.0",
|
||||
"sakuragasaki46-suou>=0.3.3"
|
||||
"sakuragasaki46-suou>=0.3.4"
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue