Compare commits

..

13 Commits

25 changed files with 697 additions and 155 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 = 13
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)
@@ -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>' 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 = { TAGS = {
"b": lambda children, attr: f"<strong>{children}</strong>", "b": lambda children, attr: f"<strong>{children}</strong>",
"i": lambda children, attr: f"<em>{children}</em>", "i": lambda children, attr: f"<em>{children}</em>",
@@ -462,6 +469,7 @@ RSS_TAGS = {
'url': tag_url_rss, 'url': tag_url_rss,
'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>', 'spoiler': lambda children, attr: f'<details><summary>{attr or "Spoiler"} (click to reveal)</summary>{children}</details>',
'code': tag_code_rss, 'code': tag_code_rss,
'quote': tag_quote_rss,
'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>', 'big': lambda children, attr: f'<span style="font-size: 1.2em">{children}</span>',
'small': lambda children, attr: f'<small>{children}</small>' 'small': lambda children, attr: f'<small>{children}</small>'

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',
'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
)
""" """
res = db.fetch_one(q, user_id) 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

@@ -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 functools import wraps
from app import cache
from ..auth import login_required, get_active_user, is_logged_in 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 ..models import Threads, Posts, Topics, Users, Reactions, Subscriptions
from ..lib.render_atom import render_atom_template
from ..util import get_form_checkbox, time_now from ..util import get_form_checkbox, time_now
import math import math
@@ -69,9 +72,23 @@ def thread(thread_id, slug):
posts=posts, page=page, posts=posts, page=page,
page_count=page_count, topic=topic, page_count=page_count, topic=topic,
started_by=started_by, topics=Topics.get_list(), 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>/') @bp.post('/<int:thread_id>/')
@login_required @login_required
def reply(thread_id): def reply(thread_id):
@@ -166,9 +183,33 @@ def unsubscribe(thread_id):
subscription.delete() subscription.delete()
return redirect(return_to) return redirect(return_to)
@bp.get('/<int:thread_id>/feed.atom/') @bp.post('/subscriptions/unsubscribe-all/')
def feed(thread_id): @login_required
return 'stub' 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/') @bp.get('/new/')
@login_required @login_required

View File

@@ -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 ..models import Topics, Threads, Subscriptions
from ..auth import get_active_user from ..auth import get_active_user
import math import math
@@ -42,8 +43,20 @@ def topic(topic_id, slug):
subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']}) subscription = Subscriptions.find({'user_id': user.id, 'thread_id': thread['id']})
if subscription: if subscription:
subscriptions[thread['id']] = subscription.get_unread_count() 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/') @bp.get('/<int:topic_id>/feed.atom/')
@cache.cached(timeout=5 * 60, unless=lambda: current_app.config['DEBUG'])
def feed(topic_id): 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)

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:

20
app/templates/base.atom Normal file
View 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>

View File

@@ -11,6 +11,9 @@
{%- else -%} {%- else -%}
<title>{{ config.SITE_NAME }}</title> <title>{{ config.SITE_NAME }}</title>
{%- endif -%} {%- endif -%}
{%- if __feedlink -%}
<link rel="alternate" type="application/atom+xml" href="{{ __feedlink }}" title="{{ __feedtitle }}">
{%- endif -%}
</head> </head>
<body> <body>
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8> <bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>

View File

@@ -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 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">&bullet;</button> <button type="button" title="insert unordered list" class="minimal" data-babycode-tag="ul" data-break-line data-s="insertBabycode">&bullet;</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 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&hellip;" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button> {#<button type="button" title="insert emoji&hellip;" class="minimal"><img src="/static/emoji/angry.png" class="emoji"></button>#}
</span> </span>
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span> <span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">0/</span>
</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}}&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>
@@ -298,3 +302,7 @@
<div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div> <div class="sortable-item-inner {{full and 'full' or ''}}">{{ caller() }}</div>
</li> </li>
{%- endmacro %} {%- endmacro %}
{% macro rss_html_content(html) -%}
<content type="html">{{ html }}</content>
{%- endmacro %}

View File

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

View 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 -%}

View File

@@ -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/icons.html' import icn_bookmark -%}
{%- from 'common/macros.html' import full_post, bookmark_menu with context -%} {%- from 'common/macros.html' import full_post, bookmark_menu with context -%}
{%- extends 'base.html' -%} {%- extends 'base.html' -%}
{%- 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>
@@ -30,7 +31,7 @@
<input type="hidden" name="last_post_id" value="{{last_post.id}}"> <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'}}"> <input type="submit" value="{{'Subscribe' if not get_active_user().is_subscribed(thread.id) else 'Unsubscribe'}}">
</form> </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&hellip;</button> {{- bookmark_button('thread', thread.id) -}}
{%- endif -%} {%- endif -%}
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a> <a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
</fieldset> </fieldset>
@@ -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

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

View File

@@ -20,7 +20,7 @@
<a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a> <a href="{{url_for('topics.feed', topic_id=topic.id)}}" class="linkbutton rss">Subscribe via RSS</a>
<form method="GET"> <form method="GET">
<select name="sort_by"> <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> <option value="thread" {% if sort_by == 'thread' %}selected{% endif %}>Sorted by newest</option>
</select> </select>
<input type="submit" value="Sort"> <input type="submit" value="Sort">
@@ -43,10 +43,10 @@
</fieldset> </fieldset>
{%- endif -%} {%- endif -%}
{%- endcall -%} {%- endcall -%}
{{ motd(get_motds()) }}
{%- if threads | length == 0 -%} {%- 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> <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 -%} {%- endif -%}
{{ motd(get_motds()) }}
{%- for thread in threads -%} {%- for thread in threads -%}
<div class="topic-info plank"> <div class="topic-info plank">
<div class="title-container"> <div class="title-container">

View File

@@ -12,7 +12,22 @@
You do not have any subscriptions. You do not have any subscriptions.
{%- endif -%} {%- endif -%}
{%- endset -%} {%- 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 -%} {%- if subscriptions | length > 0 -%}
<div class="plank"> <div class="plank">
{%- for sub in subscriptions -%} {%- for sub in subscriptions -%}
@@ -20,11 +35,17 @@
{%- set thread = sub.get_thread() -%} {%- set thread = sub.get_thread() -%}
<summary class="plank secondary-bg no-shadow even"> <summary class="plank secondary-bg no-shadow even">
{{thread.title}} ({{sub.get_unread_count()}} unread) {{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)}}"> <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> <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"> <input type="submit" value="Unsubscribe" class="warn">
</form> </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> </summary>
{%- set posts = sub.get_full_posts_view() -%} {%- set posts = sub.get_full_posts_view() -%}

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 {
@@ -335,6 +335,10 @@ form.horizontal {
flex-wrap: wrap; flex-wrap: wrap;
} }
&.inline {
display: inline flex;
}
&> fieldset { &> fieldset {
display: flex; display: flex;
gap: var(--base-padding); gap: var(--base-padding);
@@ -696,6 +700,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 +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 */ /* babycode tags */
.inline-code { .inline-code {
background-color: var(--code-bg-color); 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) { @media (max-width: 768px) {
body { body {
margin-left: 0; margin-left: 0;
@@ -1085,4 +1150,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,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;
}

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();
} }
} }
}) })