Add dark mode support with mode query parameter

This commit is contained in:
Russell Ballestrini 2025-10-12 00:35:05 +00:00
parent b993fe08d3
commit fe0cb22f23
9 changed files with 161 additions and 9 deletions

View file

@ -99,6 +99,8 @@ def load_entry_points(group_name):
def load_jinja2_themes(config):
"""Automatically load any entry_point registered Remarkbox theme."""
themes = load_entry_points("remarkbox.themes")
theme_defaults = {}
for theme_name, theme_module in themes.items():
theme_module_name = theme_module.__name__
# teach Jinja2 about the template dir in the theme package.
@ -111,6 +113,12 @@ def load_jinja2_themes(config):
"{}:static/theme/{}".format(theme_module_name, theme_name),
cache_max_age=3600,
)
# Collect theme's default mode if defined
if hasattr(theme_module, 'default_theme_mode'):
theme_defaults[theme_name] = theme_module.default_theme_mode
# Store theme defaults in config registry for later access
config.registry.settings['theme_defaults'] = theme_defaults
return config
@ -492,6 +500,30 @@ def main(global_config, **settings):
def add_mathjax(request):
return "true" if request.namespace.mathjax else "false"
def add_theme_mode(request):
"""
Return theme mode 'light' or 'dark'.
Priority: user preference > query params > theme default > 'light'.
"""
# If user is authenticated and has a preference
if request.user and request.user.authenticated and request.user.theme_mode != 'auto':
return request.user.theme_mode
# Check if there's a mode parameter (for embeds or overrides)
param_mode = request.params.get("mode")
if param_mode in ("light", "dark"):
return param_mode
# Use theme's default mode if available
if request.theme:
theme_defaults = request.registry.settings.get('theme_defaults', {})
theme_default = theme_defaults.get(request.theme)
if theme_default in ("light", "dark"):
return theme_default
# Final fallback to light
return "light"
# register functions to app config as request methods.
# each request instance will run these functions and attach results.
# cache result with `reify=True` to prevent multiple db lookups.
@ -546,6 +578,7 @@ def main(global_config, **settings):
config.add_request_method(add_page_offset, "page_offset", reify=True)
config.add_request_method(add_node_order, "node_order", reify=True)
config.add_request_method(add_mathjax, "mathjax", reify=True)
config.add_request_method(add_theme_mode, "theme_mode", reify=True)
# all of the web application routes.
config.include(".routes")

View file

@ -114,6 +114,12 @@ class User(RBase, Base):
default="daily",
nullable=False,
)
# Theme mode preference: 'auto', 'light', or 'dark'. 'auto' respects parent site.
theme_mode = Column(
Enum('auto', 'light', 'dark', name='theme_mode_enum'),
default='auto',
nullable=False,
)
# example: cus_12345678AbCdEF but may be null.
stripe_id = Column(Unicode(18), unique=True, nullable=True)

View file

@ -0,0 +1,34 @@
"""add theme_mode column to rb_user
Revision ID: b8f3c9d4e5a1
Revises: 14a6a35940c7
Create Date: 2025-10-11 00:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = 'b8f3c9d4e5a1'
down_revision = '14a6a35940c7'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column(
'rb_user',
sa.Column(
'theme_mode',
sa.Enum('auto', 'light', 'dark', name='theme_mode_enum'),
nullable=False,
server_default='auto',
),
)
def downgrade():
op.drop_column('rb_user', 'theme_mode')
# Also drop the enum type
op.execute('DROP TYPE theme_mode_enum')

View file

@ -1,10 +1,11 @@
/* Variables */
/* Variables - Light Mode (default) */
:root {
--color: #222222;
--color-muted: #3f3f3f;
--color-code: #000000;
--background: #ffffff;
--background-faint: #fafafa;
--primary: #d40000; /* Remarkbox logo color */
@ -19,6 +20,28 @@
--line-height: 1.6;
}
/* Dark Mode Variables */
:root.dark-mode {
--color: #e8e8e8;
--color-muted: #b0b0b0;
--color-code: #e8e8e8;
--background: #1a1a1a;
--background-faint: #2a2a2a;
--primary: #ff5555; /* Brighter red for dark mode */
--primary-hover: #ff7777;
--primary-inverse: #1a1a1a;
--border: #444444;
--border-faint: #333333;
--color-mod: #ff5555;
--line-height: 1.6;
}
body {
overflow-x: hidden;
line-height: var(--line-height);
@ -26,6 +49,7 @@ body {
font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial,
sans-serif;
color: var(--color);
background-color: var(--background);
}
h1,

View file

@ -1,7 +1,7 @@
{%- import 'snippets/snippets.j2' as snippets with context -%}
{% include 'snippets/owner-key-comment.j2' %}
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="{% if request.theme_mode == 'dark' %}dark-mode{% endif %}">
<head>
<title>{% block title %}{{ request.app_domain }}{% endblock %}</title>
<meta charset="utf-8" />

View file

@ -1,7 +1,7 @@
<!-- Remarkbox - Your readers want to communicate with you -->
<div id="remarkbox-div">
<noscript>
<iframe id=remarkbox-iframe src="{{ request.scheme }}://{{ request.host }}/embed?nojs=true" style="height:600px;width:100%;border:none!important" tabindex=0></iframe>
<iframe id=remarkbox-iframe src="{{ request.scheme }}://{{ request.host }}/embed?nojs=true&mode={{ mode }}" style="height:600px;width:100%;border:none!important" tabindex=0></iframe>
</noscript>
</div>
<script src="{{ request.scheme }}://{{ request.host }}/static/js/iframe-resizer/iframeResizer.min.js"></script>
@ -12,10 +12,11 @@
var thread_fragment = window.location.hash;
// rb owner was here.
var rb_src = "{{ request.scheme }}://{{ request.host }}/embed" +
var rb_src = "{{ request.scheme }}://{{ request.host }}/embed" +
"?rb_owner_key=" + rb_owner_key +
"&thread_title=" + encodeURI(thread_title) +
"&thread_uri=" + encodeURIComponent(thread_uri) +
"&thread_uri=" + encodeURIComponent(thread_uri) +
"&mode={{ mode }}" +
thread_fragment;
function create_remarkbox_iframe() {

View file

@ -29,12 +29,9 @@
{{ snippets.user_avatar_link(request.user, 130, class="user-settings-profile-pic") }}
<br>
<br>
<input type="checkbox" name="gravatar-checkbox" id="gravatar-checkbox" {% if request.user.gravatar %}checked{% endif %}></input> enable <a href="https://en.gravatar.com/support/what-is-gravatar/" target="_blank">gravatar</a>?
<br/>
<br/>
<br/>
<!--
@ -52,6 +49,19 @@ automatically watch any threads I participate in.
-->
<br/>
<label>Theme Mode</label>
<div class="theme-appearance-section">
<input type="radio" name="theme-mode" id="theme-mode-auto" value="auto" {% if request.user.theme_mode == "auto" %}checked{% endif %} onchange="previewTheme(this.value)"></input> <label for="theme-mode-auto" class="inline-label">Auto (follow parent site)</label>
<br/>
<input type="radio" name="theme-mode" id="theme-mode-light" value="light" {% if request.user.theme_mode == "light" %}checked{% endif %} onchange="previewTheme(this.value)"></input> <label for="theme-mode-light" class="inline-label">Light</label>
<br/>
<input type="radio" name="theme-mode" id="theme-mode-dark" value="dark" {% if request.user.theme_mode == "dark" %}checked{% endif %} onchange="previewTheme(this.value)"></input> <label for="theme-mode-dark" class="inline-label">Dark</label>
</div>
<br/>
<label>Reply Notifications</label>
When my comments get a reply, notify me
@ -95,4 +105,25 @@ View and adjust your <a href="{{ request.link_prefix }}/u/watching">Namespace no
</form>
<script>
function previewTheme(mode) {
const html = document.documentElement;
if (mode === 'dark') {
html.classList.add('dark-mode');
} else if (mode === 'light') {
html.classList.remove('dark-mode');
} else if (mode === 'auto') {
// Auto mode: check if parent provided a mode parameter
const urlParams = new URLSearchParams(window.location.search);
const parentMode = urlParams.get('mode');
if (parentMode === 'dark') {
html.classList.add('dark-mode');
} else {
html.classList.remove('dark-mode');
}
}
}
</script>
{%- endblock -%}

View file

@ -231,6 +231,7 @@ def user_settings(request):
default_node_watcher_frequency = request.params[
"default-node-watcher-frequency"
]
theme_mode = request.params.get("theme-mode", "auto")
if display_name != request.user.name:
if is_user_name_valid(display_name) == False:
@ -324,6 +325,25 @@ def user_settings(request):
("Invalid Enum value for reply_watcher_frequency.", "error")
)
if theme_mode != request.user.theme_mode:
if theme_mode in ('auto', 'light', 'dark'):
request.user.theme_mode = theme_mode
theme_labels = {
'auto': 'Auto (follow parent site)',
'light': 'Light',
'dark': 'Dark'
}
request.session.flash(
(
"Theme appearance set to <b>{}</b>".format(theme_labels[theme_mode]),
"success",
)
)
else:
request.session.flash(
("Invalid theme mode value.", "error")
)
request.dbsession.add(request.user)
request.dbsession.flush()

View file

@ -92,7 +92,10 @@ def show_count(request):
@view_config(route_name="embed-iframe-min")
def embed_iframe(request):
"""Jinja render a text file and serve it as plain text."""
context = {"rb_owner_key": request.params.get("rb_owner_key", "none")}
context = {
"rb_owner_key": request.params.get("rb_owner_key", "none"),
"mode": request.params.get("mode", "light")
}
response = render_to_response("embed-iframe.txt.j2", context, request=request)
response.content_type = "text/plain"
if request.matched_route.name == "embed-iframe-min":