Compare commits

...

21 Commits

Author SHA1 Message Date
a5a3565496 add delete post route 2026-05-20 00:07:06 +03:00
d74dd6c5f3 add edit thread routes 2026-04-29 21:42:22 +03:00
648b310e13 remove index app and replace index route inside __init__ 2026-04-29 21:23:15 +03:00
4bcea261b1 finish mod routes 2026-04-29 21:20:07 +03:00
4edc4f4650 style fixes 2026-04-29 20:11:00 +03:00
e670c176e8 add logout route 2026-04-29 19:23:26 +03:00
3870356ffa add user started threads view 2026-04-29 19:14:05 +03:00
d5e627ed7f add user posts view 2026-04-29 02:06:41 +03:00
afdf182bd1 re-add code highlighting styles 2026-04-29 02:05:31 +03:00
ff2c6606f8 add subscribing and unsubscribing, add post editing 2026-04-28 23:23:05 +03:00
f3acf64e6d Merge branch 'main' into v2
imports the db change
2026-04-26 14:31:52 +03:00
20554d9c5c fix memory leak in db 2026-04-26 14:26:10 +03:00
10ea1f03cd 403 page 2026-04-26 14:04:24 +03:00
9a10f30634 move icons to img tags instead of inlined 2026-04-26 12:54:17 +03:00
612d69c157 only show reply form if the user can reply 2026-04-25 23:56:46 +03:00
286a3641eb remove inner glow from buttons 2026-04-25 21:37:48 +03:00
b53556871f add more icons 2026-04-25 21:36:21 +03:00
29f2318cba add infobox support 2026-04-25 16:15:37 +03:00
3d7188eb71 auto-color buttons 2026-04-21 11:53:07 +03:00
ed4d4191d7 stub some more thread endpoints 2026-04-20 13:50:18 +03:00
66f381a434 remove warning about slug not being preserved in edit topic view 2026-04-20 13:33:03 +03:00
35 changed files with 810 additions and 96 deletions

View File

@@ -197,14 +197,12 @@ def create_app():
cache.init_app(app) cache.init_app(app)
from app.routes.app import bp as app_bp
from app.routes.topics import bp as topics_bp from app.routes.topics import bp as topics_bp
from app.routes.threads import bp as threads_bp from app.routes.threads import bp as threads_bp
from app.routes.users import bp as users_bp from app.routes.users import bp as users_bp
from app.routes.guides import bp as guides_bp from app.routes.guides import bp as guides_bp
from app.routes.mod import bp as mod_bp from app.routes.mod import bp as mod_bp
from app.routes.posts import bp as posts_bp from app.routes.posts import bp as posts_bp
app.register_blueprint(app_bp)
app.register_blueprint(topics_bp) app.register_blueprint(topics_bp)
app.register_blueprint(threads_bp) app.register_blueprint(threads_bp)
app.register_blueprint(users_bp) app.register_blueprint(users_bp)
@@ -319,6 +317,20 @@ def create_app():
return {'error': 'not found'}, e.code return {'error': 'not found'}, e.code
else: else:
return render_template('common/404.html'), e.code return render_template('common/404.html'), e.code
@app.errorhandler(403)
def _handle_403(e):
if request.path.startswith('/hyperapi/'):
return '<h1>forbiddedn</h1>', e.code
elif request.path.startswith('/api/'):
return {'error': 'forbidden'}, e.code
else:
return render_template('common/403.html'), e.code
@app.get('/')
def index():
return redirect(url_for('topics.all_topics'))
# #
# @app.errorhandler(413) # @app.errorhandler(413)
# def _handle_413(e): # def _handle_413(e):

View File

@@ -57,6 +57,15 @@ def create_session(user_id, temporary=False):
'expires_at': int(time.time()) + (expires_days * 24 * 60 * 60), 'expires_at': int(time.time()) + (expires_days * 24 * 60 * 60),
}) })
def revoke_session(user_id):
if not is_logged_in():
return
sess = Sessions.find({'key': session['pyrom_session_key']})
if not sess:
return
sess.delete()
session.clear()
def parse_username(username: str) -> Tuple[str, str]: def parse_username(username: str) -> Tuple[str, str]:
"""first is the unmodified name/display name, second is username""" """first is the unmodified name/display name, second is username"""
if len(username) < 3: if len(username) < 3:

View File

@@ -38,6 +38,8 @@ class DB:
if self._transaction_depth == 0: if self._transaction_depth == 0:
self._connection = None self._connection = None
conn.close() conn.close()
else:
conn.close()
@contextmanager @contextmanager

View File

@@ -50,6 +50,18 @@ class Users(Model):
WHERE users.id = ?""" WHERE users.id = ?"""
return db.fetch_one(q, self.id) return db.fetch_one(q, self.id)
def get_posts(self, per_page: int, page: int) -> list:
q = f'{Posts.FULL_POSTS_QUERY} WHERE posts.user_id = ? ORDER BY posts.created_at DESC LIMIT ? OFFSET ?'
return db.query(q, self.id, per_page, (page - 1) * per_page)
def get_started_threads(self, per_page: int, page: int) -> list:
q = f"""{Posts.FULL_POSTS_QUERY} WHERE threads.user_id = ? AND posts.created_at = (
SELECT MIN(p2.created_at)
FROM posts p2
WHERE p2.thread_id = posts.thread_id
) ORDER BY threads.created_at DESC LIMIT ? OFFSET ?"""
return db.query(q, self.id, per_page, (page - 1) * per_page)
def get_all_subscriptions(self): def get_all_subscriptions(self):
q = """ q = """
SELECT threads.title AS thread_title, threads.slug AS thread_slug SELECT threads.title AS thread_title, threads.slug AS thread_slug
@@ -61,14 +73,14 @@ class Users(Model):
subscriptions.user_id = ?""" subscriptions.user_id = ?"""
return db.query(q, self.id) return db.query(q, self.id)
def can_post_to_topic(self, topic): def can_post_to_thread_or_topic(self, thread_or_topic):
if self.is_guest(): if self.is_guest():
return False return False
if self.is_mod(): if self.is_mod():
return True return True
if topic['is_locked']: if thread_or_topic['is_locked']:
return False return False
return True return True
@@ -102,6 +114,9 @@ class Users(Model):
def get_badges(self): def get_badges(self):
return Badges.findall({'user_id': int(self.id)}) return Badges.findall({'user_id': int(self.id)})
def is_subscribed(self, thread_id):
return Subscriptions.count({'user_id': self.id, 'thread_id': thread_id}) > 0
class Topics(Model): class Topics(Model):
table = 'topics' table = 'topics'
@@ -282,6 +297,7 @@ class Posts(Model):
users.id AS user_id, post_history.original_markup, users.id AS user_id, post_history.original_markup,
users.signature_rendered, threads.slug AS thread_slug, users.signature_rendered, threads.slug AS thread_slug,
threads.is_locked AS thread_is_locked, threads.title AS thread_title, threads.is_locked AS thread_is_locked, threads.title AS thread_title,
topics.id AS topic_id, topics.name AS topic_name,
COALESCE(user_badges.badges_json, '[]') AS badges_json COALESCE(user_badges.badges_json, '[]') AS badges_json
FROM FROM
posts posts
@@ -289,6 +305,8 @@ class Posts(Model):
post_history ON posts.current_revision_id = post_history.id post_history ON posts.current_revision_id = post_history.id
JOIN JOIN
users ON posts.user_id = users.id users ON posts.user_id = users.id
JOIN
topics ON threads.topic_id = topics.id
JOIN JOIN
threads ON posts.thread_id = threads.id threads ON posts.thread_id = threads.id
LEFT JOIN LEFT JOIN
@@ -327,7 +345,8 @@ class Posts(Model):
for mention in html_content.mentions: for mention in html_content.mentions:
Mentions.create({ Mentions.create({
'revision_id': revision.id, 'revision_id': revision.id,
'mentioned_iser_id': mention['mentioned_iser_id'], 'mentioned_user_id': mention['mentioned_user_id'],
'original_mention_text': mention['mention_text'],
'start_index': mention['start'], 'start_index': mention['start'],
'end_index': mention['end'], 'end_index': mention['end'],
}) })
@@ -335,6 +354,32 @@ class Posts(Model):
post.update({'current_revision_id': revision.id}) post.update({'current_revision_id': revision.id})
return post return post
def edit(self, new_content: str, language: str = 'babycode'):
from .lib.babycode import babycode_to_html, babycode_to_rssxml, BABYCODE_VERSION
html_content = babycode_to_html(new_content)
rssxml_content = babycode_to_rssxml(new_content)
with db.transaction():
revision = PostHistory.create({
'post_id': self.id,
'content': html_content.result,
'content_rss': rssxml_content,
'is_initial_revision': False,
'original_markup': new_content,
'markup_language': language,
'format_version': BABYCODE_VERSION,
})
for mention in html_content.mentions:
Mentions.create({
'revision_id': revision.id,
'mentioned_user_id': mention['mentioned_user_id'],
'original_mention_text': mention['mention_text'],
'start_index': mention['start'],
'end_index': mention['end'],
})
self.update({'current_revision_id': revision.id})
class PostHistory(Model): class PostHistory(Model):
table = 'post_history' table = 'post_history'

View File

@@ -1,6 +0,0 @@
from flask import Blueprint, redirect, url_for, render_template
bp = Blueprint('app', __name__, url_prefix = '/')
@bp.get('/')
def index():
return redirect(url_for('topics.all_topics'))

View File

@@ -1,6 +1,10 @@
from flask import Blueprint, abort, redirect, url_for, request, render_template from flask import Blueprint, abort, redirect, url_for, request, render_template, flash
from ..constants import InfoboxKind, PermissionLevel
from ..auth import is_logged_in, get_active_user, csrf_verified from ..auth import is_logged_in, get_active_user, csrf_verified
from ..models import Topics, Threads from ..models import Topics, Threads, Users
from slugify import slugify
from functools import wraps
import time
bp = Blueprint('mod', __name__, url_prefix='/mod/') bp = Blueprint('mod', __name__, url_prefix='/mod/')
@bp.before_request @bp.before_request
@@ -10,6 +14,14 @@ def mod_only():
if not get_active_user().is_mod(): if not get_active_user().is_mod():
abort(403) abort(403)
def admin_only(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
if not get_active_user().is_admin():
abort(403)
return view_func(*args, **kwargs)
return wrapper
@bp.get('/') @bp.get('/')
def index(): def index():
return 'stub' return 'stub'
@@ -39,9 +51,21 @@ def edit_topic_post(topic_id):
topic = Topics.find({'id': topic_id}) topic = Topics.find({'id': topic_id})
if not topic: if not topic:
abort(404) abort(404)
target_name = request.form.get('name').strip()
name_exists = Topics.count([
('lower(name)', '=', target_name.lower()),
('id', '!=', topic.id)
]) > 0
if name_exists:
flash(f'A topic named "{target_name}" already exists.', InfoboxKind.ERROR)
return redirect(url_for('.edit_topic', topic_id=topic_id))
topic.update({ topic.update({
'name': request.form.get('name').strip(), 'name': target_name,
'description': request.form.get('description').strip(), 'description': request.form.get('description').strip(),
'slug': slugify(target_name[:50]),
}) })
return redirect(url_for('topics.topic_by_id', topic_id=topic.id)) return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
@@ -83,14 +107,62 @@ def sticky_thread(thread_id):
@bp.post('/users/<int:user_id>/make-guest/') @bp.post('/users/<int:user_id>/make-guest/')
@csrf_verified @csrf_verified
def make_user_guest(user_id): def make_user_guest(user_id):
return 'stub' mod = get_active_user()
target_user = Users.find({'id': user_id})
if not target_user:
abort(404)
if target_user.is_admin() or target_user.is_system():
abort(403)
if int(target_user.permission) >= int(mod.permission):
abort(403)
target_user.update({
'permission': PermissionLevel.GUEST.value,
'confirmed_on': None,
})
return redirect(url_for('users.user_page', username=target_user.username))
@bp.post('/users/<int:user_id>/make-user/') @bp.post('/users/<int:user_id>/make-user/')
@csrf_verified @csrf_verified
def make_user_regular(user_id): def make_user_regular(user_id):
return 'stub' mod = get_active_user()
target_user = Users.find({'id': user_id})
if not target_user:
abort(404)
if target_user.is_admin() or target_user.is_system():
abort(403)
# mod -> regular user, abort if not admin
if int(target_user.permission) >= int(mod.permission):
abort(403)
update_dict = {'permission': PermissionLevel.USER.value}
# set approved date if the user was guest
if target_user.is_guest():
update_dict['confirmed_on'] = int(time.time())
target_user.update(update_dict)
return redirect(url_for('users.user_page', username=target_user.username))
@bp.post('/users/<int:user_id>/make-mod/') @bp.post('/users/<int:user_id>/make-mod/')
@admin_only
@csrf_verified @csrf_verified
def make_user_mod(user_id): def make_user_mod(user_id):
return 'stub' mod = get_active_user()
target_user = Users.find({'id': user_id})
if not target_user:
abort(404)
if target_user.is_admin() or target_user.is_system():
abort(403)
if int(target_user.permission) >= int(mod.permission):
abort(403)
target_user.update({'permission': PermissionLevel.MODERATOR.value})
return redirect(url_for('users.user_page', username=target_user.username))

View File

@@ -1,7 +1,10 @@
from flask import Blueprint, abort from flask import Blueprint, abort, render_template, redirect, url_for, request, flash
from functools import wraps from functools import wraps
from ..auth import login_required, get_active_user from ..auth import login_required, get_active_user
from ..models import Posts from ..models import Posts, Threads, Topics
from ..util import get_post_url
from ..db import db
from ..constants import InfoboxKind
bp = Blueprint('posts', __name__, url_prefix='/posts/') bp = Blueprint('posts', __name__, url_prefix='/posts/')
@@ -9,10 +12,11 @@ def ownership_required(view_func):
@wraps(view_func) @wraps(view_func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
post = Posts.find({'id': kwargs.get('post_id', None)}) post = Posts.find({'id': kwargs.get('post_id', None)})
user = get_active_user()
if not post: if not post:
abort(404) abort(404)
if post.user_id != get_active_user().id: if post.user_id != user.id:
abort(403) abort(403)
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
@@ -35,10 +39,97 @@ def ownership_or_mod_required(view_func):
@login_required @login_required
@ownership_required @ownership_required
def edit(post_id): def edit(post_id):
return 'stub' post = Posts.find({'id': post_id})
if not post:
abort(404)
thread = Threads.find({'id': post.thread_id})
if not thread:
# what?
abort(404)
user = get_active_user()
if thread.locked() and not user.is_mod():
abort(403)
thread_predicate = f'{Posts.FULL_POSTS_QUERY} WHERE posts.thread_id = ?'
context_prev_q = f'{thread_predicate} AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2'
context_next_q = f'{thread_predicate} AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2'
context_next = db.query(context_next_q, thread.id, post.created_at)
context_prev = db.query(context_prev_q, thread.id, post.created_at)
return render_template(
'posts/edit.html', post=post.get_full_post_view(),
context_next=context_next, context_prev=context_prev
)
@bp.post('/<int:post_id>/edit/')
@login_required
@ownership_required
def edit_post(post_id):
post = Posts.find({'id': post_id})
if not post:
abort(404)
thread = Threads.find({'id': post.thread_id})
if not thread:
# what?
abort(404)
user = get_active_user()
if thread.locked() and not user.is_mod():
abort(403)
post.edit(request.form.get('babycode_content', ''))
return redirect(get_post_url(post.id, _anchor=True))
@bp.get('/<int:post_id>/delete/') @bp.get('/<int:post_id>/delete/')
@login_required @login_required
@ownership_or_mod_required @ownership_or_mod_required
def delete(post_id): def delete(post_id):
return 'stub' post = Posts.find({'id': post_id})
if not post:
abort(404)
thread = Threads.find({'id': post.thread_id})
if not thread:
# what?
abort(404)
user = get_active_user()
if thread.locked() and not user.is_mod():
abort(403)
return render_template('posts/delete.html', post=post.get_full_post_view())
@bp.post('/<int:post_id>/delete/')
@login_required
@ownership_or_mod_required
def delete_post(post_id):
post = Posts.find({'id': post_id})
if not post:
abort(404)
thread = Threads.find({'id': post.thread_id})
if not thread:
# what?
abort(404)
user = get_active_user()
if thread.locked() and not user.is_mod():
abort(403)
post.delete()
if Posts.count({'thread_id': thread.id}) == 0:
topic = Topics.find({'id': thread.topic_id})
thread.delete()
flash('Thread deleted.', InfoboxKind.INFO)
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
flash('Post deleted.', InfoboxKind.INFO)
return redirect(url_for('threads.thread_by_id', thread_id=thread.id))

View File

@@ -1,10 +1,25 @@
from flask import Blueprint, redirect, url_for, render_template, request, abort from flask import Blueprint, redirect, url_for, render_template, request, abort
from functools import wraps
from ..auth import login_required, get_active_user from ..auth import login_required, get_active_user
from ..models import Threads, Posts, Topics, Users, Reactions from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
from ..util import get_form_checkbox, time_now
import math import math
bp = Blueprint('threads', __name__, url_prefix='/threads/') bp = Blueprint('threads', __name__, url_prefix='/threads/')
def ownership_or_mod_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
thread = Threads.find({'id': kwargs.get('thread_id', None)})
if not thread:
abort(404)
if thread.user_id != get_active_user().id and not get_active_user().is_mod():
abort(403)
return view_func(*args, **kwargs)
return wrapper
@bp.get('/<int:thread_id>/') @bp.get('/<int:thread_id>/')
def thread_by_id(thread_id): def thread_by_id(thread_id):
thread = Threads.find({'id': thread_id}) thread = Threads.find({'id': thread_id})
@@ -42,21 +57,102 @@ def thread(thread_id, slug):
page = max(1, min(int(request.args.get('page', default=1)), page_count)) page = max(1, min(int(request.args.get('page', default=1)), page_count))
except ValueError: except ValueError:
abort(404) abort(404)
return render_template('threads/thread.html', thread=thread, posts=thread.get_posts(PER_PAGE, page), page=page, page_count=page_count, topic=topic, started_by=started_by, topics=Topics.get_list(), Reactions=Reactions) posts = thread.get_posts(PER_PAGE, page)
last_post = posts[-1]
return render_template(
'threads/thread.html', thread=thread,
posts=posts, page=page,
page_count=page_count, topic=topic,
started_by=started_by, topics=Topics.get_list(),
Reactions=Reactions, last_post=last_post
)
@bp.post('/<int:thread_id>/reply/') @bp.post('/<int:thread_id>/')
@login_required @login_required
def reply(thread_id): def reply(thread_id):
user = get_active_user() user = get_active_user()
thread = Threads.find({'id': thread_id}) thread = Threads.find({'id': thread_id})
if not thread: if not thread:
abort(404) abort(404)
if thread.locked() and not user.is_mod(): if not user.can_post_to_thread_or_topic(thread):
# TODO: flash
return redirect(url_for('.thread_by_id', thread_id=thread_id)) return redirect(url_for('.thread_by_id', thread_id=thread_id))
post = Posts.new(user.id, thread.id, request.form.get('babycode_content')) post = Posts.new(user.id, thread.id, request.form.get('babycode_content'))
if get_form_checkbox('subscribe'):
if not Subscriptions.find({'user_id': user.id, 'thread_id': thread.id}):
Subscriptions.create({
'user_id': user.id,
'thread_id': thread.id,
'last_seen': time_now(),
})
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}')) return redirect(url_for('.thread_by_id', thread_id=thread_id, after=post.id, _anchor=f'post-{post.id}'))
@bp.get('/<int:thread_id>/edit/')
@login_required
@ownership_or_mod_required
def edit(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
return render_template('threads/edit.html', thread=thread)
@bp.post('/<int:thread_id>/edit/')
@login_required
@ownership_or_mod_required
def edit_post(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
new_title = request.form.get('title', '').strip()
if not new_title:
abort(400)
if new_title != thread.title:
thread.update({'title': new_title})
return redirect(url_for('.thread_by_id', thread_id=thread_id))
@bp.post('/<int:thread_id>/subscribe/')
@login_required
def subscribe(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
user = get_active_user()
last_post_id = request.form.get('last_post_id', None)
if last_post_id is None:
abort(400)
if user.is_subscribed(thread_id):
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
Subscriptions.create({
'user_id': user.id,
'thread_id': thread_id,
'last_seen': request.form.get('last_post_timestamp', time_now())
})
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
@bp.post('/<int:thread_id>/unsubscribe/')
@login_required
def unsubscribe(thread_id):
thread = Threads.find({'id': thread_id})
if not thread:
abort(404)
user = get_active_user()
last_post_id = request.form.get('last_post_id', None)
if last_post_id is None:
abort(400)
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread_id})
if not subscription:
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
subscription.delete()
return redirect(url_for('.thread_by_id', thread_id=thread_id, after=last_post_id))
@bp.get('/<int:thread_id>/feed.atom/') @bp.get('/<int:thread_id>/feed.atom/')
def feed(thread_id): def feed(thread_id):
return 'stub' return 'stub'
@@ -84,19 +180,16 @@ def new_post():
abort(404) abort(404)
user = get_active_user() user = get_active_user()
if not user.can_post_to_topic(topic): if not user.can_post_to_thread_or_topic(topic):
abort(404) abort(403)
title = request.form.get('title') title = request.form.get('title', '').strip()
if not title: if not title:
abort(404) abort(400)
if not title.strip():
abort(404)
title = title.strip() title = title.strip()
content = request.form.get('babycode_content') content = request.form.get('babycode_content')
thread = Threads.new(user.id, topic.id, title, content) thread = Threads.new(user.id, topic.id, title, content)
return redirect(url_for('.thread', slug=thread.slug)) return redirect(url_for('.thread_by_id', thread_id=thread.id))

View File

@@ -1,15 +1,16 @@
from flask import Blueprint, redirect, url_for, render_template, request, session from flask import Blueprint, redirect, url_for, render_template, request, session, abort
from functools import wraps from functools import wraps
import time import time
from ..auth import ( from ..auth import (
digest, verify, create_session, digest, verify, create_session,
is_logged_in, parse_username, is_password_valid, is_logged_in, parse_username, is_password_valid,
login_required login_required, revoke_session, get_active_user
) )
from ..models import Users from ..models import Users, Posts, Reactions, Threads
from ..constants import PermissionLevel from ..constants import PermissionLevel
from secrets import compare_digest as compare_timesafe from secrets import compare_digest as compare_timesafe
import math
bp = Blueprint('users', __name__, url_prefix='/users/') bp = Blueprint('users', __name__, url_prefix='/users/')
@@ -23,16 +24,24 @@ def redirect_if_logged_in(destination='topics.all_topics'):
return wrapper return wrapper
return decorator return decorator
def redirect_to_own(view_func):
@wraps(view_func)
def wrapper(username, *args, **kwargs):
user = get_active_user()
if username.lower() != user.username:
view_args = dict(request.view_args)
view_args.pop('username', None)
new_args = {**view_args, 'username': user.username}
return redirect(url_for(request.endpoint, **new_args))
return view_func(username, *args, **kwargs)
return wrapper
@bp.get('/log-in/') @bp.get('/log-in/')
@redirect_if_logged_in() @redirect_if_logged_in()
def log_in(): def log_in():
return render_template('users/log_in.html') return render_template('users/log_in.html')
@bp.post('/log-out/')
@login_required
def log_out():
return 'stub'
@bp.post('/log-in/') @bp.post('/log-in/')
@redirect_if_logged_in() @redirect_if_logged_in()
def log_in_post(): def log_in_post():
@@ -51,6 +60,12 @@ def log_in_post():
session.permanent = True session.permanent = True
return redirect(request.form.get('return_to', default=url_for('topics.all_topics'))) return redirect(request.form.get('return_to', default=url_for('topics.all_topics')))
@bp.post('/log-out/')
@login_required
def log_out():
revoke_session(get_active_user().id)
return redirect(url_for('topics.all_topics'))
@bp.get('/sign-up/') @bp.get('/sign-up/')
@redirect_if_logged_in() @redirect_if_logged_in()
def sign_up(): def sign_up():
@@ -104,6 +119,7 @@ def sign_up_post():
@bp.get('/<username>/') @bp.get('/<username>/')
def user_page(username): def user_page(username):
username = username.lower()
target_user = Users.find({'username': username}) target_user = Users.find({'username': username})
if not target_user: if not target_user:
abort(404) abort(404)
@@ -111,25 +127,75 @@ def user_page(username):
@bp.get('/<username>/posts/') @bp.get('/<username>/posts/')
def posts(username): def posts(username):
return 'stub' username = username.lower()
if username == 'deleteduser':
abort(404)
target_user = Users.find({'username': username})
if not target_user:
abort(404)
PER_PAGE = 10
posts_count = Posts.count({'user_id': target_user.id})
page_count = max(1, math.ceil(posts_count / PER_PAGE))
page = 1
try:
page = max(1, min(int(request.args.get('page', default=1)), page_count))
except ValueError:
abort(404)
posts = target_user.get_posts(PER_PAGE, page)
return render_template(
'users/posts.html', posts=posts,
page=page, page_count=page_count,
target_user=target_user, Reactions=Reactions
)
@bp.get('/<username>/threads/') @bp.get('/<username>/threads/')
def threads(username): def threads(username):
return 'stub' username = username.lower()
if username == 'deleteduser':
abort(404)
target_user = Users.find({'username': username})
if not target_user:
abort(404)
PER_PAGE = 10
threads_count = Threads.count({'user_id': target_user.id})
page_count = max(1, math.ceil(threads_count / PER_PAGE))
page = 1
try:
page = max(1, min(int(request.args.get('page', default=1)), page_count))
except ValueError:
abort(404)
threads = target_user.get_started_threads(PER_PAGE, page)
return render_template(
'users/threads.html', threads=threads,
page=page, page_count=page_count,
target_user=target_user, Reactions=Reactions
)
@bp.get('/<username>/comments/') @bp.get('/<username>/comments/')
def comments(username): def comments(username):
username = username.lower()
if username == 'deleteduser':
abort(404)
return 'stub' return 'stub'
@bp.get('/<username>/settings/') @bp.get('/<username>/settings/')
@login_required
@redirect_to_own
def settings(username): def settings(username):
username = username.lower()
return 'stub' return 'stub'
@bp.get('/<username>/inbox/') @bp.get('/<username>/inbox/')
@login_required
@redirect_to_own
def inbox(username): def inbox(username):
username = username.lower()
return 'stub' return 'stub'
@bp.get('/<username>/bookmarks/') @bp.get('/<username>/bookmarks/')
@login_required
@redirect_to_own
def bookmarks(username): def bookmarks(username):
username = username.lower()
return 'stub' return 'stub'

View File

@@ -1,3 +1,4 @@
{%- from 'common/macros.html' import infobox with context -%}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -13,6 +14,13 @@
</head> </head>
<body> <body>
{%- include 'common/topnav.html' -%} {%- include 'common/topnav.html' -%}
{%- with messages = get_flashed_messages(with_categories=true) -%}
{%- if messages -%}
{%- for category, message in messages -%}
{{- infobox(message, category) -}}
{%- endfor -%}
{%- endif -%}
{%- endwith -%}
{%- block content -%}{%- endblock -%} {%- block content -%}{%- endblock -%}
{%- include 'common/footer.html' -%} {%- include 'common/footer.html' -%}
</body> </body>

View File

@@ -0,0 +1,8 @@
{%- from 'common/macros.html' import subheader -%}
{%- extends 'base.html' -%}
{%- block title -%}Forbidden{%- endblock -%}
{%- block content -%}
{%- call() subheader('403 Forbidden') -%}
<span>You are not allowed to access this page or perform this action.</span>
{%- endcall -%}
{%- endblock -%}

View File

@@ -3,6 +3,6 @@
{%- block title -%}Not found{%- endblock -%} {%- block title -%}Not found{%- endblock -%}
{%- block content -%} {%- block content -%}
{%- call() subheader('404 Not Found') -%} {%- call() subheader('404 Not Found') -%}
<span>The requested URL was not found.</span> <span>The requested page was not found.</span>
{%- endcall -%} {%- endcall -%}
{%- endblock -%} {%- endblock -%}

View File

@@ -0,0 +1,23 @@
{%- macro icn_info(width=48) -%}
<img src="/static/icons/info.svg" alt="info" style="width: {{width}}px;">
{%- endmacro -%}
{%- macro icn_warn(width=48) -%}
<img src="/static/icons/warn.svg" alt="warning" style="width: {{width}}px;">
{%- endmacro -%}
{%- macro icn_error(width=48) -%}
<img src="/static/icons/error.svg" alt="error" style="width: {{width}}px;">
{%- endmacro -%}
{%- macro icn_bookmark(width=16) -%}
<img src="/static/icons/bookmark.svg" alt="bookmark" style="width: {{width}}px;">
{%- endmacro -%}
{%- macro icn_locked(width=16) -%}
<img src="/static/icons/locked.svg" title="Locked" alt="lock" style="width: {{width}}px;">
{%- endmacro -%}
{%- macro icn_stickied(width=16) -%}
<img src="/static/icons/sticky.svg" title="Stickied" alt="paper held by pushpin" style="width: {{width}}px;">
{%- endmacro -%}

View File

@@ -1,5 +1,7 @@
{%- from 'common/icons.html' import icn_info, icn_warn, icn_error, icn_bookmark -%}
{% macro timestamp(unix_ts) -%} {% macro timestamp(unix_ts) -%}
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span> <time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
{%- endmacro %} {%- endmacro %}
{% macro subheader(title, desc='') -%} {% macro subheader(title, desc='') -%}
@@ -101,9 +103,9 @@
<button type="button" class="minimal">&bullet;</button> <button type="button" class="minimal">&bullet;</button>
<button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button> <button type="button" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
</span> </span>
<a href="##">babycode help</a>
</span> </span>
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea> <textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}}>{{ prefill }}</textarea>
<a href="##">babycode help</a>
{%- endif -%} {%- endif -%}
{%- endcall -%} {%- endcall -%}
{%- endmacro %} {%- endmacro %}
@@ -111,7 +113,7 @@
{% macro full_post( {% macro full_post(
post, render_sig=true, is_latest=false, post, render_sig=true, is_latest=false,
show_toolbar=true, is_editing=false, thread=none, show_toolbar=true, is_editing=false, thread=none,
show_reactions=true show_reactions=true, show_thread=false, allow_reacting=true
) -%} ) -%}
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
{%- set can_delete = post.user_id == get_active_user().id or is_mod() -%} {%- set can_delete = post.user_id == get_active_user().id or is_mod() -%}
@@ -140,11 +142,22 @@
</div> </div>
<div class="post-content"> <div class="post-content">
<div class="plank even minimal secondary-bg no-shadow post-info"> <div class="plank even minimal secondary-bg no-shadow post-info">
<a href="{{get_post_url(post.id, _anchor=true)}}"><i>Posted on {{timestamp(post.created_at)}}</i></a> <span>
<a href="{{get_post_url(post.id, _anchor=true)}}">
{%- if post.edited_at <= post.created_at -%}
<i>Posted on {{timestamp(post.created_at)}}</i>
{%- else -%}
<i>Edited on {{timestamp(post.edited_at)}}</i>
{%- endif -%}
</a>
{%- if show_thread -%}
<span> in thread <a href="{{url_for('threads.thread_by_id', thread_id=post.thread_id)}}"> {{post.thread_title}}</a></span>
{%- endif -%}
</span>
{%- if show_toolbar -%} {%- if show_toolbar -%}
<span class="thread-actions"> <span class="thread-actions">
{%- if owns -%} {%- if owns -%}
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id)}}">Edit</a> <a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
{%- endif -%} {%- endif -%}
{%- if can_reply -%} {%- if can_reply -%}
<button disabled title="This feature requires JavaScript to be enabled.">Quote</button> <button disabled title="This feature requires JavaScript to be enabled.">Quote</button>
@@ -152,31 +165,57 @@
{%- if can_delete -%} {%- if can_delete -%}
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a> <a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
{%- endif -%} {%- endif -%}
<button disabled title="This feature requires JavaScript to be enabled.">Bookmark&hellip;</button> <button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
</span> </span>
{%- endif -%} {%- endif -%}
</div> </div>
<div class="plank even no-shadow post-content-inner minimal">{{post.content | safe}} <div class="plank even no-shadow post-content-inner minimal">
{%- if render_sig and post.signature_rendered -%} {%- if not is_editing -%}
<aside class="post-signature">{{post.signature_rendered | safe}}</aside> {{post.content | safe}}
{%- if render_sig and post.signature_rendered -%}
<aside class="post-signature">{{post.signature_rendered | safe}}</aside>
{%- endif -%}
{%- else -%}
{{- babycode_editor_component(prefill=post.original_markup) -}}
{%- endif -%} {%- endif -%}
</div> </div>
{%- if show_reactions -%}
<div class="plank even secondary-bg minimal no-shadow"> <div class="plank even secondary-bg minimal no-shadow">
<span class="button-row"> {%- if show_reactions -%}
{%- for reaction in Reactions.for_post(post.id) -%} <span class="button-row">
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %} {%- for reaction in Reactions.for_post(post.id) -%}
{% set reactors_trimmed = reactors[:10] %} {% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_str = reactors_trimmed | join (',\n') %} {% set reactors_trimmed = reactors[:10] %}
{% if reactors | count > 10 %} {% set reactors_str = reactors_trimmed | join (',\n') %}
{% set reactors_str = reactors_str + '\n...and many others' %} {% if reactors | count > 10 %}
{% endif %} {% set reactors_str = reactors_str + '\n...and many others' %}
{% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %} {% endif %}
<button disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button> {% set has_reacted = get_active_user() is not none and get_active_user().username in reactors %}
{%- endfor -%} <button type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
</span> {%- endfor -%}
{%- if is_logged_in() -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%} </span>
{%- if is_logged_in() and allow_reacting -%}<button disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%}
{%- elif is_editing -%}
<input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
{%- endif -%}
</div> </div>
</div>
{%- endmacro %}
{% macro infobox(message, kind=InfoboxKind.INFO) -%}
<div class="infobox plank top contain-svg horizontal {{InfoboxHTMLClass[kind]}}">
{%- if kind == InfoboxKind.INFO -%}
{{- icn_info() -}}
{%- elif kind == InfoboxKind.WARN -%}
{{- icn_warn() -}}
{%- elif kind == InfoboxKind.ERROR -%}
{{- icn_error() -}}
{%- endif -%}
{%- set m = message.split(';', maxsplit=1) -%}
<strong>{{m[0]}}</strong>
{%- if m[1] -%}
{{m[1]}}
{%- endif -%} {%- endif -%}
</div> </div>
{%- endmacro %} {%- endmacro %}

View File

@@ -2,7 +2,7 @@
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}editing topic {{topic.name}}{%- endblock -%} {%- block title -%}editing topic {{topic.name}}{%- endblock -%}
{%- block content -%} {%- block content -%}
{{subheader('Editing topic %s' % topic.name, 'To preserve history, the URL of the topic can not be changed.')}} {{subheader('Editing topic %s' % topic.name)}}
<form class="plank primary-bg full-width" method="POST"> <form class="plank primary-bg full-width" method="POST">
<label for="name">Name</label> <label for="name">Name</label>
<input type="text" id="name" name="name" required value="{{topic.name}}"> <input type="text" id="name" name="name" required value="{{topic.name}}">

View File

@@ -0,0 +1,16 @@
{%- from 'common/macros.html' import subheader -%}
{%- from 'common/macros.html' import full_post with context -%}
{%- extends 'base.html' -%}
{%- block title -%}deleting a post{%- endblock -%}
{%- block content -%}
{%- call() subheader("Delete post", "Are you sure you want to delete this post? This action can not be undone.") -%}
<form method="POST">
<fieldset class="plank minimal even no-shadow thread-actions">
<legend>Please confirm</legend>
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton">Cancel</a>
<input type="submit" value="Delete" class="critical">
</fieldset>
</form>
{%- endcall -%}
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
{%- endblock -%}

View File

@@ -0,0 +1,29 @@
{%- from 'common/macros.html' import subheader -%}
{%- from 'common/macros.html' import full_post with context -%}
{%- extends 'base.html' -%}
{%- block title -%}editing a post{%- endblock -%}
{%- block content -%}
{%- set nav -%}
<a href="{{get_post_url(post.id, _anchor=true)}}">&larr; Back to thread</a>
{%- endset -%}
{{ subheader("Editing your post", nav)}}
{%- for post in context_prev -%}
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
{%- endfor -%}
<div class="plank secondary-bg context-explain">
<span>&uarr;&uarr;&uarr;</span>
<i>Context</i>
<span>&uarr;&uarr;&uarr;</span>
</div>
<form class="post plank" method="POST">
{{- full_post(post=post, is_editing=true, show_toolbar=false, show_reactions=false) -}}
</form>
<div class="plank secondary-bg context-explain">
<span>&darr;&darr;&darr;</span>
<i>Context</i>
<span>&darr;&darr;&darr;</span>
</div>
{%- for post in context_next -%}
<div class="post plank">{{- full_post(post=post, show_toolbar=false, show_reactions=false) -}}</div>
{%- endfor -%}
{%- endblock -%}

View File

@@ -0,0 +1,11 @@
{%- from 'common/macros.html' import subheader, babycode_editor_component -%}
{%- extends 'base.html' -%}
{%- block title -%}editing thread "{{thread.title}}"{%- endblock -%}
{%- block content -%}
{{subheader('Rename thread "%s"' % thread.title, 'You can change the thread title here. To edit the OP of this thread, press the Edit button on the post instead.')}}
<form class="plank primary-bg full-width" method="POST">
<label for="title">New title</label>
<input type="text" id="title" name="title" required value="{{thread.title}}">
<input type="submit" value="Save">
</form>
{%- endblock -%}

View File

@@ -7,7 +7,7 @@
<label for="topic">Topic</label> <label for="topic">Topic</label>
<select name="topic_id" id="topic" autocomplete="off"> <select name="topic_id" id="topic" autocomplete="off">
{%- for topic in topics -%} {%- for topic in topics -%}
<option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option> <option value="{{topic.id}}" {{'selected' if selected_topic == topic.id else ''}} {{'disabled' if not get_active_user().can_post_to_thread_or_topic(topic) else ''}}>{{topic.name}}{{ ' (locked)' if topic.locked() else ''}}</option>
{%- endfor -%} {%- endfor -%}
</select> </select>
<label for="title">Title</label> <label for="title">Title</label>

View File

@@ -1,4 +1,5 @@
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%} {%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
{%- from 'common/icons.html' import icn_bookmark -%}
{%- from 'common/macros.html' import full_post with context -%} {%- from 'common/macros.html' import full_post with context -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}{{thread.title}}{%- endblock -%} {%- block title -%}{{thread.title}}{%- endblock -%}
@@ -20,14 +21,24 @@
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Actions</legend> <legend>Actions</legend>
{%- if is_logged_in() -%} {%- if is_logged_in() -%}
<button>Subscribe</button> {%- if thread.user_id == get_active_user().id -%}
<button disabled title="This feature requires JavaScript to be enabled.">Bookmark&hellip;</button> <a class="linkbutton" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a>
{%- endif -%}
<form action="{{url_for('threads.subscribe' if not get_active_user().is_subscribed(thread.id) else 'threads.unsubscribe', thread_id=thread.id)}}" method="POST">
<input type="hidden" name="last_post_timestamp" value="{{last_post.created_at}}">
<input type="hidden" name="last_post_id" value="{{last_post.id}}">
<input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
</form>
<button disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark&hellip;</button>
{%- endif -%} {%- endif -%}
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a> <a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
</fieldset> </fieldset>
{%- if is_mod() -%} {%- if is_mod() -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
{%- if thread.user_id != get_active_user().id -%}
<a class="linkbutton warn" href="{{url_for('threads.edit', thread_id=thread.id)}}">Rename</a>
{%- endif -%}
<form method="POST"> <form method="POST">
<input type="hidden" name="lock" value="{{(not thread.locked()) | int}}"> <input type="hidden" name="lock" value="{{(not thread.locked()) | int}}">
<input type="hidden" name="sticky" value="{{(not thread.stickied()) | int}}"> <input type="hidden" name="sticky" value="{{(not thread.stickied()) | int}}">
@@ -63,7 +74,15 @@
{{- pager(page, page_count) -}} {{- pager(page, page_count) -}}
</fieldset> </fieldset>
</div> </div>
{%- if is_logged_in() -%} <div id="new-post-toast" class="plank even contrast-bg hidden">
<span>New post in thread!</span>
<span class="notification-buttons">
<button>Dismiss</button>
<button>View post</button>
<button>Stop updates</button>
</span>
</div>
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form"> <form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form">
<h2 class="info">Reply to "{{thread.title}}"</h2> <h2 class="info">Reply to "{{thread.title}}"</h2>
{{- babycode_editor_component() -}} {{- babycode_editor_component() -}}

View File

@@ -1,4 +1,5 @@
{% from 'common/macros.html' import timestamp, subheader, pager %} {% from 'common/macros.html' import timestamp, subheader, pager %}
{% from 'common/icons.html' import icn_locked, icn_stickied %}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}browsing topic {{topic.name}}{%- endblock -%} {%- block title -%}browsing topic {{topic.name}}{%- endblock -%}
{%- block content -%} {%- block content -%}
@@ -13,7 +14,7 @@
{%- call() subheader(('Threads in "%s"' % topic.name), td) -%} {%- call() subheader(('Threads in "%s"' % topic.name), td) -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Actions</legend> <legend>Actions</legend>
{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) -%} {%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) -%}
<a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a> <a href="{{url_for('threads.new', topic_id=topic.id)}}" class="linkbutton">New thread</a>
{%- endif -%} {%- endif -%}
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a> <a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
@@ -43,13 +44,21 @@
{%- endif -%} {%- endif -%}
{%- endcall -%} {%- endcall -%}
{%- if threads | length == 0 -%} {%- if threads | length == 0 -%}
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div> <div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
{%- endif -%} {%- endif -%}
{%- for thread in threads -%} {%- for thread in threads -%}
<div class="topic-info plank"> <div class="topic-info plank">
<div class="title-container"> <div class="title-container">
<span class="info thread-title-counter"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a></span> <span class="info thread-title-counter"><a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}">{{thread.title}}</a>
<ul class="horizontal"></ul> </span>
<ul class="horizontal">
{%- if thread.is_locked -%}
<li>{{icn_locked(24)}}</li>
{%- endif -%}
{%- if thread.is_stickied -%}
<li>{{icn_stickied(24)}}</li>
{%- endif -%}
</ul>
{%- if thread.posts_count / 10 > 1 -%} {%- if thread.posts_count / 10 > 1 -%}
{{pager(0, (((thread.posts_count / 10) | round(0, 'ceil') )| int), 'flex-last', url=url_for('threads.thread_by_id', thread_id=thread.id))}} {{pager(0, (((thread.posts_count / 10) | round(0, 'ceil') )| int), 'flex-last', url=url_for('threads.thread_by_id', thread_id=thread.id))}}
{%- endif -%} {%- endif -%}

View File

@@ -1,4 +1,5 @@
{% from 'common/macros.html' import timestamp, subheader %} {% from 'common/macros.html' import timestamp, subheader %}
{% from 'common/icons.html' import icn_locked %}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block content -%} {%- block content -%}
{%- call() subheader('All topics') -%} {%- call() subheader('All topics') -%}
@@ -14,6 +15,9 @@
<div class="topic-info plank"> <div class="topic-info plank">
<div class="title-container"> <div class="title-container">
<a class="info" href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a> <a class="info" href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a>
{%- if topic.is_locked -%}
{{icn_locked(24)}}
{%- endif -%}
</div> </div>
<div>{{topic.description}}</div> <div>{{topic.description}}</div>
<ul class="horizontal"> <ul class="horizontal">

View File

@@ -1,3 +1,4 @@
{% from 'common/macros.html' import infobox with context %}
{% from 'common/macros.html' import subheader %} {% from 'common/macros.html' import subheader %}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}log in{%- endblock -%} {%- block title -%}log in{%- endblock -%}
@@ -7,9 +8,7 @@ Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
{%- endset -%} {%- endset -%}
{{ subheader('Log in', welcome)}} {{ subheader('Log in', welcome)}}
{%- if request.args.get('error') -%} {%- if request.args.get('error') -%}
<div class="infobox plank critical"> {{infobox(request.args.error, InfoboxKind.ERROR)}}
{{request.args.get('error')}}
</div>
{%- endif -%} {%- endif -%}
<form class="plank primary-bg full-width" method="POST"> <form class="plank primary-bg full-width" method="POST">
<label for="username">Username</label> <label for="username">Username</label>

View File

@@ -0,0 +1,30 @@
{%- from 'common/macros.html' import full_post with context -%}
{%- from 'common/macros.html' import subheader, pager -%}
{%- extends 'base.html' -%}
{%- block title -%}{{ target_user.get_readable_name() }}'s posts{%- endblock -%}
{%- block content -%}
{%- set td -%}
<a href="{{url_for('users.user_page', username=target_user.username)}}">&larr; Back to profile</a>
{%- endset -%}
{%- call() subheader("%s's posts" % target_user.get_readable_name(), td) -%}
{%- if posts -%}
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Page</legend>
{{- pager(page, page_count) -}}
</fieldset>
{%- endif -%}
{%- endcall -%}
{%- if posts -%}
{%- for post in posts -%}
<div class="post plank">{{full_post(post, show_toolbar=false, show_thread=true, allow_reacting=false)}}</div>
{%- endfor -%}
<div class="plank">
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Page</legend>
{{- pager(page, page_count) -}}
</fieldset>
</div>
{%- else -%}
<div class="plank">{{target_user.get_readable_name()}} has no posts.</div>
{%- endif -%}
{%- endblock -%}

View File

@@ -1,3 +1,4 @@
{% from 'common/macros.html' import infobox with context %}
{% from 'common/macros.html' import subheader %} {% from 'common/macros.html' import subheader %}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- block title -%}sign up{%- endblock -%} {%- block title -%}sign up{%- endblock -%}
@@ -7,9 +8,7 @@ Please read the rules etc. stub
{%- endset -%} {%- endset -%}
{{ subheader('Sign up', welcome)}} {{ subheader('Sign up', welcome)}}
{%- if request.args.get('error') -%} {%- if request.args.get('error') -%}
<div class="infobox plank critical"> {{infobox(request.args.error, InfoboxKind.ERROR)}}
{{request.args.get('error')}}
</div>
{%- endif -%} {%- endif -%}
<form class="plank primary-bg full-width" method="POST"> <form class="plank primary-bg full-width" method="POST">
<label for="username">Username</label> <label for="username">Username</label>

View File

@@ -0,0 +1,33 @@
{%- from 'common/macros.html' import full_post with context -%}
{%- from 'common/macros.html' import subheader, pager -%}
{%- extends 'base.html' -%}
{%- block title -%}threads by {{ target_user.get_readable_name() }}{%- endblock -%}
{%- block content -%}
{%- set td -%}
<a href="{{url_for('users.user_page', username=target_user.username)}}">&larr; Back to profile</a>
{%- endset -%}
{%- call() subheader("%s's started threads" % target_user.get_readable_name(), td) -%}
{%- if threads -%}
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Page</legend>
{{- pager(page, page_count) -}}
</fieldset>
{%- endif -%}
{%- endcall -%}
{%- if threads -%}
{%- for post in threads -%}
<div class="plank">
<h2 class="info"><a href="{{url_for('threads.thread_by_id', thread_id=post.thread_id)}}">"{{post.thread_title}}"</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=post.topic_id)}}">{{post.topic_name}}</a></h2>
<div class="post">{{full_post(post, show_toolbar=false, allow_reacting=false)}}</div>
</div>
{%- endfor -%}
<div class="plank">
<fieldset class="plank even no-shadow minimal thread-actions">
<legend>Page</legend>
{{- pager(page, page_count) -}}
</fieldset>
</div>
{%- else -%}
<div class="plank">{{target_user.get_readable_name()}} has not started any threads.</div>
{%- endif -%}
{%- endblock -%}

View File

@@ -15,10 +15,10 @@
</fieldset> </fieldset>
{%- endif -%} {%- endif -%}
{%- if get_active_user().is_mod() and target_user.id != get_active_user().id -%} {%- if get_active_user().is_mod() and target_user.id != get_active_user().id and target_user.permission < get_active_user().permission -%}
<fieldset class="plank even no-shadow minimal thread-actions"> <fieldset class="plank even no-shadow minimal thread-actions">
<legend>Moderation actions</legend> <legend>Moderation actions</legend>
<form method="POST"> <form class="thread-actions" method="POST">
{{csrf_input() | safe}} {{csrf_input() | safe}}
{%- if target_user.is_guest() -%} {%- if target_user.is_guest() -%}
<input class="warn" type="submit" value="Approve user" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}"> <input class="warn" type="submit" value="Approve user" formaction="{{url_for('mod.make_user_regular', user_id=target_user.id)}}">

View File

@@ -1,6 +1,7 @@
from flask import url_for, session from flask import url_for, session, request
from .models import Posts, Threads from .models import Posts, Threads
from .auth import is_logged_in from .auth import is_logged_in
import time
def get_post_url(post_id, _anchor=False, external=False): def get_post_url(post_id, _anchor=False, external=False):
post = Posts.find({'id': post_id}) post = Posts.find({'id': post_id})
@@ -24,3 +25,9 @@ def get_csrf_token():
def csrf_input(): def csrf_input():
return f'<input type="hidden" name="csrf" value="{get_csrf_token()}">' return f'<input type="hidden" name="csrf" value="{get_csrf_token()}">'
def get_form_checkbox(name: str) -> bool:
return request.form.get(name, None) == 'on'
def time_now() -> int:
return int(time.time())

View File

@@ -103,8 +103,11 @@ button, .linkbutton, input[type="submit"] {
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: solid var(--border-thickness) var(--border-color); border: solid var(--border-thickness) var(--border-color);
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--main-color) 26%, var(--main-color) 50%, var(--bottom-color) 100%); background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--main-color) 26%, var(--main-color) 50%, var(--bottom-color) 100%);
box-shadow: inset 0px 2px 5px 3px var(--inset-color); /* box-shadow: inset 0px 2px 5px 3px var(--inset-color); */
color: var(--font-color); /* color: var(--font-color); */
/* HACK: better than contrast-color on critical */
/* https://css-tricks.com/approximating-contrast-color-with-other-css-features/ */
color: oklch(from var(--main-color) round(1.21 - L) 0 0);
text-decoration: none; text-decoration: none;
user-select: none; user-select: none;
@@ -179,7 +182,7 @@ button, .linkbutton, input[type="submit"] {
} }
.tab-content { .tab-content {
min-height: 250px;
&.hidden { &.hidden {
display: none; display: none;
@@ -189,7 +192,7 @@ button, .linkbutton, input[type="submit"] {
.babycode-editor { .babycode-editor {
width: 100%; width: 100%;
height: 150px; min-height: 150px;
} }
.post-edit-form { .post-edit-form {
@@ -298,7 +301,7 @@ a.site-title {
} }
.info { .info {
margin: 0; margin: var(--base-padding) 0;
font-size: 1.5em; font-size: 1.5em;
font-weight: bold; font-weight: bold;
} }
@@ -405,14 +408,14 @@ ul.horizontal, ol.horizontal {
.infobox { .infobox {
--main-color: var(--infobox-color); --main-color: var(--infobox-color);
justify-content: start; justify-content: start;
color: oklch(from var(--main-color) round(1.21 - L) 0 0);
&.critical { &.critical {
--main-color: hsl(from var(--critical-color) h 50% calc(l * 0.7)); --main-color: hsl(from var(--critical-color) h 50% l);
color: var(--font-color-anti);
} }
&.warn { &.warn {
--main-color: hsl(from var(--warn-color) h 50% calc(l * 1.2)); --main-color: hsl(from var(--warn-color) h 50% 75%);
} }
} }
@@ -441,7 +444,7 @@ ul.horizontal, ol.horizontal {
display: flex; display: flex;
gap: var(--base-padding); gap: var(--base-padding);
justify-content: start; justify-content: start;
align-items: end; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -617,6 +620,11 @@ form.full-width {
} }
} }
.context-explain {
display: flex;
justify-content: space-evenly;
}
/* babycode tags */ /* babycode tags */
.inline-code { .inline-code {
background-color: var(--code-bg-color); background-color: var(--code-bg-color);
@@ -676,6 +684,88 @@ pre code {
color: var(--font-color-anti); color: var(--font-color-anti);
padding: var(--base-padding); padding: var(--base-padding);
overflow: scroll; overflow: scroll;
tab-size: 4;
.hll { background-color: #6e7681 }
.c { color: #8B949E; font-style: italic } /* Comment */
.err { color: #F85149 } /* Error */
.esc { color: #E6EDF3 } /* Escape */
.g { color: #E6EDF3 } /* Generic */
.k { color: #FF7B72 } /* Keyword */
.l { color: #A5D6FF } /* Literal */
.n { color: #E6EDF3 } /* Name */
.o { color: #FF7B72; font-weight: bold } /* Operator */
.x { color: #E6EDF3 } /* Other */
.p { color: #E6EDF3 } /* Punctuation */
.ch { color: #8B949E; font-style: italic } /* Comment.Hashbang */
.cm { color: #8B949E; font-style: italic } /* Comment.Multiline */
.cp { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Preproc */
.cpf { color: #8B949E; font-style: italic } /* Comment.PreprocFile */
.c1 { color: #8B949E; font-style: italic } /* Comment.Single */
.cs { color: #8B949E; font-weight: bold; font-style: italic } /* Comment.Special */
.gd { color: #FFA198; background-color: #490202 } /* Generic.Deleted */
.ge { color: #E6EDF3; font-style: italic } /* Generic.Emph */
.ges { color: #E6EDF3; font-weight: bold; font-style: italic } /* Generic.EmphStrong */
.gr { color: #FFA198 } /* Generic.Error */
.gh { color: #79C0FF; font-weight: bold } /* Generic.Heading */
.gi { color: #56D364; background-color: #0F5323 } /* Generic.Inserted */
.go { color: #8B949E } /* Generic.Output */
.gp { color: #8B949E } /* Generic.Prompt */
.gs { color: #E6EDF3; font-weight: bold } /* Generic.Strong */
.gu { color: #79C0FF } /* Generic.Subheading */
.gt { color: #FF7B72 } /* Generic.Traceback */
.g-Underline { color: #E6EDF3; text-decoration: underline } /* Generic.Underline */
.kc { color: #79C0FF } /* Keyword.Constant */
.kd { color: #FF7B72 } /* Keyword.Declaration */
.kn { color: #FF7B72 } /* Keyword.Namespace */
.kp { color: #79C0FF } /* Keyword.Pseudo */
.kr { color: #FF7B72 } /* Keyword.Reserved */
.kt { color: #FF7B72 } /* Keyword.Type */
.ld { color: #79C0FF } /* Literal.Date */
.m { color: #A5D6FF } /* Literal.Number */
.s { color: #A5D6FF } /* Literal.String */
.na { color: #E6EDF3 } /* Name.Attribute */
.nb { color: #E6EDF3 } /* Name.Builtin */
.nc { color: #F0883E; font-weight: bold } /* Name.Class */
.no { color: #79C0FF; font-weight: bold } /* Name.Constant */
.nd { color: #D2A8FF; font-weight: bold } /* Name.Decorator */
.ni { color: #FFA657 } /* Name.Entity */
.ne { color: #F0883E; font-weight: bold } /* Name.Exception */
.nf { color: #D2A8FF; font-weight: bold } /* Name.Function */
.nl { color: #79C0FF; font-weight: bold } /* Name.Label */
.nn { color: #FF7B72 } /* Name.Namespace */
.nx { color: #E6EDF3 } /* Name.Other */
.py { color: #79C0FF } /* Name.Property */
.nt { color: #7EE787 } /* Name.Tag */
.nv { color: #79C0FF } /* Name.Variable */
.ow { color: #FF7B72; font-weight: bold } /* Operator.Word */
.pm { color: #E6EDF3 } /* Punctuation.Marker */
.w { color: #6E7681 } /* Text.Whitespace */
.mb { color: #A5D6FF } /* Literal.Number.Bin */
.mf { color: #A5D6FF } /* Literal.Number.Float */
.mh { color: #A5D6FF } /* Literal.Number.Hex */
.mi { color: #A5D6FF } /* Literal.Number.Integer */
.mo { color: #A5D6FF } /* Literal.Number.Oct */
.sa { color: #79C0FF } /* Literal.String.Affix */
.sb { color: #A5D6FF } /* Literal.String.Backtick */
.sc { color: #A5D6FF } /* Literal.String.Char */
.dl { color: #79C0FF } /* Literal.String.Delimiter */
.sd { color: #A5D6FF } /* Literal.String.Doc */
.s2 { color: #A5D6FF } /* Literal.String.Double */
.se { color: #79C0FF } /* Literal.String.Escape */
.sh { color: #79C0FF } /* Literal.String.Heredoc */
.si { color: #A5D6FF } /* Literal.String.Interpol */
.sx { color: #A5D6FF } /* Literal.String.Other */
.sr { color: #79C0FF } /* Literal.String.Regex */
.s1 { color: #A5D6FF } /* Literal.String.Single */
.ss { color: #A5D6FF } /* Literal.String.Symbol */
.bp { color: #E6EDF3 } /* Name.Builtin.Pseudo */
.fm { color: #D2A8FF; font-weight: bold } /* Name.Function.Magic */
.vc { color: #79C0FF } /* Name.Variable.Class */
.vg { color: #79C0FF } /* Name.Variable.Global */
.vi { color: #79C0FF } /* Name.Variable.Instance */
.vm { color: #79C0FF } /* Name.Variable.Magic */
.il { color: #A5D6FF } /* Literal.Number.Integer.Long */
} }
summary { summary {

View File

@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="8" x2="8" y1="2" y2="14"><stop offset="0" stop-color="#cdcdcd"/><stop offset=".5" stop-color="#e6e6e6"/><stop offset="1" stop-color="#cdcdcd"/></linearGradient><g><path d="m3 1h10c.554 0 1 .446 1 1v9.5l-3.5 3.5h-7.5c-.554 0-1-.446-1-1v-12c0-.554.446-1 1-1z" fill="#515151" stroke-linecap="round" stroke-linejoin="round"/><path d="m3.5 2h9c.277 0 .5.223.5.5v8.5l-3 3h-6.5c-.277 0-.5-.223-.5-.5v-11c0-.277.223-.5.5-.5z" fill="url(#a)" stroke-linecap="round" stroke-linejoin="round"/><path d="m4 1v7.0000002l1-1.0000002 1 1.0000002v-7.0000002z" fill="#ff522f"/><path d="m10 13.5 2.5-2.5h-3c1 1 .5 2.5.5 2.5z" fill="#515151"/></g></svg>

After

Width:  |  Height:  |  Size: 816 B

View File

@@ -0,0 +1 @@
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientTransform="matrix(.921875 0 0 .9225 1.249998 1.239999)" gradientUnits="userSpaceOnUse" x1="16" x2="16" y1="0" y2="32"><stop offset=".15427744" stop-color="#de4747"/><stop offset="1" stop-color="#b52222"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="15.99999832" x2="15.99999832" y1=".126983" y2="31.87301564"><stop offset="0" stop-color="#f09797"/><stop offset=".73127526" stop-color="#d24141"/></linearGradient><filter id="c" color-interpolation-filters="sRGB" height="1.09976" width="1" x="0" y="0"><feFlood flood-opacity=".14902" in="SourceGraphic" result="flood"/><feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="0"/><feOffset dx="0" dy="1.5" in="blur" result="offset"/><feComposite in="flood" in2="offset" operator="in" result="comp1"/><feComposite in="SourceGraphic" in2="comp1" operator="over" result="comp2"/></filter><g><circle cx="16" cy="16" fill="#590e0e" fill-opacity=".500866" r="16"/><circle cx="16" cy="16" fill="url(#b)" r="15.625" stroke="#7d4d4d" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25"/><ellipse cx="16" cy="16" fill="url(#a)" rx="14.75" ry="14.76"/><g fill="#fff"><path d="m8 10 2-2 6 6 6-6 2 2-6 6 6 6-2 2-6-6-6 6-2-2 6-6z" filter="url(#c)"/><path d="m29.494141 10.042735a14.75 14.76 0 0 0 -13.494141-8.8027352 14.75 14.76 0 0 0 -13.49414 8.8027352c2.987994-2.3171382 8.240661-4.0429692 13.49414-4.0429692s10.506146 1.725831 13.494141 4.0429692z" fill-opacity=".216645" stroke-linecap="round" stroke-linejoin="round"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><filter id="a" color-interpolation-filters="sRGB" height="1.09976" width="1" x="0" y="0"><feFlood flood-opacity=".14902" in="SourceGraphic" result="flood"/><feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="0"/><feOffset dx="0" dy="1.5" in="blur" result="offset"/><feComposite in="flood" in2="offset" operator="in" result="comp1"/><feComposite in="SourceGraphic" in2="comp1" operator="over" result="comp2"/></filter><linearGradient id="b" gradientTransform="matrix(.921875 0 0 .9225 1.249998 1.239999)" gradientUnits="userSpaceOnUse" x1="16" x2="16" y1="0" y2="32"><stop offset=".15427744" stop-color="#4b8be1" stop-opacity=".992157"/><stop offset="1" stop-color="#1078e8" stop-opacity=".992157"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="15.99999832" x2="15.99999832" y1=".126983" y2="31.87301564"><stop offset="0" stop-color="#a9cef8"/><stop offset=".73127526" stop-color="#4f99e7"/></linearGradient><g><circle cx="16" cy="16" fill="#0e3459" fill-opacity=".500866" r="16"/><circle cx="16" cy="16" fill="url(#c)" r="15.625" stroke="#4d677d" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25"/><ellipse cx="16" cy="16" fill="url(#b)" rx="14.75" ry="14.76"/><g fill="#fff" stroke-linecap="round" stroke-linejoin="round"><path d="m29.494141 10.042735a14.75 14.76 0 0 0 -13.494141-8.8027352 14.75 14.76 0 0 0 -13.49414 8.8027352c2.987994-2.3171382 8.240661-4.0429692 13.49414-4.0429692s10.506146 1.725831 13.494141 4.0429692z" fill-opacity=".216645"/><path d="m15.716477 10.189534q-.789603 0-1.344793-.5428521-.542852-.5551897-.542852-1.3447928 0-.7772656.542852-1.3201177.55519-.5551897 1.344793-.5551897.777266 0 1.320118.5551897.55519.5428521.55519 1.3201177 0 .7896031-.55519 1.3447928-.542852.5428521-1.320118.5428521zm1.529856 14.780383h-2.282446v-11.091456h-1.233755v-1.86297h3.516201z" filter="url(#a)" stroke-width=".25"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#cc8e17" stop-opacity=".546079"/><stop offset=".34962881" stop-color="#cc7e17" stop-opacity=".202344"/><stop offset=".65078419" stop-color="#cc7e17" stop-opacity=".204745"/><stop offset="1" stop-color="#cc8e17" stop-opacity=".545098"/></linearGradient><linearGradient id="b"><stop offset="0" stop-color="#fff" stop-opacity=".748929"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="c"><stop offset="0" stop-color="#ecaa39"/><stop offset="1" stop-color="#ecaa39" stop-opacity="0"/></linearGradient><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="3" x2="13" xlink:href="#a" y1="12.5" y2="12.5"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="11.5" x2="11.5" xlink:href="#b" y1="7" y2="14"/><linearGradient id="f" gradientUnits="userSpaceOnUse" x1="4.5" x2="4.5" xlink:href="#b" y1="7" y2="14"/><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="3.5" x2="3.5" xlink:href="#c" y1="14" y2="7"/><linearGradient id="h" gradientUnits="userSpaceOnUse" x1="12.5" x2="12.5" xlink:href="#c" y1="14" y2="7"/><radialGradient id="i" cx="8" cy="6.928571" gradientTransform="matrix(0 1.2000001 -.9999999 -.00000004 14.928571 -1.600001)" gradientUnits="userSpaceOnUse" r="5"><stop offset="0" stop-color="#ffeabe"/><stop offset="1" stop-color="#dab270"/></radialGradient><linearGradient id="j" gradientUnits="userSpaceOnUse" x1="3" x2="13" xlink:href="#a" y1="10.5" y2="10.5"/><linearGradient id="k" gradientUnits="userSpaceOnUse" x1="3" x2="13" xlink:href="#a" y1="8.5" y2="8.5"/><linearGradient id="l" gradientUnits="userSpaceOnUse" x1="8" x2="8" y1="7" y2="7.5"><stop offset="0" stop-color="#eee" stop-opacity=".75476"/><stop offset="1" stop-color="#eee" stop-opacity="0"/></linearGradient><rect fill="#876c3d" height="9" ry="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-width=".250001" width="12" x="2" y="6"/><rect fill="url(#i)" height="7" ry=".5" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25" width="10" x="3" y="7"/><path d="m8 1c-4 0-4 4-4 4v1h1v-1s0-3 3-3 3 3 3 3v1h1v-1s0-4-4-4zm0 2c-2 0-2 2-2 2v1h1v-1s0-1 1-1 1 1 1 1v1h1v-1s0-2-2-2z" fill="#515151"/><path d="m5 6v-1s0-3 3-3 3 3 3 3v1h-1v-1s0-2-2-2-2 2-2 2v1z" fill="#eee"/><path d="m3 12h10v1h-10z" fill="url(#d)"/><rect fill="url(#e)" height="7" rx=".5" ry="1" width="1" x="11" y="7"/><rect fill="url(#f)" height="7" rx=".5" ry="1" width="1" x="4" y="7"/><rect fill="url(#g)" height="7" rx=".5" width="1" x="3" y="7"/><rect fill="url(#h)" height="7" rx=".5" width="1" x="12" y="7"/><path d="m3 10h10v1h-10z" fill="url(#j)"/><path d="m3 8h10v1h-10z" fill="url(#k)"/><rect fill="url(#l)" height="1" rx=".5" width="10" x="3" y="7"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1 @@
<svg height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset="0" stop-color="#9a2424"/><stop offset="1" stop-color="#c82e2e"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="8" x2="8" y1="2" y2="14"><stop offset="0" stop-color="#f0dd85"/><stop offset=".5" stop-color="#f7ecaf"/><stop offset="1" stop-color="#f0dd85"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="8" x2="8" xlink:href="#a" y1="5.5" y2="9.5"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="8" x2="8" xlink:href="#a" y1="5.5" y2="4"/><linearGradient id="e" gradientUnits="userSpaceOnUse" x1="8" x2="8" xlink:href="#a" y1="10.5" y2="6.145898"/><g><path d="m3 2h10c.554 0 1 .446 1 1v7.5l-3.5 3.5h-7.5c-.554 0-1-.446-1-1v-10c0-.554.446-1 1-1z" fill="#6e632e" stroke-linecap="round" stroke-linejoin="round"/><path d="m3.5 3h9c.277 0 .5.223.5.5v6.5l-3 3h-6.5c-.277 0-.5-.223-.5-.5v-9c0-.277.223-.5.5-.5z" fill="url(#b)" stroke-linecap="round" stroke-linejoin="round"/><path d="m10 12.5 2.5-2.5h-3c1 1 .5 2.5.5 2.5z" fill="#6e632e"/><ellipse cx="6.5" cy="9.50974" fill-opacity=".355175" rx="2.5" ry="1.49026"/><g transform="matrix(.70710678 .70710678 -.70710678 .70710678 11.449748 -6.763456)"><path d="m7.5 10.5v2.5l.5 1 .5-1v-2.5z" fill="#cfcfcf"/><path d="m7 10.5h.5v2.5l.5 1 .5-1v-2.5h.5v2.5l-1 2-1-2z" fill="#727272"/><ellipse cx="8" cy="9.5" fill="url(#e)" rx="3" ry="1.5"/><path d="m6 9 .5-4h3l.5 4c0 .5-1 1-2 1s-2-.5-2-1z" fill="url(#c)"/><ellipse cx="8" cy="5" fill="url(#d)" rx="2" ry="1"/><g fill="#d23d3d"><path d="m6 5s.5.5 2 .5 2-.5 2-.5c0 1-2 1-2 1s-2 0-2-1z"/><path d="m5 9.5s.5 1 3 1 3-1 3-1c0 1.5-3 1.5-3 1.5s-3 0-3-1.5z"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><filter id="a" color-interpolation-filters="sRGB" height="1.09976" width="1" x="0" y="0"><feFlood flood-opacity=".14902" in="SourceGraphic" result="flood"/><feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="0"/><feOffset dx="0" dy="1.5" in="blur" result="offset"/><feComposite in="flood" in2="offset" operator="in" result="comp1"/><feComposite in="SourceGraphic" in2="comp1" operator="over" result="comp2"/></filter><linearGradient id="b" gradientTransform="matrix(1.01415 0 0 .99686492 -.226398 -.52981)" gradientUnits="userSpaceOnUse" x1="16" x2="15.999999" y1="1.302475" y2="30.000002"><stop offset="0" stop-color="#f4f4ed"/><stop offset=".72657222" stop-color="#ddcf76"/></linearGradient><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="16" x2="16" y1="2.369791" y2="29.630209"><stop offset=".25" stop-color="#edd86d"/><stop offset="1" stop-color="#d5901a"/></linearGradient><g><path d="m14.960961 2.718822-14.675898 25.36219a1.2153386 1.2001658 0 0 0 1.055209 1.795616h29.319455a1.2153386 1.2001658 0 0 0 1.05521-1.795616l-14.6759-25.36219a1.1967125 1.1817724 0 0 0 -2.078076 0z" fill="#443c09" fill-opacity=".500632"/><path d="m14.99368 3.1949619-14.21375986 24.4501341a1.1770681 1.1570063 0 0 0 1.02198136 1.731044h28.3961965a1.1770681 1.1570063 0 0 0 1.021982-1.731044l-14.213761-24.4501341a1.1590285 1.1392744 0 0 0 -2.012639 0z" fill="url(#b)" stroke="#6b5b07" stroke-linecap="round" stroke-linejoin="round" stroke-width=".25"/><path d="m15.11254 4.0052032-12.6044459 22.9058128c-.3954388.72251.1042399 1.621487.901271 1.621487l25.1812709-.0031c.79703-.000097 1.296709-.898977.90127-1.621487l-12.604448-22.9027128c-.39239-.7169421-1.382527-.7169421-1.774918 0z" fill="url(#c)"/><g stroke-linecap="round" stroke-linejoin="round"><path d="m11.150645 10.205078c1.849355-1.205078 3.775283-1.205078 4.848141-1.205078 1.072971 0 3.001214 0 4.851104 1.205078l-3.962432-6.1998748c-.39239-.716943-1.383-.716943-1.775391 0z" fill="#fff" fill-opacity=".397479"/><path d="m16.00535 25.361656q-.620708 0-1.059484-.428074-.428074-.438776-.428074-1.059484 0-.610006.428074-1.03808.438776-.438776 1.059484-.438776.610005 0 1.03808.438776.438776.428074.438776 1.03808 0 .620708-.438776 1.059484-.428075.428074-1.03808.428074zm.791937-4.494779h-1.551769l-.310354-10.541328h2.129669z" fill="#443c09" filter="url(#a)" stroke-width=".220327" transform="matrix(1.1346739 0 0 1.1346739 -2.154783 -2.497989)"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB