diff --git a/remarkbox/__init__.py b/remarkbox/__init__.py index 6551a7e..ab9811b 100644 --- a/remarkbox/__init__.py +++ b/remarkbox/__init__.py @@ -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") diff --git a/remarkbox/models/user.py b/remarkbox/models/user.py index 3865e45..1e194d3 100644 --- a/remarkbox/models/user.py +++ b/remarkbox/models/user.py @@ -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) diff --git a/remarkbox/scripts/alembic/versions/add_theme_mode_to_user.py b/remarkbox/scripts/alembic/versions/add_theme_mode_to_user.py new file mode 100644 index 0000000..724cc09 --- /dev/null +++ b/remarkbox/scripts/alembic/versions/add_theme_mode_to_user.py @@ -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') diff --git a/remarkbox/static/css/common.css b/remarkbox/static/css/common.css index 40bf713..ebc86be 100644 --- a/remarkbox/static/css/common.css +++ b/remarkbox/static/css/common.css @@ -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, diff --git a/remarkbox/templates/base.j2 b/remarkbox/templates/base.j2 index 766d105..10f8a1c 100644 --- a/remarkbox/templates/base.j2 +++ b/remarkbox/templates/base.j2 @@ -1,7 +1,7 @@ {%- import 'snippets/snippets.j2' as snippets with context -%} {% include 'snippets/owner-key-comment.j2' %} - + {% block title %}{{ request.app_domain }}{% endblock %} diff --git a/remarkbox/templates/embed-iframe.txt.j2 b/remarkbox/templates/embed-iframe.txt.j2 index 5f44edf..d2dfc26 100644 --- a/remarkbox/templates/embed-iframe.txt.j2 +++ b/remarkbox/templates/embed-iframe.txt.j2 @@ -1,7 +1,7 @@
@@ -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() { diff --git a/remarkbox/templates/user-settings.j2 b/remarkbox/templates/user-settings.j2 index d3d3382..5e89141 100644 --- a/remarkbox/templates/user-settings.j2 +++ b/remarkbox/templates/user-settings.j2 @@ -29,12 +29,9 @@ {{ snippets.user_avatar_link(request.user, 130, class="user-settings-profile-pic") }}
-
enable gravatar? -
-

+
+ + +
+ +
+ +
+ +
+ +
+ When my comments get a reply, notify me @@ -95,4 +105,25 @@ View and adjust your Namespace no + + {%- endblock -%} diff --git a/remarkbox/views/authenticated/authenticated.py b/remarkbox/views/authenticated/authenticated.py index 2c25197..39067e4 100644 --- a/remarkbox/views/authenticated/authenticated.py +++ b/remarkbox/views/authenticated/authenticated.py @@ -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 {}".format(theme_labels[theme_mode]), + "success", + ) + ) + else: + request.session.flash( + ("Invalid theme mode value.", "error") + ) + request.dbsession.add(request.user) request.dbsession.flush() diff --git a/remarkbox/views/misc.py b/remarkbox/views/misc.py index 3133f5d..c2af0e7 100644 --- a/remarkbox/views/misc.py +++ b/remarkbox/views/misc.py @@ -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":