Compare commits
10 Commits
b0793b8a86
...
ae9d33473c
| Author | SHA1 | Date | |
|---|---|---|---|
|
ae9d33473c
|
|||
|
d87d9c2977
|
|||
|
8c87489f70
|
|||
|
07623b294e
|
|||
|
4d2f87baf5
|
|||
|
af5e838232
|
|||
|
81fa054ddf
|
|||
|
818e43dd1b
|
|||
|
3d633bd529
|
|||
|
2f78c7459c
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,6 +6,7 @@ data/static/avatars/*
|
||||
!data/static/avatars/default.webp
|
||||
data/static/badges/user
|
||||
data/_cached
|
||||
data/static/js/vnd/*.source.*
|
||||
|
||||
config/secrets.prod.env
|
||||
config/pyrom_config.toml
|
||||
|
||||
@@ -8,7 +8,7 @@ a live example can be seen in action over at [Porom](https://forum.poto.cafe/).
|
||||
## stack & structure
|
||||
on the server side, pyrom is built in Python using the Flask framework. content is rendered mostly server-side with Jinja templates. the database used is SQLite.
|
||||
|
||||
on the client side, JS with only one library ([Bitty](https://bitty-js.com)) is used. for CSS, pyrom uses Sass.
|
||||
on the client side, JS with only one library ([Bitty](https://bittyjs.com)) is used. for CSS, pyrom uses Sass.
|
||||
|
||||
below is an explanation of the folder structure:
|
||||
|
||||
@@ -32,13 +32,10 @@ below is an explanation of the folder structure:
|
||||
- `static/` - static files
|
||||
- `avatars/` - user avatar uploads
|
||||
- `badges/` - user badge uploads
|
||||
- `css/` - CSS files generated from Sass sources
|
||||
- `css/` - stylesheets
|
||||
- `emoji/` - emoji images used on the forum
|
||||
- `fonts/`
|
||||
- `js/`
|
||||
- `sass/`
|
||||
- `_default.scss` - the default theme. Sass variables that other themes modify are defined here, along with the default styles. other files define the available themes.
|
||||
- `build-themes.sh` - script for building Sass files into CSS
|
||||
- `nginx.conf` - nginx config (production only)
|
||||
- `uwsgi.ini` - uwsgi config (production only)
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ Repo: https://github.com/emcconville/wand
|
||||
|
||||
## Bitty
|
||||
|
||||
Affected files: [`data/static/js/vnd/bitty-7.0.0.js`](./data/static/js/vnd/bitty-7.0.0.js)
|
||||
URL: https://bitty-js.com/
|
||||
Affected files: [`data/static/js/vnd/bitty-8.0.0.js`](./data/static/js/vnd/bitty-8.0.0.js)
|
||||
URL: https://bittyjs.com/
|
||||
License: CC0 1.0
|
||||
Author: alan w smith https://www.alanwsmith.com/
|
||||
Repo: https://github.com/alanwsmith/bitty
|
||||
|
||||
@@ -211,6 +211,7 @@ def create_app():
|
||||
from app.routes.mod import bp as mod_bp
|
||||
from app.routes.posts import bp as posts_bp
|
||||
from app.routes.api import bp as api_bp
|
||||
from app.routes.hyperapi import bp as hyperapi_bp
|
||||
app.register_blueprint(topics_bp)
|
||||
app.register_blueprint(threads_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
@@ -218,6 +219,7 @@ def create_app():
|
||||
app.register_blueprint(mod_bp)
|
||||
app.register_blueprint(posts_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(hyperapi_bp)
|
||||
|
||||
with app.app_context():
|
||||
from .schema import create as create_tables
|
||||
@@ -367,13 +369,6 @@ def create_app():
|
||||
def cachebust(subject):
|
||||
return f'{subject}?v={str(int(time.time()))}'
|
||||
|
||||
@app.template_filter('theme_name')
|
||||
def get_theme_name(subject: str):
|
||||
if subject == 'style':
|
||||
return 'Default'
|
||||
|
||||
return f'{subject.removeprefix('theme-').replace('-', ' ').capitalize()} (beta)'
|
||||
|
||||
@app.template_filter('fromjson')
|
||||
def fromjson(subject: str):
|
||||
return json.loads(subject)
|
||||
|
||||
@@ -357,8 +357,8 @@ def tag_code(children, attr):
|
||||
else:
|
||||
code = input_code
|
||||
|
||||
button = f'<button type=button class="copy-code" data-s="copyCode">Copy</button>'
|
||||
block = f'<fieldset data-r="copyCode" value="{input_code}" class="code-block-container plank minimal no-shadow secondary-bg"><legend>{language}</legend>{button}<pre><code>{code}</code></pre></fieldset>'
|
||||
button = f'<button autocomplete=off disabled data-r="enhance" title="This feature requires JavaScript to be enabled." type=button class="copy-code" data-s="copyCode">Copy</button>'
|
||||
block = f'<fieldset data-r="copyCode" data-code="{input_code}" class="code-block-container plank minimal no-shadow secondary-bg"><legend>{language}</legend>{button}<pre><code>{code}</code></pre></fieldset>'
|
||||
return block
|
||||
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ class Topics(Model):
|
||||
name = name.strip()
|
||||
description = description.strip()
|
||||
now = int(time.time())
|
||||
slug = f'{slugify(name)}-{now}'
|
||||
slug = slugify(name, max_length=50)
|
||||
|
||||
topic_count = Topics.count()
|
||||
return Topics.create({
|
||||
@@ -287,7 +287,7 @@ class Threads(Model):
|
||||
def new(cls, user_id: int, topic_id: int, title: str, content: str, language: str = 'babycode') -> Threads:
|
||||
from slugify import slugify
|
||||
now = int(time.time())
|
||||
slug = f'{slugify(title)}-{now}'
|
||||
slug = slugify(title, max_length=50)
|
||||
thread = Threads.create({
|
||||
'topic_id': topic_id,
|
||||
'user_id': user_id,
|
||||
@@ -558,6 +558,21 @@ class BookmarkCollections(Model):
|
||||
class BookmarkedPosts(Model):
|
||||
table = 'bookmarked_posts'
|
||||
|
||||
@classmethod
|
||||
def get_for_user(cls, post_id, user_id):
|
||||
q = """SELECT
|
||||
bookmarked_posts.id, collection_id, post_id, note
|
||||
FROM
|
||||
bookmarked_posts
|
||||
JOIN
|
||||
bookmark_collections ON bookmark_collections.id = bookmarked_posts.collection_id
|
||||
WHERE
|
||||
post_id = ?
|
||||
AND
|
||||
user_id = ?"""
|
||||
res = db.fetch_one(q, post_id, user_id)
|
||||
return cls.from_data(res) if res is not None else None
|
||||
|
||||
def get_post(self):
|
||||
return Posts.find({'id': self.post_id})
|
||||
|
||||
@@ -565,6 +580,21 @@ class BookmarkedPosts(Model):
|
||||
class BookmarkedThreads(Model):
|
||||
table = 'bookmarked_threads'
|
||||
|
||||
@classmethod
|
||||
def get_for_user(cls, thread_id, user_id):
|
||||
q = """SELECT
|
||||
bookmarked_threads.id, collection_id, thread_id, note
|
||||
FROM
|
||||
bookmarked_threads
|
||||
JOIN
|
||||
bookmark_collections ON bookmark_collections.id = bookmarked_threads.collection_id
|
||||
WHERE
|
||||
thread_id = ?
|
||||
AND
|
||||
user_id = ?"""
|
||||
res = db.fetch_one(q, thread_id, user_id)
|
||||
return cls.from_data(res) if res is not None else None
|
||||
|
||||
def get_thread(self):
|
||||
return Threads.find({'id': self.thread_id})
|
||||
|
||||
|
||||
112
app/routes/hyperapi.py
Normal file
112
app/routes/hyperapi.py
Normal file
@@ -0,0 +1,112 @@
|
||||
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
|
||||
from functools import wraps
|
||||
|
||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||
|
||||
def user_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if get_active_user().is_guest():
|
||||
return '<span>Your account must be approved by a moderator before you may perform this action.</span>', 403
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@bp.get('/bookmarks/dropdown/')
|
||||
@hard_login_required
|
||||
@user_required
|
||||
def get_bookmark_dropdown():
|
||||
user = get_active_user()
|
||||
concept_kind = request.args.get('concept_kind', 'thread')
|
||||
try:
|
||||
concept_id = int(request.args.get('concept_id', 0))
|
||||
except ValueError:
|
||||
return 'error', 400
|
||||
is_thread = concept_kind == 'thread'
|
||||
collections = BookmarkCollections.findall({'user_id': user.id})
|
||||
in_collection = None
|
||||
note = ''
|
||||
for collection in collections:
|
||||
callable = collection.has_thread if is_thread else collection.has_post
|
||||
if callable(concept_id):
|
||||
in_collection = collection.id
|
||||
concept = 'thread_id' if is_thread else 'post_id'
|
||||
note = (BookmarkedThreads if is_thread else BookmarkedPosts).find({'collection_id': in_collection, concept: concept_id}).note
|
||||
break
|
||||
submit_url = url_for('.bookmark_thread' if is_thread else '.bookmark_post')
|
||||
return render_template('hyper/bookmark_dropdown.html', collections=collections, in_collection=in_collection, is_thread=is_thread, concept_id=concept_id, submit_url=submit_url, note=note)
|
||||
|
||||
@bp.post('/bookmarks/thread/')
|
||||
@hard_login_required
|
||||
@user_required
|
||||
def bookmark_thread():
|
||||
user = get_active_user()
|
||||
try:
|
||||
thread_id = int(request.form['concept_id'])
|
||||
target_collection_id = int(request.form['target_collection'])
|
||||
except ValueError, KeyError:
|
||||
return 'error', 400
|
||||
|
||||
if target_collection_id == -1:
|
||||
bt = BookmarkedThreads.get_for_user(thread_id, user.id)
|
||||
if bt:
|
||||
bt.delete()
|
||||
return '', 204
|
||||
|
||||
target_collection = BookmarkCollections.find({'id': target_collection_id})
|
||||
note = request.form.get('note', '')
|
||||
if not target_collection:
|
||||
return 'error', 400
|
||||
|
||||
if int(user.id) != int(target_collection.user_id):
|
||||
return 'error', 400
|
||||
|
||||
bt = BookmarkedThreads.get_for_user(thread_id, user.id)
|
||||
if bt:
|
||||
bt.update({'collection_id': target_collection_id, 'note': note})
|
||||
else:
|
||||
BookmarkedThreads.create({
|
||||
'collection_id': target_collection_id,
|
||||
'thread_id': thread_id,
|
||||
'note': note,
|
||||
})
|
||||
|
||||
return '', 204
|
||||
|
||||
@bp.post('/bookmarks/post/')
|
||||
@hard_login_required
|
||||
@user_required
|
||||
def bookmark_post():
|
||||
user = get_active_user()
|
||||
try:
|
||||
post_id = int(request.form['concept_id'])
|
||||
target_collection_id = int(request.form['target_collection'])
|
||||
except ValueError, KeyError:
|
||||
return 'error', 400
|
||||
|
||||
if target_collection_id == -1:
|
||||
bp = BookmarkedPosts.get_for_user(post_id, user.id)
|
||||
if bp:
|
||||
bp.delete()
|
||||
return '', 204
|
||||
|
||||
target_collection = BookmarkCollections.find({'id': target_collection_id})
|
||||
note = request.form.get('note', '')
|
||||
if not target_collection:
|
||||
return 'error', 400
|
||||
|
||||
if int(user.id) != int(target_collection.user_id):
|
||||
return 'error', 400
|
||||
|
||||
bp = BookmarkedPosts.get_for_user(post_id, user.id)
|
||||
if bp:
|
||||
bp.update({'collection_id': target_collection_id, 'note': note})
|
||||
else:
|
||||
BookmarkedPosts.create({
|
||||
'collection_id': target_collection_id,
|
||||
'post_id': post_id,
|
||||
'note': note,
|
||||
})
|
||||
|
||||
return '', 204
|
||||
@@ -106,7 +106,7 @@ def edit_topic_post(topic_id):
|
||||
topic.update({
|
||||
'name': target_name,
|
||||
'description': request.form.get('description').strip(),
|
||||
'slug': slugify(target_name[:50]),
|
||||
'slug': slugify(target_name, max_length=50),
|
||||
})
|
||||
return redirect(url_for('topics.topic_by_id', topic_id=topic.id))
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ def thread(thread_id, slug):
|
||||
if not thread:
|
||||
abort(404)
|
||||
if thread.slug != slug:
|
||||
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.kwargs))
|
||||
return redirect(url_for('.thread', thread_id=thread_id, slug=thread.slug, **request.args))
|
||||
|
||||
topic = Topics.find({'id': thread.topic_id})
|
||||
started_by = Users.find({'id': thread.user_id})
|
||||
@@ -115,7 +115,8 @@ def edit_post(thread_id):
|
||||
abort(400)
|
||||
|
||||
if new_title != thread.title:
|
||||
thread.update({'title': new_title})
|
||||
from slugify import slugify
|
||||
thread.update({'title': new_title, 'slug': slugify(new_title, max_length=50)})
|
||||
|
||||
return redirect(url_for('.thread_by_id', thread_id=thread_id))
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<body>
|
||||
<bitty-8 data-connect="/static/js/bits/progressive-enhancement.js"></bitty-8>
|
||||
<bitty-8 data-connect="/static/js/bits/ui.js"></bitty-8>
|
||||
<bitty-8 data-connect="/static/js/bits/bookmark-menu.js"></bitty-8>
|
||||
{%- include 'common/topnav.html' -%}
|
||||
{%- with messages = get_flashed_messages(with_categories=true) -%}
|
||||
{%- if messages -%}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro timestamp(unix_ts) -%}
|
||||
<time datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
|
||||
<time data-r="localizeTimestamps" datetime="{{ unix_ts | iso8601 }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></time>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro subheader(title, desc='') -%}
|
||||
@@ -112,7 +112,7 @@
|
||||
<span class="flex-last js-only" data-r="enhance babycodeEditorCharCount">stub: char count</span>
|
||||
</span>
|
||||
<input type="hidden" name="babycode_banned_tags" id="{{id}}-banned-tags" value="{{banned_tags | unique | list | tojson | forceescape}}">
|
||||
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000" data-r="insertBabycode babycodePreviewInit babycodeEditorCharCountInit babycodeEditorQuote" data-listeners="input" data-s="babycodeEditorCharCount" data-banned-tags="{{banned_tags | unique | list | tojson | forceescape}}">{{ prefill }}</textarea>
|
||||
<textarea name="babycode_content" id="{{id}}" class="babycode-editor" placeholder="{{placeholder}}" {{'required' if required else ''}} autocomplete="off" maxlength="5000" data-r="insertBabycode babycodePreviewInit babycodeEditorCharCountInit babycodeEditorQuote" data-listen="input" data-s="babycodeEditorCharCount" data-banned-tags="{{banned_tags | unique | list | tojson | forceescape}}">{{ prefill }}</textarea>
|
||||
{%- if banned_tags -%}
|
||||
<div>
|
||||
<span>Forbidden tags:</span>
|
||||
@@ -186,12 +186,12 @@
|
||||
<a class="linkbutton" href="{{url_for('posts.edit', post_id=post.id, _anchor='babycode-content')}}">Edit</a>
|
||||
{%- endif -%}
|
||||
{%- if can_reply -%}
|
||||
<button data-r="enhance" data-s="babycodeEditorQuote" disabled title="This feature requires JavaScript to be enabled." data-quote="{{post.original_markup}}" data-poster-name="{{ post.display_name if post.display_name else post.username }}">Quote</button>
|
||||
<button autocomplete='off' data-r="enhance" data-s="babycodeEditorQuote" disabled title="This feature requires JavaScript to be enabled." data-quote="{{post.original_markup}}" data-poster-name="{{ post.display_name if post.display_name else post.username }}">Quote</button>
|
||||
{%- endif -%}
|
||||
{%- if can_delete -%}
|
||||
<a class="linkbutton critical" href="{{url_for('posts.delete', post_id=post.id)}}">Delete</a>
|
||||
{%- endif -%}
|
||||
<button data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark…</button>
|
||||
<button autocomplete='off' data-r="enhance" data-s="showBookmarkMenu" disabled title="This feature requires JavaScript to be enabled." data-concept-kind="post" data-concept-id="{{post.id}}">{{icn_bookmark(24)}}Bookmark…</button>
|
||||
</span>
|
||||
{%- endif -%}
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@
|
||||
<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>
|
||||
{%- if is_logged_in() and allow_reacting -%}<button 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="enhance" disabled title="This feature requires JavaScript to be enabled.">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>
|
||||
|
||||
24
app/templates/hyper/bookmark_dropdown.html
Normal file
24
app/templates/hyper/bookmark_dropdown.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<form class="full-width" method="POST" action="{{submit_url}}" data-listen="submit" data-s="bookmarkMenuSubmit" data-r="bookmarkMenuSubmit">
|
||||
<input type="hidden" name="concept_id" value="{{concept_id}}">
|
||||
<div class="inline-group bookmark-menu-item">
|
||||
<input data-s="bookmarkMenuResetSavedButton" autocomplete="off" type="radio" name="target_collection" id="collection-none" {{'checked' if in_collection==none else ''}} value="-1">
|
||||
<label class="bookmark-menu-label" for="collection-none">
|
||||
No collection
|
||||
<small>Choose this option to remove this {{'thread' if is_thread else 'post'}} from your bookmarks.</small>
|
||||
</label>
|
||||
</div>
|
||||
{%- for collection in collections -%}
|
||||
<div class="inline-group bookmark-menu-item">
|
||||
<input data-s="bookmarkMenuResetSavedButton" autocomplete="off" type="radio" name="target_collection" id="collection-{{collection.id}}" {{'checked' if in_collection==collection.id else ''}} value="{{collection.id}}">
|
||||
{%- set tc = collection.get_threads_count() -%}
|
||||
{%- set pc = collection.get_posts_count() -%}
|
||||
<label class="bookmark-menu-label" for="collection-{{collection.id}}">
|
||||
{{collection.name}}
|
||||
<small>{{tc}} {{'thread' | pluralize(num=tc)}}, {{pc}} {{'post' | pluralize(num=pc)}}</small>
|
||||
</label>
|
||||
</div>
|
||||
{%- endfor -%}
|
||||
<input type="text" placeholder="Optional memo" maxlength=100 name="note" autocomplete="off" value="{{note}}">
|
||||
<input type="submit" value="{{'Saved!' if request.args.saved else 'Save'}}" data-r="bookmarkMenuShowSavedButton bookmarkMenuResetSavedButton">
|
||||
<span class="errors hidden" data-r="bookmarkMenuShowError bookmarkMenuHideError">Something went wrong. Try again later.</span>
|
||||
</form>
|
||||
@@ -29,7 +29,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 data-r="enhance" disabled title="This feature requires JavaScript to be enabled.">{{icn_bookmark(24)}}Bookmark…</button>
|
||||
<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>
|
||||
{%- endif -%}
|
||||
<a href="{{url_for('threads.feed', thread_id=thread.id)}}" class="linkbutton rss">Subscribe via RSS</a>
|
||||
</fieldset>
|
||||
@@ -82,8 +82,17 @@
|
||||
<button>Stop updates</button>
|
||||
</span>
|
||||
</div>
|
||||
{%- if is_logged_in() -%}
|
||||
<div id="bookmark-popover" data-r="showBookmarkMenu" class="plank even" popover>
|
||||
<div class="bookmark-menu-header">
|
||||
<span>Bookmark collections</span>
|
||||
<a href="{{url_for('users.bookmarks', username=get_active_user().username)}}">View bookmarks</a>
|
||||
</div>
|
||||
<div class="bookmark-menu-inner" data-r="fillBookmarkMenu">Loading…</div>
|
||||
</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">
|
||||
<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>
|
||||
{{- babycode_editor_component() -}}
|
||||
<span>
|
||||
|
||||
@@ -15,7 +15,7 @@ Welcome back! No account yet? <a href="{{url_for('users.sign_up')}}">Sign up</a>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
<span><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></span>
|
||||
<div class="inline-group"><input type="checkbox" name="remember" id="remember"> <label for="remember">Remember me</label></div>
|
||||
<input type="submit" value="Log in">
|
||||
</form>
|
||||
{%- endblock -%}
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
<input type="text" name="display_name" id="display-name" value="{{user.display_name}}" placeholder="Same as username" pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title="Optional. 3-50 characters, no @, no <>, no &." maxlength="50" autocomplete=off>
|
||||
<label for="status">Status</label>
|
||||
<input type="text" name="status" id="status" maxlength="100" value="{{user.status}}" placeholder="Will be shown under your username on posts. Max. 100 characters." autocomplete="off">
|
||||
<span>
|
||||
<div class="inline-group">
|
||||
<input type="checkbox" id="subscribe-by-default" name="subscribe_by_default" {{'' if session['dont_subscribe_by_default'] else 'checked'}} autocomplete="off">
|
||||
<label for="subscribe-by-default">Automatically subscribe to thread when responding</label>
|
||||
</span>
|
||||
</div>
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
</fieldset>
|
||||
|
||||
@@ -93,7 +93,7 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
||||
--active-color: hsl(from var(--main-color) h s calc(l * 0.8));
|
||||
--disabled-color: hsl(from var(--main-color) h calc(s * 0.5) l);
|
||||
--bottom-color: hsl(from var(--main-color) h s calc(l * 0.7));
|
||||
--top-color: hsl(from var(--main-color) h s calc(l * 1.2));
|
||||
--top-color: hsl(from var(--main-color) h s 95);
|
||||
--top-color2: hsl(from var(--main-color) h s calc(l * 1.1));
|
||||
--inset-color: #fff7;
|
||||
/* position: relative; */
|
||||
@@ -102,7 +102,7 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
||||
margin: var(--base-padding) 0px;
|
||||
border-radius: var(--border-radius);
|
||||
border: solid var(--border-thickness) var(--border-color);
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--main-color) 26%, var(--main-color) 50%, var(--bottom-color) 100%);
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 10%, var(--main-color) 12%, var(--main-color) 66%, var(--bottom-color) 100%);
|
||||
/* box-shadow: inset 0px 2px 5px 3px var(--inset-color); */
|
||||
/* color: var(--font-color); */
|
||||
/* HACK: better than contrast-color on critical */
|
||||
@@ -141,7 +141,7 @@ button, .linkbutton, input[type="submit"], input[type="file"]::file-selector-but
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 25%, var(--hover-color) 26%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||
background: linear-gradient(var(--top-color) 0%, var(--top-color2) 10%, var(--hover-color) 12%, var(--hover-color) 80%, var(--bottom-color) 100%);
|
||||
}
|
||||
|
||||
&:is(:active, .active, [aria-selected='true']) {
|
||||
@@ -629,9 +629,20 @@ form.full-width {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
&> textarea, &> select, &> input[type="text"], &> input[type="password"] {
|
||||
gap: var(--small-padding);
|
||||
&> textarea, &> select, &> input[type="text"], &> input[type="password"], &> .inline-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&> .inline-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--base-padding);
|
||||
|
||||
&> label {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-explain {
|
||||
@@ -678,6 +689,42 @@ details.separated {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#bookmark-popover {
|
||||
position: absolute;
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
max-height: 500px;
|
||||
margin-block: var(--small-padding);
|
||||
margin-inline: 0;
|
||||
padding-inline: var(--medium-padding);
|
||||
|
||||
overflow: scroll;
|
||||
|
||||
.bookmark-menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bookmark-menu-inner .errors.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-menu-item {
|
||||
padding-block: var(--medium-padding);
|
||||
padding-inline: var(--base-padding);
|
||||
|
||||
&:has(.bookmark-menu-label:hover, input:hover) {
|
||||
background-color: #0001;
|
||||
}
|
||||
.bookmark-menu-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* babycode tags */
|
||||
.inline-code {
|
||||
background-color: var(--code-bg-color);
|
||||
|
||||
121
data/static/js/bits/bookmark-menu.js
Normal file
121
data/static/js/bits/bookmark-menu.js
Normal file
@@ -0,0 +1,121 @@
|
||||
async function getHTML(endpoint, options = {}) {
|
||||
let query = {};
|
||||
if (options._query !== undefined) {
|
||||
query = options._query;
|
||||
delete options._query;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(query);
|
||||
const res = await fetch(`${endpoint}?${params}`, options);
|
||||
if (!res.ok) {
|
||||
console.error(res);
|
||||
}
|
||||
|
||||
return { body: await res.text(), status: res.status };
|
||||
}
|
||||
|
||||
export const b = {
|
||||
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
|
||||
bookmarkMenuState: {},
|
||||
}
|
||||
|
||||
export async function showBookmarkMenu(ev, sender, el) {
|
||||
if (b.bookmarkMenuState.state === undefined) {
|
||||
el.addEventListener('toggle', e => {
|
||||
if (e.newState === 'closed') {
|
||||
b.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) {
|
||||
el.hidePopover();
|
||||
return;
|
||||
}
|
||||
|
||||
b.bookmarkMenuState.invoker = sender;
|
||||
b.bookmarkMenuState.state = 'open';
|
||||
b.send({ 'plain': 'Loading…' }, 'fillBookmarkMenu');
|
||||
el.showPopover();
|
||||
const bRect = sender.getBoundingClientRect();
|
||||
const menuRect = el.getBoundingClientRect();
|
||||
const preferredLeft = bRect.right - menuRect.width;
|
||||
const enoughSpace = preferredLeft >= 0;
|
||||
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
if (enoughSpace) {
|
||||
el.style.left = `${preferredLeft}px`;
|
||||
} else {
|
||||
el.style.left = `${bRect.left}px`;
|
||||
}
|
||||
el.style.top = `${bRect.bottom + scrollY}px`;
|
||||
|
||||
b.bookmarkMenuState.kind = sender.dataset.conceptKind;
|
||||
b.bookmarkMenuState.id = sender.dataset.conceptId;
|
||||
|
||||
const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, {
|
||||
_query: {
|
||||
concept_kind: b.bookmarkMenuState.kind,
|
||||
concept_id: b.bookmarkMenuState.id,
|
||||
}
|
||||
});
|
||||
b.send({ 'html': bookmarkCollections.body }, 'fillBookmarkMenu');
|
||||
}
|
||||
|
||||
export function fillBookmarkMenu(payload, __, el) {
|
||||
if (payload.plain) {
|
||||
el.innerText = payload.plain;
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = payload.html;
|
||||
}
|
||||
|
||||
export async function bookmarkMenuSubmit(ev, _, el) {
|
||||
ev.preventDefault();
|
||||
const url = el.action;
|
||||
const body = new URLSearchParams(new FormData(el));
|
||||
const options = { body: body, method: 'POST' };
|
||||
const status = (await getHTML(url, options)).status;
|
||||
|
||||
if (status !== 204) {
|
||||
b.trigger('bookmarkMenuShowError');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCollections = await getHTML(b.bookmarksCollectionEndpoint, {
|
||||
_query: {
|
||||
concept_kind: b.bookmarkMenuState.kind,
|
||||
concept_id: b.bookmarkMenuState.id,
|
||||
saved: true,
|
||||
}
|
||||
});
|
||||
b.send({ 'html': newCollections.body }, 'fillBookmarkMenu');
|
||||
}
|
||||
|
||||
export function bookmarkMenuResetSavedButton(_, __, el) {
|
||||
el.value = 'Save';
|
||||
}
|
||||
|
||||
export function bookmarkMenuShowError(_, __, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (el.classList.contains('hidden')) {
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => { b.trigger('bookmarkMenuHideError') }, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
export function bookmarkMenuHideError(_, __, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el.classList.contains('hidden')) {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ export const b = {
|
||||
}
|
||||
|
||||
export function enhance(_, __, el) {
|
||||
if (el === undefined) { // nothing to enhance but init still runs
|
||||
return;
|
||||
}
|
||||
|
||||
if (el.classList.contains('js-only')) {
|
||||
el.classList.remove('js-only');
|
||||
}
|
||||
@@ -10,7 +14,7 @@ export function enhance(_, __, el) {
|
||||
if (el.disabled) {
|
||||
el.disabled = false;
|
||||
if (el.title.search('JavaScript') !== -1) {
|
||||
el.title = '';
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
export const b = {
|
||||
babycodePreviewEndpoint: '/api/babycode-preview/',
|
||||
init: 'babycodeEditorCharCountInit',
|
||||
init: 'babycodeEditorCharCountInit localizeTimestamps',
|
||||
}
|
||||
|
||||
const getThreadId = () => {
|
||||
const scheme = window.location.pathname.split("/");
|
||||
if (scheme[1] !== 'threads' || scheme[2] === 'new') {
|
||||
return -1;
|
||||
}
|
||||
return parseInt(scheme[2]);
|
||||
}
|
||||
|
||||
export function setTab(_, sender, el) {
|
||||
@@ -95,10 +103,29 @@ export function babycodeEditorCharCount(evOrPayload, sender, el) {
|
||||
const maxLength = sender.maxLength;
|
||||
const currentLength = sender.value.length;
|
||||
|
||||
el.innerText = `${currentLength}/${maxLength}`
|
||||
el.innerText = `${currentLength}/${maxLength}`;
|
||||
|
||||
const threadId = getThreadId();
|
||||
|
||||
if (threadId !== -1) {
|
||||
localStorage.setItem(`thread-${threadId}`, sender.value);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearThreadDraft(_, __, ___) {
|
||||
const threadId = getThreadId();
|
||||
localStorage.removeItem(`thread-${threadId}`);
|
||||
}
|
||||
|
||||
export function babycodeEditorCharCountInit(_, __, el) {
|
||||
if (el === undefined) { // no editors on page
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = getThreadId();
|
||||
if (threadId !== -1) {
|
||||
el.value = localStorage.getItem(`thread-${threadId}`) || '';
|
||||
}
|
||||
b.send({ sender: el }, 'babycodeEditorCharCount');
|
||||
}
|
||||
|
||||
@@ -170,3 +197,29 @@ export function babycodeEditorQuote(ev, sender, el) {
|
||||
b.send({ sender: el }, 'babycodeEditorCharCount');
|
||||
el.focus();
|
||||
}
|
||||
|
||||
export async function copyCode(ev, sender, el) {
|
||||
if (!el.contains(sender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = sender.textContent;
|
||||
const doneText = 'Copied!';
|
||||
const code = el.dataset.code;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
sender.textContent = doneText;
|
||||
setTimeout(() => { sender.textContent = originalText }, 2000);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function localizeTimestamps(_, __, el) {
|
||||
if (el === undefined) {
|
||||
return;
|
||||
}
|
||||
const d = new Date(el.dateTime);
|
||||
el.innerText = d.toLocaleString();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user