Compare commits
13 Commits
6fab93ebeb
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
13c5c5cf69
|
|||
|
bf3028e7d6
|
|||
|
50c61da8b6
|
|||
|
812f322141
|
|||
|
6e73186127
|
|||
|
8a7eb91a34
|
|||
|
b63b6a1682
|
|||
|
5dfe477607
|
|||
|
b6450a29fd
|
|||
|
7b16ac91ed
|
|||
|
84dbaa2cd8
|
|||
|
200bd37a28
|
|||
|
d01bbaca54
|
@@ -162,6 +162,13 @@ def clear_api_limits():
|
||||
for l in limits:
|
||||
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()
|
||||
|
||||
def create_app():
|
||||
@@ -243,6 +250,8 @@ def create_app():
|
||||
|
||||
reparse_babycode()
|
||||
|
||||
ensure_default_collection()
|
||||
|
||||
bind_default_badges(app.config['BADGES_PATH'])
|
||||
|
||||
app.config['SESSION_COOKIE_SECURE'] = True
|
||||
|
||||
@@ -6,7 +6,7 @@ from pygments.lexers import get_lexer_by_name
|
||||
from pygments.util import ClassNotFound as PygmentsClassNotFound
|
||||
import re
|
||||
|
||||
BABYCODE_VERSION = 10
|
||||
BABYCODE_VERSION = 13
|
||||
|
||||
|
||||
class BabycodeError(Exception):
|
||||
@@ -183,7 +183,7 @@ class HTMLRenderer(BabycodeRenderer):
|
||||
if mention_data not in self.mentions:
|
||||
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):
|
||||
out = super().render(ast)
|
||||
@@ -406,6 +406,13 @@ def tag_quote(children, attr):
|
||||
|
||||
return f'<fieldset class="plank minimal no-shadow secondary-bg"><legend>{quotee}</legend><blockquote>{children}</blockquote></fieldset>'
|
||||
|
||||
def tag_quote_rss(children, attr):
|
||||
if attr:
|
||||
quotee = f'Quoting: {attr.strip()}'
|
||||
return f'<figure><blockquote>{children}</blockquote><figcaption>{quotee}</figcaption></figure>'
|
||||
else:
|
||||
return f'<blockquote>{children}</blockquote>'
|
||||
|
||||
TAGS = {
|
||||
"b": lambda children, attr: f"<strong>{children}</strong>",
|
||||
"i": lambda children, attr: f"<em>{children}</em>",
|
||||
@@ -462,6 +469,7 @@ RSS_TAGS = {
|
||||
'url': tag_url_rss,
|
||||
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
|
||||
'code': tag_code_rss,
|
||||
'quote': tag_quote_rss,
|
||||
|
||||
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
|
||||
'small': lambda children, attr: f'<small>{children}</small>'
|
||||
|
||||
@@ -506,10 +506,24 @@ class BookmarkCollections(Model):
|
||||
|
||||
@classmethod
|
||||
def create_default(cls, user_id):
|
||||
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order)
|
||||
VALUES (?, "Bookmarks", 1, 0) RETURNING id
|
||||
"""
|
||||
res = db.fetch_one(q, user_id)
|
||||
return cls.create({
|
||||
'user_id': user_id,
|
||||
'name': 'Bookmarks',
|
||||
'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
|
||||
def get_for_user(cls, user_id):
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
from flask import Blueprint, request
|
||||
from ..auth import is_logged_in, hard_login_required, get_active_user
|
||||
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.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/')
|
||||
@hard_login_required
|
||||
def babycode_preview():
|
||||
@@ -19,3 +30,60 @@ def babycode_preview():
|
||||
return {'error': 'banned_tags field is invalid type'}, 400
|
||||
rendered = babycode_to_html(markup, banned_tags).result
|
||||
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)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from flask import Blueprint, render_template, request, url_for
|
||||
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
|
||||
|
||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||
@@ -133,3 +133,7 @@ def badge_editor():
|
||||
badges = Badges.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)
|
||||
|
||||
@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)
|
||||
|
||||
@@ -35,6 +35,13 @@ def ownership_or_mod_required(view_func):
|
||||
return view_func(*args, **kwargs)
|
||||
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/')
|
||||
@login_required
|
||||
@ownership_required
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, abort
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, abort, current_app
|
||||
from functools import wraps
|
||||
from app import cache
|
||||
from ..auth import login_required, get_active_user, is_logged_in
|
||||
from ..db import db
|
||||
from ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
|
||||
from ..lib.render_atom import render_atom_template
|
||||
from ..util import get_form_checkbox, time_now
|
||||
import math
|
||||
|
||||
@@ -69,9 +72,23 @@ def thread(thread_id, slug):
|
||||
posts=posts, page=page,
|
||||
page_count=page_count, topic=topic,
|
||||
started_by=started_by, topics=Topics.get_list(),
|
||||
Reactions=Reactions, last_post=last_post
|
||||
Reactions=Reactions, last_post=last_post,
|
||||
__feedlink=url_for('.feed', thread_id=thread_id, _external=True),
|
||||
__feedtitle=f'replies to {thread.title}',
|
||||
)
|
||||
|
||||
@bp.get('/<int:thread_id>/feed.atom/')
|
||||
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
|
||||
def feed(thread_id):
|
||||
thread = Threads.find({'id': thread_id})
|
||||
if not thread:
|
||||
abort(404)
|
||||
|
||||
topic = Topics.find({'id': thread.topic_id})
|
||||
posts = thread.get_posts_rss()
|
||||
|
||||
return render_atom_template('threads/thread.atom', thread=thread, topic=topic, posts=posts)
|
||||
|
||||
@bp.post('/<int:thread_id>/')
|
||||
@login_required
|
||||
def reply(thread_id):
|
||||
@@ -166,9 +183,33 @@ def unsubscribe(thread_id):
|
||||
subscription.delete()
|
||||
return redirect(return_to)
|
||||
|
||||
@bp.get('/<int:thread_id>/feed.atom/')
|
||||
def feed(thread_id):
|
||||
return 'stub'
|
||||
@bp.post('/subscriptions/unsubscribe-all/')
|
||||
@login_required
|
||||
def unsubscribe_all():
|
||||
user = get_active_user()
|
||||
subs = Subscriptions.findall({'user_id': user.id})
|
||||
if not subs:
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
with db.transaction():
|
||||
for sub in subs:
|
||||
sub.delete()
|
||||
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
|
||||
@bp.post('/subscriptions/mark-read/')
|
||||
@login_required
|
||||
def mark_read():
|
||||
# TODO: make a return_to param
|
||||
user = get_active_user()
|
||||
sub_ids = request.form.getlist('id[]', type=int)
|
||||
now = time_now()
|
||||
for sub_id in sub_ids:
|
||||
sub = Subscriptions.find({'id': sub_id, 'user_id': user.id})
|
||||
if not sub:
|
||||
continue
|
||||
sub.update({'last_seen': now})
|
||||
|
||||
return redirect(url_for('users.inbox', username=user.username))
|
||||
|
||||
@bp.get('/new/')
|
||||
@login_required
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort
|
||||
|
||||
from flask import Blueprint, redirect, url_for, render_template, request, session, abort, current_app
|
||||
from app import cache
|
||||
from ..lib.render_atom import render_atom_template
|
||||
from ..models import Topics, Threads, Subscriptions
|
||||
from ..auth import get_active_user
|
||||
import math
|
||||
@@ -42,8 +43,20 @@ def topic(topic_id, slug):
|
||||
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']})
|
||||
if subscription:
|
||||
subscriptions[thread['id']] = subscription.get_unread_count()
|
||||
return render_template('topics/topic.html', topic=topic, threads=threads, sort_by=sort_by, page=page, page_count=page_count, subscriptions=subscriptions)
|
||||
return render_template(
|
||||
'topics/topic.html', topic=topic,
|
||||
threads=threads, sort_by=sort_by,
|
||||
page=page, page_count=page_count, subscriptions=subscriptions,
|
||||
__feedlink=url_for('.feed', topic_id=topic_id, _external=True),
|
||||
__feedtitle=f'latest threads in {topic.name}',
|
||||
)
|
||||
|
||||
@bp.get('/<int:topic_id>/feed.atom/')
|
||||
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
|
||||
def feed(topic_id):
|
||||
return 'stub'
|
||||
topic = Topics.find({'id': topic_id})
|
||||
if not topic:
|
||||
abort(404)
|
||||
|
||||
threads_list = topic.get_threads_with_op_rss()
|
||||
return render_atom_template('topics/topic.atom', topic=topic, threads_list=threads_list)
|
||||
|
||||
@@ -658,7 +658,6 @@ def save_badges(username):
|
||||
('id', 'NOT IN', ids),
|
||||
('user_id', '=', user.id),
|
||||
])
|
||||
print(list(map(lambda x: x.id, deleted_badges)))
|
||||
|
||||
with db.transaction():
|
||||
for b in deleted_badges:
|
||||
|
||||
20
app/templates/base.atom
Normal file
20
app/templates/base.atom
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
{%- if self.title() -%}
|
||||
<title>{%- block title -%}{%- endblock -%}</title>
|
||||
{%- else -%}
|
||||
<title>{{- config.SITE_NAME -}}</title>
|
||||
{%- endif -%}
|
||||
{%- if self.feed_updated() -%}
|
||||
<updated>{%- block feed_updated -%}{%- endblock -%}</updated>
|
||||
{%- else -%}
|
||||
<updated>{{- get_time_now() | iso8601 -}}</updated>
|
||||
{%- endif -%}
|
||||
<id>{{- __current_page -}}</id>
|
||||
<link rel="self" href="{{ __current_page }}" />
|
||||
<link href="{%- block canonical_link -%}{%- endblock -%}" />
|
||||
{%- if self.feed_author() -%}
|
||||
<author>{%- block feed_author -%}{%- endblock -%}</author>
|
||||
{%- endif -%}
|
||||
{%- block content -%}{%- endblock -%}
|
||||
</feed>
|
||||
@@ -11,6 +11,9 @@
|
||||
{%- else -%}
|
||||
<title>{{ config.SITE_NAME }}</title>
|
||||
{%- endif -%}
|
||||
{%- if __feedlink -%}
|
||||
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
|
||||
{%- endif -%}
|
||||
</head>
|
||||
<body>
|
||||
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<button type="button" title="insert ordered list" class="minimal" data-babycode-tag="ol" data-break-line data-s="insertBabycode">1.</button>
|
||||
<button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">•</button>
|
||||
<button type="button" title="insert spoiler" class="minimal" data-babycode-tag="spoiler=" data-break-line data-prefill="spoiler content" data-s="insertBabycode">s</button>
|
||||
<button type="button" title="insert emoji…" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>
|
||||
{#<button type="button" title="insert emoji…" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>#}
|
||||
</span>
|
||||
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span>
|
||||
</span>
|
||||
@@ -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}}…</button>
|
||||
{%- 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(
|
||||
post, render_sig=true, is_latest=false,
|
||||
show_toolbar=true, is_editing=false, thread=none,
|
||||
@@ -172,7 +185,7 @@
|
||||
</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">
|
||||
<span>
|
||||
{%- if tb_pretext -%}
|
||||
@@ -218,19 +231,10 @@
|
||||
</div>
|
||||
<div class="plank even secondary-bg minimal no-shadow">
|
||||
{%- if show_reactions -%}
|
||||
<span class="button-row">
|
||||
{%- 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 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 class="button-row" data-r="replaceReactionButtons">
|
||||
{{- reaction_buttons(post.id) -}}
|
||||
</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 -%}
|
||||
<input type="submit" value="Save">
|
||||
<a href="{{get_post_url(post.id, _anchor=true)}}" class="linkbutton warn">Cancel</a>
|
||||
@@ -298,3 +302,7 @@
|
||||
<div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div>
|
||||
</li>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro rss_html_content(html) -%}
|
||||
<content type="html">{{ html }}</content>
|
||||
{%- endmacro %}
|
||||
|
||||
2
app/templates/hyper/reaction_buttons.html
Normal file
2
app/templates/hyper/reaction_buttons.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{%- from 'common/macros.html' import reaction_buttons with context -%}
|
||||
{{- reaction_buttons(post_id) -}}
|
||||
20
app/templates/threads/thread.atom
Normal file
20
app/templates/threads/thread.atom
Normal file
@@ -0,0 +1,20 @@
|
||||
{% from 'common/macros.html' import rss_html_content %}
|
||||
{%- extends 'base.atom' -%}
|
||||
{%- block title -%}replies to {{thread.title}}{%- endblock -%}
|
||||
{%- block canonical_link -%}{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
{%- for post in posts -%}
|
||||
{%- set post_url = get_post_url(post.id, _anchor=true, external=true) -%}
|
||||
<entry>
|
||||
<title>Re: {{ thread.title | escape }}</title>
|
||||
<link href="{{ post_url }}"/>
|
||||
<id>{{ post_url }}</id>
|
||||
<updated>{{ post.edited_at | iso8601 }}</updated>
|
||||
{{ rss_html_content(post.content_rss) }}
|
||||
<author>
|
||||
<name>{{ post.display_name | escape }} @{{ post.username }}</name>
|
||||
<uri>{{ url_for('users.user_page', username=post.username, _external=true) }}</uri>
|
||||
</author>
|
||||
</entry>
|
||||
{%- endfor -%}
|
||||
{%- endblock -%}
|
||||
@@ -1,10 +1,11 @@
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component -%}
|
||||
{%- from 'common/macros.html' import subheader, timestamp, pager, babycode_editor_component, bookmark_button -%}
|
||||
{%- from 'common/icons.html' import icn_bookmark -%}
|
||||
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
|
||||
{%- extends 'base.html' -%}
|
||||
{%- block title -%}{{thread.title}}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
<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 -%}
|
||||
<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>
|
||||
@@ -30,7 +31,7 @@
|
||||
<input type="hidden" name="last_post_id" value="{{last_post.id}}">
|
||||
<input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
|
||||
</form>
|
||||
<button disabled autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" title="This feature requires JavaScript to be enabled." data-concept-kind="thread" data-concept-id="{{thread.id}}">{{icn_bookmark(24)}}Bookmark…</button>
|
||||
{{- bookmark_button('thread', thread.id) -}}
|
||||
{%- endif -%}
|
||||
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
</fieldset>
|
||||
@@ -64,7 +65,7 @@
|
||||
{%- endcall -%}
|
||||
<main>
|
||||
{%- 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)}}
|
||||
</article>
|
||||
{%- endfor -%}
|
||||
@@ -84,6 +85,25 @@
|
||||
</span>
|
||||
</div>
|
||||
{{ 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) -%}
|
||||
<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>
|
||||
|
||||
21
app/templates/topics/topic.atom
Normal file
21
app/templates/topics/topic.atom
Normal file
@@ -0,0 +1,21 @@
|
||||
{% from 'common/macros.html' import rss_html_content %}
|
||||
{%- extends 'base.atom' -%}
|
||||
{%- block title -%}latest threads in {{topic.name | escape}}{%- endblock -%}
|
||||
{%- block canonical_link -%}{{ url_for('topics.topic_by_id', topic_id=topic.id, _external=true) }}{%- endblock -%}
|
||||
{%- block content -%}
|
||||
<subtitle>{{ topic.description | escape }}</subtitle>
|
||||
{%- for thread in threads_list -%}
|
||||
<entry>
|
||||
<title>{{ thread.title | escape }}</title>
|
||||
<link href="{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}"/>
|
||||
<link rel="replies" type="application/atom+xml" href="{{ url_for('threads.feed', thread_id=thread.id, _external=true) }}"/>
|
||||
<id>{{ url_for('threads.thread_by_id', thread_id=thread.id, _external=true) }}</id>
|
||||
{{ rss_html_content(thread.original_post_content) }}
|
||||
<updated>{{ thread.created_at | iso8601 }}</updated>
|
||||
<author>
|
||||
<name>{{ thread.started_by_display_name | escape }} @{{ thread.started_by }}</name>
|
||||
<uri>{{ url_for('users.user_page', username=thread.started_by, _external=true) }}</uri>
|
||||
</author>
|
||||
</entry>
|
||||
{%- endfor -%}
|
||||
{%- endblock -%}
|
||||
@@ -20,7 +20,7 @@
|
||||
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
<form method="GET">
|
||||
<select name="sort_by">
|
||||
<option value="activity"{% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||
<option value="activity" {% if sort_by == 'activity' %}selected{% endif %}>Sorted by activity</option>
|
||||
<option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
|
||||
</select>
|
||||
<input type="submit" value="Sort">
|
||||
@@ -43,10 +43,10 @@
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{{ motd(get_motds()) }}
|
||||
{%- if threads | length == 0 -%}
|
||||
<div class="plank"><p>There are no threads in this topic yet.{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(topic) %} Be the first to start a discussion!{%- endif -%}</p></div>
|
||||
{%- endif -%}
|
||||
{{ motd(get_motds()) }}
|
||||
{%- for thread in threads -%}
|
||||
<div class="topic-info plank">
|
||||
<div class="title-container">
|
||||
|
||||
@@ -12,7 +12,22 @@
|
||||
You do not have any subscriptions.
|
||||
{%- endif -%}
|
||||
{%- endset -%}
|
||||
{{ subheader('Your inbox', topline) }}
|
||||
{%- call() subheader('Your inbox', topline) -%}
|
||||
{%- if subscriptions -%}
|
||||
<fieldset class="plank even no-shadow minimal subheader-actions">
|
||||
<legend>Actions</legend>
|
||||
<form method="POST" action="{{url_for('threads.mark_read')}}">
|
||||
{%- for sub in subscriptions -%}
|
||||
<input type="hidden" name="id[]" value="{{sub.id}}">
|
||||
{%- endfor -%}
|
||||
<button>Mark all as read</button>
|
||||
</form>
|
||||
<form method="POST" action="{{url_for('threads.unsubscribe_all')}}">
|
||||
<button class="warn">Unsubscribe from all</button>
|
||||
</form>
|
||||
</fieldset>
|
||||
{%- endif -%}
|
||||
{%- endcall -%}
|
||||
{%- if subscriptions | length > 0 -%}
|
||||
<div class="plank">
|
||||
{%- for sub in subscriptions -%}
|
||||
@@ -20,11 +35,17 @@
|
||||
{%- set thread = sub.get_thread() -%}
|
||||
<summary class="plank secondary-bg no-shadow even">
|
||||
{{thread.title}} ({{sub.get_unread_count()}} unread)
|
||||
<form method="POST" action="{{url_for('threads.unsubscribe', thread_id=thread.id)}}">
|
||||
<div>
|
||||
<form class="inline horizontal" method="POST" action="{{url_for('threads.unsubscribe', thread_id=thread.id)}}">
|
||||
<input type="hidden" name="return_to" value="{{url_for('users.inbox', username=get_active_user().username)}}">
|
||||
<a href="{{url_for('threads.thread_by_id', thread_id=thread.id)}}" class="linkbutton">Go to thread</a>
|
||||
<input type="submit" value="Unsubscribe" class="warn">
|
||||
</form>
|
||||
<form class="inline horizontal" method="POST" action="{{url_for('threads.mark_read')}}">
|
||||
<input type="hidden" name="id[]" value="{{sub.id}}">
|
||||
<button>Mark as read</button>
|
||||
</form>
|
||||
</div>
|
||||
</summary>
|
||||
{%- set posts = sub.get_full_posts_view() -%}
|
||||
|
||||
|
||||
@@ -208,7 +208,6 @@ input[type="text"], input[type="password"], input[type="url"], textarea, select
|
||||
background-color: var(--main-color);
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
resize: vertical;
|
||||
|
||||
padding: var(--small-padding) var(--medium-padding);
|
||||
margin: var(--base-padding) 0px;
|
||||
@@ -219,7 +218,8 @@ input[type="text"], input[type="password"], input[type="url"], textarea, select
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-family: 'Atkinson Hyperlegible Mono'
|
||||
font-family: 'Atkinson Hyperlegible Mono';
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -335,6 +335,10 @@ form.horizontal {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline flex;
|
||||
}
|
||||
|
||||
&> fieldset {
|
||||
display: flex;
|
||||
gap: var(--base-padding);
|
||||
@@ -696,6 +700,39 @@ details.inner {
|
||||
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 {
|
||||
position: absolute;
|
||||
min-width: 400px;
|
||||
@@ -743,6 +780,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 */
|
||||
.inline-code {
|
||||
background-color: var(--code-bg-color);
|
||||
@@ -924,97 +1080,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) {
|
||||
body {
|
||||
margin-left: 0;
|
||||
@@ -1085,4 +1150,17 @@ ol.sortable-list {
|
||||
flex-direction: column;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +91,10 @@ export function badgeEditorToggleFilePicker(ev, sender, el) {
|
||||
|
||||
export function badgeEditorAddBadge(ev, sender, el) {
|
||||
// TODO: page templates do not get updated on mutation
|
||||
const badge = document.getElementById('badge-template').innerText;
|
||||
el.innerHTML += badge;
|
||||
const badgeTemplate = document.getElementById('badge-template').innerText;
|
||||
const parser = new DOMParser();
|
||||
const e = parser.parseFromString(badgeTemplate, 'text/html').body.firstElementChild;
|
||||
el.appendChild(e);
|
||||
b.trigger('badgeEditorAssignImgId');
|
||||
badgesCount++;
|
||||
b.trigger('setBadgeCount');
|
||||
|
||||
@@ -14,28 +14,28 @@ async function getHTML(endpoint, options = {}) {
|
||||
return { body: await res.text(), status: res.status };
|
||||
}
|
||||
|
||||
export const b = {
|
||||
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
|
||||
bookmarkMenuState: {},
|
||||
}
|
||||
export const b = {};
|
||||
|
||||
const BOOKMARKS_COLLECTION_ENDPOINT = '/hyperapi/bookmarks/dropdown/';
|
||||
let bookmarkMenuState = {};
|
||||
|
||||
export async function showBookmarkMenu(ev, sender, el) {
|
||||
if (b.bookmarkMenuState.state === undefined) {
|
||||
if (bookmarkMenuState.state === undefined) {
|
||||
el.addEventListener('toggle', e => {
|
||||
if (e.newState === 'closed') {
|
||||
b.bookmarkMenuState.state = 'closed';
|
||||
bookmarkMenuState.state = 'closed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
|
||||
b.bookmarkMenuState.invoker = sender;
|
||||
b.bookmarkMenuState.state = 'open';
|
||||
bookmarkMenuState.invoker = sender;
|
||||
bookmarkMenuState.state = 'open';
|
||||
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
|
||||
el.showPopover();
|
||||
const bRect = sender.getBoundingClientRect();
|
||||
@@ -52,13 +52,13 @@ export async function showBookmarkMenu(ev, sender, el) {
|
||||
}
|
||||
el.style.top = `${bRect.bottom + scrollY}px`;
|
||||
|
||||
b.bookmarkMenuState.kind = sender.dataset.conceptKind;
|
||||
b.bookmarkMenuState.id = sender.dataset.conceptId;
|
||||
bookmarkMenuState.kind = sender.dataset.conceptKind;
|
||||
bookmarkMenuState.id = sender.dataset.conceptId;
|
||||
|
||||
const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, {
|
||||
const bookmarkCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
|
||||
_query: {
|
||||
concept_kind: b.bookmarkMenuState.kind,
|
||||
concept_id: b.bookmarkMenuState.id,
|
||||
concept_kind: bookmarkMenuState.kind,
|
||||
concept_id: bookmarkMenuState.id,
|
||||
}
|
||||
});
|
||||
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
|
||||
@@ -85,10 +85,10 @@ export async function bookmarkMenuSubmit(ev, _, el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCollections = await getHTML(b.bookmarksCollectionEndpoint, {
|
||||
const newCollections = await getHTML(BOOKMARKS_COLLECTION_ENDPOINT, {
|
||||
_query: {
|
||||
concept_kind: b.bookmarkMenuState.kind,
|
||||
concept_id: b.bookmarkMenuState.id,
|
||||
concept_kind: bookmarkMenuState.kind,
|
||||
concept_id: bookmarkMenuState.id,
|
||||
saved: true,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const b = {}
|
||||
|
||||
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) {
|
||||
|
||||
180
data/static/js/bits/thread.js
Normal file
180
data/static/js/bits/thread.js
Normal file
@@ -0,0 +1,180 @@
|
||||
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) {
|
||||
if (!el) return;
|
||||
el.disabled = false;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
export const b = {
|
||||
babycodePreviewEndpoint: '/api/babycode-preview/',
|
||||
init: 'babycodeEditorCharCountInit localizeTimestamps',
|
||||
}
|
||||
|
||||
const BABYCODE_PREVIEW_ENDPOINT = '/api/babycode-preview/';
|
||||
|
||||
const getThreadId = () => {
|
||||
const scheme = window.location.pathname.split("/");
|
||||
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
|
||||
@@ -114,6 +115,7 @@ export function babycodeEditorCharCount(evOrPayload, sender, el) {
|
||||
|
||||
export function clearThreadDraft(_, __, ___) {
|
||||
const threadId = getThreadId();
|
||||
if (threadId === -1) return;
|
||||
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 {
|
||||
if (!f.ok) {
|
||||
console.error(f);
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
ta.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
if (ta.form.reportValidity()) {
|
||||
ta.form.submit();
|
||||
ta.form.requestSubmit();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user