bookmark collections

This commit is contained in:
2025-11-21 12:38:23 +03:00
parent 831eb32b8a
commit 71b04ca4bd
13 changed files with 352 additions and 19 deletions

View File

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

View File

@@ -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/<user_id>')
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

View File

@@ -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('/<username>/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)

View File

@@ -100,7 +100,12 @@
</form>
{% 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&hellip;"
) %}
{% set postclass = "post" %}
{% if editing %}
{% set postclass = postclass + " editing" %}
@@ -122,6 +127,9 @@
<div class="post-content-container" {{ "id=latest-post" if is_latest else "" }}>
<div class="post-info">
<span>
{% if memo -%}
Memo: <i>{{ memo }}</i> &bullet;
{%- endif %}
{% if show_thread_title %}
<a href="{{ url_for('threads.thread', slug=post.thread_slug) }}">Thread: {{ post.thread_title }}</a>
&bullet;
@@ -174,7 +182,7 @@
{% endif %}
{% if show_bookmark %}
<button type="button" class="contain-svg inline icon">{{ icn_bookmark(20) }}Bookmark</button>
<button type="button" class="contain-svg inline icon">{{ icn_bookmark(20) }}{{ bookmark_message | safe }}</button>
{% endif %}
</span>
</div>

View File

@@ -30,7 +30,7 @@
</form>
{% endif %}
{% if can_bookmark %}
<button type="button" class="contain-svg inline icon">{{ icn_bookmark(20) }}Bookmark</button>
<button type="button" class="contain-svg inline icon">{{ icn_bookmark(20) }}Bookmark&hellip;</button>
{% endif %}
{% if can_lock %}
<form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post">

View File

@@ -53,7 +53,7 @@
</span>
<span>
{% if active_user and not active_user.is_guest() -%}
<button class="thread-info-bookmark-button contain-svg icon" type="button">{{ icn_bookmark(20) }}Bookmark</button>
<button class="thread-info-bookmark-button contain-svg icon" type="button">{{ icn_bookmark(20) }}Bookmark&hellip;</button>
{%- endif %}
</span>
</span>

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}managing bookmark collections{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1>Manage bookmark collections</h1>
<p>Drag collections to reoder them. You cannot move or remove the default collection, but you can rename it.</p>
<div>
<button type="button" id="add-collection-button">Add new collection</button>
<div id="collections-container">
{% for collection in collections | sort(attribute='sort_order') %}
<div class="draggable-collection {{ "default" if collection.is_default else ""}}"
{% if not collection.is_default %}
draggable="true"
ondragover="dragOver(event)"
ondragstart="dragStart(event)"
ondragend="dragEnd()"
{% else %}
id="default-collection"
{% endif %}
data-collection-id="{{ collection.id }}">
<input type="text" class="collection-name" value="{{ collection.name }}" placeholder="Collection name" required autocomplete="off" maxlength="60"><br>
<div>{{ collection.get_threads_count() }} {{ "thread" | pluralize(num=collection.get_threads_count()) }}, {{ collection.get_posts_count() }} {{ "post" | pluralize(num=collection.get_posts_count()) }}</div>
{% if collection.is_default %}
<i>Default collection</i>
{% else %}
<button type="button" class="delete-button critical">Delete</button>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" id="save-button" data-submit-href="{{ url_for('api.manage_bookmark_collections', user_id=active_user.id) }}">Save</button>
</div>
</div>
<script src="{{ "/static/js/manage-bookmark-collections.js" | cachebust }}"></script>
{% endblock %}

View File

@@ -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 %}
<div class="darkbg inbox-container">
{% for collection in collections %}
{% for collection in collections | sort(attribute='sort_order') %}
{% call(section) accordion(disabled=collection.is_empty()) %}
{% if section == 'header' %}
<h1 class="thread-title">{{ collection.name }}</h1>{{" (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 %}
<ul>
{% for thread in collection.get_threads()|sort(attribute='created_at', reverse=true) %}
<li><a href="{{ url_for('threads.thread', slug=thread.slug) }}">{{ thread.title }}</a></li>
{% endfor %}
</ul>
<table class="colorful-table">
<thead>
<th>Title</th>
<th>Memo</th>
<th class="small">Manage</th>
</thead>
{% for thread in collection.get_threads() %}
<tr>
<td>
<a href="{{ url_for('threads.thread', slug=thread.get_thread().slug) }}">{{ thread.get_thread().title }}</a>
</td>
<td>
<i>{{ thread.note }}</i>
</td>
<td>
<button type="button" class="contain-svg inline icon">{{ icn_bookmark(20) }}Manage&hellip;</button>
</td>
</tr>
{% endfor %}
</table>
{% 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&hellip;") }}
{% endfor %}
{% endif %}
{% endcall %}

View File

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

View File

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

View File

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

View File

@@ -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 = `
<div class="draggable-collection"
data-collection-id="${newId}"
draggable="true"
ondragover="dragOver(event)"
ondragstart="dragStart(event)"
ondragend="dragEnd()">
<input type="text" class="collection-name" value="" required placeholder="Enter collection name" autocomplete="off" maxlength="60"><br>
<div>0 threads, 0 posts</div>
<button type="button" class="delete-button critical">Delete</button>
</div>
`;
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")
}

View File

@@ -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;