From d87d9c2977cc724c5fb459bb5a9d9e660605e11f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Sat, 30 May 2026 08:51:53 +0300 Subject: [PATCH] backend for bookmark menu --- app/models.py | 30 +++++ app/routes/hyperapi.py | 69 +++++++++++- app/templates/base.html | 1 + app/templates/hyper/bookmark_dropdown.html | 3 +- data/static/js/bits/bookmark-menu.js | 121 +++++++++++++++++++++ data/static/js/bits/ui.js | 117 -------------------- 6 files changed, 221 insertions(+), 120 deletions(-) create mode 100644 data/static/js/bits/bookmark-menu.js diff --git a/app/models.py b/app/models.py index d5ccdcc..2c9d267 100644 --- a/app/models.py +++ b/app/models.py @@ -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}) diff --git a/app/routes/hyperapi.py b/app/routes/hyperapi.py index 7ffa74f..9a57beb 100644 --- a/app/routes/hyperapi.py +++ b/app/routes/hyperapi.py @@ -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 diff --git a/app/templates/base.html b/app/templates/base.html index 37f6c13..c320607 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -15,6 +15,7 @@ + {%- include 'common/topnav.html' -%} {%- with messages = get_flashed_messages(with_categories=true) -%} {%- if messages -%} diff --git a/app/templates/hyper/bookmark_dropdown.html b/app/templates/hyper/bookmark_dropdown.html index 0285858..79882da 100644 --- a/app/templates/hyper/bookmark_dropdown.html +++ b/app/templates/hyper/bookmark_dropdown.html @@ -18,6 +18,7 @@ {%- endfor -%} - + + diff --git a/data/static/js/bits/bookmark-menu.js b/data/static/js/bits/bookmark-menu.js new file mode 100644 index 0000000..9a5e737 --- /dev/null +++ b/data/static/js/bits/bookmark-menu.js @@ -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'); + } +} diff --git a/data/static/js/bits/ui.js b/data/static/js/bits/ui.js index 175c9f8..a94f86a 100644 --- a/data/static/js/bits/ui.js +++ b/data/static/js/bits/ui.js @@ -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'); - } -}