diff --git a/app/__init__.py b/app/__init__.py
index cea12d2..dd91726 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -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
diff --git a/app/lib/babycode.py b/app/lib/babycode.py
index dea0228..b578978 100644
--- a/app/lib/babycode.py
+++ b/app/lib/babycode.py
@@ -357,7 +357,7 @@ def tag_code(children, attr):
else:
code = input_code
- button = f''
+ button = f''
block = f'
'
return block
diff --git a/app/routes/hyperapi.py b/app/routes/hyperapi.py
new file mode 100644
index 0000000..7ffa74f
--- /dev/null
+++ b/app/routes/hyperapi.py
@@ -0,0 +1,47 @@
+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
+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 'Your account must be approved by a moderator before you may perform this action.', 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
+ for collection in collections:
+ callable = collection.has_thread if is_thread else collection.has_post
+ if callable(concept_id):
+ in_collection = collection.id
+ 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)
+
+@bp.post('/bookmarks/thread/')
+@hard_login_required
+@user_required
+def bookmark_thread():
+ return '', 204
+
+@bp.post('/bookmarks/post/')
+@hard_login_required
+@user_required
+def bookmark_post():
+ return '', 204
diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html
index ec69331..f39884e 100644
--- a/app/templates/common/macros.html
+++ b/app/templates/common/macros.html
@@ -112,7 +112,7 @@
stub: char count
-
+
{%- if banned_tags -%}
Forbidden tags:
@@ -186,12 +186,12 @@
Edit
{%- endif -%}
{%- if can_reply -%}
-
+
{%- endif -%}
{%- if can_delete -%}
Delete
{%- endif -%}
-
+
{%- endif -%}
@@ -219,7 +219,7 @@
{%- endfor -%}
- {%- if is_logged_in() and allow_reacting -%}{%- endif -%}
+ {%- if is_logged_in() and allow_reacting -%}{%- endif -%}
{%- elif is_editing -%}
Cancel
diff --git a/app/templates/hyper/bookmark_dropdown.html b/app/templates/hyper/bookmark_dropdown.html
new file mode 100644
index 0000000..0285858
--- /dev/null
+++ b/app/templates/hyper/bookmark_dropdown.html
@@ -0,0 +1,23 @@
+
diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html
index 9fb556f..d3192ac 100644
--- a/app/templates/threads/thread.html
+++ b/app/templates/threads/thread.html
@@ -29,7 +29,7 @@
-
+
{%- endif -%}
@@ -82,6 +82,15 @@
+{%- if is_logged_in() -%}
+
+
+
+
+{%- endif -%}
{%- if is_logged_in() and get_active_user().can_post_to_thread_or_topic(thread) -%}
{%- endblock -%}
diff --git a/app/templates/users/settings.html b/app/templates/users/settings.html
index 07e5e1c..b4f65a8 100644
--- a/app/templates/users/settings.html
+++ b/app/templates/users/settings.html
@@ -48,10 +48,10 @@
-
+
-
+
diff --git a/data/static/css/style.css b/data/static/css/style.css
index 595bd7e..f2c0ca9 100644
--- a/data/static/css/style.css
+++ b/data/static/css/style.css
@@ -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);
diff --git a/data/static/js/bits/progressive-enhancement.js b/data/static/js/bits/progressive-enhancement.js
index 1ad7822..dc2716e 100644
--- a/data/static/js/bits/progressive-enhancement.js
+++ b/data/static/js/bits/progressive-enhancement.js
@@ -14,7 +14,7 @@ export function enhance(_, __, el) {
if (el.disabled) {
el.disabled = false;
if (el.title.search('JavaScript') !== -1) {
- el.title = '';
+ el.removeAttribute('title');
}
}
}
diff --git a/data/static/js/bits/ui.js b/data/static/js/bits/ui.js
index a94f86a..175c9f8 100644
--- a/data/static/js/bits/ui.js
+++ b/data/static/js/bits/ui.js
@@ -1,6 +1,9 @@
export const b = {
babycodePreviewEndpoint: '/api/babycode-preview/',
+ bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
init: 'babycodeEditorCharCountInit localizeTimestamps',
+
+ bookmarkState: {},
}
const getThreadId = () => {
@@ -11,6 +14,22 @@ const getThreadId = () => {
return parseInt(scheme[2]);
}
+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 function setTab(_, sender, el) {
if (sender.ariaSelected === 'true') {
return;
@@ -223,3 +242,101 @@ export function localizeTimestamps(_, __, el) {
const d = new Date(el.dateTime);
el.innerText = d.toLocaleString();
}
+
+export async function showBookmarkMenu(ev, sender, el) {
+ if (b.bookmarkState.state === undefined) {
+ el.addEventListener('toggle', e => {
+ if (e.newState === 'closed') {
+ b.bookmarkState.state = 'closed';
+ }
+ });
+ }
+
+ // dismiss if open and last invoker is the same button that opened it
+ if (b.bookmarkState.state === 'open' && b.bookmarkState.invoker === sender) {
+ el.hidePopover();
+ return;
+ }
+
+ b.bookmarkState.invoker = sender;
+ b.bookmarkState.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`;
+
+ const conceptKind = sender.prop('conceptKind');
+ const conceptId = sender.prop('conceptId');
+
+ const bookmarkCollections = await getHTML(b.bookmarksCollectionEndpoint, {
+ _query: {
+ concept_kind: conceptKind,
+ concept_id: conceptId,
+ }
+ });
+ 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;
+ }
+ b.trigger('bookmarkMenuShowSavedButton');
+}
+
+export function bookmarkMenuShowSavedButton(_, __, el) {
+ el.value = 'Saved!';
+}
+
+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');
+ }
+}