backend for bookmark menu
This commit is contained in:
@@ -558,6 +558,21 @@ class BookmarkCollections(Model):
|
|||||||
class BookmarkedPosts(Model):
|
class BookmarkedPosts(Model):
|
||||||
table = 'bookmarked_posts'
|
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):
|
def get_post(self):
|
||||||
return Posts.find({'id': self.post_id})
|
return Posts.find({'id': self.post_id})
|
||||||
|
|
||||||
@@ -565,6 +580,21 @@ class BookmarkedPosts(Model):
|
|||||||
class BookmarkedThreads(Model):
|
class BookmarkedThreads(Model):
|
||||||
table = 'bookmarked_threads'
|
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):
|
def get_thread(self):
|
||||||
return Threads.find({'id': self.thread_id})
|
return Threads.find({'id': self.thread_id})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
|
||||||
@@ -26,22 +26,87 @@ def get_bookmark_dropdown():
|
|||||||
is_thread = concept_kind == 'thread'
|
is_thread = concept_kind == 'thread'
|
||||||
collections = BookmarkCollections.findall({'user_id': user.id})
|
collections = BookmarkCollections.findall({'user_id': user.id})
|
||||||
in_collection = None
|
in_collection = None
|
||||||
|
note = ''
|
||||||
for collection in collections:
|
for collection in collections:
|
||||||
callable = collection.has_thread if is_thread else collection.has_post
|
callable = collection.has_thread if is_thread else collection.has_post
|
||||||
if callable(concept_id):
|
if callable(concept_id):
|
||||||
in_collection = collection.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
|
break
|
||||||
submit_url = url_for('.bookmark_thread' if is_thread else '.bookmark_post')
|
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/')
|
@bp.post('/bookmarks/thread/')
|
||||||
@hard_login_required
|
@hard_login_required
|
||||||
@user_required
|
@user_required
|
||||||
def bookmark_thread():
|
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
|
return '', 204
|
||||||
|
|
||||||
@bp.post('/bookmarks/post/')
|
@bp.post('/bookmarks/post/')
|
||||||
@hard_login_required
|
@hard_login_required
|
||||||
@user_required
|
@user_required
|
||||||
def bookmark_post():
|
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
|
return '', 204
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<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>
|
||||||
<bitty-8 data-connect="/static/js/bits/ui.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' -%}
|
{%- include 'common/topnav.html' -%}
|
||||||
{%- with messages = get_flashed_messages(with_categories=true) -%}
|
{%- with messages = get_flashed_messages(with_categories=true) -%}
|
||||||
{%- if messages -%}
|
{%- if messages -%}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{%- endfor -%}
|
{%- 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>
|
<span class="errors hidden" data-r="bookmarkMenuShowError bookmarkMenuHideError">Something went wrong. Try again later.</span>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
export const b = {
|
export const b = {
|
||||||
babycodePreviewEndpoint: '/api/babycode-preview/',
|
babycodePreviewEndpoint: '/api/babycode-preview/',
|
||||||
bookmarksCollectionEndpoint: '/hyperapi/bookmarks/dropdown/',
|
|
||||||
init: 'babycodeEditorCharCountInit localizeTimestamps',
|
init: 'babycodeEditorCharCountInit localizeTimestamps',
|
||||||
|
|
||||||
bookmarkState: {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getThreadId = () => {
|
const getThreadId = () => {
|
||||||
@@ -14,22 +11,6 @@ const getThreadId = () => {
|
|||||||
return parseInt(scheme[2]);
|
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) {
|
export function setTab(_, sender, el) {
|
||||||
if (sender.ariaSelected === 'true') {
|
if (sender.ariaSelected === 'true') {
|
||||||
return;
|
return;
|
||||||
@@ -242,101 +223,3 @@ export function localizeTimestamps(_, __, el) {
|
|||||||
const d = new Date(el.dateTime);
|
const d = new Date(el.dateTime);
|
||||||
el.innerText = d.toLocaleString();
|
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user