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
|
## 0.4.0
|
||||||
|
|
||||||
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou)
|
- Added dependency to [SUOU](https://github.com/sakuragasaki46/suou) library
|
||||||
- Added user strikes, memberships and user blocks
|
- 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✨
|
- 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
|
## 0.3.3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ AJAX hooks for the website.
|
||||||
'''
|
'''
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, abort, flash, redirect, request
|
||||||
from sqlalchemy import delete, insert, select
|
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
|
from flask_login import current_user, login_required
|
||||||
|
|
||||||
|
current_user: User
|
||||||
|
|
||||||
bp = Blueprint('ajax', __name__)
|
bp = Blueprint('ajax', __name__)
|
||||||
|
|
||||||
@bp.route('/username_availability/<username>')
|
@bp.route('/username_availability/<username>')
|
||||||
|
|
@ -71,3 +73,81 @@ def post_upvote(id):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return { 'status': 'ok', 'count': p.upvotes() }
|
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 flask_login import current_user
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import and_, distinct, func, select
|
||||||
from .models import db, Post, Guild, User
|
from .models import Comment, Member, db, Post, Guild, User
|
||||||
|
|
||||||
|
current_user: User
|
||||||
|
|
||||||
def cuser() -> User:
|
def cuser() -> User:
|
||||||
return current_user if current_user.is_authenticated else None
|
return current_user if current_user.is_authenticated else None
|
||||||
|
|
@ -22,13 +24,18 @@ def topic_timeline(gname):
|
||||||
|
|
||||||
def user_timeline(user_id):
|
def user_timeline(user_id):
|
||||||
return select(Post).join(User, User.id == Post.author_id).where(
|
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())
|
).order_by(Post.created_at.desc())
|
||||||
|
|
||||||
def top_guilds_query():
|
def top_guilds_query():
|
||||||
q_post_count = func.count().label('post_count')
|
q_post_count = func.count(distinct(Post.id)).label('post_count')
|
||||||
qr = select(Guild, q_post_count)\
|
q_sub_count = func.count(distinct(Member.id)).label('sub_count')
|
||||||
.join(Post, Post.topic_id == Guild.id).group_by(Guild)\
|
qr = select(Guild, q_post_count, q_sub_count)\
|
||||||
.having(q_post_count > 5).order_by(q_post_count.desc())
|
.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
|
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
|
import markdown
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
|
|
||||||
from suou import Snowflake
|
from suou import Siq, Snowflake
|
||||||
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
|
from suou.markdown import StrikethroughExtension, SpoilerExtension, PingExtension
|
||||||
|
|
||||||
from . import app
|
from . import app
|
||||||
|
|
@ -29,6 +29,12 @@ def to_b32l(n):
|
||||||
|
|
||||||
app.template_filter('b32l')(to_b32l)
|
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()
|
@app.template_filter()
|
||||||
def append(text, l: list):
|
def append(text, l: list):
|
||||||
l.append(text)
|
l.append(text)
|
||||||
|
|
|
||||||
|
|
@ -214,10 +214,28 @@ class User(Base):
|
||||||
def not_suspended(cls):
|
def not_suspended(cls):
|
||||||
return or_(User.banned_at == None, User.banned_until <= datetime.datetime.now())
|
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
|
@classmethod
|
||||||
def has_not_blocked(cls, actor, target):
|
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.
|
XXX untested.
|
||||||
"""
|
"""
|
||||||
|
|
@ -242,6 +260,8 @@ class User(Base):
|
||||||
def strike_count(self) -> int:
|
def strike_count(self) -> int:
|
||||||
return db.session.execute(select(func.count('*')).select_from(UserStrike).where(UserStrike.user_id == self.id)).scalar()
|
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
|
## END User
|
||||||
|
|
||||||
class Guild(Base):
|
class Guild(Base):
|
||||||
|
|
@ -272,9 +292,16 @@ class Guild(Base):
|
||||||
def handle(self):
|
def handle(self):
|
||||||
return f'+{self.name}'
|
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
|
# utilities
|
||||||
posts = relationship('Post', back_populates='guild')
|
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)
|
Topic = deprecated('renamed to Guild')(Guild)
|
||||||
|
|
||||||
|
|
@ -356,9 +383,9 @@ class Post(Base):
|
||||||
def url(self):
|
def url(self):
|
||||||
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
|
return self.topic_or_user().url() + '/comments/' + Snowflake(self.id).to_b32l() + '/' + (self.slug or '')
|
||||||
|
|
||||||
@not_implemented
|
@not_implemented('slugify is not a dependency as of now')
|
||||||
def generate_slug(self):
|
def generate_slug(self) -> str:
|
||||||
return slugify.slugify(self.title, max_length=64)
|
return "slugify.slugify(self.title, max_length=64)"
|
||||||
|
|
||||||
def upvotes(self) -> int:
|
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()
|
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
|
return Post.removed_at == None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def visible_by(cls, user: User):
|
def visible_by(cls, user_id: int | None):
|
||||||
return or_(Post.author_id == user.id, Post.privacy.in_((0, 1)))
|
return or_(Post.author_id == user_id, Post.privacy.in_((0, 1)))
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
|
|
||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint, redirect, url_for
|
||||||
from flask_restx import Resource, Api
|
from flask_restx import Resource
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from suou import Snowflake
|
from suou import Snowflake
|
||||||
|
from suou.flask_sqlalchemy import require_auth
|
||||||
|
|
||||||
|
from suou.flask_restx import Api
|
||||||
|
|
||||||
from ..models import Post, User, db
|
from ..models import Post, User, db
|
||||||
|
|
||||||
rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
|
rest_bp = Blueprint('rest', __name__, url_prefix='/v1')
|
||||||
rest = Api(rest_bp)
|
rest = Api(rest_bp)
|
||||||
|
|
||||||
|
auth_required = require_auth(User, db)
|
||||||
|
|
||||||
@rest.route('/nurupo')
|
@rest.route('/nurupo')
|
||||||
class Nurupo(Resource):
|
class Nurupo(Resource):
|
||||||
def get(self):
|
def get(self):
|
||||||
|
|
@ -18,9 +23,20 @@ class Nurupo(Resource):
|
||||||
## TODO coverage of REST is still partial, but it's planned
|
## TODO coverage of REST is still partial, but it's planned
|
||||||
## to get complete sooner or later
|
## 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>')
|
@rest.route('/user/<b32l:id>')
|
||||||
class UserInfo(Resource):
|
class UserInfo(Resource):
|
||||||
def get(self, id: int):
|
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()
|
u: User | None = db.session.execute(select(User).where(User.id == id)).scalar()
|
||||||
if u is None:
|
if u is None:
|
||||||
return dict(error='User not found'), 404
|
return dict(error='User not found'), 404
|
||||||
|
|
@ -34,6 +50,7 @@ class UserInfo(Resource):
|
||||||
)
|
)
|
||||||
return dict(users={f'{Snowflake(id):l}': uj})
|
return dict(users={f'{Snowflake(id):l}': uj})
|
||||||
|
|
||||||
|
|
||||||
@rest.route('/post/<b32l:id>')
|
@rest.route('/post/<b32l:id>')
|
||||||
class SinglePost(Resource):
|
class SinglePost(Resource):
|
||||||
def get(self, id: int):
|
def get(self, id: int):
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
--c7-accent: #606080
|
--c7-accent: #606080
|
||||||
--c8-accent: #aeaac0
|
--c8-accent: #aeaac0
|
||||||
--c9-accent: #3ae0b8
|
--c9-accent: #3ae0b8
|
||||||
--c10-accent: #a828ba
|
--c10-accent: #8828ea
|
||||||
--c11-accent: #1871d8
|
--c11-accent: #1871d8
|
||||||
--c12-accent: #885a18
|
--c12-accent: #885a18
|
||||||
--c13-accent: #38a856
|
--c13-accent: #38a856
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,15 @@ blockquote
|
||||||
padding-right: 1em
|
padding-right: 1em
|
||||||
border-right: 4px solid var(--border)
|
border-right: 4px solid var(--border)
|
||||||
|
|
||||||
|
.callout
|
||||||
|
color: var(--text-alt)
|
||||||
|
|
||||||
.success
|
.success
|
||||||
color: var(--success)
|
color: var(--success)
|
||||||
|
|
||||||
.error
|
.error
|
||||||
color: var(--error)
|
color: var(--error)
|
||||||
|
|
||||||
.callout
|
|
||||||
color: var(--text-alt)
|
|
||||||
|
|
||||||
.message-content
|
.message-content
|
||||||
p
|
p
|
||||||
margin: 4px 0
|
margin: 4px 0
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,9 @@ header.header
|
||||||
// __ aside styles __ //
|
// __ aside styles __ //
|
||||||
aside.card
|
aside.card
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
> :first-child
|
> :is(h1, h2, h3, h4, h5, h6):first-child
|
||||||
background-color: var(--accent)
|
background-color: var(--accent)
|
||||||
padding: 12px
|
padding: 6px 12px
|
||||||
margin: -12px -12px 0 -12px
|
margin: -12px -12px 0 -12px
|
||||||
position: relative
|
position: relative
|
||||||
a
|
a
|
||||||
|
|
@ -120,6 +120,9 @@ aside.card
|
||||||
padding: 12px
|
padding: 12px
|
||||||
&:last-child
|
&:last-child
|
||||||
border-bottom: none
|
border-bottom: none
|
||||||
|
> p
|
||||||
|
padding: 12px
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
|
||||||
.flash
|
.flash
|
||||||
|
|
@ -171,8 +174,6 @@ ul.message-options
|
||||||
list-style: none
|
list-style: none
|
||||||
padding: 0
|
padding: 0
|
||||||
font-size: smaller
|
font-size: smaller
|
||||||
.comment-frame &
|
|
||||||
margin-bottom: -4px
|
|
||||||
|
|
||||||
.post-frame
|
.post-frame
|
||||||
margin-left: 3em
|
margin-left: 3em
|
||||||
|
|
@ -321,12 +322,30 @@ button, [type="submit"], [type="reset"], [type="button"]
|
||||||
|
|
||||||
.comment-frame
|
.comment-frame
|
||||||
border: 1px solid var(--border)
|
border: 1px solid var(--border)
|
||||||
padding: 12px
|
background: var(--background)
|
||||||
|
padding: 12px 12px 6px
|
||||||
border-radius: 24px
|
border-radius: 24px
|
||||||
border-start-start-radius: 0
|
border-start-start-radius: 0
|
||||||
min-width: 50%
|
min-width: 50%
|
||||||
width: 0
|
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-accent
|
||||||
border: var(--accent) 1px solid
|
border: var(--accent) 1px solid
|
||||||
|
|
@ -352,3 +371,8 @@ button.card
|
||||||
background-color: var(--accent)
|
background-color: var(--accent)
|
||||||
color: var(--background)
|
color: var(--background)
|
||||||
|
|
||||||
|
.big_icon
|
||||||
|
display: block
|
||||||
|
margin: 12px auto
|
||||||
|
font-size: 36px
|
||||||
|
text-align: center
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,18 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</head>
|
</head>
|
||||||
<body class="admin">
|
<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>
|
<h1><a href="{{ url_for('admin.homepage') }}"><span class="faint">{{ app_name }}:</span> Admin</a></h1>
|
||||||
</div>
|
</header>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{% for message in get_flashed_messages() %}
|
{% for message in get_flashed_messages() %}
|
||||||
<div class="flash">{{ message }}</div>
|
<div class="flash">{{ message }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<footer class="footer">
|
||||||
<p><a href="/">Back to {{ app_name }}</a>.</p>
|
<p><a href="/">Back to {{ app_name }}</a>.</p>
|
||||||
</div>
|
</footer>
|
||||||
<script src="/static/lib.js"></script>
|
<script src="/static/lib.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% for strike in strike_list %}
|
{% for strike in strike_list %}
|
||||||
<li>
|
<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">
|
<ul class="inline">
|
||||||
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
|
<li>Reason: <strong>{{ report_reasons[strike.reason_code] }}</strong></li>
|
||||||
<!-- you might not want to see why -->
|
<!-- you might not want to see why -->
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
<form action="{{ url_for('create.create') }}" method="POST" enctype="multipart/form-data" class="boundaryless">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros/title.html" import title_tag with context %}
|
{% from "macros/title.html" import title_tag with context %}
|
||||||
{% from "macros/create.html" import privacy_select 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 %}
|
{% 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>
|
<textarea name="text" placeholder="What's happening?" class="create_text">{{ p.text_content }}</textarea></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ privacy_select(p.privacy) }}</div>
|
<div>{{ privacy_select(p.privacy) }}</div>
|
||||||
<div><input type="submit" value="Save" /></dd>
|
<div>
|
||||||
|
<input type="submit" value="Save" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<p class="error">{{ icon('delete') }} <a href="/delete/post/{{ p.id | to_b32l }}">Delete post</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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) %}
|
{% macro checked_if(cond) %}
|
||||||
{% if cond -%}
|
{% if cond -%}
|
||||||
checked=""
|
checked=""
|
||||||
|
|
@ -12,11 +14,11 @@ disabled=""
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro privacy_select(value = 0) %}
|
{% macro privacy_select(value = 0) %}
|
||||||
<ul>
|
<ul class="grid">
|
||||||
<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="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" >Unlisted <small class="faint">(everyone in your profile, hide from 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" >Friends <small class="faint">(only people you follow each other)</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" >Only you <small class="faint">(nobody else)</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>
|
</ul>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,13 @@
|
||||||
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
|
<i class="icon icon-{{ name }}{{ '_fill' if fill }}"></i>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro callout(useicon = "spoiler") %}
|
{% macro callout(useicon = "spoiler", classes = "") %}
|
||||||
<div class="callout">
|
<div class="callout {{ classes }}">
|
||||||
{{ icon(useicon) }}
|
{{ icon(useicon) }}
|
||||||
{{ caller() }}
|
{{ caller() }}
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% 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) %}
|
{% macro nav_guild(gu) %}
|
||||||
<aside class="card">
|
<aside class="card">
|
||||||
<h3>About <a href="{{ gu.url() }}">{{ gu.handle() }}</a></h3>
|
<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><i class="icon icon-info" style="font-size:inherit"></i> {{ gu.description }}</li>
|
||||||
<li>
|
<li>
|
||||||
<strong>{{ gu.posts | count }}</strong> posts -
|
<strong>{{ gu.posts | count }}</strong> posts -
|
||||||
<strong>-</strong> subscribers
|
<strong>{{ gu.subscriber_count() }}</strong> subscribers
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
{{ subscribe_button(gu, gu.has_subscriber(current_user)) }}
|
||||||
|
{% endif %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro nav_user(user) %}
|
{% macro nav_user(user) %}
|
||||||
|
|
@ -19,8 +25,21 @@
|
||||||
{% if user.biography %}
|
{% if user.biography %}
|
||||||
<li>{{ icon('info') }} {{ user.biography }}</li>
|
<li>{{ icon('info') }} {{ user.biography }}</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if current_user.is_authenticated and current_user.age() >= 18 and user.age() < 18 %}
|
||||||
|
<li class="error">{{ icon('spoiler') }} MINOR</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</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 %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro nav_top_communities(top_communities) %}
|
{% macro nav_top_communities(top_communities) %}
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_administrator and p.report_count() %}
|
{% 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>
|
<strong>{{ p.report_count() }}</strong> reports. <a href="{{ url_for('admin.reports') }}">Take action</a>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if p.is_removed %}
|
{% if p.is_removed %}
|
||||||
{% call callout('delete') %}
|
{% call callout('delete', 'error') %}
|
||||||
This post has been removed
|
This post has been removed
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
{{ comment_area(p.url()) }}
|
{{ comment_area(p.url()) }}
|
||||||
<div class="comment-section">
|
<div class="comment-section">
|
||||||
<ul>
|
<ul>
|
||||||
{% for comment in p.top_level_comments() %}
|
{% for comment in comments %}
|
||||||
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
|
<li id="comment-{{ comment.id }}" data-endpoint="{{ comment.id|to_b32l }}">
|
||||||
{{ single_comment(comment) }}
|
{{ single_comment(comment) }}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "macros/feed.html" import feed_post, stop_scrolling, no_more_scrolling with context %}
|
{% 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/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 %}
|
{% from "macros/nav.html" import nav_user with context %}
|
||||||
|
|
||||||
{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
{% block title %}{{ title_tag(user.handle() + '’s content') }}{% endblock %}
|
||||||
|
|
@ -16,9 +16,8 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block nav %}
|
{% block nav %}
|
||||||
|
{% if user.is_active and not user.has_blocked(current_user) %}
|
||||||
{{ nav_user(user) }}
|
{{ nav_user(user) }}
|
||||||
{% if user == current_user %}
|
|
||||||
<a href="/settings"><button class="card">{{ icon('settings') }} Settings</button></a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -40,9 +39,10 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% elif not user.is_active %}
|
{% elif not user.is_active %}
|
||||||
|
{{ big_icon('ban') }}
|
||||||
<p class="centered">{{ user.handle() }} is suspended</p>
|
<p class="centered">{{ user.handle() }} is suspended</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="centered">{{ user.handle() }} never posted any content</p>
|
<p class="centered">{{ user.handle() }} has never posted any content</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
<h2>Appearance</h2>
|
<h2>Appearance</h2>
|
||||||
<div>
|
<div>
|
||||||
<label>Color scheme</label>
|
<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_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_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>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label>Color theme</label>
|
<label>Color theme</label>
|
||||||
<ul class="apply-theme">
|
<ul class="apply-theme grid">
|
||||||
{% for color in colors %}
|
{% 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>
|
<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 %}
|
{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
|
from flask import Blueprint, abort, flash, request, redirect, render_template, url_for
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
from sqlalchemy import insert, select
|
from sqlalchemy import insert, select
|
||||||
|
|
@ -6,7 +7,7 @@ from suou import Snowflake
|
||||||
|
|
||||||
from ..utils import is_b32l
|
from ..utils import is_b32l
|
||||||
from ..models import Comment, Guild, db, User, Post
|
from ..models import Comment, Guild, db, User, Post
|
||||||
from ..algorithms import user_timeline
|
from ..algorithms import new_comments, user_timeline
|
||||||
|
|
||||||
bp = Blueprint('detail', __name__)
|
bp = Blueprint('detail', __name__)
|
||||||
|
|
||||||
|
|
@ -64,12 +65,17 @@ def post_detail(id: int):
|
||||||
else:
|
else:
|
||||||
abort(404)
|
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>/', methods=['GET', 'POST'])
|
||||||
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
@bp.route('/@<username>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||||
def user_post_detail(username: str, id: int, slug: str = ''):
|
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()
|
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)
|
abort(404)
|
||||||
|
|
||||||
if post.slug and slug != post.slug:
|
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':
|
if request.method == 'POST':
|
||||||
single_post_post_hook(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>/', methods=['GET', 'POST'])
|
||||||
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
@bp.route('/+<gname>/comments/<b32l:id>/<slug:slug>', methods=['GET', 'POST'])
|
||||||
def guild_post_detail(gname, id, slug=''):
|
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()
|
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)
|
abort(404)
|
||||||
|
|
||||||
if post.slug and slug != post.slug:
|
if post.slug and slug != post.slug:
|
||||||
|
|
@ -94,7 +100,7 @@ def guild_post_detail(gname, id, slug=''):
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
single_post_post_hook(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('/')
|
@bp.route('/')
|
||||||
def homepage():
|
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()]
|
db.session.execute(top_guilds_query().limit(10)).fetchall()]
|
||||||
|
|
||||||
if current_user and current_user.is_authenticated:
|
if current_user and current_user.is_authenticated:
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ dependencies = [
|
||||||
"PsycoPG2-binary",
|
"PsycoPG2-binary",
|
||||||
"libsass",
|
"libsass",
|
||||||
"setuptools>=78.1.0",
|
"setuptools>=78.1.0",
|
||||||
"sakuragasaki46-suou>=0.3.3"
|
"sakuragasaki46-suou>=0.3.4"
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue