diff --git a/app/models.py b/app/models.py index da2e7ab..ed40939 100644 --- a/app/models.py +++ b/app/models.py @@ -352,19 +352,35 @@ class BookmarkCollections(Model): return not (self.has_posts() or self.has_threads()) def get_threads(self): - q = 'SELECT thread_id FROM bookmarked_threads WHERE collection_id = ?' + q = 'SELECT id FROM bookmarked_threads WHERE collection_id = ?' res = db.query(q, self.id) - return [Threads.find({'id': bt['thread_id']}) for bt in res] + return [BookmarkedThreads.find({'id': bt['id']}) for bt in res] def get_posts(self): - q = 'SELECT post_id FROM bookmarked_posts WHERE collection_id = ?' + q = 'SELECT id FROM bookmarked_posts WHERE collection_id = ?' res = db.query(q, self.id) - return [Posts.find({'id': bt['post_id']}) for bt in res] + return [BookmarkedPosts.find({'id': bt['id']}) for bt in res] + + def get_threads_count(self): + q = 'SELECT COUNT(*) as tc FROM bookmarked_threads WHERE collection_id = ?' + res = db.fetch_one(q, self.id) + return int(res['tc']) + + def get_posts_count(self): + q = 'SELECT COUNT(*) as pc FROM bookmarked_posts WHERE collection_id = ?' + res = db.fetch_one(q, self.id) + return int(res['pc']) class BookmarkedPosts(Model): table = 'bookmarked_posts' + def get_post(self): + return Posts.find({'id': self.post_id}) + class BookmarkedThreads(Model): table = 'bookmarked_threads' + + def get_thread(self): + return Threads.find({'id': self.thread_id}) diff --git a/app/routes/api.py b/app/routes/api.py index b8deb88..94191a6 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2,7 +2,7 @@ from flask import Blueprint, request, url_for from ..lib.babycode import babycode_to_html from ..constants import REACTION_EMOJI from .users import is_logged_in, get_active_user -from ..models import APIRateLimits, Threads, Reactions +from ..models import APIRateLimits, Threads, Reactions, Users, BookmarkCollections from ..db import db bp = Blueprint("api", __name__, url_prefix="/api/") @@ -96,3 +96,46 @@ def remove_reaction(post_id): reaction.delete() return {'status': 'removed'} + +@bp.post('/manage-bookmark-collections/') +def manage_bookmark_collections(user_id): + if not is_logged_in(): + return {'error': 'not authorized', 'error_code': 401}, 401 + + target_user = Users.find({'id': user_id}) + if target_user.id != get_active_user().id: + return {'error': 'forbidden', 'error_code': 403}, 403 + + if target_user.is_guest(): + return {'error': 'forbidden', 'error_code': 403}, 403 + + collections_data = request.json + for idx, coll_data in enumerate(collections_data.get('collections')): + if coll_data['is_new']: + collection = BookmarkCollections.create({ + 'name': coll_data['name'], + 'user_id': target_user.id, + 'sort_order': idx, + }) + else: + collection = BookmarkCollections.find({'id': coll_data['id']}) + if not collection: + continue + + update = {'name': coll_data['name']} + if not collection.is_default: + update['sort_order'] = idx + collection.update(update) + + for removed_id in collections_data.get('removed_collections'): + collection = BookmarkCollections.find({'id': removed_id}) + if not collection: + continue + + if collection.is_default: + continue + + collection.delete() + + + return {'status': 'ok'}, 200 diff --git a/app/routes/users.py b/app/routes/users.py index 1e1803e..cb802ee 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -4,7 +4,7 @@ from flask import ( from functools import wraps from ..db import db from ..lib.babycode import babycode_to_html, BABYCODE_VERSION -from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections +from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections, BookmarkedThreads from ..constants import InfoboxKind, PermissionLevel from ..auth import digest, verify from wand.image import Image @@ -700,4 +700,16 @@ def bookmarks(username): return redirect(url_for('.bookmarks', username=get_active_user().username)) collections = target_user.get_bookmark_collections() + return render_template('users/bookmarks.html', collections=collections) + + +@bp.get('//bookmarks/collections') +@login_required +def bookmark_collections(username): + target_user = Users.find({'username': username}) + if not target_user or target_user.username != get_active_user().username: + return redirect(url_for('.bookmark_collections', username=get_active_user().username)) + + collections = target_user.get_bookmark_collections() + return render_template('users/bookmark_collections.html', collections=collections) diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index 168d4bc..7b41902 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -100,7 +100,12 @@ {% endmacro %} -{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false, Reactions = none, show_thread_title = false, show_bookmark = false) %} +{% macro full_post( + post, render_sig = True, is_latest = False, + editing = False, active_user = None, no_reply = false, + Reactions = none, show_thread_title = false, + show_bookmark = false, memo = None, bookmark_message = "Bookmark…" +) %} {% set postclass = "post" %} {% if editing %} {% set postclass = postclass + " editing" %} @@ -122,6 +127,9 @@
diff --git a/app/templates/threads/thread.html b/app/templates/threads/thread.html index 47cb3e4..f3f96c4 100644 --- a/app/templates/threads/thread.html +++ b/app/templates/threads/thread.html @@ -30,7 +30,7 @@ {% endif %} {% if can_bookmark %} - + {% endif %} {% if can_lock %}
diff --git a/app/templates/topics/topic.html b/app/templates/topics/topic.html index c495999..1e55615 100644 --- a/app/templates/topics/topic.html +++ b/app/templates/topics/topic.html @@ -53,7 +53,7 @@ {% if active_user and not active_user.is_guest() -%} - + {%- endif %} diff --git a/app/templates/users/bookmark_collections.html b/app/templates/users/bookmark_collections.html new file mode 100644 index 0000000..39ba16f --- /dev/null +++ b/app/templates/users/bookmark_collections.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}managing bookmark collections{% endblock %} +{% block content %} +
+

Manage bookmark collections

+

Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.

+
+ +
+ {% for collection in collections | sort(attribute='sort_order') %} +
+
+
{{ collection.get_threads_count() }} {{ "thread" | pluralize(num=collection.get_threads_count()) }}, {{ collection.get_posts_count() }} {{ "post" | pluralize(num=collection.get_posts_count()) }}
+ {% if collection.is_default %} + Default collection + {% else %} + + {% endif %} +
+ {% endfor %} +
+ +
+
+ +{% endblock %} diff --git a/app/templates/users/bookmarks.html b/app/templates/users/bookmarks.html index 20d5918..9dd643e 100644 --- a/app/templates/users/bookmarks.html +++ b/app/templates/users/bookmarks.html @@ -1,9 +1,10 @@ {% from "common/macros.html" import accordion, full_post %} +{% from "common/icons.html" import icn_bookmark %} {% extends "base.html" %} {% block title %}bookmarks{% endblock %} {% block content %}
- {% for collection in collections %} + {% for collection in collections | sort(attribute='sort_order') %} {% call(section) accordion(disabled=collection.is_empty()) %} {% if section == 'header' %}

{{ collection.name }}

{{" (no bookmarks)" if collection.is_empty() else ""}} @@ -12,19 +13,34 @@ {% if inner_section == 'header' %} Threads{{" (no bookmarks)" if not collection.has_threads() else ""}} {% else %} -
    - {% for thread in collection.get_threads()|sort(attribute='created_at', reverse=true) %} -
  • {{ thread.title }}
  • - {% endfor %} -
+ + + + + + + {% for thread in collection.get_threads() %} + + + + + + {% endfor %} +
TitleMemoManage
+ {{ thread.get_thread().title }} + + {{ thread.note }} + + +
{% endif %} {% endcall %} {% call(inner_section) accordion(disabled=not collection.has_posts()) %} {% if inner_section == 'header' %} Posts{{" (no bookmarks)" if not collection.has_posts() else ""}} {% else %} - {% for post in collection.get_posts()|sort(attribute='created_at', reverse=true) %} - {{ full_post(post.get_full_post_view(), no_reply=false, render_sig=false, show_thread_title=true) }} + {% for post in collection.get_posts() %} + {{ full_post(post.get_post().get_full_post_view(), no_reply=false, render_sig=false, show_thread_title=true, show_bookmark=true, memo=post.note, bookmark_message="Manage…") }} {% endfor %} {% endif %} {% endcall %} diff --git a/data/static/css/style.css b/data/static/css/style.css index fe08fda..876cc81 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -1060,6 +1060,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus background-color: rgb(177, 206, 204.5); } +.draggable-collection { + cursor: pointer; + user-select: none; + background-color: #c1ceb1; + padding: 20px; + margin: 15px 0; + border-top: 5px outset rgb(217.26, 220.38, 213.42); + border-bottom: 5px outset rgb(135.1928346457, 145.0974015748, 123.0025984252); +} +.draggable-collection.dragged { + background-color: rgb(177, 206, 204.5); +} +.draggable-collection.default { + background-color: #beb1ce; +} + .editing { background-color: rgb(217.26, 220.38, 213.42); } diff --git a/data/static/css/theme-otomotone.css b/data/static/css/theme-otomotone.css index 953ddb7..b8e2fc8 100644 --- a/data/static/css/theme-otomotone.css +++ b/data/static/css/theme-otomotone.css @@ -1060,6 +1060,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus background-color: #3c283c; } +.draggable-collection { + cursor: pointer; + user-select: none; + background-color: #9b649b; + padding: 20px; + margin: 15px 0; + border-top: 5px outset #503250; + border-bottom: 5px outset rgb(96.95, 81.55, 96.95); +} +.draggable-collection.dragged { + background-color: #3c283c; +} +.draggable-collection.default { + background-color: #8a5584; +} + .editing { background-color: #503250; } diff --git a/data/static/css/theme-peachy.css b/data/static/css/theme-peachy.css index 25b3eff..89da7f8 100644 --- a/data/static/css/theme-peachy.css +++ b/data/static/css/theme-peachy.css @@ -1060,6 +1060,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus background-color: #f27a5a; } +.draggable-collection { + cursor: pointer; + user-select: none; + background-color: #f27a5a; + padding: 12px; + margin: 8px 0; + border-top: 5px outset rgb(219.84, 191.04, 183.36); + border-bottom: 5px outset rgb(155.8907865169, 93.2211235955, 76.5092134831); +} +.draggable-collection.dragged { + background-color: #f27a5a; +} +.draggable-collection.default { + background-color: #b54444; +} + .editing { background-color: rgb(219.84, 191.04, 183.36); } diff --git a/data/static/js/manage-bookmark-collections.js b/data/static/js/manage-bookmark-collections.js new file mode 100644 index 0000000..75f1ade --- /dev/null +++ b/data/static/js/manage-bookmark-collections.js @@ -0,0 +1,128 @@ +let removedCollections = []; + +document.getElementById("add-collection-button").addEventListener("click", () => { + const container = document.getElementById("collections-container"); + const currentCount = container.querySelectorAll(".draggable-collection").length; + + const newId = `new-${Date.now()}` + const collectionHtml = ` +
+
+
0 threads, 0 posts
+ +
+ `; + container.insertAdjacentHTML('beforeend', collectionHtml); +}) + +document.addEventListener("click", e => { + if (!e.target.classList.contains("delete-button")) { + return; + } + const collectionDiv = e.target.closest(".draggable-collection"); + const collectionId = collectionDiv.dataset.collectionId; + + if (!collectionId.startsWith("new-")) { + removedCollections.push(collectionId); + } + + collectionDiv.remove(); +}) + +document.getElementById("save-button").addEventListener("click", async () => { + const collections = []; + const collectionDivs = document.querySelectorAll(".draggable-collection"); + let isValid = true; + collectionDivs.forEach((collection, index) => { + const collectionId = collection.dataset.collectionId; + const nameInput = collection.querySelector(".collection-name"); + + if (!nameInput.reportValidity()) { + isValid = false; + return; + } + + collections.push({ + id: collectionId, + name: nameInput.value, + is_new: collectionId.startsWith("new-"), + }); + }) + + if (!isValid) { + return; + } + + const data = { + collections: collections, + removed_collections: removedCollections, + }; + + try { + const saveHref = document.getElementById('save-button').dataset.submitHref; + const response = await fetch(saveHref, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (response.ok) { + window.location.reload(); + } else { + console.error("Error saving collections"); + } + + } catch (error) { + console.error("Error saving collections: ", error); + } +}) + +// drag logic +// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap + +let selected = null; +const container = document.getElementById("collections-container"); +function isBefore(el1, el2) { + let cur; + if (el2.parentNode === el1.parentNode) { + for (cur = el1.previousSibling; cur; cur = cur.previousSibling) { + if (cur === el2) return true; + } + } + return false; +} + +function dragOver(e) { + let target = e.target.closest(".draggable-collection") + + if (!target || target === selected) { + return; + } + + if (isBefore(selected, target)) { + container.insertBefore(selected, target) + } else { + container.insertBefore(selected, target.nextSibling) + } +} + +function dragEnd() { + if (!selected) return; + + selected.classList.remove("dragged") + selected = null; +} + +function dragStart(e) { + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', "") + selected = e.target + selected.classList.add("dragged") +} diff --git a/sass/_default.scss b/sass/_default.scss index 26165cc..4d52b9e 100644 --- a/sass/_default.scss +++ b/sass/_default.scss @@ -954,12 +954,39 @@ $draggable_topic_border_bottom: $draggable_topic_border $DARK_2 !default; margin: $draggable_topic_margin; border-top: $draggable_topic_border_top; border-bottom: $draggable_topic_border_bottom; - + &.dragged { background-color: $draggable_topic_dragged_color; } } +$draggable_collection_background: $ACCENT_COLOR !default; +$draggable_collection_dragged_color: $BUTTON_COLOR !default; +$draggable_collection_default_color: $BUTTON_COLOR_2 !default; +$draggable_collection_padding: $BIG_PADDING !default; +$draggable_collection_margin: $MEDIUM_BIG_PADDING 0 !default; +$draggable_collection_border: 5px outset !default; +$draggable_collection_border_top: $draggable_collection_border $LIGHT !default; +$draggable_collection_border_bottom: $draggable_collection_border $DARK_2 !default; +.draggable-collection { + cursor: pointer; + user-select: none; + background-color: $draggable_collection_background; + padding: $draggable_collection_padding; + margin: $draggable_collection_margin; + border-top: $draggable_collection_border_top; + border-bottom: $draggable_collection_border_bottom; + + &.dragged { + background-color: $draggable_collection_dragged_color; + } + + &.default { + background-color: $draggable_collection_default_color; + } +} + + $post_editing_header_color: $LIGHT !default; .editing { background-color: $post_editing_header_color;