Compare commits
3 Commits
3699daa44a
...
b0fd2a4f0c
Author | SHA1 | Date | |
---|---|---|---|
b0fd2a4f0c
|
|||
a529c1db65
|
|||
acac6ed778
|
@ -5,7 +5,8 @@ from .auth import digest
|
||||
from .routes.users import is_logged_in, get_active_user
|
||||
from .constants import (
|
||||
PermissionLevel, permission_level_string,
|
||||
InfoboxKind, InfoboxIcons, InfoboxHTMLClass
|
||||
InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
|
||||
REACTION_EMOJI,
|
||||
)
|
||||
from .lib.babycode import babycode_to_html, EMOJI
|
||||
from datetime import datetime
|
||||
@ -106,6 +107,7 @@ def create_app():
|
||||
"PermissionLevel": PermissionLevel,
|
||||
"__commit": commit,
|
||||
"__emoji": EMOJI,
|
||||
"REACTION_EMOJI": REACTION_EMOJI,
|
||||
}
|
||||
|
||||
@app.context_processor
|
||||
|
@ -15,6 +15,38 @@ PermissionLevelString = {
|
||||
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):
|
||||
return PermissionLevelString[PermissionLevel(int(perm))]
|
||||
|
||||
|
25
app/db.py
25
app/db.py
@ -89,6 +89,9 @@ class DB:
|
||||
self.table = table
|
||||
self._where = [] # list of tuples
|
||||
self._select = "*"
|
||||
self._group_by = ""
|
||||
self._order_by = ""
|
||||
self._order_asc = True
|
||||
|
||||
|
||||
def _build_where(self):
|
||||
@ -104,6 +107,17 @@ class DB:
|
||||
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 = "*"):
|
||||
self._select = columns
|
||||
return self
|
||||
@ -122,7 +136,16 @@ class DB:
|
||||
def build_select(self):
|
||||
sql = f"SELECT {self._select} FROM {self.table}"
|
||||
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):
|
||||
|
@ -2,6 +2,37 @@ from .babycode_parser import Parser
|
||||
from markupsafe import escape
|
||||
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):
|
||||
is_inline = children.find('\n') == -1
|
||||
if is_inline:
|
||||
@ -16,16 +47,39 @@ def tag_list(children):
|
||||
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])
|
||||
|
||||
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 = {
|
||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||
"i": lambda children, attr: f"<em>{children}</em>",
|
||||
"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>",
|
||||
"url": lambda children, attr: f"<a href={attr}>{children}</a>",
|
||||
"quote": lambda children, attr: f"<blockquote>{children}</blockquote>",
|
||||
"code": tag_code,
|
||||
"ul": lambda children, attr: f"<ul>{tag_list(children)}</ul>",
|
||||
"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):
|
||||
|
@ -256,3 +256,28 @@ class APIRateLimits(Model):
|
||||
return True
|
||||
else:
|
||||
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)
|
||||
|
@ -1,7 +1,8 @@
|
||||
from flask import Blueprint, request, url_for
|
||||
from ..lib.babycode import babycode_to_html
|
||||
from ..constants import REACTION_EMOJI
|
||||
from .users import is_logged_in, get_active_user
|
||||
from ..models import APIRateLimits, Threads
|
||||
from ..models import APIRateLimits, Threads, Reactions
|
||||
from ..db import db
|
||||
|
||||
bp = Blueprint("api", __name__, url_prefix="/api/")
|
||||
@ -41,3 +42,57 @@ def babycode_preview():
|
||||
return {'error': 'markup field missing or invalid type'}, 400
|
||||
rendered = babycode_to_html(markup)
|
||||
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'}
|
||||
|
@ -3,7 +3,7 @@ from flask import (
|
||||
)
|
||||
from .users import login_required, mod_only, get_active_user, is_logged_in
|
||||
from ..db import db
|
||||
from ..models import Threads, Topics, Posts, Subscriptions
|
||||
from ..models import Threads, Topics, Posts, Subscriptions, Reactions
|
||||
from ..constants import InfoboxKind
|
||||
from .posts import create_post
|
||||
from slugify import slugify
|
||||
@ -61,6 +61,7 @@ def thread(slug):
|
||||
topic = topic,
|
||||
topics = other_topics,
|
||||
is_subscribed = is_subscribed,
|
||||
Reactions = Reactions,
|
||||
)
|
||||
|
||||
|
||||
|
@ -510,7 +510,9 @@ def inbox(username):
|
||||
'thread_id': row['thread_id'],
|
||||
'user_id': row['user_id'],
|
||||
'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)
|
||||
|
@ -76,6 +76,13 @@ SCHEMA = [
|
||||
"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
|
||||
"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)",
|
||||
@ -87,6 +94,9 @@ SCHEMA = [
|
||||
"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 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():
|
||||
|
@ -14,23 +14,68 @@
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<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>
|
||||
[b]Hello World[/b]<br>
|
||||
Will become<br>
|
||||
<strong>Hello World</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<ul class='babycode-guide-list'>
|
||||
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
|
||||
[i]Hello World[/i]<br>
|
||||
Will become<br>
|
||||
<em>Hello World</em>
|
||||
</li>
|
||||
</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>
|
||||
[s]Hello World[/s]<br>
|
||||
Will become<br>
|
||||
<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>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
@ -60,6 +105,15 @@
|
||||
{{ '[code]paragraph 1 \nstill paragraph 1[/code]' | babycode | safe }}
|
||||
That will produce:<br>
|
||||
{{ '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 class="babycode-guide-section">
|
||||
<h2 id="links">Links</h2>
|
||||
|
@ -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-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-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-code" title="Insert Code block"><code></></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>
|
||||
@ -89,13 +90,13 @@
|
||||
</form>
|
||||
{% 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" %}
|
||||
{% if editing %}
|
||||
{% set postclass = postclass + " editing" %}
|
||||
{% endif %}
|
||||
{% 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-inner">
|
||||
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
|
||||
@ -128,9 +129,11 @@
|
||||
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% elif editing %}
|
||||
{% 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>
|
||||
{% if render_sig and post['signature_rendered'] %}
|
||||
<div class="signature-container">
|
||||
<hr>
|
||||
{{ post['signature_rendered'] | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -168,6 +170,37 @@
|
||||
{{ babycode_editor_form(cancel_url = post_permalink, prefill = post['original_markup'], ta_name = "new_content") }}
|
||||
{% endif %}
|
||||
</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>
|
||||
{% endmacro %}
|
||||
|
@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
{% 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 %}
|
||||
</main>
|
||||
|
||||
@ -72,6 +72,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</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) }}'>
|
||||
<div id="new-post-notification" class="new-concept-notification hidden">
|
||||
<div class="new-notification-content">
|
||||
|
@ -43,7 +43,7 @@
|
||||
{% if section == "header" %}
|
||||
{% set latest_post_id = thread.posts[-1].id %}
|
||||
{% 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) }}">
|
||||
<input type="hidden" name="subscribe" value="read">
|
||||
<input type="submit" value="Mark thread as Read">
|
||||
|
@ -80,8 +80,8 @@
|
||||
{% endif %}
|
||||
</i></a>
|
||||
</div>
|
||||
<div class="post-content wider user-page-post-preview">
|
||||
<div class="post-inner">{{ post.content | safe }}</div>
|
||||
<div class="post-content user-page-post-preview">
|
||||
<div class="post-inner wider">{{ post.content | safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
@ -42,6 +42,7 @@
|
||||
const buttonBold = document.getElementById("post-editor-bold");
|
||||
const buttonItalics = document.getElementById("post-editor-italics");
|
||||
const buttonStrike = document.getElementById("post-editor-strike");
|
||||
const buttonUnderline = document.getElementById("post-editor-underline");
|
||||
const buttonUrl = document.getElementById("post-editor-url");
|
||||
const buttonCode = document.getElementById("post-editor-code");
|
||||
const buttonImg = document.getElementById("post-editor-img");
|
||||
@ -105,6 +106,10 @@
|
||||
e.preventDefault();
|
||||
insertTag("s")
|
||||
})
|
||||
buttonUnderline.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("u")
|
||||
})
|
||||
buttonUrl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("url=", false, "link label");
|
||||
|
@ -62,26 +62,26 @@
|
||||
setTimeout(() => {
|
||||
const valid = isQuoteSelectionValid();
|
||||
if (isSelecting || !valid) {
|
||||
removePopover();
|
||||
removeQuotePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = document.getSelection();
|
||||
const selectionStr = selection.toString().trim();
|
||||
if (selection.isCollapsed || selectionStr === "") {
|
||||
removePopover();
|
||||
removeQuotePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
showPopover();
|
||||
showQuotePopover();
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function removePopover() {
|
||||
function removeQuotePopover() {
|
||||
quotePopover?.hidePopover();
|
||||
}
|
||||
|
||||
function createPopover() {
|
||||
function createQuotePopover() {
|
||||
quotePopover = document.createElement("div");
|
||||
quotePopover.popover = "auto";
|
||||
quotePopover.className = "quote-popover";
|
||||
@ -95,9 +95,9 @@
|
||||
return quoteButton;
|
||||
}
|
||||
|
||||
function showPopover() {
|
||||
function showQuotePopover() {
|
||||
if (!quotePopover) {
|
||||
const quoteButton = createPopover();
|
||||
const quoteButton = createQuotePopover();
|
||||
quoteButton.addEventListener("click", () => {
|
||||
console.log("Quoting:", document.getSelection().toString());
|
||||
const postPermalink = quotedPostContainer.dataset.postPermalink;
|
||||
@ -111,7 +111,7 @@
|
||||
ta.focus();
|
||||
|
||||
document.getSelection().empty();
|
||||
removePopover();
|
||||
removeQuotePopover();
|
||||
})
|
||||
}
|
||||
|
||||
@ -196,4 +196,174 @@
|
||||
.catch(error => console.log(error))
|
||||
}
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@
|
||||
font-weight: bold;
|
||||
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;
|
||||
color: black;
|
||||
font-size: 0.9em;
|
||||
@ -119,16 +119,18 @@ body {
|
||||
.post-content-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 70px 2.5fr;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
gap: 0px 0px;
|
||||
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;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
grid-area: post-info;
|
||||
display: flex;
|
||||
min-height: 70px;
|
||||
justify-content: space-between;
|
||||
padding: 5px 20px;
|
||||
align-items: center;
|
||||
@ -138,19 +140,36 @@ body {
|
||||
|
||||
.post-content {
|
||||
grid-area: post-content;
|
||||
padding: 20px;
|
||||
margin-right: 25%;
|
||||
padding: 20px 20px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: #c1ceb1;
|
||||
}
|
||||
|
||||
.post-content.wider {
|
||||
margin-right: 12.5%;
|
||||
.post-reactions {
|
||||
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 {
|
||||
height: 100%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
.post-inner.wider {
|
||||
padding-right: 12.5%;
|
||||
}
|
||||
|
||||
.signature-container {
|
||||
border-top: 2px dotted gray;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
pre code {
|
||||
@ -773,7 +792,8 @@ ul, ol {
|
||||
|
||||
.babycode-button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.babycode-button {
|
||||
@ -797,3 +817,42 @@ ul, ol {
|
||||
footer {
|
||||
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;
|
||||
}
|
||||
|
@ -166,18 +166,22 @@ body {
|
||||
.post-content-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 70px 2.5fr;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"post-info"
|
||||
"post-content";
|
||||
"post-content"
|
||||
"post-reactions";
|
||||
grid-area: post-content-container;
|
||||
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.post-info {
|
||||
grid-area: post-info;
|
||||
display: flex;
|
||||
min-height: 70px;
|
||||
justify-content: space-between;
|
||||
padding: 5px 20px;
|
||||
align-items: center;
|
||||
@ -187,19 +191,39 @@ body {
|
||||
|
||||
.post-content {
|
||||
grid-area: post-content;
|
||||
padding: 20px;
|
||||
margin-right: 25%;
|
||||
padding: 20px 20px 0 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: $accent_color;
|
||||
|
||||
// min-height: 0;
|
||||
}
|
||||
|
||||
.post-content.wider {
|
||||
margin-right: 12.5%;
|
||||
.post-reactions {
|
||||
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 {
|
||||
height: 100%;
|
||||
padding-right: 25%;
|
||||
|
||||
&.wider {
|
||||
padding-right: 12.5%;
|
||||
}
|
||||
}
|
||||
|
||||
.signature-container {
|
||||
border-top: 2px dotted gray;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
pre code {
|
||||
@ -763,7 +787,8 @@ ul, ol {
|
||||
|
||||
.babycode-button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.babycode-button {
|
||||
@ -788,3 +813,29 @@ ul, ol {
|
||||
footer {
|
||||
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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user