Compare commits

...

7 Commits

17 changed files with 525 additions and 139 deletions

View File

@@ -162,6 +162,13 @@ def clear_api_limits():
for l in limits: for l in limits:
l.delete() l.delete()
def ensure_default_collection():
from .db import db
from .models import BookmarkCollections
with db.transaction():
for missing_user in BookmarkCollections.get_users_without_default():
BookmarkCollections.create_default(missing_user)
cache = Cache() cache = Cache()
def create_app(): def create_app():
@@ -243,6 +250,8 @@ def create_app():
reparse_babycode() reparse_babycode()
ensure_default_collection()
bind_default_badges(app.config['BADGES_PATH']) bind_default_badges(app.config['BADGES_PATH'])
app.config['SESSION_COOKIE_SECURE'] = True app.config['SESSION_COOKIE_SECURE'] = True

View File

@@ -6,7 +6,7 @@ from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound as PygmentsClassNotFound from pygments.util import ClassNotFound as PygmentsClassNotFound
import re import re
BABYCODE_VERSION = 10 BABYCODE_VERSION = 11
class BabycodeError(Exception): class BabycodeError(Exception):
@@ -183,7 +183,7 @@ class HTMLRenderer(BabycodeRenderer):
if mention_data not in self.mentions: if mention_data not in self.mentions:
self.mentions.append(mention_data) self.mentions.append(mention_data)
return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.user_page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>" return f"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.user_page', username=target_user.username)}' title='@{target_user.username}' data-r='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
def render(self, ast): def render(self, ast):
out = super().render(ast) out = super().render(ast)

View File

@@ -506,10 +506,24 @@ class BookmarkCollections(Model):
@classmethod @classmethod
def create_default(cls, user_id): def create_default(cls, user_id):
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order) return cls.create({
VALUES (?, "Bookmarks", 1, 0) RETURNING id 'user_id': user_id,
""" 'name': 'Bookmarks',
res = db.fetch_one(q, user_id) 'is_default': True,
'sort_order': 0,
})
@staticmethod
def get_users_without_default():
q = """
SELECT users.id FROM users
WHERE NOT EXISTS (
SELECT 1 FROM bookmark_collections bc
WHERE bc.user_id = users.id
AND bc.is_default = 1
)
"""
return [row['id'] for row in db.query(q)]
@classmethod @classmethod
def get_for_user(cls, user_id): def get_for_user(cls, user_id):

View File

@@ -1,10 +1,21 @@
from flask import Blueprint, request from flask import Blueprint, request
from ..auth import is_logged_in, hard_login_required, get_active_user from ..auth import is_logged_in, hard_login_required, get_active_user
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html
from ..models import APIRateLimits from ..models import APIRateLimits, Posts, Threads, Reactions
from ..constants import REACTION_EMOJI
bp = Blueprint('api', __name__, url_prefix='/api/') bp = Blueprint('api', __name__, url_prefix='/api/')
@bp.before_request
def ensure_json():
if request.method == 'POST':
if not request.is_json:
return {'error': 'unsupported media type'}, 415
elif not request.content_length:
return {'error': 'body expected'}, 400
elif not isinstance(request.json, dict):
return {'error': 'body must be an object'}, 400
@bp.post('/babycode-preview/') @bp.post('/babycode-preview/')
@hard_login_required @hard_login_required
def babycode_preview(): def babycode_preview():
@@ -19,3 +30,60 @@ def babycode_preview():
return {'error': 'banned_tags field is invalid type'}, 400 return {'error': 'banned_tags field is invalid type'}, 400
rendered = babycode_to_html(markup, banned_tags).result rendered = babycode_to_html(markup, banned_tags).result
return {'html': rendered} return {'html': rendered}
@bp.get('/whoami/')
def whoami():
user = get_active_user()
if not user:
return {}
return {
'id': user.id,
'username': user.username,
'display_name': user.display_name,
}
@bp.post('/toggle-reaction/')
@hard_login_required
def toggle_reaction():
user = get_active_user()
emoji = request.json.get('reaction')
if emoji not in REACTION_EMOJI:
return {'error': f'invalid reaction string, given: {emoji}'}, 400
post_id = request.json.get('post', -1)
post = Posts.find({'id': post_id})
if not post:
return {'error': 'post not found'}, 404
thread = Threads.find({'id': post.thread_id})
if not user.can_post_to_thread_or_topic(thread):
return {'error': 'thread is locked'}, 403
reaction_obj = {
'user_id': int(user.id),
'post_id': int(post_id),
'reaction_text': emoji,
}
r = Reactions.find(reaction_obj)
if r:
# remove
r.delete()
return {'status': 'ok', 'added': False}
else:
# add
r = Reactions.create(reaction_obj)
return {'status': 'ok', 'added': True}
@bp.get('/thread-permission/<int:thread_id>')
def thread_permission(thread_id):
user = get_active_user()
if not user:
return {'can_post': False}
thread = Threads.find({'id': thread_id})
if not thread:
return {'can_post': False}
return {'can_post': user.can_post_to_thread_or_topic(thread)}

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, request, url_for from flask import Blueprint, render_template, request, url_for
from ..auth import get_active_user, is_logged_in, hard_login_required from ..auth import get_active_user, is_logged_in, hard_login_required
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads, Threads, Posts, Badges, BadgeUploads, Reactions
from functools import wraps from functools import wraps
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/') bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
@@ -133,3 +133,7 @@ def badge_editor():
badges = Badges.get_for_user(user.id) badges = Badges.get_for_user(user.id)
badge_uploads = BadgeUploads.get_for_user(user.id) badge_uploads = BadgeUploads.get_for_user(user.id)
return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads) return render_template('hyper/badge_editor.html', badges=badges, badge_uploads=badge_uploads)
@bp.get('/reactions/<int:post_id>')
def get_reaction_buttons(post_id):
return render_template('hyper/reaction_buttons.html', Reactions=Reactions, post_id=post_id)

View File

@@ -35,6 +35,13 @@ def ownership_or_mod_required(view_func):
return view_func(*args, **kwargs) return view_func(*args, **kwargs)
return wrapper return wrapper
@bp.get('/<int:post_id>/')
def post_by_id(post_id):
post = get_post_url(post_id, _anchor=True)
if not post:
abort(404)
return redirect(post)
@bp.get('/<int:post_id>/edit/') @bp.get('/<int:post_id>/edit/')
@login_required @login_required
@ownership_required @ownership_required

View File

@@ -658,7 +658,6 @@ def save_badges(username):
('id', 'NOT IN', ids), ('id', 'NOT IN', ids),
('user_id', '=', user.id), ('user_id', '=', user.id),
]) ])
print(list(map(lambda x: x.id, deleted_badges)))
with db.transaction(): with db.transaction():
for b in deleted_badges: for b in deleted_badges:

View File

@@ -140,6 +140,19 @@
<button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}&hellip;</button> <button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="{{kind}}" data-concept-id="{{id}}">{{icn_bookmark(24)}}{{text}}&hellip;</button>
{%- endmacro %} {%- endmacro %}
{% macro reaction_buttons(post_id) -%}
{%- for reaction in Reactions.for_post(post_id) -%}
{% 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 = get_active_user() is not none and get_active_user().username in reactors %}
<button autocomplete="off" type="button" title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}" data-emoji="{{reaction.reaction_text}}" data-s="toggleReaction" data-r="enableReactionButtons" disabled><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
{%- endmacro %}
{% macro full_post( {% macro full_post(
post, render_sig=true, is_latest=false, post, render_sig=true, is_latest=false,
show_toolbar=true, is_editing=false, thread=none, show_toolbar=true, is_editing=false, thread=none,
@@ -172,7 +185,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="post-content"> <div class="post-content" data-r="collectImages">
<div class="plank even minimal secondary-bg no-shadow post-info"> <div class="plank even minimal secondary-bg no-shadow post-info">
<span> <span>
{%- if tb_pretext -%} {%- if tb_pretext -%}
@@ -218,19 +231,10 @@
</div> </div>
<div class="plank even secondary-bg minimal no-shadow"> <div class="plank even secondary-bg minimal no-shadow">
{%- if show_reactions -%} {%- if show_reactions -%}
<span class="button-row"> <span class="button-row" data-r="replaceReactionButtons">
{%- for reaction in Reactions.for_post(post.id) -%} {{- reaction_buttons(post.id) -}}
{% 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 = get_active_user() is not none and get_active_user().username in reactors %}
<button data-r="enhance" type="button" disabled title="{{reactors_str}}" class="minimal {{'alt' if has_reacted else ''}}"><img src="/static/emoji/{{reaction.reaction_text}}.png">{{reaction.c}}</button>
{%- endfor -%}
</span> </span>
{%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">Add reaction</button>{%- endif -%} {%- if is_logged_in() and allow_reacting -%}<button autocomplete='off' data-r="disableReactionMenuButton enableReactionMenuButton" disabled title="This feature requires JavaScript to be enabled." data-s="openReactionMenu">Add reaction</button>{%- endif -%}
{%- elif is_editing -%} {%- elif is_editing -%}
<input type="submit" value="Save"> <input type="submit" value="Save">
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a> <a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>

View File

@@ -0,0 +1,2 @@
{%- from 'common/macros.html' import reaction_buttons with context -%}
{{- reaction_buttons(post_id) -}}

View File

@@ -5,6 +5,7 @@
{%- block title -%}{{thread.title}}{%- endblock -%} {%- block title -%}{{thread.title}}{%- endblock -%}
{%- block content -%} {%- block content -%}
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8> <bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
<bitty-8 data-connect="/static/js/bits/thread.js"></bitty-8>
{%- set td -%} {%- set td -%}
<ul class="horizontal"> <ul class="horizontal">
<li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li> <li>Started by <a href="{{url_for('users.user_page', username=started_by.username)}}">{{started_by.get_readable_name()}}</a> in topic <a href="{{url_for('topics.topic_by_id', topic_id=topic.id)}}">{{topic.name}}</a></li>
@@ -64,7 +65,7 @@
{%- endcall -%} {%- endcall -%}
<main> <main>
{%- for post in posts -%} {%- for post in posts -%}
<article id="post-{{post.id}}" class="post plank"> <article id="post-{{post.id}}" class="post plank" data-postid="{{post.id}}">
{{full_post(post)}} {{full_post(post)}}
</article> </article>
{%- endfor -%} {%- endfor -%}
@@ -84,6 +85,25 @@
</span> </span>
</div> </div>
{{ bookmark_menu() }} {{ bookmark_menu() }}
<dialog closedby="any" class="plank thread-lighbox" data-r="showLightbox closeLightbox">
<div class="menu">
<button data-s="closeLightbox">Close</button>
<a href="" target="_blank" rel="noreferrer noopener" class="linkbutton alt">Open original</a>
</div>
<img class="lightbox-image" src="https://placehold.co/900x710">
<div class="menu">
<button data-s="lightboxPrevious">Previous</button>
<span data-r="lightboxSetCounter">0/0</span>
<button data-s="lightboxNext">Next</button>
</div>
</dialog>
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<div class="plank even" id="reaction-popover" popover data-r="openReactionMenu closeReactionMenu">
{%- for emoji in REACTION_EMOJI -%}
<button class="minimal emoji-button" title=":{{emoji}}:" data-emoji="{{emoji}}" data-s="toggleReaction"><img src="/static/emoji/{{emoji}}.png"></button>
{%- endfor -%}
</div>
{%- endif -%}
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%} {%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
<form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft"> <form action="{{url_for('threads.reply', thread_id=thread.id)}}" method="POST" class="plank post-edit-form" data-listen="submit" data-r="clearThreadDraft" data-s="clearThreadDraft">
<h2 class="info">Reply to "{{thread.title}}"</h2> <h2 class="info">Reply to "{{thread.title}}"</h2>

View File

@@ -208,7 +208,6 @@ input[type="text"], input[type="password"], input[type="url"], textarea, select
background-color: var(--main-color); background-color: var(--main-color);
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: solid var(--border-thickness) var(--border-color); border: solid var(--border-thickness) var(--border-color);
resize: vertical;
padding: var(--small-padding) var(--medium-padding); padding: var(--small-padding) var(--medium-padding);
margin: var(--base-padding) 0px; margin: var(--base-padding) 0px;
@@ -219,7 +218,8 @@ input[type="text"], input[type="password"], input[type="url"], textarea, select
} }
textarea { textarea {
font-family: 'Atkinson Hyperlegible Mono' font-family: 'Atkinson Hyperlegible Mono';
resize: vertical;
} }
h1 { h1 {
@@ -696,6 +696,39 @@ details.inner {
justify-content: center; justify-content: center;
} }
#reaction-popover {
position: absolute;
margin-block: var(--small-padding);
margin-inline: 0;
width: 300px;
--button-size: calc(var(--huge-padding) * 2);
.emoji-button {
min-width: var(--button-size);
min-height: var(--button-size);
img {
image-rendering: crisp-edges;
width: 30px;
}
}
}
#reaction-popover:popover-open {
--gap: var(--base-padding);
--max-columns: 4;
display: grid;
gap: var(--gap);
justify-items: center;
--grid-item-size: calc((100% - var(--gap) * var(--max-columns)) / var(--max-columns));
grid-template-columns: repeat(
auto-fit,
minmax(max(var(--button-size), var(--grid-item-size)), 1fr)
);
}
#bookmark-popover { #bookmark-popover {
position: absolute; position: absolute;
min-width: 400px; min-width: 400px;
@@ -743,6 +776,125 @@ table {
} }
} }
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row):not(.full) > * {
margin-right: auto;
}
}
.badge-editor-badge-container {
display: flex;
align-items: baseline;
gap: var(--base-padding);
& > input[type=text], & > input[type=url] {
width: 100%;
}
}
.badge-editor-file-picker {
display: flex;
gap: var(--base-padding);
flex-direction: column;
align-items: center;
min-width: 150px;
& > input[type=file] {
width: 100%;
&::file-selector-button {
display: none;
}
}
&.hidden {
display: none;
}
}
.badge-editor-badge-select {
display: flex;
gap: var(--base-padding);
flex-direction: column;
align-items: center;
min-width: 200px;
& > select {
width: 100%;
}
}
.js-only {
display: none;
}
dialog.plank.thread-lighbox {
margin: auto;
min-width: 80vw;
min-height: 70vh;
max-width: 80vw;
max-height: 70vh;
flex-direction: column;
justify-content: space-between;
align-items: center;
& > .menu {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
&:open {
display: flex;
}
}
.lightbox-image {
max-width: 50vw;
max-height: 50vh;
}
/* babycode tags */ /* babycode tags */
.inline-code { .inline-code {
background-color: var(--code-bg-color); background-color: var(--code-bg-color);
@@ -924,97 +1076,6 @@ a.mention {
} }
} }
ol.sortable-list {
display: flex;
gap: var(--base-padding);
flex-direction: column;
list-style: none;
flex-grow: 1;
margin: 0;
padding-left: 0;
li {
display: flex;
gap: var(--big-padding);
}
li.immovable .dragger {
cursor: not-allowed;
}
}
.plank.dragger {
display: flex;
align-items: center;
/*background-color: var(--bg-color-tertiary);*/
padding: var(--base-padding);
cursor: move;
}
.sortable-item-inner {
display: flex;
gap: var(--base-padding);
flex-grow: 1;
flex-direction: column;
& > * {
flex-grow: 1;
}
&.row {
flex-direction: row;
}
&:not(.row):not(.full) > * {
margin-right: auto;
}
}
.badge-editor-badge-container {
display: flex;
align-items: baseline;
gap: var(--base-padding);
& > input[type=text], & > input[type=url] {
width: 100%;
}
}
.badge-editor-file-picker {
display: flex;
gap: var(--base-padding);
flex-direction: column;
align-items: center;
min-width: 150px;
& > input[type=file] {
width: 100%;
&::file-selector-button {
display: none;
}
}
&.hidden {
display: none;
}
}
.badge-editor-badge-select {
display: flex;
gap: var(--base-padding);
flex-direction: column;
align-items: center;
min-width: 200px;
& > select {
width: 100%;
}
}
.js-only {
display: none;
}
@media (max-width: 768px) { @media (max-width: 768px) {
body { body {
margin-left: 0; margin-left: 0;
@@ -1085,4 +1146,17 @@ ol.sortable-list {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
dialog.plank.thread-lighbox {
margin: 0;
min-width: 100vw;
min-height: 100vh;
max-width: unset;
max-height: unset;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
}
} }

View File

@@ -91,8 +91,10 @@ export function badgeEditorToggleFilePicker(ev, sender, el) {
export function badgeEditorAddBadge(ev, sender, el) { export function badgeEditorAddBadge(ev, sender, el) {
// TODO: page templates do not get updated on mutation // TODO: page templates do not get updated on mutation
const badge = document.getElementById('badge-template').innerText; const badgeTemplate = document.getElementById('badge-template').innerText;
el.innerHTML += badge; const parser = new DOMParser();
const e = parser.parseFromString(badgeTemplate, 'text/html').body.firstElementChild;
el.appendChild(e);
b.trigger('badgeEditorAssignImgId'); b.trigger('badgeEditorAssignImgId');
badgesCount++; badgesCount++;
b.trigger('setBadgeCount'); b.trigger('setBadgeCount');

View File

@@ -14,28 +14,28 @@ async function getHTML(endpoint, options = {}) {
return { body: await res.text(), status: res.status }; return { body: await res.text(), status: res.status };
} }
export const b = { export const b = {};
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
bookmarkMenuState: {}, const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/';
} let bookmarkMenuState = {};
export async function showBookmarkMenu(ev, sender, el) { export async function showBookmarkMenu(ev, sender, el) {
if (b.bookmarkMenuState.state === undefined) { if (bookmarkMenuState.state === undefined) {
el.addEventListener('toggle', e => { el.addEventListener('toggle', e => {
if (e.newState === 'closed') { if (e.newState === 'closed') {
b.bookmarkMenuState.state = 'closed'; bookmarkMenuState.state = 'closed';
} }
}); });
} }
// dismiss if open and last invoker is the same button that opened it // dismiss if open and last invoker is the same button that opened it
if (b.bookmarkMenuState.state === 'open' && b.bookmarkMenuState.invoker === sender) { if (bookmarkMenuState.state === 'open' && bookmarkMenuState.invoker === sender) {
el.hidePopover(); el.hidePopover();
return; return;
} }
b.bookmarkMenuState.invoker = sender; bookmarkMenuState.invoker = sender;
b.bookmarkMenuState.state = 'open'; bookmarkMenuState.state = 'open';
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu'); b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
el.showPopover(); el.showPopover();
const bRect = sender.getBoundingClientRect(); const bRect = sender.getBoundingClientRect();
@@ -52,13 +52,13 @@ export async function showBookmarkMenu(ev, sender, el) {
} }
el.style.top = `${bRect.bottom + scrollY}px`; el.style.top = `${bRect.bottom + scrollY}px`;
b.bookmarkMenuState.kind = sender.dataset.conceptKind; bookmarkMenuState.kind = sender.dataset.conceptKind;
b.bookmarkMenuState.id = sender.dataset.conceptId; bookmarkMenuState.id = sender.dataset.conceptId;
const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, { const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: { _query: {
concept_kind: b.bookmarkMenuState.kind, concept_kind: bookmarkMenuState.kind,
concept_id: b.bookmarkMenuState.id, concept_id: bookmarkMenuState.id,
} }
}); });
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu'); b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
@@ -85,10 +85,10 @@ export async function bookmarkMenuSubmit(ev, _, el) {
return; return;
} }
const newCollections = await getHTML(b.bookmarksCollectionEndpoint, { const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
_query: { _query: {
concept_kind: b.bookmarkMenuState.kind, concept_kind: bookmarkMenuState.kind,
concept_id: b.bookmarkMenuState.id, concept_id: bookmarkMenuState.id,
saved: true, saved: true,
} }
}); });

View File

@@ -1,7 +1,9 @@
export const b = {} export const b = {}
export function addCollection(ev, sender, el) { export function addCollection(ev, sender, el) {
el.innerHTML += b.templates.collectionItem; const parser = new DOMParser();
const e = parser.parseFromString(b.templates.collectionItem, 'text/html').body.firstElementChild;
el.appendChild(e);
} }
export function deleteCollection(ev, sender, el) { export function deleteCollection(ev, sender, el) {

View File

@@ -0,0 +1,179 @@
export const b = {
init: 'activatePostImages getUserData',
}
const POST_IMAGES_SELECTOR = 'img.post-image:not(aside img.post-image)'
const WHOAMI_ENDPOINT = '/api/whoami/'
const THREAD_PERM_ENDPOINT = '/api/thread-permission/'
const TOGGLE_REACTION_ENDPOINT = '/api/toggle-reaction/'
const REPLACE_REACTIONS_ENDPOINT = '/hyperapi/reactions/'
const getThreadId = () => {
const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
return -1;
}
return parseInt(scheme[2]);
}
let images = [];
let currentIndex = 0;
let currentUser = null;
let reactionMenuState = {};
export function activatePostImages(_, __, ___) {
const images = document.querySelectorAll(POST_IMAGES_SELECTOR);
images.forEach(image => {
image.style.cursor = 'pointer';
image.dataset.s = 'collectImages';
});
}
export function collectImages(_, sender, el) {
if (!el.contains(sender)) return;
images = Array.from(el.querySelectorAll(POST_IMAGES_SELECTOR));
currentIndex = images.indexOf(sender);
b.trigger('showLightbox');
}
export function showLightbox(_, __, el) {
const originalImg = images[currentIndex];
const lightboxImg = el.querySelector('img');
const anchor = el.querySelector('a');
anchor.href = originalImg.src;
lightboxImg.src = originalImg.src;
lightboxImg.alt = originalImg.alt;
if (!el.open) {
el.showModal();
}
b.trigger('lightboxSetCounter');
}
export function closeLightbox(_, __, el) {
el.close();
}
export function lightboxSetCounter(_, __, el) {
el.innerText = `${currentIndex + 1}/${images.length}`;
}
export function lightboxNext(_, __, ___) {
if (images.length == 1) return;
currentIndex++;
if (currentIndex >= images.length) {
currentIndex = 0;
}
b.trigger('showLightbox');
}
export function lightboxPrevious(_, __, ___) {
if (images.length == 1) return;
currentIndex--;
if (currentIndex < 0) {
currentIndex = images.length - 1;
}
b.trigger('showLightbox');
}
export async function getUserData(_, __, ___) {
currentUser = await b.getData(WHOAMI_ENDPOINT);
b.trigger('highlightMentions');
const d = (await b.getData(`${THREAD_PERM_ENDPOINT}${getThreadId()}`)).can_post;
if (d) {
b.trigger('enableReactionMenuButton');
b.trigger('enableReactionButtons');
} else {
b.trigger('disableReactionMenuButton');
}
}
export function highlightMentions(_, __, el) {
if (!el) return;
if (el.dataset.username === currentUser.username) {
el.classList.add('me');
}
}
export function openReactionMenu(ev, sender, el) {
if (!el) return;
if (reactionMenuState.state === undefined) {
el.addEventListener('toggle', e => {
if (e.newState === 'closed') {
reactionMenuState.state = 'closed';
}
});
}
if (reactionMenuState.state === 'open' && reactionMenuState.invoker === sender) {
el.hidePopover();
return;
}
// TODO: [el, sender].prop(key) searches for ancestors with attr [data-${key}] if current element does not have `dataset[key]` but dataset transforms key names whereas css does not
reactionMenuState.post = sender.prop('postid');
reactionMenuState.invoker = sender;
reactionMenuState.state = 'open';
el.showPopover();
const bRect = sender.getBoundingClientRect();
const scrollY = window.scrollY;
el.style.left = `${bRect.left}px`;
el.style.top = `${bRect.bottom + scrollY}px`;
}
export function closeReactionMenu(_, __, el) {
el.hidePopover();
}
export async function toggleReaction(_, sender, __) {
const emoji = sender.dataset.emoji;
const post = sender.prop('postid') ? sender.prop('postid') : reactionMenuState.post;
const res = await fetch(TOGGLE_REACTION_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ reaction: emoji, post: post }),
headers: {
'content-type': 'application/json',
},
});
if (res.status !== 200) {
return;
}
b.send({ postId: post }, 'replaceReactionButtons');
}
export async function replaceReactionButtons(payload, __, el) {
if (payload.postId !== el.prop('postid')) return;
const res = await fetch(`${REPLACE_REACTIONS_ENDPOINT}${payload.postId}`);
if (res.status !== 200) {
return;
}
const body = await res.text();
const p = new DOMParser();
const e = p.parseFromString(body, 'text/html').body;
el.replaceChildren(...e.children);
el.childNodes.forEach(b => {
if (!b instanceof HTMLButtonElement) return;
b.disabled = false;
})
}
export function disableReactionMenuButton(_, __, el) {
el.title = 'You do not have permission to add reactions to this post.';
}
export function enableReactionMenuButton(_, __, el) {
el.disabled = false;
el.title = '';
}
export function enableReactionButtons(_, __, el) {
el.disabled = false;
}

View File

@@ -1,8 +1,9 @@
export const b = { export const b = {
babycodePreviewEndpoint: '/api/babycode-preview/',
init: 'babycodeEditorCharCountInit localizeTimestamps', init: 'babycodeEditorCharCountInit localizeTimestamps',
} }
const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/';
const getThreadId = () => { const getThreadId = () => {
const scheme = window.location.pathname.split("/"); const scheme = window.location.pathname.split("/");
if (scheme[1] !== 'threads' || scheme[2] === 'new') { if (scheme[1] !== 'threads' || scheme[2] === 'new') {
@@ -114,6 +115,7 @@ export function babycodeEditorCharCount(evOrPayload, sender, el) {
export function clearThreadDraft(_, __, ___) { export function clearThreadDraft(_, __, ___) {
const threadId = getThreadId(); const threadId = getThreadId();
if (threadId === -1) return;
localStorage.removeItem(`thread-${threadId}`); localStorage.removeItem(`thread-${threadId}`);
} }
@@ -157,7 +159,7 @@ export async function babycodePreview(payload, _, el) {
}), }),
} }
const f = await fetch(b.babycodePreviewEndpoint, options); const f = await fetch(BABYCODE_PREVIEW_ENDPOINT, options);
try { try {
if (!f.ok) { if (!f.ok) {
console.error(f); console.error(f);

View File

@@ -106,7 +106,7 @@
ta.addEventListener('keydown', e => { ta.addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') { if (e.ctrlKey && e.key === 'Enter') {
if (ta.form.reportValidity()) { if (ta.form.reportValidity()) {
ta.form.submit(); ta.form.requestSubmit();
} }
} }
}) })