Compare commits

...

3 Commits

18 changed files with 617 additions and 40 deletions

View File

@ -5,7 +5,8 @@ from .auth import digest
from .routes.users import is_logged_in, get_active_user from .routes.users import is_logged_in, get_active_user
from .constants import ( from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
REACTION_EMOJI,
) )
from .lib.babycode import babycode_to_html, EMOJI from .lib.babycode import babycode_to_html, EMOJI
from datetime import datetime from datetime import datetime
@ -106,6 +107,7 @@ def create_app():
"PermissionLevel": PermissionLevel, "PermissionLevel": PermissionLevel,
"__commit": commit, "__commit": commit,
"__emoji": EMOJI, "__emoji": EMOJI,
"REACTION_EMOJI": REACTION_EMOJI,
} }
@app.context_processor @app.context_processor

View File

@ -15,6 +15,38 @@ PermissionLevelString = {
PermissionLevel.ADMIN: 'Administrator', PermissionLevel.ADMIN: 'Administrator',
} }
REACTION_EMOJI = [
'smile',
'grin',
'neutral',
'wink',
'frown',
'angry',
'think',
'sob',
'surprised',
'smiletear',
'tongue',
'pensive',
'weary',
'imp',
'impangry',
'lobster',
'scissors',
]
def permission_level_string(perm): def permission_level_string(perm):
return PermissionLevelString[PermissionLevel(int(perm))] return PermissionLevelString[PermissionLevel(int(perm))]

View File

@ -89,6 +89,9 @@ class DB:
self.table = table self.table = table
self._where = [] # list of tuples self._where = [] # list of tuples
self._select = "*" self._select = "*"
self._group_by = ""
self._order_by = ""
self._order_asc = True
def _build_where(self): def _build_where(self):
@ -104,6 +107,17 @@ class DB:
return " WHERE " + " AND ".join(conditions), params return " WHERE " + " AND ".join(conditions), params
def group_by(self, stmt):
self._group_by = stmt
return self
def order_by(self, stmt, asc = True):
self._order_by = stmt
self._order_asc = asc
return self
def select(self, columns = "*"): def select(self, columns = "*"):
self._select = columns self._select = columns
return self return self
@ -122,7 +136,16 @@ class DB:
def build_select(self): def build_select(self):
sql = f"SELECT {self._select} FROM {self.table}" sql = f"SELECT {self._select} FROM {self.table}"
where_clause, params = self._build_where() where_clause, params = self._build_where()
return sql + where_clause, params
stmt = sql + where_clause
if self._group_by:
stmt += " GROUP BY " + self._group_by
if self._order_by:
stmt += " ORDER BY " + self._order_by + (" ASC" if self._order_asc else " DESC")
return stmt, params
def build_update(self, data): def build_update(self, data):

View File

@ -2,6 +2,37 @@ from .babycode_parser import Parser
from markupsafe import escape from markupsafe import escape
import re import re
NAMED_COLORS = [
'black', 'silver', 'gray', 'white', 'maroon', 'red',
'purple', 'fuchsia', 'green', 'lime', 'olive', 'yellow',
'navy', 'blue', 'teal', 'aqua', 'aliceblue', 'antiquewhite',
'aqua', 'aquamarine', 'azure', 'beige', 'bisque', 'black',
'blanchedalmond', 'blue', 'blueviolet', 'brown', 'burlywood', 'cadetblue',
'chartreuse', 'chocolate', 'coral', 'cornflowerblue', 'cornsilk', 'crimson',
'cyan', 'aqua', 'darkblue', 'darkcyan', 'darkgoldenrod', 'darkgray',
'darkgreen', 'darkgrey', 'darkkhaki', 'darkmagenta', 'darkolivegreen', 'darkorange',
'darkorchid', 'darkred', 'darksalmon', 'darkseagreen', 'darkslateblue', 'darkslategray',
'darkslategrey', 'darkturquoise', 'darkviolet', 'deeppink', 'deepskyblue', 'dimgray',
'dimgrey', 'dodgerblue', 'firebrick', 'floralwhite', 'forestgreen', 'fuchsia',
'gainsboro', 'ghostwhite', 'gold', 'goldenrod', 'gray', 'green',
'greenyellow', 'grey', 'gray', 'honeydew', 'hotpink', 'indianred',
'indigo', 'ivory', 'khaki', 'lavender', 'lavenderblush', 'lawngreen',
'lemonchiffon', 'lightblue', 'lightcoral', 'lightcyan', 'lightgoldenrodyellow', 'lightgray',
'lightgreen', 'lightgrey', 'lightpink', 'lightsalmon', 'lightseagreen', 'lightskyblue',
'lightslategray', 'lightslategrey', 'lightsteelblue', 'lightyellow', 'lime', 'limegreen',
'linen', 'magenta', 'fuchsia', 'maroon', 'mediumaquamarine', 'mediumblue',
'mediumorchid', 'mediumpurple', 'mediumseagreen', 'mediumslateblue', 'mediumspringgreen', 'mediumturquoise',
'mediumvioletred', 'midnightblue', 'mintcream', 'mistyrose', 'moccasin', 'navajowhite',
'navy', 'oldlace', 'olive', 'olivedrab', 'orange', 'orangered',
'orchid', 'palegoldenrod', 'palegreen', 'paleturquoise', 'palevioletred', 'papayawhip',
'peachpuff', 'peru', 'pink', 'plum', 'powderblue', 'purple',
'rebeccapurple', 'red', 'rosybrown', 'royalblue', 'saddlebrown', 'salmon',
'sandybrown', 'seagreen', 'seashell', 'sienna', 'silver', 'skyblue',
'slateblue', 'slategray', 'slategrey', 'snow', 'springgreen', 'steelblue',
'tan', 'teal', 'thistle', 'tomato', 'transparent', 'turquoise',
'violet', 'wheat', 'white', 'whitesmoke', 'yellow', 'yellowgreen',
]
def tag_code(children, attr): def tag_code(children, attr):
is_inline = children.find('\n') == -1 is_inline = children.find('\n') == -1
if is_inline: if is_inline:
@ -16,16 +47,39 @@ def tag_list(children):
list_body = re.sub(r"\n\n+", "\1", list_body) list_body = re.sub(r"\n\n+", "\1", list_body)
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x]) return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr):
hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
potential_color = attr.lower().strip()
if potential_color in NAMED_COLORS:
return f"<span style='color: {potential_color};'>{children}</span>"
m = re.match(hex_re, potential_color)
if m:
return f"<span style='color: #{m.group(1)};'>{children}</span>"
# return just the way it was if we can't parse it
return f"[color={attr}]{children}[/color]"
TAGS = { TAGS = {
"b": lambda children, attr: f"<strong>{children}</strong>", "b": lambda children, attr: f"<strong>{children}</strong>",
"i": lambda children, attr: f"<em>{children}</em>", "i": lambda children, attr: f"<em>{children}</em>",
"s": lambda children, attr: f"<del>{children}</del>", "s": lambda children, attr: f"<del>{children}</del>",
"u": lambda children, attr: f"<u>{children}</u>",
"img": lambda children, attr: f"<div class=\"post-img-container\"><img class=\"block-img\" src=\"{attr}\" alt=\"{children}\"></div>", "img": lambda children, attr: f"<div class=\"post-img-container\"><img class=\"block-img\" src=\"{attr}\" alt=\"{children}\"></div>",
"url": lambda children, attr: f"<a href={attr}>{children}</a>", "url": lambda children, attr: f"<a href={attr}>{children}</a>",
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>", "quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
"code": tag_code, "code": tag_code,
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>", "ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
"ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>", "ol": lambda children, attr: f"<ol>{tag_list(children)}</ol>",
"big": lambda children, attr: f"<span style='font-size: 2rem;'>{children}</span>",
"small": lambda children, attr: f"<span style='font-size: 0.75rem;'>{children}</span>",
"color": tag_color,
"center": lambda children, attr: f"<div style='text-align: center;'>{children}</div>",
"right": lambda children, attr: f"<div style='text-align: right;'>{children}</div>",
} }
def make_emoji(name, code): def make_emoji(name, code):

View File

@ -256,3 +256,28 @@ class APIRateLimits(Model):
return True return True
else: else:
return False return False
class Reactions(Model):
table = "reactions"
@classmethod
def for_post(cls, post_id):
qb = db.QueryBuilder(cls.table)\
.select("reaction_text, COUNT(*) as c")\
.where({"post_id": post_id})\
.group_by("reaction_text")\
.order_by("c", False)
result = qb.all()
return result if result else []
@classmethod
def get_users(cls, post_id, reaction_text):
q = """
SELECT user_id, username FROM reactions
JOIN
users ON users.id = user_id
WHERE
post_id = ? AND reaction_text = ?
"""
return db.query(q, post_id, reaction_text)

View File

@ -1,7 +1,8 @@
from flask import Blueprint, request, url_for from flask import Blueprint, request, url_for
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html
from ..constants import REACTION_EMOJI
from .users import is_logged_in, get_active_user from .users import is_logged_in, get_active_user
from ..models import APIRateLimits, Threads from ..models import APIRateLimits, Threads, Reactions
from ..db import db from ..db import db
bp = Blueprint("api", __name__, url_prefix="/api/") bp = Blueprint("api", __name__, url_prefix="/api/")
@ -41,3 +42,57 @@ def babycode_preview():
return {'error': 'markup field missing or invalid type'}, 400 return {'error': 'markup field missing or invalid type'}, 400
rendered = babycode_to_html(markup) rendered = babycode_to_html(markup)
return {'html': rendered} return {'html': rendered}
@bp.post('/add-reaction/<post_id>')
def add_reaction(post_id):
if not is_logged_in():
return {'error': 'not authorized', 'error_code': 401}, 401
user = get_active_user()
reaction_text = request.json.get('emoji')
if not reaction_text or not isinstance(reaction_text, str):
return {'error': 'emoji field missing or invalid type', 'error_code': 400}, 400
if reaction_text not in REACTION_EMOJI:
return {'error': 'unsupported reaction', 'error_code': 400}, 400
reaction = Reactions.find({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
if reaction:
return {'error': 'reaction already exists', 'error_code': 409}, 409
reaction = Reactions.create({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
return {'status': 'added'}
@bp.post('/remove-reaction/<post_id>')
def remove_reaction(post_id):
if not is_logged_in():
return {'error': 'not authorized'}, 401
user = get_active_user()
reaction_text = request.json.get('emoji')
if not reaction_text or not isinstance(reaction_text, str):
return {'error': 'emoji field missing or invalid type'}, 400
if reaction_text not in REACTION_EMOJI:
return {'error': 'unsupported reaction'}, 400
reaction = Reactions.find({
'user_id': user.id,
'post_id': int(post_id),
'reaction_text': reaction_text,
})
if not reaction:
return {'error': 'reaction does not exist'}, 404
reaction.delete()
return {'status': 'removed'}

View File

@ -3,7 +3,7 @@ from flask import (
) )
from .users import login_required, mod_only, get_active_user, is_logged_in from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db from ..db import db
from ..models import Threads, Topics, Posts, Subscriptions from ..models import Threads, Topics, Posts, Subscriptions, Reactions
from ..constants import InfoboxKind from ..constants import InfoboxKind
from .posts import create_post from .posts import create_post
from slugify import slugify from slugify import slugify
@ -61,6 +61,7 @@ def thread(slug):
topic = topic, topic = topic,
topics = other_topics, topics = other_topics,
is_subscribed = is_subscribed, is_subscribed = is_subscribed,
Reactions = Reactions,
) )

View File

@ -510,7 +510,9 @@ def inbox(username):
'thread_id': row['thread_id'], 'thread_id': row['thread_id'],
'user_id': row['user_id'], 'user_id': row['user_id'],
'original_markup': row['original_markup'], 'original_markup': row['original_markup'],
'signature_rendered': row['signature_rendered'] 'signature_rendered': row['signature_rendered'],
'thread_slug': row['thread_slug'],
}) })
return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions) return render_template("users/inbox.html", new_posts = new_posts, total_unreads_count = total_unreads_count, all_subscriptions = all_subscriptions)

View File

@ -76,6 +76,13 @@ SCHEMA = [
"signature_rendered" TEXT NOT NULL DEFAULT '' "signature_rendered" TEXT NOT NULL DEFAULT ''
)""", )""",
"""CREATE TABLE IF NOT EXISTS "reactions" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
"reaction_text" TEXT NOT NULL DEFAULT ''
)""",
# INDEXES # INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", "CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
@ -87,6 +94,9 @@ SCHEMA = [
"CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)", "CREATE INDEX IF NOT EXISTS idx_topics_slug ON topics(slug)",
"CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)", "CREATE INDEX IF NOT EXISTS session_keys ON sessions(key)",
"CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)", "CREATE INDEX IF NOT EXISTS sessions_user_id ON sessions(user_id)",
"CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)",
"CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)",
] ]
def create(): def create():

View File

@ -14,23 +14,68 @@
</section> </section>
<section class="babycode-guide-section"> <section class="babycode-guide-section">
<h2 id="text-formatting-tags">Text formatting tags</h2> <h2 id="text-formatting-tags">Text formatting tags</h2>
<ul> <ul class='babycode-guide-list'>
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br> <li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
[b]Hello World[/b]<br> [b]Hello World[/b]<br>
Will become<br> Will become<br>
<strong>Hello World</strong> <strong>Hello World</strong>
</li>
</ul> </ul>
<ul> <ul class='babycode-guide-list'>
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br> <li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
[i]Hello World[/i]<br> [i]Hello World[/i]<br>
Will become<br> Will become<br>
<em>Hello World</em> <em>Hello World</em>
</li>
</ul> </ul>
<ul> <ul class='babycode-guide-list'>
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br> <li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
[s]Hello World[/s]<br> [s]Hello World[/s]<br>
Will become<br> Will become<br>
<del>Hello World</del> <del>Hello World</del>
</li>
</ul>
<ul class='babycode-guide-list'>
<li>To <u>underline</u> some text, enclose it in <code class="inline-code">[u][/u]</code>:<br>
[u]Hello World[/u]<br>
Will become<br>
<u>Hello World</u>
</li>
</ul>
<ul class='babycode-guide-list'>
<li>To make some text {{ "[big]big[/big]" | babycode | safe }}, enclose it in <code class="inline-code">[big][/big]</code>:<br>
[big]Hello World[/big]<br>
Will become<br>
{{ "[big]Hello World[/big]" | babycode | safe }}
<li>Similarly, you can make text {{ "[small]small[/small]" | babycode | safe }} with <code class="inline-code">[small][/small]</code>:<br>
[small]Hello World[/small]<br>
Will become<br>
{{ "[small]Hello World[/small]" | babycode | safe }}
</li>
</ul>
<ul class='babycode-guide-list'>
<li>You can change the text color by using <code class="inline-code">[color][/color]</code>:<br>
[color=red]Red text[/color]<br>
[color=white]White text[/color]<br>
[color=#3b08f0]Blueish text[/color]<br>
Will become<br>
{{ "[color=red]Red text[/color]" | babycode | safe }}<br>
{{ "[color=white]White text[/color]" | babycode | safe }}<br>
{{ "[color=#3b08f0]Blueish text[/color]" | babycode | safe }}<br>
</li>
</ul>
<ul class='babycode-guide-list'>
<li>You can center text by enclosing it in <code class="inline-code">[center][/center]</code>:<br>
[center]Hello World[/center]<br>
Will become<br>
{{ "[center]Hello World[/center]" | babycode | safe }}
</li>
<li>You can right-align text by enclosing it in <code class="inline-code">[right][/right]</code>:<br>
[right]Hello World[/right]<br>
Will become<br>
{{ "[right]Hello World[/right]" | babycode | safe }}
</li>
Note: the center and right tags will break the paragraph. See <a href="#paragraph-rules">Paragraph rules</a> for more details.
</ul> </ul>
</section> </section>
<section class="babycode-guide-section"> <section class="babycode-guide-section">
@ -60,6 +105,15 @@
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }} {{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
That will produce:<br> That will produce:<br>
{{ 'paragraph 1 \nstill paragraph 1' | babycode | safe }} {{ 'paragraph 1 \nstill paragraph 1' | babycode | safe }}
<p>Additionally, the following tags will break into a new paragraph:</p>
<ul>
<li><code class="inline-code">[code]</code> (code block, not inline);</li>
<li><code class="inline-code">[img]</code>;</li>
<li><code class="inline-code">[center]</code>;</li>
<li><code class="inline-code">[right]</code>;</li>
<li><code class="inline-code">[ul]</code> and <code class="inline-code">[ol]</code>;</li>
<li><code class="inline-code">[quote]</code>.</li>
</ul>
</section> </section>
<section class="babycode-guide-section"> <section class="babycode-guide-section">
<h2 id="links">Links</h2> <h2 id="links">Links</h2>

View File

@ -53,6 +53,7 @@
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button> <button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button> <button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button> <button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
<button class="babycode-button" type=button id="post-editor-underline" title="Insert Underline"><u>U</u></button>
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button> <button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button> <button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button>
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button> <button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button>
@ -89,13 +90,13 @@
</form> </form>
{% endmacro %} {% endmacro %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false) %} {% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false, Reactions = none) %}
{% set postclass = "post" %} {% set postclass = "post" %}
{% if editing %} {% if editing %}
{% set postclass = postclass + " editing" %} {% set postclass = postclass + " editing" %}
{% endif %} {% endif %}
{% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %} {% set post_permalink = url_for("threads.thread", slug = post['thread_slug'], after = post['id'], _anchor = ("post-" + (post['id'] | string))) %}
<div class=" {{ postclass }}" id="post-{{ post['id'] }}"> <div class=" {{ postclass }}" id="post-{{ post['id'] }}" data-post-id="{{ post['id'] }}">
<div class="usercard"> <div class="usercard">
<div class="usercard-inner"> <div class="usercard-inner">
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;"> <a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
@ -128,9 +129,11 @@
{% set show_reply = true %} {% set show_reply = true %}
{% if active_user and post['thread_is_locked'] and not active_user.is_mod() %} {% if not active_user %}
{% set show_reply = false %} {% set show_reply = false %}
{% elif active_user and active_user.is_guest() %} {% elif post['thread_is_locked'] and not active_user.is_mod() %}
{% set show_reply = false %}
{% elif active_user.is_guest() %}
{% set show_reply = false %} {% set show_reply = false %}
{% elif editing %} {% elif editing %}
{% set show_reply = false %} {% set show_reply = false %}
@ -160,7 +163,6 @@
<div class="post-inner" data-post-permalink="{{ post_permalink }}" data-author-username="{{ post.username }}">{{ post['content'] | safe }}</div> <div class="post-inner" data-post-permalink="{{ post_permalink }}" data-author-username="{{ post.username }}">{{ post['content'] | safe }}</div>
{% if render_sig and post['signature_rendered'] %} {% if render_sig and post['signature_rendered'] %}
<div class="signature-container"> <div class="signature-container">
<hr>
{{ post['signature_rendered'] | safe }} {{ post['signature_rendered'] | safe }}
</div> </div>
{% endif %} {% endif %}
@ -168,6 +170,37 @@
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }} {{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
{% endif %} {% endif %}
</div> </div>
{% if Reactions -%}
{% set can_react = true -%}
{% if not active_user -%}
{% set can_react = false -%}
{% elif post['thread_is_locked'] and not active_user.is_mod() -%}
{% set can_react = false -%}
{% elif active_user.is_guest() -%}
{% set can_react = false -%}
{% elif editing -%}
{% set can_react = false -%}
{% endif -%}
{% set reactions = Reactions.for_post(post.id) -%}
<div class="post-reactions">
{% for reaction in reactions %}
{% set reactors = Reactions.get_users(post.id, reaction.reaction_text) | map(attribute='username') | list %}
{% set reactors_trimmed = reactors[:10] %}
{% set reactors_str = reactors_trimmed | join (',\n') %}
{% if reactors | count > 10 %}
{% set reactors_str = reactors_str + '\n...and many others' %}
{% endif %}
{% set has_reacted = active_user is not none and active_user.username in reactors %}
<span class="reaction-container" data-emoji="{{ reaction.reaction_text }}" data-post-id="{{ post.id }}"><button type="button" class="reduced reaction-button {{"active" if has_reacted else ""}}" {{ "disabled" if not can_react else ""}} title="{{reactors_str}}"><img class=emoji src="/static/emoji/{{reaction.reaction_text}}.png"> x<span class="reaction-count" data-emoji="{{ reaction.reaction_text }}">{{reaction.c}}</span></button>
</span>
{% endfor %}
{% if can_react %}
<button type="button" class="reduced add-reaction-button" data-post-id="{{ post.id }}">Add reaction</button>
{% endif %}
</div>
{% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@ -50,7 +50,7 @@
</div> </div>
</nav> </nav>
{% for post in posts %} {% for post in posts %}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length)) }} {{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length), Reactions = Reactions) }}
{% endfor %} {% endfor %}
</main> </main>
@ -72,6 +72,7 @@
</span> </span>
</div> </div>
</dialog> </dialog>
<input type='hidden' id='allowed-reaction-emoji' value='{{ REACTION_EMOJI | join(' ') }}'>
<input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'> <input type='hidden' id='thread-subscribe-endpoint' value='{{ url_for('api.thread_updates', thread_id=thread.id) }}'>
<div id="new-post-notification" class="new-concept-notification hidden"> <div id="new-post-notification" class="new-concept-notification hidden">
<div class="new-notification-content"> <div class="new-notification-content">

View File

@ -43,7 +43,7 @@
{% if section == "header" %} {% if section == "header" %}
{% set latest_post_id = thread.posts[-1].id %} {% set latest_post_id = thread.posts[-1].id %}
{% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %} {% set unread_posts_text = " (" + (thread.unread_count | string) + (" unread post" | pluralize(num=thread.unread_count)) %}
<a class="accordion-title" href="{{ url_for("threads.thread", slug=latest_post_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a> <a class="accordion-title" href="{{ url_for("threads.thread", slug=thread.thread_slug, after=latest_post_id, _anchor="post-" + (latest_post_id | string)) }}" title="Jump to latest post">{{thread.thread_title + unread_posts_text}}, latest at {{ timestamp(thread.newest_post_time) }})</a>
<form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}"> <form class="modform" method="post" action="{{ url_for("threads.subscribe", slug = thread.thread_slug) }}">
<input type="hidden" name="subscribe" value="read"> <input type="hidden" name="subscribe" value="read">
<input type="submit" value="Mark thread as Read"> <input type="submit" value="Mark thread as Read">

View File

@ -80,8 +80,8 @@
{% endif %} {% endif %}
</i></a> </i></a>
</div> </div>
<div class="post-content wider user-page-post-preview"> <div class="post-content user-page-post-preview">
<div class="post-inner">{{ post.content | safe }}</div> <div class="post-inner wider">{{ post.content | safe }}</div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View File

@ -42,6 +42,7 @@
const buttonBold = document.getElementById("post-editor-bold"); const buttonBold = document.getElementById("post-editor-bold");
const buttonItalics = document.getElementById("post-editor-italics"); const buttonItalics = document.getElementById("post-editor-italics");
const buttonStrike = document.getElementById("post-editor-strike"); const buttonStrike = document.getElementById("post-editor-strike");
const buttonUnderline = document.getElementById("post-editor-underline");
const buttonUrl = document.getElementById("post-editor-url"); const buttonUrl = document.getElementById("post-editor-url");
const buttonCode = document.getElementById("post-editor-code"); const buttonCode = document.getElementById("post-editor-code");
const buttonImg = document.getElementById("post-editor-img"); const buttonImg = document.getElementById("post-editor-img");
@ -105,6 +106,10 @@
e.preventDefault(); e.preventDefault();
insertTag("s") insertTag("s")
}) })
buttonUnderline.addEventListener("click", (e) => {
e.preventDefault();
insertTag("u")
})
buttonUrl.addEventListener("click", (e) => { buttonUrl.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
insertTag("url=", false, "link label"); insertTag("url=", false, "link label");

View File

@ -62,26 +62,26 @@
setTimeout(() => { setTimeout(() => {
const valid = isQuoteSelectionValid(); const valid = isQuoteSelectionValid();
if (isSelecting || !valid) { if (isSelecting || !valid) {
removePopover(); removeQuotePopover();
return; return;
} }
const selection = document.getSelection(); const selection = document.getSelection();
const selectionStr = selection.toString().trim(); const selectionStr = selection.toString().trim();
if (selection.isCollapsed || selectionStr === "") { if (selection.isCollapsed || selectionStr === "") {
removePopover(); removeQuotePopover();
return; return;
} }
showPopover(); showQuotePopover();
}, 50) }, 50)
} }
function removePopover() { function removeQuotePopover() {
quotePopover?.hidePopover(); quotePopover?.hidePopover();
} }
function createPopover() { function createQuotePopover() {
quotePopover = document.createElement("div"); quotePopover = document.createElement("div");
quotePopover.popover = "auto"; quotePopover.popover = "auto";
quotePopover.className = "quote-popover"; quotePopover.className = "quote-popover";
@ -95,9 +95,9 @@
return quoteButton; return quoteButton;
} }
function showPopover() { function showQuotePopover() {
if (!quotePopover) { if (!quotePopover) {
const quoteButton = createPopover(); const quoteButton = createQuotePopover();
quoteButton.addEventListener("click", () => { quoteButton.addEventListener("click", () => {
console.log("Quoting:", document.getSelection().toString()); console.log("Quoting:", document.getSelection().toString());
const postPermalink = quotedPostContainer.dataset.postPermalink; const postPermalink = quotedPostContainer.dataset.postPermalink;
@ -111,7 +111,7 @@
ta.focus(); ta.focus();
document.getSelection().empty(); document.getSelection().empty();
removePopover(); removeQuotePopover();
}) })
} }
@ -196,4 +196,174 @@
.catch(error => console.log(error)) .catch(error => console.log(error))
} }
tryFetchUpdate(); tryFetchUpdate();
if (supportsPopover()){
const reactionEmoji = document.getElementById("allowed-reaction-emoji").value.split(" ");
let reactionPopover = null;
let reactionTargetPostId = null;
function tryAddReaction(emoji, postId = reactionTargetPostId) {
const body = JSON.stringify({
"emoji": emoji,
});
fetch(`/api/add-reaction/${postId}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
.then(res => res.json())
.then(json => {
if (json.status === "added") {
const post = document.getElementById(`post-${postId}`);
const spans = Array.from(post.querySelectorAll(".reaction-count")).filter((span) => {
return span.dataset.emoji === emoji
});
if (spans.length > 0) {
const currentValue = spans[0].textContent;
spans[0].textContent = `${parseInt(currentValue) + 1}`;
const button = spans[0].closest(".reaction-button");
button.classList.add("active");
} else {
const span = document.createElement("span");
span.classList = "reaction-container";
span.dataset.emoji = emoji;
const button = document.createElement("button");
button.type = "button";
button.className = "reduced reaction-button active";
button.addEventListener("click", () => {
tryAddReaction(emoji, postId);
})
const img = document.createElement("img");
img.src = `/static/emoji/${emoji}.png`;
button.textContent = " x";
const reactionCountSpan = document.createElement("span")
reactionCountSpan.className = "reaction-count"
reactionCountSpan.textContent = "1"
button.insertAdjacentElement("afterbegin", img);
button.appendChild(reactionCountSpan);
span.appendChild(button);
const post = document.getElementById(`post-${postId}`);
post.querySelector(".post-reactions").insertBefore(span, post.querySelector(".add-reaction-button"));
}
} else if (json.error_code === 409) {
console.log("reaction exists, gonna try and remove");
tryRemoveReaction(emoji, postId);
} else {
console.warn(json)
}
})
.catch(error => console.error(error));
}
function tryRemoveReaction(emoji, postId = reactionTargetPostId) {
const body = JSON.stringify({
"emoji": emoji,
});
fetch(`/api/remove-reaction/${postId}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
.then(res => res.json())
.then(json => {
if (json.status === "removed") {
const post = document.getElementById(`post-${postId}`);
const spans = Array.from(post.querySelectorAll(".reaction-container")).filter((span) => {
return span.dataset.emoji === emoji
});
if (spans.length > 0) {
const reactionCountSpan = spans[0].querySelector(".reaction-count");
const currentValue = parseInt(reactionCountSpan.textContent);
if (currentValue - 1 === 0) {
spans[0].remove();
} else {
reactionCountSpan.textContent = `${parseInt(currentValue) - 1}`;
const button = reactionCountSpan.closest(".reaction-button");
button.classList.remove("active");
}
}
} else {
console.warn(json)
}
})
.catch(error => console.error(error));
}
function createReactionPopover() {
reactionPopover = document.createElement("div");
reactionPopover.className = "reaction-popover";
reactionPopover.popover = "auto";
const inner = document.createElement("div");
inner.className = "reaction-popover-inner";
reactionPopover.appendChild(inner);
for (let emoji of reactionEmoji) {
const img = document.createElement("img");
img.src = `/static/emoji/${emoji}.png`;
const button = document.createElement("button");
button.type = "button";
button.className = "reduced";
button.appendChild(img);
button.addEventListener("click", () => {
tryAddReaction(emoji);
})
button.dataset.emojiName = emoji;
inner.appendChild(button);
}
reactionPopover.addEventListener("beforetoggle", (e) => {
if (e.newState === "closed") {
reactionTargetPostId = null;
}
})
document.body.appendChild(reactionPopover);
}
function showReactionPopover() {
if (!reactionPopover) {
createReactionPopover();
}
if (!reactionPopover.matches(':popover-open')) {
reactionPopover.showPopover();
}
}
for (let button of document.querySelectorAll(".add-reaction-button")) {
button.addEventListener("click", (e) => {
showReactionPopover();
reactionTargetPostId = e.target.dataset.postId;
const rect = e.target.getBoundingClientRect();
const popoverRect = reactionPopover.getBoundingClientRect();
const scrollY = window.scrollY || window.pageYOffset;
reactionPopover.style.setProperty("top", `${rect.top + scrollY + rect.height}px`)
reactionPopover.style.setProperty("left", `${rect.left + rect.width/2 - popoverRect.width/2}px`)
})
}
for (let button of document.querySelectorAll(".reaction-button")) {
button.addEventListener("click", () => {
const reactionContainer = button.closest(".reaction-container")
const emoji = reactionContainer.dataset.emoji;
const postId = reactionContainer.dataset.postId;
console.log(reactionContainer);
tryAddReaction(emoji, postId);
})
}
} else {
for (let button of document.querySelectorAll(".add-reaction-button")) {
button.disabled = true;
button.title = "Enable JS to add reactions."
}
}
} }

View File

@ -26,7 +26,7 @@
font-weight: bold; font-weight: bold;
font-style: italic; font-style: italic;
} }
.tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton { .reaction-button.active, .tab-button, .currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
cursor: default; cursor: default;
color: black; color: black;
font-size: 0.9em; font-size: 0.9em;
@ -119,16 +119,18 @@ body {
.post-content-container { .post-content-container {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 70px 2.5fr; grid-template-rows: min-content 1fr min-content;
gap: 0px 0px; gap: 0px 0px;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-areas: "post-info" "post-content"; grid-template-areas: "post-info" "post-content" "post-reactions";
grid-area: post-content-container; grid-area: post-content-container;
min-height: 100%;
} }
.post-info { .post-info {
grid-area: post-info; grid-area: post-info;
display: flex; display: flex;
min-height: 70px;
justify-content: space-between; justify-content: space-between;
padding: 5px 20px; padding: 5px 20px;
align-items: center; align-items: center;
@ -138,19 +140,36 @@ body {
.post-content { .post-content {
grid-area: post-content; grid-area: post-content;
padding: 20px; padding: 20px 20px 0 20px;
margin-right: 25%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background-color: #c1ceb1;
} }
.post-content.wider { .post-reactions {
margin-right: 12.5%; grid-area: post-reactions;
min-height: 50px;
display: flex;
padding: 5px 20px;
align-items: center;
flex-wrap: wrap;
gap: 5px;
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
border-top: 2px dotted gray;
} }
.post-inner { .post-inner {
height: 100%; height: 100%;
padding-right: 25%;
}
.post-inner.wider {
padding-right: 12.5%;
}
.signature-container {
border-top: 2px dotted gray;
padding: 10px 0;
} }
pre code { pre code {
@ -773,7 +792,8 @@ ul, ol {
.babycode-button-container { .babycode-button-container {
display: flex; display: flex;
gap: 10px; gap: 5px;
flex-wrap: wrap;
} }
.babycode-button { .babycode-button {
@ -797,3 +817,42 @@ ul, ol {
footer { footer {
border-top: 1px solid black; border-top: 1px solid black;
} }
.reaction-button.active {
background-color: #beb1ce;
}
.reaction-button.active:hover {
background-color: rgb(203, 192.6, 215.8);
}
.reaction-button.active:active {
background-color: rgb(171.7642913386, 166.6881496063, 178.0118503937);
}
.reaction-button.active:disabled {
background-color: rgb(210.445, 209.535, 211.565);
}
.reaction-button.active.reduced {
margin: 0;
padding: 5px;
}
.reaction-popover {
position: relative;
margin: 0;
border: none;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.6901960784);
padding: 5px 10px;
width: 250px;
}
.reaction-popover-inner {
display: flex;
flex-wrap: wrap;
overflow: scroll;
margin: auto;
justify-content: center;
}
.babycode-guide-list {
border-bottom: 1px dashed;
}

View File

@ -166,18 +166,22 @@ body {
.post-content-container { .post-content-container {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: 70px 2.5fr; grid-template-rows: min-content 1fr min-content;
gap: 0px 0px; gap: 0px 0px;
grid-auto-flow: row; grid-auto-flow: row;
grid-template-areas: grid-template-areas:
"post-info" "post-info"
"post-content"; "post-content"
"post-reactions";
grid-area: post-content-container; grid-area: post-content-container;
min-height: 100%;
} }
.post-info { .post-info {
grid-area: post-info; grid-area: post-info;
display: flex; display: flex;
min-height: 70px;
justify-content: space-between; justify-content: space-between;
padding: 5px 20px; padding: 5px 20px;
align-items: center; align-items: center;
@ -187,19 +191,39 @@ body {
.post-content { .post-content {
grid-area: post-content; grid-area: post-content;
padding: 20px; padding: 20px 20px 0 20px;
margin-right: 25%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
background-color: $accent_color;
// min-height: 0;
} }
.post-content.wider { .post-reactions {
margin-right: 12.5%; grid-area: post-reactions;
min-height: 50px;
display: flex;
padding: 5px 20px;
align-items: center;
flex-wrap: wrap;
gap: 5px;
background-color: $main_bg;
border-top: 2px dotted gray;
} }
.post-inner { .post-inner {
height: 100%; height: 100%;
padding-right: 25%;
&.wider {
padding-right: 12.5%;
}
}
.signature-container {
border-top: 2px dotted gray;
padding: 10px 0;
} }
pre code { pre code {
@ -763,7 +787,8 @@ ul, ol {
.babycode-button-container { .babycode-button-container {
display: flex; display: flex;
gap: 10px; gap: 5px;
flex-wrap: wrap;
} }
.babycode-button { .babycode-button {
@ -788,3 +813,29 @@ ul, ol {
footer { footer {
border-top: 1px solid black; border-top: 1px solid black;
} }
.reaction-button.active {
@include button($button_color2);
}
.reaction-popover {
position: relative;
margin: 0;
border: none;
border-radius: 4px;
background-color: #000000b0;
padding: 5px 10px;
width: 250px;
}
.reaction-popover-inner {
display: flex;
flex-wrap: wrap;
overflow: scroll;
margin: auto;
justify-content: center;
}
.babycode-guide-list {
border-bottom: 1px dashed;
}