backend for bookmark menu

This commit is contained in:
2026-05-30 08:51:53 +03:00
parent 8c87489f70
commit d87d9c2977
6 changed files with 221 additions and 120 deletions

View File

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

View File

@@ -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
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads
from functools import wraps
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
@@ -26,22 +26,87 @@ def get_bookmark_dropdown():
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)
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

View File

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

View File

@@ -18,6 +18,7 @@
</label>
</div>
{%- endfor -%}
<input type="submit" value="Save" data-r="bookmarkMenuShowSavedButton bookmarkMenuResetSavedButton">
<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>

View 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');
}
}

View File

@@ -1,9 +1,6 @@
export const b = {
babycodePreviewEndpoint: '/api/babycode-preview/',
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
init: 'babycodeEditorCharCountInit localizeTimestamps',
bookmarkState: {},
}
const getThreadId = () => {
@@ -14,22 +11,6 @@ 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;
@@ -242,101 +223,3 @@ 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');
}
}