]> git.agnieray.net Git - galette.git/commitdiff
Add dark mode
authorJohan Cwiklinski <johan@x-tnd.be>
Mon, 23 Oct 2023 16:48:47 +0000 (18:48 +0200)
committerJohan Cwiklinski <johan@x-tnd.be>
Thu, 26 Oct 2023 20:57:19 +0000 (22:57 +0200)
Add Dark Reader in js dependencies
Generate and store dark CSS file
Better name for included file

Rely on browser configuration to switch theme

Tested with:
- firefox
- chrome (using chrome://flags/#enable-force-dark)

14 files changed:
galette/includes/routes/main.routes.php
galette/lib/Galette/Core/Authentication.php
galette/templates/default/elements/footer.html.twig
galette/templates/default/elements/header.html.twig
galette/templates/default/elements/logged_user.html.twig [new file with mode: 0644]
galette/templates/default/elements/logout.html.twig [deleted file]
galette/templates/default/elements/navigation/navigation_aside.html.twig
galette/templates/default/elements/navigation/navigation_sidebar.html.twig
galette/templates/default/elements/navigation/public_pages.html.twig
galette/templates/default/elements/scripts.html.twig
gulpfile.js
package-lock.json
package.json
ui/semantic/galette/globals/site.overrides

index f868097a38e2bd2b0b6077dc465771c36a5a6292..24a09ff77df2b9b819956f157416a968868e148a 100644 (file)
@@ -81,3 +81,25 @@ $app->get(
     '/system-information',
     [GaletteController::class, 'systemInformation']
 )->setName('sysinfos')->add($authenticate);
+
+$app->post(
+    '/write-dark-css',
+    function ($request, $response) {
+        $post = $request->getParsedBody();
+        file_put_contents(GALETTE_CACHE_DIR . '/dark.css', $post);
+        return $response->withStatus(200);
+    }
+)->setName('writeDarkCSS');
+
+$app->get(
+    '/get-dark-css',
+    function ($request, $response) {
+        $cssfile = GALETTE_CACHE_DIR . '/dark.css';
+        if (file_exists($cssfile)) {
+            $response = $response->withHeader('Content-type', 'text/css');
+            $body = $response->getBody();
+            $body->write(file_get_contents($cssfile));
+        }
+        return $response;
+    }
+)->setName('getDarkCSS');
index da32eb55c2f6531d77b2adda0aea8ea804b3cae2..cf6f85a121ab20d81645e3135e0f2441e4d1a3db 100644 (file)
@@ -78,6 +78,7 @@ abstract class Authentication
     protected $managed_groups = [];
     protected $cron = false;
     protected $compact_menu = false;
+    protected $dark_mode = false;
 
     /**
      * Logs in user.
@@ -269,12 +270,22 @@ abstract class Authentication
      */
     public function getCompactMenu(): bool
     {
-        return ($this->logged && isset($_COOKIE['galette_compact_menu']) && $_COOKIE['galette_compact_menu']) ? true : false;
+        return $this->logged && isset($_COOKIE['galette_compact_menu']) && $_COOKIE['galette_compact_menu'];
+    }
+
+    /**
+     * Is dark mode enabled?
+     *
+     * @return bool
+     */
+    public function isDarkModeEnabled(): bool
+    {
+        return isset($_COOKIE['galette_dark_mode']) && $_COOKIE['galette_dark_mode'];
     }
 
     /**
      * Is user currently up to date?
-     * An up to date member is active and either due free, or with up to date
+     * An up-to-date member is active and either due free, or with up-to-date
      * subscription
      *
      * @return bool
index c3333c62f0344a82df93b1b390223d167254ee03..a4a246fe009821d59d63e7d00f0718e084acc528 100644 (file)
@@ -33,7 +33,7 @@
 {% endif %}
                 </nav>
 {% if login.isLogged() and cur_route == 'dashboard' %}
-                {# Comply with the Twemoji project's requirements about attribution #}
+                {# Comply with the Twemoji project's requirements about attribution #}
                 <!--
                     Emojis used by Galette's core and its official plugins on the dashboard
                     are licensed under CC-BY 4.0 <https://creativecommons.org/licenses/by/4.0/>
index e2b28454ada80d58f6104e6810e10d648849e2b3..a84a3af21f2f5a0fee1c9cf2f1ff246328cdca32 100644 (file)
@@ -30,3 +30,8 @@
         {% include header with {'module_id': mid} %}
     {% endfor %}
 {% endif %}
+
+{% set darkcssfile = constant('GALETTE_CACHE_DIR') ~ "dark.css" %}
+{% if login.isDarkModeEnabled() and file_exists(darkcssfile) %}
+    <link rel="stylesheet" type="text/css" href="{{ url_for('getDarkCSS') }}">
+{% endif %}
diff --git a/galette/templates/default/elements/logged_user.html.twig b/galette/templates/default/elements/logged_user.html.twig
new file mode 100644 (file)
index 0000000..ebb52a7
--- /dev/null
@@ -0,0 +1,101 @@
+{% if ui is defined %}
+    {% if ui == 'item' %}
+       {% set component_classes = "item" %}
+    {%  elseif ui == 'menu' %}
+       {% set component_classes = "ui text compact small fluid menu" %}
+    {% endif %}
+{% endif %}
+{% if login.isLogged() %}
+    {% if ui == 'item' %}
+        <div class="{{ component_classes }}">
+            <div class="ui basic center aligned fitted segment">
+                <span class="ui tiny header">{{ login.loggedInAs()|raw }}</span>
+            </div>
+            <a
+                href="#"
+                class="ui fluid darkmode{% if login.isDarkModeEnabled() %} black{% endif %} basic button"
+            >
+                <i class="icon adjust"></i>
+                {% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}
+            </a>
+            <a
+                class="ui fluid {% if login.isImpersonated() %}purple{% else %}red{% endif %} basic button"
+                href="{% if login.isImpersonated() %}{{ url_for("unimpersonate") }}{% else %}{{ url_for("logout") }}{% endif %}"
+            >
+                <i class="icon {% if login.isImpersonated() %}user secret{% else %}sign out alt{% endif %}"></i>
+                {% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}
+            </a>
+        </div>
+    {% else %}
+        {% if not login.getCompactMenu() %}
+            <div class="{{ component_classes }}">
+                <div class="ui item">
+                    <i class="user circle big icon"></i>
+                    {{ login.loggedInAs()|raw }}
+                </div>
+                <div class="right menu">
+                    <div class="item">
+                        <div class="ui icon buttons">
+                            <a
+                                href="#"
+                                class="ui darkmode{% if login.isDarkModeEnabled() %} black{% endif %} icon button"
+                                title="{% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}"
+                            >
+                                <i class="icon adjust"></i>
+                                <span class="displaynone">{% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}</span>
+                            </a>
+                            <a
+                                class="ui {% if login.isImpersonated() %}purple{% else %}red{% endif %} icon button"
+                                href="{% if login.isImpersonated() %}{{ url_for("unimpersonate") }}{% else %}{{ url_for("logout") }}{% endif %}"
+                                title="{% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}"
+                                data-position="bottom right"
+                            >
+                                <i class="icon {% if login.isImpersonated() %}user secret{% else %}sign out alt{% endif %}"></i>
+                                <span class="displaynone">{% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}</span>
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+            {% include "elements/modes.html.twig" %}
+        {% else %}
+            {% set component_classes = "ui vertical centered tiny fluid icon menu" %}
+            <div id="logoutmenu" class="{{ component_classes }}">
+                <div class="ui dropdown item no-touch tooltip" data-html="{{ login.loggedInAs()|raw }}" data-position="right center">
+                    <i class="user circle icon"></i>
+                    <span class="text displaynone">{{ login.loggedInAs()|raw }}</span>
+                    <div class="menu">
+                        <div class="item">
+                            <div class="ui basic center aligned fitted segment">
+                                <img src="{{ url_for('logo') }}" width="{{ logo.getOptimalWidth() }}" height="{{ logo.getOptimalHeight() }}" alt="{{ preferences.pref_nom }}" class="icon"/>
+                                <div class="ui block huge brand header">
+                                    {{ preferences.pref_nom }}
+                                    {% if preferences.pref_slogan %}<div class="sub tiny header">{{ __(preferences.pref_slogan) }}</div>{% endif %}
+                                </div>
+                            </div>
+                            {{ login.loggedInAs()|raw }}
+                            <div class="ui basic fitted segment">
+                                {% include "elements/modes.html.twig" %}
+                            </div>
+                            <a
+                                href="#"
+                                class="ui darkmode{% if login.isDarkModeEnabled() %} black{% endif %} fluid icon button"
+                                title="{% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}"
+                            >
+                                <i class="icon adjust"></i>
+                                {% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}
+                            </a>
+                            <a
+                                class="ui {% if login.isImpersonated() %}purple{% else %}red{% endif %} fluid icon button"
+                                href="{% if login.isImpersonated() %}{{ url_for("unimpersonate") }}{% else %}{{ url_for("logout") }}{% endif %}"
+                            >
+                                <i class="icon {% if login.isImpersonated() %}user secret{% else %}sign out alt{% endif %}"></i>
+                                {% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}
+                            </a>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        {% endif %}
+    {% endif %}
+{% endif %}
diff --git a/galette/templates/default/elements/logout.html.twig b/galette/templates/default/elements/logout.html.twig
deleted file mode 100644 (file)
index 16f9afd..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-{% if ui is defined %}
-    {% if ui == 'item' %}
-       {% set component_classes = "item" %}
-    {%  elseif ui == 'menu' %}
-       {% set component_classes = "ui text compact small fluid menu" %}
-    {% endif %}
-{% endif %}
-{% if login.isLogged() %}
-    {% if ui == 'item' %}
-        <div class="{{ component_classes }}">
-            <div class="ui basic center aligned fitted segment">
-                <span class="ui tiny header">{{ login.loggedInAs()|raw }}</span>
-            </div>
-            <a
-                class="ui fluid {% if login.isImpersonated() %}purple{% else %}red{% endif %} basic button"
-                href="{% if login.isImpersonated() %}{{ url_for("unimpersonate") }}{% else %}{{ url_for("logout") }}{% endif %}"
-            >
-                <i class="icon {% if login.isImpersonated() %}user secret{% else %}sign out alt{% endif %}"></i>
-                {% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}
-            </a>
-        </div>
-    {% else %}
-        {% if not login.getCompactMenu() %}
-            <div class="{{ component_classes }}">
-                <div class="ui item">
-                    <i class="user circle big icon"></i>
-                    {{ login.loggedInAs()|raw }}
-                </div>
-                <div class="right menu">
-                    <div class="item">
-                        <a
-                            class="ui {% if login.isImpersonated() %}purple{% else %}red{% endif %} icon button"
-                            href="{% if login.isImpersonated() %}{{ url_for("unimpersonate") }}{% else %}{{ url_for("logout") }}{% endif %}"
-                            title="{% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}"
-                            data-position="bottom right"
-                        >
-                            <i class="icon {% if login.isImpersonated() %}user secret{% else %}sign out alt{% endif %}"></i>
-                            <span class="displaynone">{% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}</span>
-                        </a>
-                    </div>
-                </div>
-            </div>
-            {% include "elements/modes.html.twig" %}
-        {% else %}
-            {% set component_classes = "ui vertical centered tiny fluid icon menu" %}
-            <div id="logoutmenu" class="{{ component_classes }}">
-                <div class="ui dropdown item no-touch tooltip" data-html="{{ login.loggedInAs()|raw }}" data-position="right center">
-                    <i class="user circle icon"></i>
-                    <span class="text displaynone">{{ login.loggedInAs()|raw }}</span>
-                    <div class="menu">
-                        <div class="item">
-                            <div class="ui basic center aligned fitted segment">
-                                <img src="{{ url_for('logo') }}" width="{{ logo.getOptimalWidth() }}" height="{{ logo.getOptimalHeight() }}" alt="{{ preferences.pref_nom }}" class="icon"/>
-                                <div class="ui block huge brand header">
-                                    {{ preferences.pref_nom }}
-                                    {% if preferences.pref_slogan %}<div class="sub tiny header">{{ __(preferences.pref_slogan) }}</div>{% endif %}
-                                </div>
-                            </div>
-                            {{ login.loggedInAs()|raw }}
-                            <div class="ui basic fitted segment">
-                                {% include "elements/modes.html.twig" %}
-                            </div>
-                            <a
-                                class="ui {% if login.isImpersonated() %}purple{% else %}red{% endif %} icon button"
-                                href="{% if login.isImpersonated() %}{{ url_for("unimpersonate") }}{% else %}{{ url_for("logout") }}{% endif %}"
-                            >
-                                <i class="icon {% if login.isImpersonated() %}user secret{% else %}sign out alt{% endif %}"></i>
-                                {% if login.isImpersonated() %}{{ _T("Unimpersonate") }}{% else %}{{ _T("Log off") }}{% endif %}
-                            </a>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        {% endif %}
-    {% endif %}
-{% endif %}
index a8c29141e29696064fc576dafdd99d478efbd022..7b85e62befeac94e88d4f0358d290d7bf3f1d886 100644 (file)
@@ -1,5 +1,5 @@
 <aside id="sidemenu" class="ui computer only toc{% if login.getCompactMenu() %} compact_menu{% endif %}">
-    {% include "elements/logout.html.twig" with {
+    {% include "elements/logged_user.html.twig" with {
             ui: "menu"
     } %}
 
index e4e4a8ad914d91bfe6f075a16119c63c9f60e3bb..6b6d80fe413aac0d7656e96f9be176f943ae9ab0 100644 (file)
@@ -22,7 +22,7 @@
             ui: "item"
     } %}
 {% if login.isLogged() %}
-        {% include "elements/logout.html.twig" with {
+        {% include "elements/logged_user.html.twig" with {
                 ui: "item"
         } %}
 {% endif %}
index f704602ebf4e2222454ed44caab207478f9d31cc..b5af4a8f338604204685f3d4f4c1369bef2aca72 100644 (file)
     {% for public_item in public_items %}
         {{ menus_macros.renderMenuItem(public_item.label, public_item.title, public_item.route, public_item.icon, public_item.class, tips_position) }}
     {% endfor %}
+        <a
+            href="#"
+            class="ui darkmode{% if login.isDarkModeEnabled() %} black{% endif %} icon button"
+            data-position="{{ tips_position }}"
+            title="{% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}"
+        >
+            <i class="icon adjust"></i>
+            <span>{% if login.isDarkModeEnabled() %}{{ _T("Disable dark mode") }}{% else %}{{ _T("Enable dark mode") }}{% endif %}</span>
+        </a>
     </div>
 {% endif %}
index fc781b96c5d92f98a1e2013d5ddb96095e90b06c..89c0ef72c98a477cd9574db2d87f48363a7646b5 100644 (file)
@@ -1,5 +1,8 @@
         <script type="text/javascript" src="{{ base_path() }}/{{ constant('GALETTE_THEME') }}ui/semantic.min.js"></script>
         <script type="text/javascript" src="{{ base_path() }}/assets/js/galette-main.bundle.min.js"></script>
+    {% if login.isDarkModeEnabled() %}
+        <script type="text/javascript" src="{{ base_path() }}/assets/js/darkreader.min.js"></script>
+    {% endif %}
 
         <script type="text/javascript">
             function csrfSafeMethod(method) {
@@ -8,6 +11,67 @@
             }
 
             $(function(){
+                function _darkMode() {
+                    var _dark_enabled = Cookies.get('galette_dark_mode');
+                    var _cookie_value = 1;
+                    if (_dark_enabled && _dark_enabled == 1) {
+                        var _cookie_value = 0;
+    {% set darkcssfile = constant('GALETTE_CACHE_DIR') ~ "dark.css" %}
+    {% if not file_exists(darkcssfile) %}
+                        function writeDarkTheme() {
+                            DarkReader.enable({
+                                brightness: 100,
+                                contrast: 90,
+                                sepia: 10
+                            });
+                            return DarkReader.exportGeneratedCSS();
+                        }
+                        writeDarkTheme().then(function(cssdata) {
+                            $.ajax({
+                                url: '{{ url_for("writeDarkCSS") }}',
+                                method: 'post',
+                                data: cssdata.replaceAll('themes/galette/assets', 'themes/default/ui/themes/galette/assets'),
+                                success: function(res) {
+                                    console.log('Dark theme CSS stored');
+                                },
+                                error: function() {
+                                    console.log('Error storing dark theme CSS');
+                                }
+                            });
+                        });
+    {% endif %}
+                    }
+                    $('.darkmode').on('click', function(e) {
+                        e.preventDefault();
+                        Cookies.set(
+                            'galette_dark_mode',
+                            _cookie_value,
+                            {
+                                expires: 365,
+                                path: '/'
+                            }
+                        );
+                        window.location.reload();
+                    });
+                    if (window.matchMedia) {
+                        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
+                            if (event.matches) {
+                                _cookie_value = 1;
+                            }
+                            Cookies.set(
+                                'galette_dark_mode',
+                                _cookie_value,
+                                {
+                                    expires: 365,
+                                    path: '/'
+                                }
+                            );
+                            window.location.reload();
+                        });
+                    }
+                }
+                _darkMode();
+
                 $.ajaxPrefilter(function(options, originalOptions, jqXHR){
                     if (options.type.toLowerCase() === "post") {
                         // initialize `data` to empty string if it does not exist
index c03e086146051ebce48a234a4837e500992c99f5..0181184f154b5cf1eeeb9e0fe3a532fecf3ff7ca 100644 (file)
@@ -96,6 +96,9 @@ var paths = {
     ],
     codemirrorformatting: [
       'https://cdnjs.cloudflare.com/ajax/libs/codemirror/2.36.0/formatting.js'
+    ],
+    darkreader: [
+      './node_modules/darkreader/darkreader.js'
     ]
   },
   extras: [
@@ -218,7 +221,17 @@ function scripts() {
     .pipe(gulp.dest(paths.assets.js))
     .pipe(browserSync.stream());
 
-  return merge(main, masschanges, chartjs, sortablejs, summernote, codemirror, codemirrorxml, codemirrorformatting);
+  darkreader = gulp.src(paths.scripts.darkreader)
+    .pipe(concat('darkreader.min.js'))
+    .pipe(uglify({
+      output: {
+        comments: /^!/
+      }
+    }))
+    .pipe(gulp.dest(paths.assets.js))
+    .pipe(browserSync.stream());
+
+  return merge(main, masschanges, chartjs, sortablejs, summernote, codemirror, codemirrorxml, codemirrorformatting, darkreader);
 }
 
 function movefiles() {
index b25fc588b6609efacd2b2413b5d11b557ee4f50e..f00b0d6aa024ca6c17bf7ec6a60534a096534b7b 100644 (file)
@@ -12,6 +12,7 @@
         "chart.js": "^4.4.0",
         "chartjs-plugin-autocolors": "^0.2.2",
         "chartjs-plugin-datalabels": "^2.2.0",
+        "darkreader": "^4.9.67",
         "fomantic-ui": "^2.9.3",
         "jquery": "^3.7.1",
         "js-cookie": "^3.0.5",
         "type": "^1.0.1"
       }
     },
+    "node_modules/darkreader": {
+      "version": "4.9.67",
+      "resolved": "https://registry.npmjs.org/darkreader/-/darkreader-4.9.67.tgz",
+      "integrity": "sha512-gFEz+GZGCWVtnRbTb/7oa3QdvPqe21Cyjj/hxg3sMP8RK7zUYptjnDFxe77xvVcfhkf+bz6CfweqrixVoHxaPg==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/darkreader/donate"
+      }
+    },
     "node_modules/dashdash": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
         "type": "^1.0.1"
       }
     },
+    "darkreader": {
+      "version": "4.9.67",
+      "resolved": "https://registry.npmjs.org/darkreader/-/darkreader-4.9.67.tgz",
+      "integrity": "sha512-gFEz+GZGCWVtnRbTb/7oa3QdvPqe21Cyjj/hxg3sMP8RK7zUYptjnDFxe77xvVcfhkf+bz6CfweqrixVoHxaPg=="
+    },
     "dashdash": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
index 5baef3ca3ee3767495418a614bc3007ed37c08e7..c62c0f2a9cdc4e7b4cb789826db40d919914d783 100644 (file)
@@ -41,6 +41,7 @@
     "chart.js": "^4.4.0",
     "chartjs-plugin-autocolors": "^0.2.2",
     "chartjs-plugin-datalabels": "^2.2.0",
+    "darkreader": "^4.9.67",
     "fomantic-ui": "^2.9.3",
     "jquery": "^3.7.1",
     "js-cookie": "^3.0.5",
index 9fe77bb36a2b5dfeba2d3aa8b939a28e04cce09a..f55c2660d7e3b01a372d7be8a2c4c0c28b0c450e 100644 (file)
@@ -130,6 +130,10 @@ footer .ui.horizontal.list > .item{
     padding-top: 0;
   }
 
+  #top-navbar div.item a.button.darkmode span {
+    display: none;
+  }
+
   aside.computer.toc {
     background: @galetteNavBackground;
   }
@@ -219,6 +223,18 @@ footer .ui.horizontal.list > .item{
   }
 }
 
+@media only screen and (max-width: 1199px) {
+  aside.computer.toc {
+    .ui.text.compact.small.fluid.menu {
+      font-size: .8em;
+
+      .ui.buttons .button {
+        padding: .5em;
+      }
+    }
+  }
+}
+
 @media only screen and (min-width: 1200px) {
   aside.toc {
     width: 350px;