Compare commits

...

12 Commits

34 changed files with 1329 additions and 136 deletions

View File

@@ -60,4 +60,4 @@ $ python -m app.run
``` ```
# icons # icons
the icons in the `data/static/misc/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license) the icons in the `app/templates/common/icons.html` file are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)

View File

@@ -25,12 +25,11 @@ Designers: Paul James Miller
## ICONCINO ## ICONCINO
Affected files: [`data/static/misc/error.svg`](./data/static/misc/error.svg) [`data/static/misc/image.svg`](./data/static/misc/image.svg) [`data/static/misc/info.svg`](./data/static/misc/info.svg) [`data/static/misc/lock.svg`](./data/static/misc/lock.svg) [`data/static/misc/spoiler.svg`](./data/static/misc/spoiler.svg) [`data/static/misc/sticky.svg`](./data/static/misc/sticky.svg) [`data/static/misc/warn.svg`](./data/static/misc/warn.svg) Affected files: [`app/templates/common/icons.html`](./app/templates/common/icons.html)
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
Copyright: Gabriele Malaspina Copyright: Gabriele Malaspina
Designers: Gabriele Malaspina Designers: Gabriele Malaspina
License: CC0 1.0/CC BY 4.0 License: CC0 1.0
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
## Forumoji ## Forumoji
@@ -72,3 +71,11 @@ Modified work Copyright (C) 2019-2025 by E. McConville <https://emcconville.com>
License: MIT License: MIT
Repo: https://github.com/emcconville/wand Repo: https://github.com/emcconville/wand
## Bitty
Affected files: [`data/static/js/vnd/bitty-5.1.0-rc6.min.js`](./data/static/js/vnd/bitty-5.1.0-rc6.min.js)
URL: https://bitty.alanwsmith.com/
Copyright: `Copyright (c) 2025 Alan Smith - https://www.alanwsmith.com/`
License: MIT
Repo: https://github.com/alanwsmith/bitty

View File

@@ -1,4 +1,4 @@
from flask import Flask, session from flask import Flask, session, request
from dotenv import load_dotenv from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts from .models import Avatars, Users, PostHistory, Posts
from .auth import digest from .auth import digest
@@ -6,7 +6,7 @@ from .routes.users import is_logged_in, get_active_user, get_prefers_theme
from .routes.threads import get_post_url from .routes.threads import get_post_url
from .constants import ( from .constants import (
PermissionLevel, permission_level_string, PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass, InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI, REACTION_EMOJI,
) )
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
@@ -48,21 +48,38 @@ def create_deleted_user():
"permission": PermissionLevel.SYSTEM.value, "permission": PermissionLevel.SYSTEM.value,
}) })
def reparse_posts(): def reparse_babycode():
print('Re-parsing babycode, this may take a while...')
from .db import db from .db import db
post_histories = PostHistory.findall([ post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'), ('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION) ('format_version', 'IS NOT', BABYCODE_VERSION)
]) ])
if len(post_histories) == 0: if len(post_histories) > 0:
return print('Re-parsing user posts...')
print('Re-parsing babycode, this may take a while...') with db.transaction():
with db.transaction(): for ph in post_histories:
for ph in post_histories: ph.update({
ph.update({ 'content': babycode_to_html(ph['original_markup']),
'content': babycode_to_html(ph['original_markup']), 'format_version': BABYCODE_VERSION,
'format_version': BABYCODE_VERSION, })
print('Re-parsing posts done.')
users_with_sigs = Users.findall([
('signature_markup_language', '=', 'babycode'),
('signature_format_version', 'IS NOT', BABYCODE_VERSION),
('signature_original_markup', 'IS NOT', '')
])
if len(users_with_sigs) > 0:
print('Re-parsing user sigs...')
with db.transaction():
for user in users_with_sigs:
user.update({
'signature_rendered': babycode_to_html(user['signature_original_markup']),
'signature_format_version': BABYCODE_VERSION,
}) })
print(f'Re-parsed {len(users_with_sigs)} user sigs.')
print('Re-parsing done.') print('Re-parsing done.')
def create_app(): def create_app():
@@ -105,7 +122,7 @@ def create_app():
create_admin() create_admin()
create_deleted_user() create_deleted_user()
reparse_posts() reparse_babycode()
from app.routes.app import bp as app_bp from app.routes.app import bp as app_bp
from app.routes.topics import bp as topics_bp from app.routes.topics import bp as topics_bp
@@ -114,6 +131,7 @@ def create_app():
from app.routes.mod import bp as mod_bp from app.routes.mod import bp as mod_bp
from app.routes.api import bp as api_bp from app.routes.api import bp as api_bp
from app.routes.posts import bp as posts_bp from app.routes.posts import bp as posts_bp
from app.routes.hyperapi import bp as hyperapi_bp
app.register_blueprint(app_bp) app.register_blueprint(app_bp)
app.register_blueprint(topics_bp) app.register_blueprint(topics_bp)
app.register_blueprint(threads_bp) app.register_blueprint(threads_bp)
@@ -121,6 +139,7 @@ def create_app():
app.register_blueprint(mod_bp) app.register_blueprint(mod_bp)
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
app.register_blueprint(posts_bp) app.register_blueprint(posts_bp)
app.register_blueprint(hyperapi_bp)
app.config['SESSION_COOKIE_SECURE'] = True app.config['SESSION_COOKIE_SECURE'] = True
@@ -135,7 +154,6 @@ def create_app():
@app.context_processor @app.context_processor
def inject_constants(): def inject_constants():
return { return {
"InfoboxIcons": InfoboxIcons,
"InfoboxHTMLClass": InfoboxHTMLClass, "InfoboxHTMLClass": InfoboxHTMLClass,
"InfoboxKind": InfoboxKind, "InfoboxKind": InfoboxKind,
"PermissionLevel": PermissionLevel, "PermissionLevel": PermissionLevel,
@@ -184,6 +202,15 @@ def create_app():
for id_, text in matches for id_, text in matches
] ]
@app.errorhandler(404)
def _handle_404(e):
if request.path.startswith('/hyperapi/'):
return '<h1>not found</h1>', e.code
elif request.path.startswith('/api/'):
return {'error': 'not found'}, e.code
else:
return e
# this only happens at build time but # this only happens at build time but
# build time is when updates are done anyway # build time is when updates are done anyway
# sooo... /shrug # sooo... /shrug

View File

@@ -56,13 +56,6 @@ class InfoboxKind(IntEnum):
WARN = 2 WARN = 2
ERROR = 3 ERROR = 3
InfoboxIcons = {
InfoboxKind.INFO: "/static/misc/info.svg",
InfoboxKind.LOCK: "/static/misc/lock.svg",
InfoboxKind.WARN: "/static/misc/warn.svg",
InfoboxKind.ERROR: "/static/misc/error.svg",
}
InfoboxHTMLClass = { InfoboxHTMLClass = {
InfoboxKind.INFO: "", InfoboxKind.INFO: "",
InfoboxKind.LOCK: "warn", InfoboxKind.LOCK: "warn",

View File

@@ -5,12 +5,31 @@ def migrate_old_avatars():
new_path = f"/static{avatar['file_path']}" new_path = f"/static{avatar['file_path']}"
db.execute('UPDATE avatars SET file_path = ? WHERE id = ?', new_path, avatar['id']) db.execute('UPDATE avatars SET file_path = ? WHERE id = ?', new_path, avatar['id'])
def add_signature_format():
db.execute('ALTER TABLE "users" ADD COLUMN "signature_markup_language" TEXT NOT NULL DEFAULT "babycode"')
db.execute('ALTER TABLE "users" ADD COLUMN "signature_format_version" INTEGER DEFAULT NULL')
def create_default_bookmark_collections():
from .constants import PermissionLevel
q = """SELECT users.id FROM users
LEFT JOIN bookmark_collections bc ON (users.id = bc.user_id AND bc.is_default = TRUE)
WHERE bc.id IS NULL and users.permission IS NOT ?"""
user_ids_without_default_collection = db.query(q, PermissionLevel.SYSTEM.value)
if len(user_ids_without_default_collection) == 0:
return
from .models import BookmarkCollections
for user in user_ids_without_default_collection:
BookmarkCollections.create_default(user['id'])
# format: [str|tuple(str, any...)|callable] # format: [str|tuple(str, any...)|callable]
MIGRATIONS = [ MIGRATIONS = [
migrate_old_avatars, migrate_old_avatars,
'DELETE FROM sessions', # delete old lua porom sessions 'DELETE FROM sessions', # delete old lua porom sessions
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system 'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL', 'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
add_signature_format,
create_default_bookmark_collections,
] ]
def run_migrations(): def run_migrations():

View File

@@ -91,7 +91,7 @@ class Users(Model):
def can_invite(self): def can_invite(self):
if not current_app.config['DISABLE_SIGNUP']: if not current_app.config['DISABLE_SIGNUP']:
return True return False
if current_app.config['MODS_CAN_INVITE'] and self.is_mod(): if current_app.config['MODS_CAN_INVITE'] and self.is_mod():
return True return True
@@ -101,6 +101,11 @@ class Users(Model):
return False return False
def get_bookmark_collections(self):
q = 'SELECT id FROM bookmark_collections WHERE user_id = ? ORDER BY sort_order ASC'
res = db.query(q, self.id)
return [BookmarkCollections.find({'id': bc['id']}) for bc in res]
class Topics(Model): class Topics(Model):
table = "topics" table = "topics"
@@ -225,7 +230,7 @@ class Threads(Model):
class Posts(Model): class Posts(Model):
FULL_POSTS_QUERY = """ FULL_POSTS_QUERY = """
SELECT SELECT
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered, threads.slug AS thread_slug, threads.is_locked AS thread_is_locked posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered, threads.slug AS thread_slug, threads.is_locked AS thread_is_locked, threads.title AS thread_title
FROM FROM
posts posts
JOIN JOIN
@@ -239,6 +244,10 @@ class Posts(Model):
table = "posts" table = "posts"
def get_full_post_view(self):
q = f'{self.FULL_POSTS_QUERY} WHERE posts.id = ?'
return db.fetch_one(q, self.id)
class PostHistory(Model): class PostHistory(Model):
table = "post_history" table = "post_history"
@@ -317,3 +326,71 @@ class PasswordResetLinks(Model):
class InviteKeys(Model): class InviteKeys(Model):
table = 'invite_keys' table = 'invite_keys'
class BookmarkCollections(Model):
table = 'bookmark_collections'
@classmethod
def create_default(cls, user_id):
q = """INSERT INTO bookmark_collections (user_id, name, is_default, sort_order)
VALUES (?, "Bookmarks", 1, 0) RETURNING id
"""
res = db.fetch_one(q, user_id)
def has_posts(self):
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ?) as e'
res = db.fetch_one(q, self.id)['e']
return int(res) == 1
def has_threads(self):
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_threads WHERE collection_id = ?) as e'
res = db.fetch_one(q, self.id)['e']
return int(res) == 1
def is_empty(self):
return not (self.has_posts() or self.has_threads())
def get_threads(self):
q = 'SELECT id FROM bookmarked_threads WHERE collection_id = ?'
res = db.query(q, self.id)
return [BookmarkedThreads.find({'id': bt['id']}) for bt in res]
def get_posts(self):
q = 'SELECT id FROM bookmarked_posts WHERE collection_id = ?'
res = db.query(q, self.id)
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'])
def has_thread(self, thread_id):
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_threads WHERE collection_id = ? AND thread_id = ?) as e'
res = db.fetch_one(q, self.id, int(thread_id))['e']
return int(res) == 1
def has_post(self, post_id):
q = 'SELECT EXISTS(SELECT 1 FROM bookmarked_posts WHERE collection_id = ? AND post_id = ?) as e'
res = db.fetch_one(q, self.id, int(post_id))['e']
return int(res) == 1
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 ..lib.babycode import babycode_to_html
from ..constants import REACTION_EMOJI from ..constants import REACTION_EMOJI
from .users import is_logged_in, get_active_user from .users import is_logged_in, get_active_user
from ..models import APIRateLimits, Threads, Reactions from ..models import APIRateLimits, Threads, Reactions, Users, BookmarkCollections, BookmarkedThreads, BookmarkedPosts
from ..db import db from ..db import db
bp = Blueprint("api", __name__, url_prefix="/api/") bp = Blueprint("api", __name__, url_prefix="/api/")
@@ -96,3 +96,119 @@ def remove_reaction(post_id):
reaction.delete() reaction.delete()
return {'status': 'removed'} 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
@bp.post('/bookmark-post/<post_id>')
def bookmark_post(post_id):
if not is_logged_in():
return {'error': 'not authorized', 'error_code': 401}, 401
operation = request.json.get('operation')
if operation == 'remove' and request.json.get('collection_id', '') == '':
return {'status': 'not modified'}, 304
collection_id = int(request.json.get('collection_id'))
post_id = int(post_id)
memo = request.json.get('memo', '')
if operation == 'move':
bm = BookmarkedPosts.find({'post_id': post_id})
if not bm:
BookmarkedPosts.create({
'post_id': post_id,
'collection_id': collection_id,
'note': memo,
})
else:
bm.update({
'collection_id': collection_id,
'note': memo,
})
elif operation == 'remove':
bm = BookmarkedPosts.find({'post_id': post_id})
if bm:
bm.delete()
else:
return {'error': 'bad request'}, 400
return {'status': 'ok'}, 200
@bp.post('/bookmark-thread/<thread_id>')
def bookmark_thread(thread_id):
if not is_logged_in():
return {'error': 'not authorized', 'error_code': 401}, 401
operation = request.json.get('operation')
if operation == 'remove' and request.json.get('collection_id', '') == '':
return {'status': 'not modified'}, 304
collection_id = int(request.json.get('collection_id'))
thread_id = int(thread_id)
memo = request.json.get('memo', '')
if operation == 'move':
bm = BookmarkedThreads.find({'thread_id': thread_id})
if not bm:
BookmarkedThreads.create({
'thread_id': thread_id,
'collection_id': collection_id,
'note': memo,
})
else:
bm.update({
'collection_id': collection_id,
'note': memo,
})
elif operation == 'remove':
bm = BookmarkedThreads.find({
'thread_id': thread_id,
'note': memo,
})
if bm:
bm.delete()
else:
return {'error': 'bad request'}, 400
return {'status': 'ok'}, 200

53
app/routes/hyperapi.py Normal file
View File

@@ -0,0 +1,53 @@
from flask import Blueprint, render_template, abort, request
from .users import get_active_user, is_logged_in
from ..models import BookmarkCollections, BookmarkedPosts, BookmarkedThreads
from functools import wraps
bp = Blueprint('hyperapi', __name__, url_prefix='/hyperapi/')
def login_required(view_func):
@wraps(view_func)
def dec(*args, **kwargs):
if not is_logged_in():
abort(403)
return view_func(*args, **kwargs)
return dec
def account_required(view_func):
@wraps(view_func)
def dec(*args, **kwargs):
if get_active_user().is_guest():
abort(403)
return view_func(*args, **kwargs)
return dec
@bp.errorhandler(403)
def handle_403(e):
return "<h1>forbidden</h1>", 403
@bp.get('bookmarks-dropdown/<bookmark_type>')
@login_required
@account_required
def bookmarks_dropdown(bookmark_type):
collections = BookmarkCollections.findall({'user_id': get_active_user().id})
concept_id = request.args.get('id')
require_reload = bool(int(request.args.get('require_reload', default=0)))
if bookmark_type.lower() == 'thread':
selected = next(filter(lambda bc: bc.has_thread(concept_id), collections), None)
elif bookmark_type.lower() == 'post':
selected = next(filter(lambda bc: bc.has_post(concept_id), collections), None)
else:
abort(400)
return
if selected:
if bookmark_type.lower() == 'thread':
memo = BookmarkedThreads.find({'collection_id': selected.id, 'thread_id': int(concept_id)}).note
else:
memo = BookmarkedPosts.find({'collection_id': selected.id, 'post_id': int(concept_id)}).note
else:
memo = ''
return render_template('components/bookmarks_dropdown.html', collections=collections, id=concept_id, selected=selected, type=bookmark_type, memo=memo, require_reload=require_reload)

View File

@@ -3,8 +3,8 @@ from flask import (
) )
from functools import wraps from functools import wraps
from ..db import db from ..db import db
from ..lib.babycode import babycode_to_html from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections, BookmarkedThreads
from ..constants import InfoboxKind, PermissionLevel from ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify from ..auth import digest, verify
from wand.image import Image from wand.image import Image
@@ -260,6 +260,8 @@ def sign_up_post():
"permission": PermissionLevel.GUEST.value, "permission": PermissionLevel.GUEST.value,
}) })
BookmarkCollections.create_default(new_user.id)
if current_app.config['DISABLE_SIGNUP']: if current_app.config['DISABLE_SIGNUP']:
invite_key = InviteKeys.find({'key': key}) invite_key = InviteKeys.find({'key': key})
new_user.update({ new_user.update({
@@ -307,14 +309,19 @@ def settings_form(username):
if topic_sort_by == 'activity' or topic_sort_by == 'thread': if topic_sort_by == 'activity' or topic_sort_by == 'thread':
sort_by = session['sort_by'] = topic_sort_by sort_by = session['sort_by'] = topic_sort_by
status = request.form.get('status', default="")[:100] status = request.form.get('status', default="")[:100]
original_sig = request.form.get('signature', default='') original_sig = request.form.get('signature', default='').strip()
rendered_sig = babycode_to_html(original_sig) if original_sig:
rendered_sig = babycode_to_html(original_sig)
else:
rendered_sig = ''
session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on' session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on'
user.update({ user.update({
'status': status, 'status': status,
'signature_original_markup': original_sig, 'signature_original_markup': original_sig,
'signature_rendered': rendered_sig, 'signature_rendered': rendered_sig,
'signature_format_version': BABYCODE_VERSION,
'signature_markup_language': 'babycode',
}) })
flash('Settings updated.', InfoboxKind.INFO) flash('Settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username)) return redirect(url_for('.settings', username=user.username))
@@ -683,3 +690,26 @@ def revoke_invite_link(username):
invite.delete() invite.delete()
return redirect(url_for('.invite_links', username=target_user.username)) return redirect(url_for('.invite_links', username=target_user.username))
@bp.get('/<username>/bookmarks')
@login_required
def bookmarks(username):
target_user = Users.find({'username': username})
if not target_user or target_user.username != get_active_user().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

@@ -96,6 +96,30 @@ SCHEMA = [
"key" TEXT NOT NULL UNIQUE "key" TEXT NOT NULL UNIQUE
)""", )""",
"""CREATE TABLE IF NOT EXISTS "bookmark_collections" (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" REFERENCES users(id) ON DELETE CASCADE,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT FALSE,
"sort_order" INTEGER NOT NULL DEFAULT 0
)""",
"""CREATE TABLE IF NOT EXISTS "bookmarked_posts" (
"id" INTEGER NOT NULL PRIMARY KEY,
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
"post_id" REFERENCES posts(id) ON DELETE CASCADE,
"note" TEXT,
UNIQUE(collection_id, post_id)
)""",
"""CREATE TABLE IF NOT EXISTS "bookmarked_threads" (
"id" INTEGER NOT NULL PRIMARY KEY,
"collection_id" REFERENCES bookmark_collections(id) ON DELETE CASCADE,
"thread_id" REFERENCES threads(id) ON DELETE CASCADE,
"note" TEXT,
UNIQUE(collection_id, thread_id)
)""",
# INDEXES # INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)",
"CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", "CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)",
@@ -110,6 +134,15 @@ SCHEMA = [
"CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)", "CREATE INDEX IF NOT EXISTS reaction_post_text ON reactions(post_id, reaction_text)",
"CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)", "CREATE INDEX IF NOT EXISTS reaction_user_post_text ON reactions(user_id, post_id, reaction_text)",
"CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_id ON bookmark_collections(user_id)",
"CREATE INDEX IF NOT EXISTS idx_bookmark_collections_user_default ON bookmark_collections(user_id, is_default) WHERE is_default = 1",
"CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_collection ON bookmarked_posts(collection_id)",
"CREATE INDEX IF NOT EXISTS idx_bookmarked_posts_post ON bookmarked_posts(post_id)",
"CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)",
"CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)",
] ]
def create(): def create():

View File

@@ -10,20 +10,23 @@
{% endif %} {% endif %}
<link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}"> <link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}">
<link rel="icon" type="image/png" href="/static/favicon.png"> <link rel="icon" type="image/png" href="/static/favicon.png">
<script src="/static/js/vnd/bitty-5.1.0-rc6.min.js" type="module"></script>
</head> </head>
<body> <body>
{% include 'common/topnav.html' %} <bitty-5-1 data-connect="/static/js/bitties/pyrom-bitty.js">
{% with messages = get_flashed_messages(with_categories=true) %} {% include 'common/topnav.html' %}
{% if messages %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% if messages %}
{{ infobox(message, category) }} {% for category, message in messages %}
{% endfor %} {{ infobox(message, category) }}
{% endif %} {% endfor %}
{% endwith %} {% endif %}
{% block content %}{% endblock %} {% endwith %}
<footer class="darkbg"> {% block content %}{% endblock %}
<span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span> <footer class="darkbg">
</footer> <span>Pyrom commit <a href="{{ "https://git.poto.cafe/yagich/pyrom/commit/" + __commit }}">{{ __commit[:8] }}</a></span>
</footer>
</bitty-5-1>
<script src="{{ "/static/js/ui.js" | cachebust }}"></script> <script src="{{ "/static/js/ui.js" | cachebust }}"></script>
<script src="{{ "/static/js/date-fmt.js" | cachebust }}"></script> <script src="{{ "/static/js/date-fmt.js" | cachebust }}"></script>
</body> </body>

View File

@@ -0,0 +1,49 @@
{# https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license #}
{% macro icn_bookmark(width=24) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6 6C6 4.89543 6.89543 4 8 4H16C17.1046 4 18 4.89543 18 6V18.7268C18 19.5969 16.9657 20.0519 16.3243 19.4639L12 15.5L7.67573 19.4639C7.03432 20.0519 6 19.5969 6 18.7268V6Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_error(width=60) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.364 5.63604C19.9926 7.26472 21 9.51472 21 12C21 16.9706 16.9706 21 12 21C9.51472 21 7.26472 19.9926 5.63604 18.364M18.364 5.63604C16.7353 4.00736 14.4853 3 12 3C7.02944 3 3 7.02944 3 12C3 14.4853 4.00736 16.7353 5.63604 18.364M18.364 5.63604L5.63604 18.364" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_info(width=60) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8V8.5M12 12V16M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_lock(width=60) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14V16M8 9V6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V9M7 21H17C18.1046 21 19 20.1046 19 19V11C19 9.89543 18.1046 9 17 9H7C5.89543 9 5 9.89543 5 11V19C5 20.1046 5.89543 21 7 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_warn(width=60) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15H12.01M12 12V9M4.98207 19H19.0179C20.5615 19 21.5233 17.3256 20.7455 15.9923L13.7276 3.96153C12.9558 2.63852 11.0442 2.63852 10.2724 3.96153L3.25452 15.9923C2.47675 17.3256 3.43849 19 4.98207 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_image(width=24) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_spoiler(width=24) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L9.87868 9.87868M20 20L14.1213 14.1213M9.87868 9.87868C9.33579 10.4216 9 11.1716 9 12C9 13.6569 10.3431 15 12 15C12.8284 15 13.5784 14.6642 14.1213 14.1213M9.87868 9.87868L14.1213 14.1213M6.76821 6.76821C4.72843 8.09899 2.96378 10.026 2 11.9998C3.74646 15.5764 8.12201 19 11.9998 19C13.7376 19 15.5753 18.3124 17.2317 17.2317M9.76138 5.34717C10.5114 5.12316 11.2649 5 12.0005 5C15.8782 5 20.2531 8.42398 22 12.0002C21.448 13.1302 20.6336 14.2449 19.6554 15.2412" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}
{% macro icn_sticky(width=24) -%}
<svg width="{{width}}px" height="{{width}}px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V13M13 20L20 13M13 20V14C13 13.4477 13.4477 13 14 13H20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{%- endmacro %}

View File

@@ -1,3 +1,4 @@
{% from 'common/icons.html' import icn_image, icn_spoiler, icn_info, icn_lock, icn_warn, icn_error, icn_bookmark %}
{% macro pager(current_page, page_count) %} {% macro pager(current_page, page_count) %}
{% set left_start = [1, current_page - 5] | max %} {% set left_start = [1, current_page - 5] | max %}
{% set right_end = [page_count, current_page + 5] | min %} {% set right_end = [page_count, current_page + 5] | min %}
@@ -27,20 +28,36 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro bookmark_button(type, id, message = "Bookmark&hellip;", require_reload=false) %}
{% set bid = type[0] + id | string %}
<div class="bookmark-dropdown">
<button type="button" class="contain-svg inline icon" data-bookmark-type="{{type}}" data-send="showBookmarkMenu" data-concept-id="{{id}}" data-bookmark-id="{{bid}}">{{ icn_bookmark(20) }}{{ message | safe }}</button>
<div class="bookmark-dropdown-inner" data-receive="showBookmarkMenu" data-bookmark-id="{{bid}}" data-require-reload={{require_reload | int}}></div>
</div>
{% endmacro %}
{% macro infobox(message, kind=InfoboxKind.INFO) %} {% macro infobox(message, kind=InfoboxKind.INFO) %}
<div class="{{ "infobox " + InfoboxHTMLClass[kind] }}"> <div class="{{ "infobox " + InfoboxHTMLClass[kind] }}">
<span> <span>
<div class="infobox-icon-container"> <div class="infobox-icon-container">
<img src="{{ InfoboxIcons[kind] }}"> {%- if kind == InfoboxKind.INFO -%}
{{- icn_info() -}}
{%- elif kind == InfoboxKind.LOCK -%}
{{- icn_lock() -}}
{%- elif kind == InfoboxKind.WARN -%}
{{- icn_warn() -}}
{%- elif kind == InfoboxKind.ERROR -%}
{{- icn_error() -}}
{%- endif -%}
</div> </div>
{{ message }} {{ message }}
</span> </span>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro timestamp(unix_ts) %} {% macro timestamp(unix_ts) -%}
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span> <span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
{% endmacro %} {%- endmacro %}
{% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="") %} {% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="") %}
<div class="babycode-editor-container"> <div class="babycode-editor-container">
@@ -56,10 +73,10 @@
<button class="babycode-button" type=button id="post-editor-underline" title="Insert Underline"><u>U</u></button> <button class="babycode-button" type=button id="post-editor-underline" title="Insert Underline"><u>U</u></button>
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button> <button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button> <button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code>&lt;/&gt;</code></button>
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><img src="/static/misc/image.svg"></button> <button class="babycode-button contain-svg" type=button id="post-editor-img" title="Insert Image">{{ icn_image() }}</button>
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button> <button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">&bullet;</button> <button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">&bullet;</button>
<button class="babycode-button contain-svg full" type=button id="post-editor-spoiler" title="Insert spoiler"><img src="/static/misc/spoiler.svg"></button> <button class="babycode-button contain-svg" type=button id="post-editor-spoiler" title="Insert spoiler">{{ icn_spoiler() }}</button>
</span> </span>
<textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }}>{{ prefill }}</textarea> <textarea class="babycode-editor" name="{{ ta_name }}" id="babycode-content" placeholder="{{ ta_placeholder }}" {{ "required" if not optional else "" }}>{{ prefill }}</textarea>
<a href="{{ url_for("app.babycode_guide") }}" target="_blank">babycode guide</a> <a href="{{ url_for("app.babycode_guide") }}" target="_blank">babycode guide</a>
@@ -91,7 +108,13 @@
</form> </form>
{% endmacro %} {% endmacro %}
{% macro full_post(post, render_sig = True, is_latest = False, editing = False, active_user = None, no_reply = false, Reactions = none) %} {% 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;",
reload_after_bookmark = false
) %}
{% set postclass = "post" %} {% set postclass = "post" %}
{% if editing %} {% if editing %}
{% set postclass = postclass + " editing" %} {% set postclass = postclass + " editing" %}
@@ -112,6 +135,14 @@
<div class="post-content-container" {{ "id=latest-post" if is_latest else "" }}> <div class="post-content-container" {{ "id=latest-post" if is_latest else "" }}>
<div class="post-info"> <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;
{% endif %}
<a href="{{ post_permalink }}" title="Permalink"><i> <a href="{{ post_permalink }}" title="Permalink"><i>
{% if (post['edited_at'] | int) > (post['created_at'] | int) %} {% if (post['edited_at'] | int) > (post['created_at'] | int) %}
Edited on {{ timestamp(post['edited_at']) }} Edited on {{ timestamp(post['edited_at']) }}
@@ -119,7 +150,8 @@
Posted on {{ timestamp(post['edited_at']) }} Posted on {{ timestamp(post['edited_at']) }}
{% endif %} {% endif %}
</i></a> </i></a>
<span> </span>
<span class="thread-actions">
{% set show_edit = false %} {% set show_edit = false %}
{% if active_user %} {% if active_user %}
{% set show_edit = (active_user.id | string) == (post['user_id'] | string) and (not post['thread_is_locked'] or active_user.is_mod()) and not no_reply %} {% set show_edit = (active_user.id | string) == (post['user_id'] | string) and (not post['thread_is_locked'] or active_user.is_mod()) and not no_reply %}
@@ -157,6 +189,10 @@
{% if show_delete %} {% if show_delete %}
<button class="critical post-delete-button" value="{{ post['id'] }}">Delete</button> <button class="critical post-delete-button" value="{{ post['id'] }}">Delete</button>
{% endif %} {% endif %}
{% if show_bookmark %}
{{ bookmark_button(type="post", id=post.id, message=bookmark_message, require_reload=reload_after_bookmark)}}
{% endif %}
</span> </span>
</div> </div>
<div class="post-content"> <div class="post-content">

View File

@@ -20,6 +20,10 @@
&bullet; &bullet;
<a href="{{ url_for('users.invite_links', username=user.username )}}">Invite to {{ config.SITE_NAME }}</a> <a href="{{ url_for('users.invite_links', username=user.username )}}">Invite to {{ config.SITE_NAME }}</a>
{% endif %} {% endif %}
{% if not user.is_guest() %}
&bullet;
<a href="{{ url_for('users.bookmarks', username=user.username) }}">Bookmarks</a>
{% endif %}
{% if user.is_mod() %} {% if user.is_mod() %}
&bullet; &bullet;
<a href="{{ url_for("mod.user_list") }}">User list</a> <a href="{{ url_for("mod.user_list") }}">User list</a>

View File

@@ -0,0 +1,23 @@
{% set bookmark_url = None %}
{% if type == 'post' %}
{% set bookmark_url = url_for('api.bookmark_post', post_id=id) %}
{% else %}
{% set bookmark_url = url_for('api.bookmark_thread', thread_id=id) %}
{% endif %}
<div class="bookmarks-dropdown" data-bookmark-type="{{type}}" data-receive="saveBookmarks" data-bookmark-endpoint="{{bookmark_url}}" data-originally-contained-in="{{ selected.id if selected else ""}}" data-require-reload={{require_reload | int}}>
<div class="bookmarks-dropdown-header">
<span>Bookmark collections</span>
{% if not require_reload %}
<a href="{{ url_for('users.bookmarks', username=get_active_user().username) }}">View bookmarks</a>
{% endif %}
</div>
<div class="bookmark-dropdown-items-container">
{% for collection in collections %}
<div class="bookmark-dropdown-item {{ "selected" if selected and (selected.id | int) == (collection.id | int) else ""}}" data-send="selectBookmarkCollection" data-receive="selectBookmarkCollection" data-collection-id="{{collection.id}}">{{collection.name}} ({{ collection.get_posts_count() }}p, {{ collection.get_threads_count() }}t)</div>
{% endfor %}
</div>
<span>
<input type="text" placeholder="Memo" class="bookmark-memo-input" value="{{memo}}"></input>
<button type="button" data-send="saveBookmarks">Save</button>
</span>
</div>

View File

@@ -1,12 +1,15 @@
{% from 'common/macros.html' import pager, babycode_editor_form, full_post %} {% from 'common/macros.html' import pager, babycode_editor_form, full_post, bookmark_button %}
{% from 'common/icons.html' import icn_bookmark %}
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ thread.title }}{% endblock %} {% block title %}{{ thread.title }}{% endblock %}
{% block content %} {% block content %}
{% set can_post = false %} {% set can_post = false %}
{% set can_lock = false %} {% set can_lock = false %}
{% set can_subscribe = false %} {% set can_subscribe = false %}
{% set can_bookmark = false %}
{% if active_user %} {% if active_user %}
{% set can_subscribe = true %} {% set can_subscribe = true %}
{% set can_bookmark = not active_user.is_guest() %}
{% set can_post = (not thread.is_locked and not active_user.is_guest()) or active_user.is_mod() %} {% set can_post = (not thread.is_locked and not active_user.is_guest()) or active_user.is_mod() %}
{% set can_lock = ((active_user.id | int) == (thread.user_id | int)) or active_user.is_mod() %} {% set can_lock = ((active_user.id | int) == (thread.user_id | int)) or active_user.is_mod() %}
{% endif %} {% endif %}
@@ -18,7 +21,7 @@
&bullet; <i>stickied, so it's probably important</i> &bullet; <i>stickied, so it's probably important</i>
{% endif %} {% endif %}
</span> </span>
<div> <div class="thread-actions">
{% if can_subscribe %} {% if can_subscribe %}
<form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post"> <form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post">
<input type='hidden' name='last_visible_post' value='{{posts[-1].id}}'> <input type='hidden' name='last_visible_post' value='{{posts[-1].id}}'>
@@ -26,6 +29,9 @@
<input type='submit' value='{{ 'Unsubscribe' if is_subscribed else 'Subscribe' }}'> <input type='submit' value='{{ 'Unsubscribe' if is_subscribed else 'Subscribe' }}'>
</form> </form>
{% endif %} {% endif %}
{% if can_bookmark %}
{{ bookmark_button(type="thread", id=thread.id) }}
{% endif %}
{% if can_lock %} {% if can_lock %}
<form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post"> <form class="modform" action="{{ url_for("threads.lock", slug=thread.slug) }}" method="post">
<input type=hidden name='target_op' value="{{ (not thread.is_locked) | int }}"> <input type=hidden name='target_op' value="{{ (not thread.is_locked) | int }}">
@@ -50,7 +56,7 @@
</div> </div>
</nav> </nav>
{% for post in posts %} {% for post in posts %}
{{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length), Reactions = Reactions) }} {{ full_post(post = post, active_user = active_user, is_latest = loop.index == (posts | length), Reactions = Reactions, show_bookmark = can_bookmark) }}
{% endfor %} {% endfor %}
</main> </main>

View File

@@ -1,4 +1,5 @@
{% from 'common/macros.html' import pager, timestamp %} {% from 'common/macros.html' import pager, timestamp, bookmark_button %}
{% from 'common/icons.html' import icn_bookmark, icn_lock, icn_sticky %}
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}browsing topic {{ topic['name'] }}{% endblock %} {% block title %}browsing topic {{ topic['name'] }}{% endblock %}
{% block content %} {% block content %}
@@ -33,18 +34,28 @@
<div class="thread"> <div class="thread">
<div class="thread-sticky-container contain-svg"> <div class="thread-sticky-container contain-svg">
{% if thread['is_stickied'] %} {% if thread['is_stickied'] %}
<img src="/static/misc/sticky.svg"> {{ icn_sticky(48) }}
<i>Stickied</i> <i>Stickied</i>
{% endif %} {% endif %}
</div> </div>
<div class="thread-info-container"> <div class="thread-info-container">
<span> <span class="thread-info-header">
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a></span> <span>
{% if thread['id'] in subscriptions %} <span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a>
({{ subscriptions[thread['id']] }} unread) {% if thread['id'] in subscriptions %}
{% endif %} ({{ subscriptions[thread['id']] }} unread)
&bullet; {% endif %}
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at'])}} </span>
&bullet;
<span>
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at']) }}
</span>
</span>
<span>
{% if active_user and not active_user.is_guest() -%}
{{ bookmark_button(type="thread", id=thread.id) }}
{%- endif %}
</span>
</span> </span>
<span> <span>
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a> Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a>
@@ -56,7 +67,7 @@
</div> </div>
<div class="thread-locked-container contain-svg"> <div class="thread-locked-container contain-svg">
{% if thread['is_locked'] %} {% if thread['is_locked'] %}
<img src="/static/misc/lock.svg"> {{ icn_lock(48) }}
<i>Locked</i> <i>Locked</i>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,3 +1,4 @@
{% from 'common/icons.html' import icn_lock %}
{% from 'common/macros.html' import timestamp %} {% from 'common/macros.html' import timestamp %}
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
@@ -33,7 +34,7 @@
</div> </div>
<div class="topic-locked-container contain-svg"> <div class="topic-locked-container contain-svg">
{% if topic['is_locked'] %} {% if topic['is_locked'] %}
<img src="/static/misc/lock.svg"></img> {{ icn_lock(48) }}
<i>Locked</i> <i>Locked</i>
{% endif %} {% endif %}
</div> </div>

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

@@ -0,0 +1,52 @@
{% from "common/macros.html" import accordion, full_post, bookmark_button %}
{% from "common/icons.html" import icn_bookmark %}
{% extends "base.html" %}
{% block title %}bookmarks{% endblock %}
{% block content %}
<div class="darkbg inbox-container">
<a class="linkbutton" href="{{ url_for('users.bookmark_collections', username=get_active_user().username) }}">Manage collections</a>
{% 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 ""}}
{% else %}
{% call(inner_section) accordion(disabled=not collection.has_threads()) %}
{% if inner_section == 'header' %}
Threads{{" (no bookmarks)" if not collection.has_threads() else ""}}
{% else %}
<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>
{{ bookmark_button(type='thread', id=thread.thread_id, message='Manage&hellip;', require_reload=true) }}
</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() %}
{{ 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;", reload_after_bookmark=true) }}
{% endfor %}
{% endif %}
{% endcall %}
{% endif %}
{% endcall %}
{% endfor %}
</div>
{% endblock %}

View File

@@ -99,6 +99,12 @@ a:visited {
font-weight: bold; font-weight: bold;
} }
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
}
.post { .post {
display: grid; display: grid;
grid-template-columns: 200px 1fr; grid-template-columns: 200px 1fr;
@@ -689,6 +695,10 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
button.icon, input[type=submit].icon, .linkbutton.icon {
padding-left: 16px;
flex-direction: row;
}
button.critical, input[type=submit].critical, .linkbutton.critical { button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: red; background-color: red;
color: white !important; color: white !important;
@@ -706,6 +716,10 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
button.critical.icon, input[type=submit].critical.icon, .linkbutton.critical.icon {
padding-left: 16px;
flex-direction: row;
}
button.warn, input[type=submit].warn, .linkbutton.warn { button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d; background-color: #fbfb8d;
color: black !important; color: black !important;
@@ -723,6 +737,10 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
button.warn.icon, input[type=submit].warn.icon, .linkbutton.warn.icon {
padding-left: 16px;
flex-direction: row;
}
input[type=file]::file-selector-button { input[type=file]::file-selector-button {
background-color: rgb(177, 206, 204.5); background-color: rgb(177, 206, 204.5);
@@ -741,6 +759,10 @@ input[type=file]::file-selector-button.reduced {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
input[type=file]::file-selector-button.icon {
padding-left: 16px;
flex-direction: row;
}
input[type=file]::file-selector-button { input[type=file]::file-selector-button {
margin: 10px; margin: 10px;
} }
@@ -766,6 +788,10 @@ p {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
.pagebutton.icon {
padding-left: 16px;
flex-direction: row;
}
.pagebutton { .pagebutton {
padding: 5px 5px; padding: 5px 5px;
margin: 0; margin: 0;
@@ -871,13 +897,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
} }
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img { .contain-svg.inline {
height: 50%; display: inline-flex;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
} }
.post-img-container { .post-img-container {
@@ -910,6 +931,17 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
mask-image: linear-gradient(180deg, #000 60%, transparent); mask-image: linear-gradient(180deg, #000 60%, transparent);
} }
.thread-info-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 5px;
}
.thread-info-bookmark-button {
margin-left: auto !important;
}
.thread-info-post-preview { .thread-info-post-preview {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -973,7 +1005,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 10px 0; margin: 10px 0;
overflow: hidden;
} }
.colorful-table tr th { .colorful-table tr th {
@@ -1028,6 +1059,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: rgb(177, 206, 204.5); 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 { .editing {
background-color: rgb(217.26, 220.38, 213.42); background-color: rgb(217.26, 220.38, 213.42);
} }
@@ -1075,6 +1122,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
.tab-button.icon {
padding-left: 16px;
flex-direction: row;
}
.tab-button { .tab-button {
border-bottom: none; border-bottom: none;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
@@ -1131,7 +1182,6 @@ ul, ol {
box-sizing: border-box; box-sizing: border-box;
border: 1px solid black; border: 1px solid black;
margin: 10px 5px; margin: 10px 5px;
overflow: hidden;
} }
.accordion.hidden { .accordion.hidden {
@@ -1225,6 +1275,10 @@ footer {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
.reaction-button.active.icon {
padding-left: 16px;
flex-direction: row;
}
.reaction-popover { .reaction-popover {
position: relative; position: relative;
@@ -1247,3 +1301,58 @@ footer {
.babycode-guide-list { .babycode-guide-list {
border-bottom: 1px dashed; border-bottom: 1px dashed;
} }
.bookmark-dropdown-inner {
position: relative;
}
.bookmarks-dropdown {
background-color: #c1ceb1;
border: 1px solid black;
border-radius: 4px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
position: absolute;
right: 0;
min-width: 400px;
padding: 10px;
z-index: 100;
}
.bookmark-dropdown-item {
display: flex;
padding: 10px 0;
margin: 10px 0;
cursor: pointer;
border: 1px solid black;
border-radius: 4px;
color: black;
background-color: rgb(177, 206, 204.5);
}
.bookmark-dropdown-item:hover {
background-color: rgb(192.6, 215.8, 214.6);
}
.bookmark-dropdown-item::before {
content: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E");
width: 24px;
height: 24px;
padding: 0 10px;
}
.bookmark-dropdown-item.selected {
background-color: #beb1ce;
}
.bookmark-dropdown-item.selected:hover {
background-color: rgb(203, 192.6, 215.8);
}
.bookmark-dropdown-item.selected::before {
content: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3Crect%20x%3D%225%22%20y%3D%225%22%20width%3D%2214%22%20height%3D%2214%22%20rx%3D%222%22%20stroke%3D%22none%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E");
}
.bookmarks-dropdown-header {
display: flex;
justify-content: space-between;
}
.bookmark-dropdown-items-container {
max-height: 300px;
overflow: scroll;
}

View File

@@ -99,6 +99,12 @@ a:visited {
font-weight: bold; font-weight: bold;
} }
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
}
.post { .post {
display: grid; display: grid;
grid-template-columns: 200px 1fr; grid-template-columns: 200px 1fr;
@@ -689,6 +695,10 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
button.icon, input[type=submit].icon, .linkbutton.icon {
padding-left: 16px;
flex-direction: row;
}
button.critical, input[type=submit].critical, .linkbutton.critical { button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: #d53232; background-color: #d53232;
color: #e6e6e6 !important; color: #e6e6e6 !important;
@@ -706,6 +716,10 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
button.critical.icon, input[type=submit].critical.icon, .linkbutton.critical.icon {
padding-left: 16px;
flex-direction: row;
}
button.warn, input[type=submit].warn, .linkbutton.warn { button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #eaea6a; background-color: #eaea6a;
color: black !important; color: black !important;
@@ -723,6 +737,10 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
button.warn.icon, input[type=submit].warn.icon, .linkbutton.warn.icon {
padding-left: 16px;
flex-direction: row;
}
input[type=file]::file-selector-button { input[type=file]::file-selector-button {
background-color: #3c283c; background-color: #3c283c;
@@ -741,6 +759,10 @@ input[type=file]::file-selector-button.reduced {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
input[type=file]::file-selector-button.icon {
padding-left: 16px;
flex-direction: row;
}
input[type=file]::file-selector-button { input[type=file]::file-selector-button {
margin: 10px; margin: 10px;
} }
@@ -766,6 +788,10 @@ p {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
.pagebutton.icon {
padding-left: 16px;
flex-direction: row;
}
.pagebutton { .pagebutton {
padding: 5px 5px; padding: 5px 5px;
margin: 0; margin: 0;
@@ -871,13 +897,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
} }
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img { .contain-svg.inline {
height: 50%; display: inline-flex;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
} }
.post-img-container { .post-img-container {
@@ -910,6 +931,17 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
mask-image: linear-gradient(180deg, #000 60%, transparent); mask-image: linear-gradient(180deg, #000 60%, transparent);
} }
.thread-info-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 5px;
}
.thread-info-bookmark-button {
margin-left: auto !important;
}
.thread-info-post-preview { .thread-info-post-preview {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -973,7 +1005,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 10px 0; margin: 10px 0;
overflow: hidden;
} }
.colorful-table tr th { .colorful-table tr th {
@@ -1028,6 +1059,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: #3c283c; 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 { .editing {
background-color: #503250; background-color: #503250;
} }
@@ -1075,6 +1122,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
.tab-button.icon {
padding-left: 16px;
flex-direction: row;
}
.tab-button { .tab-button {
border-bottom: none; border-bottom: none;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
@@ -1131,7 +1182,6 @@ ul, ol {
box-sizing: border-box; box-sizing: border-box;
border: 1px solid black; border: 1px solid black;
margin: 10px 5px; margin: 10px 5px;
overflow: hidden;
} }
.accordion.hidden { .accordion.hidden {
@@ -1225,6 +1275,10 @@ footer {
margin: 0; margin: 0;
padding: 5px; padding: 5px;
} }
.reaction-button.active.icon {
padding-left: 16px;
flex-direction: row;
}
.reaction-popover { .reaction-popover {
position: relative; position: relative;
@@ -1248,6 +1302,61 @@ footer {
border-bottom: 1px dashed; border-bottom: 1px dashed;
} }
.bookmark-dropdown-inner {
position: relative;
}
.bookmarks-dropdown {
background-color: #9b649b;
border: 1px solid black;
border-radius: 4px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
position: absolute;
right: 0;
min-width: 400px;
padding: 10px;
z-index: 100;
}
.bookmark-dropdown-item {
display: flex;
padding: 10px 0;
margin: 10px 0;
cursor: pointer;
border: 1px solid black;
border-radius: 4px;
color: #e6e6e6;
background-color: #3c283c;
}
.bookmark-dropdown-item:hover {
background-color: rgb(109.2, 72.8, 109.2);
}
.bookmark-dropdown-item::before {
content: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E");
width: 24px;
height: 24px;
padding: 0 10px;
}
.bookmark-dropdown-item.selected {
background-color: #8a5584;
}
.bookmark-dropdown-item.selected:hover {
background-color: rgb(167.4843049327, 112.9156950673, 161.3067264574);
}
.bookmark-dropdown-item.selected::before {
content: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3Crect%20x%3D%225%22%20y%3D%225%22%20width%3D%2214%22%20height%3D%2214%22%20rx%3D%222%22%20stroke%3D%22none%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E");
}
.bookmarks-dropdown-header {
display: flex;
justify-content: space-between;
}
.bookmark-dropdown-items-container {
max-height: 300px;
overflow: scroll;
}
#topnav { #topnav {
margin-bottom: 10px; margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40); border: 10px solid rgb(40, 40, 40);

View File

@@ -99,6 +99,12 @@ a:visited {
font-weight: bold; font-weight: bold;
} }
.thread-actions {
display: flex;
align-items: center;
gap: 3px;
}
.post { .post {
display: grid; display: grid;
grid-template-columns: 200px 1fr; grid-template-columns: 200px 1fr;
@@ -689,6 +695,10 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
button.icon, input[type=submit].icon, .linkbutton.icon {
padding-left: 8px;
flex-direction: row;
}
button.critical, input[type=submit].critical, .linkbutton.critical { button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: #f73030; background-color: #f73030;
color: white !important; color: white !important;
@@ -706,6 +716,10 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
button.critical.icon, input[type=submit].critical.icon, .linkbutton.critical.icon {
padding-left: 8px;
flex-direction: row;
}
button.warn, input[type=submit].warn, .linkbutton.warn { button.warn, input[type=submit].warn, .linkbutton.warn {
background-color: #fbfb8d; background-color: #fbfb8d;
color: black !important; color: black !important;
@@ -723,6 +737,10 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
button.warn.icon, input[type=submit].warn.icon, .linkbutton.warn.icon {
padding-left: 8px;
flex-direction: row;
}
input[type=file]::file-selector-button { input[type=file]::file-selector-button {
background-color: #f27a5a; background-color: #f27a5a;
@@ -741,6 +759,10 @@ input[type=file]::file-selector-button.reduced {
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
input[type=file]::file-selector-button.icon {
padding-left: 8px;
flex-direction: row;
}
input[type=file]::file-selector-button { input[type=file]::file-selector-button {
margin: 6px; margin: 6px;
} }
@@ -766,6 +788,10 @@ p {
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
.pagebutton.icon {
padding-left: 8px;
flex-direction: row;
}
.pagebutton { .pagebutton {
padding: 3px 3px; padding: 3px 3px;
margin: 0; margin: 0;
@@ -871,13 +897,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
} }
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img { .contain-svg.inline {
height: 50%; display: inline-flex;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
} }
.post-img-container { .post-img-container {
@@ -910,6 +931,17 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
mask-image: linear-gradient(180deg, #000 60%, transparent); mask-image: linear-gradient(180deg, #000 60%, transparent);
} }
.thread-info-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 3px;
}
.thread-info-bookmark-button {
margin-left: auto !important;
}
.thread-info-post-preview { .thread-info-post-preview {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -973,7 +1005,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 6px 0; margin: 6px 0;
overflow: hidden;
} }
.colorful-table tr th { .colorful-table tr th {
@@ -1028,6 +1059,22 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
background-color: #f27a5a; 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 { .editing {
background-color: rgb(219.84, 191.04, 183.36); background-color: rgb(219.84, 191.04, 183.36);
} }
@@ -1075,6 +1122,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
.tab-button.icon {
padding-left: 8px;
flex-direction: row;
}
.tab-button { .tab-button {
border-bottom: none; border-bottom: none;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
@@ -1131,7 +1182,6 @@ ul, ol {
box-sizing: border-box; box-sizing: border-box;
border: 1px solid black; border: 1px solid black;
margin: 6px 3px; margin: 6px 3px;
overflow: hidden;
} }
.accordion.hidden { .accordion.hidden {
@@ -1225,6 +1275,10 @@ footer {
margin: 0; margin: 0;
padding: 6px; padding: 6px;
} }
.reaction-button.active.icon {
padding-left: 8px;
flex-direction: row;
}
.reaction-popover { .reaction-popover {
position: relative; position: relative;
@@ -1248,6 +1302,61 @@ footer {
border-bottom: 1px dashed; border-bottom: 1px dashed;
} }
.bookmark-dropdown-inner {
position: relative;
}
.bookmarks-dropdown {
background-color: #f27a5a;
border: 1px solid black;
border-radius: 16px;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
position: absolute;
right: 0;
min-width: 400px;
padding: 6px;
z-index: 100;
}
.bookmark-dropdown-item {
display: flex;
padding: 6px 0;
margin: 6px 0;
cursor: pointer;
border: 1px solid black;
border-radius: 16px;
color: black;
background-color: #f27a5a;
}
.bookmark-dropdown-item:hover {
background-color: rgb(244.6, 148.6, 123);
}
.bookmark-dropdown-item::before {
content: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E");
width: 24px;
height: 24px;
padding: 0 6px;
}
.bookmark-dropdown-item.selected {
background-color: #b54444;
}
.bookmark-dropdown-item.selected:hover {
background-color: rgb(197.978313253, 103.221686747, 103.221686747);
}
.bookmark-dropdown-item.selected::before {
content: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3Crect%20x%3D%225%22%20y%3D%225%22%20width%3D%2214%22%20height%3D%2214%22%20rx%3D%222%22%20stroke%3D%22none%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E");
}
.bookmarks-dropdown-header {
display: flex;
justify-content: space-between;
}
.bookmark-dropdown-items-container {
max-height: 300px;
overflow: scroll;
}
#topnav { #topnav {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;

View File

@@ -0,0 +1,64 @@
const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown'
export default class {
async showBookmarkMenu(ev, el) {
if ((ev.target.dataset.bookmarkId === el.dataset.bookmarkId) && el.childElementCount === 0) {
const bookmarkMenuHref = `${bookmarkMenuHrefTemplate}/${ev.target.dataset.bookmarkType}?id=${ev.target.dataset.conceptId}&require_reload=${el.dataset.requireReload}`;
const res = await this.api.getHTML(bookmarkMenuHref);
if (res.error) {
return;
}
const frag = res.value;
el.appendChild(frag);
const menu = el.childNodes[0];
const bRect = el.getBoundingClientRect()
if (bRect.left < window.innerWidth - bRect.right) {
menu.style.right = 'unset';
}
} else if (el.childElementCount > 0) {
el.removeChild(el.childNodes[0]);
}
}
selectBookmarkCollection(ev, el) {
const clicked = ev.target;
if (clicked === el) {
if (clicked.classList.contains('selected')) {
clicked.classList.remove('selected');
} else {
clicked.classList.add('selected');
}
} else {
el.classList.remove('selected');
}
}
async saveBookmarks(ev, el) {
const bookmarkHref = el.dataset.bookmarkEndpoint;
const collection = el.querySelector('.bookmark-dropdown-item.selected');
let data = {};
if (collection) {
data['operation'] = 'move';
data['collection_id'] = collection.dataset.collectionId;
data['memo'] = el.querySelector('.bookmark-memo-input').value;
} else {
data['operation'] = 'remove';
data['collection_id'] = el.dataset.originallyContainedIn;
}
const options = {
method: 'POST',
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json',
},
}
const requireReload = parseInt(el.dataset.requireReload) !== 0;
el.remove();
await fetch(bookmarkHref, options);
if (requireReload) {
window.location.reload();
}
}
}

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

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.364 5.63604C19.9926 7.26472 21 9.51472 21 12C21 16.9706 16.9706 21 12 21C9.51472 21 7.26472 19.9926 5.63604 18.364M18.364 5.63604C16.7353 4.00736 14.4853 3 12 3C7.02944 3 3 7.02944 3 12C3 14.4853 4.00736 16.7353 5.63604 18.364M18.364 5.63604L5.63604 18.364" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 609 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 728 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8V8.5M12 12V16M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 480 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 14V16M8 9V6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6V9M7 21H17C18.1046 21 19 20.1046 19 19V11C19 9.89543 18.1046 9 17 9H7C5.89543 9 5 9.89543 5 11V19C5 20.1046 5.89543 21 7 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 539 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L9.87868 9.87868M20 20L14.1213 14.1213M9.87868 9.87868C9.33579 10.4216 9 11.1716 9 12C9 13.6569 10.3431 15 12 15C12.8284 15 13.5784 14.6642 14.1213 14.1213M9.87868 9.87868L14.1213 14.1213M6.76821 6.76821C4.72843 8.09899 2.96378 10.026 2 11.9998C3.74646 15.5764 8.12201 19 11.9998 19C13.7376 19 15.5753 18.3124 17.2317 17.2317M9.76138 5.34717C10.5114 5.12316 11.2649 5 12.0005 5C15.8782 5 20.2531 8.42398 22 12.0002C21.448 13.1302 20.6336 14.2449 19.6554 15.2412" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 814 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1046 4 20 4.89543 20 6V13M13 20L20 13M13 20V14C13 13.4477 13.4477 13 14 13H20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 498 B

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="60px" height="60px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15H12.01M12 12V9M4.98207 19H19.0179C20.5615 19 21.5233 17.3256 20.7455 15.9923L13.7276 3.96153C12.9558 2.63852 11.0442 2.63852 10.2724 3.96153L3.25452 15.9923C2.47675 17.3256 3.43849 19 4.98207 19Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->

Before

Width:  |  Height:  |  Size: 550 B

View File

@@ -18,6 +18,14 @@ $MAIN_BG: color.scale($ACCENT_COLOR, $lightness: -10%, $saturation: -40%) !defau
$BUTTON_COLOR: color.adjust($ACCENT_COLOR, $hue: 90) !default; $BUTTON_COLOR: color.adjust($ACCENT_COLOR, $hue: 90) !default;
$BUTTON_COLOR_2: color.adjust($ACCENT_COLOR, $hue: 180) !default; $BUTTON_COLOR_2: color.adjust($ACCENT_COLOR, $hue: 180) !default;
$BUTTON_COLOR_HOVER: color.scale($BUTTON_COLOR, $lightness: 20%) !default;
$BUTTON_COLOR_ACTIVE: color.scale($BUTTON_COLOR, $lightness: -10%, $saturation: -70%) !default;
$BUTTON_COLOR_DISABLED: color.scale($BUTTON_COLOR, $lightness: 30%, $saturation: -90%) !default;
$BUTTON_COLOR_2_HOVER: color.scale($BUTTON_COLOR_2, $lightness: 20%) !default;
$BUTTON_COLOR_2_ACTIVE: color.scale($BUTTON_COLOR_2, $lightness: -10%, $saturation: -70%) !default;
$BUTTON_COLOR_2_DISABLED: color.scale($BUTTON_COLOR_2, $lightness: 30%, $saturation: -90%) !default;
$ACCORDION_COLOR: color.adjust($ACCENT_COLOR, $hue: 140, $lightness: -10%, $saturation: -15%) !default; $ACCORDION_COLOR: color.adjust($ACCENT_COLOR, $hue: 140, $lightness: -10%, $saturation: -15%) !default;
$DEFAULT_FONT_COLOR: black !default; $DEFAULT_FONT_COLOR: black !default;
@@ -104,6 +112,7 @@ $button_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
$reduced_button_margin: $ZERO_PADDING !default; $reduced_button_margin: $ZERO_PADDING !default;
$reduced_button_padding: $SMALL_PADDING !default; $reduced_button_padding: $SMALL_PADDING !default;
$icon_button_padding_left: $BIG_PADDING - 4px !default;
@mixin button($color, $font_color) { @mixin button($color, $font_color) {
@extend %button-base; @extend %button-base;
background-color: $color; background-color: $color;
@@ -125,6 +134,12 @@ $reduced_button_padding: $SMALL_PADDING !default;
margin: $reduced_button_margin; margin: $reduced_button_margin;
padding: $reduced_button_padding; padding: $reduced_button_padding;
} }
// this is meant to be used with the contain-svg class, hence the flex-direction here
&.icon {
padding-left: $icon_button_padding_left;
flex-direction: row;
}
} }
$navbar_padding: $MEDIUM_PADDING !default; $navbar_padding: $MEDIUM_PADDING !default;
@@ -206,6 +221,13 @@ $thread_title_size: 1.5rem !default;
font-weight: bold; font-weight: bold;
} }
$thread_actions_gap: $SMALL_PADDING !default;
.thread-actions {
display: flex;
align-items: center;
gap: $thread_actions_gap;
}
$post_usercard_width: 200px !default; $post_usercard_width: 200px !default;
$post_border: 2px outset $DARK_2 !default; $post_border: 2px outset $DARK_2 !default;
.post { .post {
@@ -740,13 +762,9 @@ $thread_locked_background: none !default;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column; flex-direction: column;
&:not(.full) > svg, &:not(.full) > img {
height: 50%; &.inline {
width: 50%; display: inline-flex;
}
&.full > svg, &.full > img {
height: 100%;
width: 100%;
} }
} }
@@ -786,6 +804,18 @@ $thread_info_mask_image: $user_page_post_preview_mask_image !default;
mask-image: $thread_info_mask_image; mask-image: $thread_info_mask_image;
} }
$thread_info_header_gap: $SMALL_PADDING !default;
.thread-info-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $thread_info_header_gap;
}
.thread-info-bookmark-button {
margin-left: auto !important; // :(
}
$thread_info_post_preview_margin_right: $post_inner_padding_right !default; $thread_info_post_preview_margin_right: $post_inner_padding_right !default;
.thread-info-post-preview { .thread-info-post-preview {
overflow: hidden; overflow: hidden;
@@ -863,7 +893,7 @@ $colorful_table_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: $colorful_table_margin; margin: $colorful_table_margin;
overflow: hidden; // overflow: hidden;
} }
$colorful_table_th_color: $BUTTON_COLOR_2 !default; $colorful_table_th_color: $BUTTON_COLOR_2 !default;
@@ -932,12 +962,39 @@ $draggable_topic_border_bottom: $draggable_topic_border $DARK_2 !default;
margin: $draggable_topic_margin; margin: $draggable_topic_margin;
border-top: $draggable_topic_border_top; border-top: $draggable_topic_border_top;
border-bottom: $draggable_topic_border_bottom; border-bottom: $draggable_topic_border_bottom;
&.dragged { &.dragged {
background-color: $draggable_topic_dragged_color; 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; $post_editing_header_color: $LIGHT !default;
.editing { .editing {
background-color: $post_editing_header_color; background-color: $post_editing_header_color;
@@ -1046,7 +1103,7 @@ $accordion_margin: $MEDIUM_PADDING $SMALL_PADDING !default;
box-sizing: border-box; box-sizing: border-box;
border: $accordion_border; border: $accordion_border;
margin: $accordion_margin; margin: $accordion_margin;
overflow: hidden; // overflow: hidden;
} }
.accordion.hidden { .accordion.hidden {
@@ -1172,3 +1229,79 @@ $babycode_guide_list_border: 1px dashed !default;
.babycode-guide-list { .babycode-guide-list {
border-bottom: $babycode_guide_list_border; border-bottom: $babycode_guide_list_border;
} }
.bookmark-dropdown-inner {
position: relative;
}
$bookmarks_dropdown_background_color: $ACCENT_COLOR !default;
$bookmarks_dropdown_border_radius: $DEFAULT_BORDER_RADIUS !default;
$bookmarks_dropdown_border: $button_border !default;
$bookmarks_dropdown_shadow: 0 0 30px rgba(0, 0, 0, 0.25) !default;
$bookmarks_dropdown_min_width: 400px !default;
$bookmarks_dropdown_padding: $MEDIUM_PADDING !default;
.bookmarks-dropdown {
background-color: $bookmarks_dropdown_background_color;
border: $bookmarks_dropdown_border;
border-radius: $bookmarks_dropdown_border_radius;
box-shadow: $bookmarks_dropdown_shadow;
position: absolute;
right: 0;
min-width: $bookmarks_dropdown_min_width;
padding: $bookmarks_dropdown_padding;
z-index: 100;
}
$bookmark_dropdown_item_padding: $MEDIUM_PADDING 0 !default;
$bookmark_dropdown_item_margin: $MEDIUM_PADDING 0 !default;
$bookmark_dropdown_item_font_color: $BUTTON_FONT_COLOR !default;
$bookmark_dropdown_item_background: $BUTTON_COLOR !default;
$bookmark_dropdown_item_background_hover: $BUTTON_COLOR_HOVER !default;
$bookmark_dropdown_item_background_selected: $BUTTON_COLOR_2 !default;
$bookmark_dropdown_item_background_selected_hover: $BUTTON_COLOR_2_HOVER !default;
$bookmark_dropdown_item_icon_size: 24px !default;
$bookmark_dropdown_item_icon_padding: 0 $MEDIUM_PADDING !default;
.bookmark-dropdown-item {
display: flex;
padding: $bookmark_dropdown_item_padding;
margin: $bookmark_dropdown_item_margin;
cursor: pointer;
border: $button_border;
border-radius: $button_border_radius;
color: $bookmark_dropdown_item_font_color;
background-color: $bookmark_dropdown_item_background;
&:hover {
background-color: $bookmark_dropdown_item_background_hover;
}
&::before {
// TODO: un-inline this once the bitty bug is fixed
content: url('data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E');
width: $bookmark_dropdown_item_icon_size;
height: $bookmark_dropdown_item_icon_size;
padding: $bookmark_dropdown_item_icon_padding;
}
&.selected {
background-color: $bookmark_dropdown_item_background_selected;
&:hover {
background-color: $bookmark_dropdown_item_background_selected_hover;
}
&::before{
content: url('data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Crect%20x%3D%221.5%22%20y%3D%221.5%22%20width%3D%2221%22%20height%3D%2221%22%20rx%3D%223%22%20stroke%3D%22currentColor%22%20stroke-width%3D%223%22%20fill%3D%22none%22%2F%3E%3Crect%20x%3D%225%22%20y%3D%225%22%20width%3D%2214%22%20height%3D%2214%22%20rx%3D%222%22%20stroke%3D%22none%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E');
}
}
}
.bookmarks-dropdown-header {
display: flex;
justify-content: space-between;
}
$bookmark_dropdown_items_container_max_height: 300px !default;
.bookmark-dropdown-items-container {
max-height: $bookmark_dropdown_items_container_max_height;
overflow: scroll;
}