add reactions support to thread

This commit is contained in:
2025-08-04 21:10:23 +03:00
parent acac6ed778
commit a529c1db65
14 changed files with 485 additions and 34 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

@ -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

@ -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

@ -89,13 +89,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 +128,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 +162,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 +169,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

@ -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 {
@ -797,3 +816,38 @@ 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;
}

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 {
@ -788,3 +812,25 @@ 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;
}