Compare commits

...

12 Commits

34 changed files with 1329 additions and 136 deletions

View File

@@ -60,4 +60,4 @@ $ python -m app.run
```
# 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
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
Copyright: Gabriele Malaspina
Designers: Gabriele Malaspina
License: CC0 1.0/CC BY 4.0
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
License: CC0 1.0
## Forumoji
@@ -72,3 +71,11 @@ Modified work Copyright (C) 2019-2025 by E. McConville <https://emcconville.com>
License: MIT
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 .models import Avatars, Users, PostHistory, Posts
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 .constants import (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxIcons, InfoboxHTMLClass,
InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI,
)
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
@@ -48,21 +48,38 @@ def create_deleted_user():
"permission": PermissionLevel.SYSTEM.value,
})
def reparse_posts():
def reparse_babycode():
print('Re-parsing babycode, this may take a while...')
from .db import db
post_histories = PostHistory.findall([
('markup_language', '=', 'babycode'),
('format_version', 'IS NOT', BABYCODE_VERSION)
])
if len(post_histories) == 0:
return
print('Re-parsing babycode, this may take a while...')
if len(post_histories) > 0:
print('Re-parsing user posts...')
with db.transaction():
for ph in post_histories:
ph.update({
'content': babycode_to_html(ph['original_markup']),
'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.')
def create_app():
@@ -105,7 +122,7 @@ def create_app():
create_admin()
create_deleted_user()
reparse_posts()
reparse_babycode()
from app.routes.app import bp as app_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.api import bp as api_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(topics_bp)
app.register_blueprint(threads_bp)
@@ -121,6 +139,7 @@ def create_app():
app.register_blueprint(mod_bp)
app.register_blueprint(api_bp)
app.register_blueprint(posts_bp)
app.register_blueprint(hyperapi_bp)
app.config['SESSION_COOKIE_SECURE'] = True
@@ -135,7 +154,6 @@ def create_app():
@app.context_processor
def inject_constants():
return {
"InfoboxIcons": InfoboxIcons,
"InfoboxHTMLClass": InfoboxHTMLClass,
"InfoboxKind": InfoboxKind,
"PermissionLevel": PermissionLevel,
@@ -184,6 +202,15 @@ def create_app():
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
# build time is when updates are done anyway
# sooo... /shrug

View File

@@ -56,13 +56,6 @@ class InfoboxKind(IntEnum):
WARN = 2
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 = {
InfoboxKind.INFO: "",
InfoboxKind.LOCK: "warn",

View File

@@ -5,12 +5,31 @@ def migrate_old_avatars():
new_path = f"/static{avatar['file_path']}"
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]
MIGRATIONS = [
migrate_old_avatars,
'DELETE FROM sessions', # delete old lua porom sessions
'ALTER TABLE "users" ADD COLUMN "invited_by" INTEGER REFERENCES users(id)', # invitation system
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
add_signature_format,
create_default_bookmark_collections,
]
def run_migrations():

View File

@@ -91,7 +91,7 @@ class Users(Model):
def can_invite(self):
if not current_app.config['DISABLE_SIGNUP']:
return True
return False
if current_app.config['MODS_CAN_INVITE'] and self.is_mod():
return True
@@ -101,6 +101,11 @@ class Users(Model):
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):
table = "topics"
@@ -225,7 +230,7 @@ class Threads(Model):
class Posts(Model):
FULL_POSTS_QUERY = """
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
posts
JOIN
@@ -239,6 +244,10 @@ class Posts(Model):
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):
table = "post_history"
@@ -317,3 +326,71 @@ class PasswordResetLinks(Model):
class InviteKeys(Model):
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 ..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, BookmarkedThreads, BookmarkedPosts
from ..db import db
bp = Blueprint("api", __name__, url_prefix="/api/")
@@ -96,3 +96,119 @@ 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
@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 ..db import db
from ..lib.babycode import babycode_to_html
from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
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
@@ -260,6 +260,8 @@ def sign_up_post():
"permission": PermissionLevel.GUEST.value,
})
BookmarkCollections.create_default(new_user.id)
if current_app.config['DISABLE_SIGNUP']:
invite_key = InviteKeys.find({'key': key})
new_user.update({
@@ -307,14 +309,19 @@ def settings_form(username):
if topic_sort_by == 'activity' or topic_sort_by == 'thread':
sort_by = session['sort_by'] = topic_sort_by
status = request.form.get('status', default="")[:100]
original_sig = request.form.get('signature', default='')
original_sig = request.form.get('signature', default='').strip()
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'
user.update({
'status': status,
'signature_original_markup': original_sig,
'signature_rendered': rendered_sig,
'signature_format_version': BABYCODE_VERSION,
'signature_markup_language': 'babycode',
})
flash('Settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username))
@@ -683,3 +690,26 @@ def revoke_invite_link(username):
invite.delete()
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
)""",
"""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
"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)",
@@ -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_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():

View File

@@ -10,8 +10,10 @@
{% endif %}
<link rel="stylesheet" href="{{ ("/static/css/%s.css" % get_prefers_theme()) | cachebust }}">
<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>
<body>
<bitty-5-1 data-connect="/static/js/bitties/pyrom-bitty.js">
{% include 'common/topnav.html' %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
@@ -24,6 +26,7 @@
<footer class="darkbg">
<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/date-fmt.js" | cachebust }}"></script>
</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) %}
{% set left_start = [1, current_page - 5] | max %}
{% set right_end = [page_count, current_page + 5] | min %}
@@ -27,20 +28,36 @@
</div>
{% 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) %}
<div class="{{ "infobox " + InfoboxHTMLClass[kind] }}">
<span>
<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>
{{ message }}
</span>
</div>
{% 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>
{% endmacro %}
{%- endmacro %}
{% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="") %}
<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-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 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-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>
<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>
@@ -91,7 +108,13 @@
</form>
{% 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" %}
{% if editing %}
{% set postclass = postclass + " editing" %}
@@ -112,6 +135,14 @@
<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;
{% endif %}
<a href="{{ post_permalink }}" title="Permalink"><i>
{% if (post['edited_at'] | int) > (post['created_at'] | int) %}
Edited on {{ timestamp(post['edited_at']) }}
@@ -119,7 +150,8 @@
Posted on {{ timestamp(post['edited_at']) }}
{% endif %}
</i></a>
<span>
</span>
<span class="thread-actions">
{% set show_edit = false %}
{% 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 %}
@@ -157,6 +189,10 @@
{% if show_delete %}
<button class="critical post-delete-button" value="{{ post['id'] }}">Delete</button>
{% endif %}
{% if show_bookmark %}
{{ bookmark_button(type="post", id=post.id, message=bookmark_message, require_reload=reload_after_bookmark)}}
{% endif %}
</span>
</div>
<div class="post-content">

View File

@@ -20,6 +20,10 @@
&bullet;
<a href="{{ url_for('users.invite_links', username=user.username )}}">Invite to {{ config.SITE_NAME }}</a>
{% endif %}
{% if not user.is_guest() %}
&bullet;
<a href="{{ url_for('users.bookmarks', username=user.username) }}">Bookmarks</a>
{% endif %}
{% if user.is_mod() %}
&bullet;
<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" %}
{% block title %}{{ thread.title }}{% endblock %}
{% block content %}
{% set can_post = false %}
{% set can_lock = false %}
{% set can_subscribe = false %}
{% set can_bookmark = false %}
{% if active_user %}
{% 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_lock = ((active_user.id | int) == (thread.user_id | int)) or active_user.is_mod() %}
{% endif %}
@@ -18,7 +21,7 @@
&bullet; <i>stickied, so it's probably important</i>
{% endif %}
</span>
<div>
<div class="thread-actions">
{% if can_subscribe %}
<form class="modform" action="{{ url_for('threads.subscribe', slug=thread.slug) }}" method="post">
<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' }}'>
</form>
{% endif %}
{% if can_bookmark %}
{{ bookmark_button(type="thread", id=thread.id) }}
{% endif %}
{% if can_lock %}
<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 }}">
@@ -50,7 +56,7 @@
</div>
</nav>
{% 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 %}
</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" %}
{% block title %}browsing topic {{ topic['name'] }}{% endblock %}
{% block content %}
@@ -33,19 +34,29 @@
<div class="thread">
<div class="thread-sticky-container contain-svg">
{% if thread['is_stickied'] %}
<img src="/static/misc/sticky.svg">
{{ icn_sticky(48) }}
<i>Stickied</i>
{% endif %}
</div>
<div class="thread-info-container">
<span class="thread-info-header">
<span>
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a></span>
<span class="thread-title"><a href="{{ url_for("threads.thread", slug=thread['slug']) }}">{{thread['title']}}</a>
{% if thread['id'] in subscriptions %}
({{ subscriptions[thread['id']] }} unread)
{% endif %}
</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>
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a>
on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>:
@@ -56,7 +67,7 @@
</div>
<div class="thread-locked-container contain-svg">
{% if thread['is_locked'] %}
<img src="/static/misc/lock.svg">
{{ icn_lock(48) }}
<i>Locked</i>
{% endif %}
</div>

View File

@@ -1,3 +1,4 @@
{% from 'common/icons.html' import icn_lock %}
{% from 'common/macros.html' import timestamp %}
{% extends "base.html" %}
{% block content %}
@@ -33,7 +34,7 @@
</div>
<div class="topic-locked-container contain-svg">
{% if topic['is_locked'] %}
<img src="/static/misc/lock.svg"></img>
{{ icn_lock(48) }}
<i>Locked</i>
{% endif %}
</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;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
@@ -689,6 +695,10 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0;
padding: 5px;
}
button.icon, input[type=submit].icon, .linkbutton.icon {
padding-left: 16px;
flex-direction: row;
}
button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: red;
color: white !important;
@@ -706,6 +716,10 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
margin: 0;
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 {
background-color: #fbfb8d;
color: black !important;
@@ -723,6 +737,10 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0;
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 {
background-color: rgb(177, 206, 204.5);
@@ -741,6 +759,10 @@ input[type=file]::file-selector-button.reduced {
margin: 0;
padding: 5px;
}
input[type=file]::file-selector-button.icon {
padding-left: 16px;
flex-direction: row;
}
input[type=file]::file-selector-button {
margin: 10px;
}
@@ -766,6 +788,10 @@ p {
margin: 0;
padding: 5px;
}
.pagebutton.icon {
padding-left: 16px;
flex-direction: row;
}
.pagebutton {
padding: 5px 5px;
margin: 0;
@@ -871,13 +897,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
height: 50%;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
.contain-svg.inline {
display: inline-flex;
}
.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);
}
.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 {
overflow: hidden;
text-overflow: ellipsis;
@@ -973,7 +1005,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse;
width: 100%;
margin: 10px 0;
overflow: hidden;
}
.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);
}
.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);
}
@@ -1075,6 +1122,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
margin: 0;
padding: 5px;
}
.tab-button.icon {
padding-left: 16px;
flex-direction: row;
}
.tab-button {
border-bottom: none;
border-bottom-left-radius: 0;
@@ -1131,7 +1182,6 @@ ul, ol {
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
overflow: hidden;
}
.accordion.hidden {
@@ -1225,6 +1275,10 @@ footer {
margin: 0;
padding: 5px;
}
.reaction-button.active.icon {
padding-left: 16px;
flex-direction: row;
}
.reaction-popover {
position: relative;
@@ -1247,3 +1301,58 @@ footer {
.babycode-guide-list {
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;
}
.thread-actions {
display: flex;
align-items: center;
gap: 5px;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
@@ -689,6 +695,10 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0;
padding: 5px;
}
button.icon, input[type=submit].icon, .linkbutton.icon {
padding-left: 16px;
flex-direction: row;
}
button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: #d53232;
color: #e6e6e6 !important;
@@ -706,6 +716,10 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
margin: 0;
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 {
background-color: #eaea6a;
color: black !important;
@@ -723,6 +737,10 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0;
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 {
background-color: #3c283c;
@@ -741,6 +759,10 @@ input[type=file]::file-selector-button.reduced {
margin: 0;
padding: 5px;
}
input[type=file]::file-selector-button.icon {
padding-left: 16px;
flex-direction: row;
}
input[type=file]::file-selector-button {
margin: 10px;
}
@@ -766,6 +788,10 @@ p {
margin: 0;
padding: 5px;
}
.pagebutton.icon {
padding-left: 16px;
flex-direction: row;
}
.pagebutton {
padding: 5px 5px;
margin: 0;
@@ -871,13 +897,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
height: 50%;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
.contain-svg.inline {
display: inline-flex;
}
.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);
}
.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 {
overflow: hidden;
text-overflow: ellipsis;
@@ -973,7 +1005,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse;
width: 100%;
margin: 10px 0;
overflow: hidden;
}
.colorful-table tr th {
@@ -1028,6 +1059,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;
}
@@ -1075,6 +1122,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
margin: 0;
padding: 5px;
}
.tab-button.icon {
padding-left: 16px;
flex-direction: row;
}
.tab-button {
border-bottom: none;
border-bottom-left-radius: 0;
@@ -1131,7 +1182,6 @@ ul, ol {
box-sizing: border-box;
border: 1px solid black;
margin: 10px 5px;
overflow: hidden;
}
.accordion.hidden {
@@ -1225,6 +1275,10 @@ footer {
margin: 0;
padding: 5px;
}
.reaction-button.active.icon {
padding-left: 16px;
flex-direction: row;
}
.reaction-popover {
position: relative;
@@ -1248,6 +1302,61 @@ footer {
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 {
margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40);

View File

@@ -99,6 +99,12 @@ a:visited {
font-weight: bold;
}
.thread-actions {
display: flex;
align-items: center;
gap: 3px;
}
.post {
display: grid;
grid-template-columns: 200px 1fr;
@@ -689,6 +695,10 @@ button.reduced, input[type=submit].reduced, .linkbutton.reduced {
margin: 0;
padding: 6px;
}
button.icon, input[type=submit].icon, .linkbutton.icon {
padding-left: 8px;
flex-direction: row;
}
button.critical, input[type=submit].critical, .linkbutton.critical {
background-color: #f73030;
color: white !important;
@@ -706,6 +716,10 @@ button.critical.reduced, input[type=submit].critical.reduced, .linkbutton.critic
margin: 0;
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 {
background-color: #fbfb8d;
color: black !important;
@@ -723,6 +737,10 @@ button.warn.reduced, input[type=submit].warn.reduced, .linkbutton.warn.reduced {
margin: 0;
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 {
background-color: #f27a5a;
@@ -741,6 +759,10 @@ input[type=file]::file-selector-button.reduced {
margin: 0;
padding: 6px;
}
input[type=file]::file-selector-button.icon {
padding-left: 8px;
flex-direction: row;
}
input[type=file]::file-selector-button {
margin: 6px;
}
@@ -766,6 +788,10 @@ p {
margin: 0;
padding: 6px;
}
.pagebutton.icon {
padding-left: 8px;
flex-direction: row;
}
.pagebutton {
padding: 3px 3px;
margin: 0;
@@ -871,13 +897,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
justify-content: center;
flex-direction: column;
}
.contain-svg:not(.full) > svg, .contain-svg:not(.full) > img {
height: 50%;
width: 50%;
}
.contain-svg.full > svg, .contain-svg.full > img {
height: 100%;
width: 100%;
.contain-svg.inline {
display: inline-flex;
}
.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);
}
.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 {
overflow: hidden;
text-overflow: ellipsis;
@@ -973,7 +1005,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
border-collapse: collapse;
width: 100%;
margin: 6px 0;
overflow: hidden;
}
.colorful-table tr th {
@@ -1028,6 +1059,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);
}
@@ -1075,6 +1122,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
margin: 0;
padding: 6px;
}
.tab-button.icon {
padding-left: 8px;
flex-direction: row;
}
.tab-button {
border-bottom: none;
border-bottom-left-radius: 0;
@@ -1131,7 +1182,6 @@ ul, ol {
box-sizing: border-box;
border: 1px solid black;
margin: 6px 3px;
overflow: hidden;
}
.accordion.hidden {
@@ -1225,6 +1275,10 @@ footer {
margin: 0;
padding: 6px;
}
.reaction-button.active.icon {
padding-left: 8px;
flex-direction: row;
}
.reaction-popover {
position: relative;
@@ -1248,6 +1302,61 @@ footer {
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 {
border-top-left-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_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;
$DEFAULT_FONT_COLOR: black !default;
@@ -104,6 +112,7 @@ $button_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
$reduced_button_margin: $ZERO_PADDING !default;
$reduced_button_padding: $SMALL_PADDING !default;
$icon_button_padding_left: $BIG_PADDING - 4px !default;
@mixin button($color, $font_color) {
@extend %button-base;
background-color: $color;
@@ -125,6 +134,12 @@ $reduced_button_padding: $SMALL_PADDING !default;
margin: $reduced_button_margin;
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;
@@ -206,6 +221,13 @@ $thread_title_size: 1.5rem !default;
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_border: 2px outset $DARK_2 !default;
.post {
@@ -740,13 +762,9 @@ $thread_locked_background: none !default;
align-items: center;
justify-content: center;
flex-direction: column;
&:not(.full) > svg, &:not(.full) > img {
height: 50%;
width: 50%;
}
&.full > svg, &.full > img {
height: 100%;
width: 100%;
&.inline {
display: inline-flex;
}
}
@@ -786,6 +804,18 @@ $thread_info_mask_image: $user_page_post_preview_mask_image !default;
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 {
overflow: hidden;
@@ -863,7 +893,7 @@ $colorful_table_margin: $MEDIUM_PADDING $ZERO_PADDING !default;
border-collapse: collapse;
width: 100%;
margin: $colorful_table_margin;
overflow: hidden;
// overflow: hidden;
}
$colorful_table_th_color: $BUTTON_COLOR_2 !default;
@@ -938,6 +968,33 @@ $draggable_topic_border_bottom: $draggable_topic_border $DARK_2 !default;
}
}
$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;
@@ -1046,7 +1103,7 @@ $accordion_margin: $MEDIUM_PADDING $SMALL_PADDING !default;
box-sizing: border-box;
border: $accordion_border;
margin: $accordion_margin;
overflow: hidden;
// overflow: hidden;
}
.accordion.hidden {
@@ -1172,3 +1229,79 @@ $babycode_guide_list_border: 1px dashed !default;
.babycode-guide-list {
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;
}