diff --git a/ScriptumX/Dockerfile b/ScriptumX/Dockerfile
index c7e72e4..3c7a662 100644
--- a/ScriptumX/Dockerfile
+++ b/ScriptumX/Dockerfile
@@ -12,6 +12,7 @@ WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
+ gettext \
libjpeg62-turbo-dev \
zlib1g-dev \
libfreetype6-dev \
@@ -26,6 +27,7 @@ COPY . /app
RUN mkdir -p /data /app/static \
&& chmod +x /app/docker/entrypoint.sh \
+ && python manage.py compilemessages \
&& chown -R app:app /app /data
USER app
diff --git a/ScriptumX/ScriptumX/settings.py b/ScriptumX/ScriptumX/settings.py
index 702dd80..b5e05a8 100644
--- a/ScriptumX/ScriptumX/settings.py
+++ b/ScriptumX/ScriptumX/settings.py
@@ -52,6 +52,7 @@
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
+ 'X.context_processors.ux_chrome',
],
},
},
@@ -62,7 +63,14 @@
LOGOUT_REDIRECT_URL = '/'
TIME_ZONE = 'Europe/Berlin'
-LANGUAGE_CODE = 'de-de'
+LANGUAGE_CODE = 'de'
+LANGUAGES = (
+ ('de', 'Deutsch'),
+ ('en', 'English'),
+)
+LOCALE_PATHS = (
+ path.join(PROJECT_ROOT, 'locale'),
+)
SITE_ID = 1
USE_I18N = True
USE_TZ = True
@@ -108,6 +116,7 @@
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
diff --git a/ScriptumX/X/common.py b/ScriptumX/X/common.py
index a531fe6..7effc42 100644
--- a/ScriptumX/X/common.py
+++ b/ScriptumX/X/common.py
@@ -1,4 +1,5 @@
from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
from X.models import Project, Script, Scene
@@ -6,18 +7,18 @@
# Base tab definitions. Use get_tab_list(env) for hrefs that embed project/script.
_TAB_DEFS = (
- {'id': 'P', 'name': 'Project', 'path': '/project', 'class': 'x-project', 'img': 'img/tab/project-24.png'},
- {'id': 'C', 'name': 'Script', 'path': '/script', 'class': 'x-script', 'img': 'img/tab/script-24.png'},
- {'id': 'S', 'name': 'Scene', 'path': '/scene', 'class': 'x-scene', 'img': 'img/tab/scene-24.png'},
- {'id': 'H', 'name': 'Shot', 'path': '/shot', 'class': 'x-scene', 'img': 'img/tab/shot-24.png'},
- {'id': 'R', 'name': 'Role', 'path': '/role', 'class': 'x-role', 'img': 'img/tab/role-24.png'},
- {'id': 'L', 'name': 'Location', 'path': '/location', 'class': 'x-location', 'img': 'img/tab/location-24.png'},
- {'id': 'T', 'name': 'Time', 'path': '/time', 'class': 'x-time', 'img': 'img/tab/time-24.png'},
- {'id': 'F', 'name': 'Person', 'path': '/person', 'class': 'x-person', 'img': 'img/tab/person-24.png'},
- {'id': 'G', 'name': 'Gadget', 'path': '/gadget', 'class': 'x-gadget', 'img': 'img/tab/gadget-24.png'},
- {'id': 'X', 'name': 'SFX', 'path': '/sfx', 'class': 'x-sfx', 'img': 'img/tab/sfx-24.png'},
- {'id': 'A', 'name': 'Audio', 'path': '/audio', 'class': 'x-audio', 'img': 'img/tab/audio-24.png'},
- {'id': 'B', 'name': 'Plan', 'path': '/planner', 'class': 'x-planner', 'img': 'img/tab/scheduler-24.png'},
+ {'id': 'P', 'name': _('Project'), 'path': '/project', 'class': 'x-project', 'img': 'img/tab/project-24.png'},
+ {'id': 'C', 'name': _('Script'), 'path': '/script', 'class': 'x-script', 'img': 'img/tab/script-24.png'},
+ {'id': 'S', 'name': _('Scene'), 'path': '/scene', 'class': 'x-scene', 'img': 'img/tab/scene-24.png'},
+ {'id': 'H', 'name': _('Shot'), 'path': '/shot', 'class': 'x-scene', 'img': 'img/tab/shot-24.png'},
+ {'id': 'R', 'name': _('Role'), 'path': '/role', 'class': 'x-role', 'img': 'img/tab/role-24.png'},
+ {'id': 'L', 'name': _('Location'), 'path': '/location', 'class': 'x-location', 'img': 'img/tab/location-24.png'},
+ {'id': 'T', 'name': _('Time'), 'path': '/time', 'class': 'x-time', 'img': 'img/tab/time-24.png'},
+ {'id': 'F', 'name': _('Person'), 'path': '/person', 'class': 'x-person', 'img': 'img/tab/person-24.png'},
+ {'id': 'G', 'name': _('Gadget'), 'path': '/gadget', 'class': 'x-gadget', 'img': 'img/tab/gadget-24.png'},
+ {'id': 'X', 'name': _('SFX'), 'path': '/sfx', 'class': 'x-sfx', 'img': 'img/tab/sfx-24.png'},
+ {'id': 'A', 'name': _('Audio'), 'path': '/audio', 'class': 'x-audio', 'img': 'img/tab/audio-24.png'},
+ {'id': 'B', 'name': _('Plan'), 'path': '/planner', 'class': 'x-planner', 'img': 'img/tab/scheduler-24.png'},
)
diff --git a/ScriptumX/X/context_processors.py b/ScriptumX/X/context_processors.py
new file mode 100644
index 0000000..480185f
--- /dev/null
+++ b/ScriptumX/X/context_processors.py
@@ -0,0 +1,33 @@
+"""Template context for UX chrome (theme, presence, language)."""
+from django.conf import settings
+from django.utils import translation
+
+from X.common import Env
+from X.ux import project_has_multi_users, theme_from_request
+
+
+def ux_chrome(request):
+ theme = theme_from_request(request)
+ presence_enabled = False
+ scene_id = None
+ presence_label = ''
+ try:
+ if getattr(request, 'user', None) and request.user.is_authenticated:
+ env = Env(request)
+ presence_enabled = project_has_multi_users(env.project)
+ scene_id = env.scene_id or None
+ if env.scene:
+ short = (env.scene.short or '').strip()
+ presence_label = short or (env.scene.name or '')[:40]
+ except Exception:
+ # Never break page render for chrome prefs
+ pass
+
+ return {
+ 'ux_theme': theme,
+ 'presence_enabled': presence_enabled,
+ 'presence_scene_id': scene_id,
+ 'presence_label': presence_label,
+ 'ux_languages': getattr(settings, 'LANGUAGES', (('de', 'Deutsch'), ('en', 'English'))),
+ 'ux_language': translation.get_language() or settings.LANGUAGE_CODE,
+ }
diff --git a/ScriptumX/X/static/X/css/ux-checklist.css b/ScriptumX/X/static/X/css/ux-checklist.css
new file mode 100644
index 0000000..f2c75d0
--- /dev/null
+++ b/ScriptumX/X/static/X/css/ux-checklist.css
@@ -0,0 +1,146 @@
+/* Mobile-first on-set shot checklist */
+.ux-checklist-page {
+ max-width: 640px;
+ margin: 0 auto;
+ padding: 12px 12px 72px;
+}
+
+.ux-checklist-page h1 {
+ margin: 8px 0 4px;
+ font-size: 22px;
+}
+
+.ux-checklist-page .ux-check-sub {
+ margin-bottom: 16px;
+ color: #666;
+ font-size: 14px;
+}
+
+body.ux-stage-dark .ux-checklist-page .ux-check-sub {
+ color: #9aa3ad;
+}
+
+.ux-check-row {
+ display: block;
+ width: 100%;
+ margin: 0 0 10px;
+ padding: 14px 12px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background: #f5f5f5;
+ text-align: left;
+ cursor: pointer;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.ux-check-row:active,
+.ux-check-row.is-busy {
+ opacity: 0.75;
+}
+
+.ux-check-row.is-partial {
+ border-color: #ee5f5b;
+ background: #fff5f4;
+}
+
+.ux-check-row.is-done {
+ border-color: #62c462;
+ background: #f3fff3;
+}
+
+body.ux-stage-dark .ux-check-row {
+ background: #1e242b;
+ border-color: #3a424c;
+ color: #d8dde3;
+}
+
+body.ux-stage-dark .ux-check-row.is-partial {
+ background: #2a2222;
+ border-color: #ee5f5b;
+}
+
+body.ux-stage-dark .ux-check-row.is-done {
+ background: #1f2a22;
+ border-color: #62c462;
+}
+
+.ux-check-row-top {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.ux-check-mark {
+ font-size: 28px;
+ line-height: 1;
+ min-width: 32px;
+ color: #555;
+}
+
+.ux-check-row.is-done .ux-check-mark {
+ color: #3c763d;
+}
+
+.ux-check-meta {
+ flex: 1;
+ min-width: 0;
+}
+
+.ux-check-title {
+ font-size: 17px;
+ font-weight: 700;
+ margin: 0 0 4px;
+ word-wrap: break-word;
+}
+
+.ux-check-abstract {
+ font-size: 13px;
+ color: #666;
+ margin: 0;
+}
+
+body.ux-stage-dark .ux-check-abstract {
+ color: #9aa3ad;
+}
+
+.ux-check-bar {
+ margin-top: 10px;
+ height: 8px;
+ background: #ddd;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+body.ux-stage-dark .ux-check-bar {
+ background: #3a424c;
+}
+
+.ux-check-bar-fill {
+ height: 100%;
+ background: #ee5f5b;
+ width: 0;
+ transition: width 0.15s ease;
+}
+
+.ux-check-row.is-done .ux-check-bar-fill {
+ background: #62c462;
+}
+
+.ux-check-pct {
+ float: right;
+ font-size: 12px;
+ color: #888;
+ margin-top: 6px;
+}
+
+.ux-checklist-empty {
+ padding: 24px 8px;
+ text-align: center;
+ color: #888;
+}
+
+@media (min-width: 768px) {
+ .ux-checklist-page {
+ padding-top: 20px;
+ }
+}
diff --git a/ScriptumX/X/static/X/css/ux-stage.css b/ScriptumX/X/static/X/css/ux-stage.css
new file mode 100644
index 0000000..c8e9609
--- /dev/null
+++ b/ScriptumX/X/static/X/css/ux-stage.css
@@ -0,0 +1,147 @@
+/* Optional dark “stage” theme for long writing sessions */
+html.ux-stage-dark,
+body.ux-stage-dark {
+ background: #121417;
+ color: #d8dde3;
+}
+
+body.ux-stage-dark .x-back {
+ background-color: #1a1e23 !important;
+}
+
+body.ux-stage-dark .x-item-form,
+body.ux-stage-dark .panel,
+body.ux-stage-dark .panel-body,
+body.ux-stage-dark .panel-heading {
+ background-color: #22272e !important;
+ color: #d8dde3;
+ border-color: #3a424c !important;
+}
+
+body.ux-stage-dark a.list-group-item {
+ background-color: #1e242b;
+ color: #d0d6dd;
+ border-color: #3a424c;
+}
+
+body.ux-stage-dark a.list-group-item:hover {
+ background-color: #2a323c;
+ color: #fff;
+}
+
+body.ux-stage-dark a.list-group-item.active,
+body.ux-stage-dark a.x-script.list-group-item.active,
+body.ux-stage-dark a.x-sceneitem.list-group-item.active {
+ background-color: #2f3a46 !important;
+ color: #fff !important;
+ border-color: #5a6a7a !important;
+}
+
+body.ux-stage-dark .form-control,
+body.ux-stage-dark textarea,
+body.ux-stage-dark input[type="text"],
+body.ux-stage-dark input[type="number"],
+body.ux-stage-dark select {
+ background-color: #161a1f;
+ color: #e6ebf0;
+ border-color: #3a424c;
+}
+
+body.ux-stage-dark .chosen-container .chosen-single,
+body.ux-stage-dark .chosen-container .chosen-choices,
+body.ux-stage-dark .chosen-container .chosen-drop {
+ background-color: #161a1f !important;
+ color: #e6ebf0 !important;
+ border-color: #3a424c !important;
+}
+
+body.ux-stage-dark .chosen-container .chosen-results li {
+ color: #d8dde3;
+}
+
+body.ux-stage-dark .x-toolbar {
+ background-color: #1a1e23 !important;
+}
+
+body.ux-stage-dark .btn-default {
+ background-color: #2a323c;
+ border-color: #3a424c;
+ color: #d8dde3;
+}
+
+body.ux-stage-dark .progress {
+ background-color: #3a424c;
+}
+
+body.ux-stage-dark .ux-kb-hint {
+ background: #22272e;
+ color: #d8dde3;
+ border-color: #4a5562;
+}
+
+body.ux-stage-dark #ux-presence .ux-presence-pill {
+ background: #2a323c;
+ color: #c5ccd4;
+}
+
+/* Keyboard hint + chrome widgets (shared) */
+.ux-kb-hint {
+ position: fixed;
+ right: 12px;
+ bottom: 36px;
+ z-index: 1050;
+ padding: 8px 12px;
+ border: 1px solid #ccc;
+ background: #f7f7f7;
+ font-size: 12px;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
+}
+
+.ux-kb-hint kbd {
+ display: inline-block;
+ padding: 1px 5px;
+ border: 1px solid #999;
+ border-radius: 3px;
+ background: #fff;
+ font-family: monospace;
+ font-size: 11px;
+}
+
+.ux-chrome-controls {
+ display: inline-block;
+ margin-right: 8px;
+ vertical-align: middle;
+}
+
+.ux-chrome-controls form {
+ display: inline;
+ margin: 0;
+}
+
+.ux-chrome-controls .btn {
+ margin: 0 2px;
+ padding: 2px 6px;
+ line-height: 1.2;
+}
+
+#ux-presence {
+ display: inline-block;
+ margin-right: 10px;
+ vertical-align: middle;
+}
+
+#ux-presence .ux-presence-pill {
+ display: inline-block;
+ margin: 0 4px 0 0;
+ padding: 1px 8px;
+ border-radius: 3px;
+ background: #3a424c;
+ color: #eee;
+ font-size: 11px;
+}
+
+#ux-presence .ux-presence-pill .fa-circle {
+ color: #5cb85c;
+ font-size: 8px;
+ vertical-align: middle;
+}
diff --git a/ScriptumX/X/static/X/scripts/ux-checklist.js b/ScriptumX/X/static/X/scripts/ux-checklist.js
new file mode 100644
index 0000000..dbb78e9
--- /dev/null
+++ b/ScriptumX/X/static/X/scripts/ux-checklist.js
@@ -0,0 +1,67 @@
+/**
+ * Mobile shot checklist — tap rows to cycle progress_shot.
+ */
+(function (window, document) {
+ 'use strict';
+
+ function csrfToken() {
+ var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
+ if (el) return el.value;
+ var match = document.cookie.match(/csrftoken=([^;]+)/);
+ return match ? match[1] : '';
+ }
+
+ function updateRow(row, progress) {
+ row.setAttribute('data-progress', String(progress));
+ row.classList.toggle('is-done', progress >= 100);
+ row.classList.toggle('is-partial', progress > 0 && progress < 100);
+ var bar = row.querySelector('.ux-check-bar-fill');
+ if (bar) bar.style.width = progress + '%';
+ var pct = row.querySelector('.ux-check-pct');
+ if (pct) pct.textContent = progress + '%';
+ var mark = row.querySelector('.ux-check-mark');
+ if (mark) {
+ mark.innerHTML =
+ progress >= 100
+ ? ''
+ : progress > 0
+ ? ''
+ : '';
+ }
+ }
+
+ function postProgress(row) {
+ var url = row.getAttribute('data-progress-url');
+ if (!url || !window.fetch) return;
+ row.classList.add('is-busy');
+ fetch(url, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': csrfToken(),
+ },
+ body: '{}',
+ })
+ .then(function (r) {
+ return r.json();
+ })
+ .then(function (data) {
+ if (data && data.ok) updateRow(row, data.progress_shot);
+ })
+ .finally(function () {
+ row.classList.remove('is-busy');
+ });
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ var list = document.getElementById('ux-shot-checklist');
+ if (!list) return;
+ list.addEventListener('click', function (ev) {
+ var row = ev.target.closest('.ux-check-row');
+ if (!row || !list.contains(row)) return;
+ ev.preventDefault();
+ postProgress(row);
+ });
+ });
+})(window, document);
diff --git a/ScriptumX/X/static/X/scripts/ux-keyboard.js b/ScriptumX/X/static/X/scripts/ux-keyboard.js
new file mode 100644
index 0000000..a4bc821
--- /dev/null
+++ b/ScriptumX/X/static/X/scripts/ux-keyboard.js
@@ -0,0 +1,99 @@
+/**
+ * Keyboard-first navigation for Script / Scene lists.
+ * j/k — previous/next list item
+ * a — new Action after selected (scene editor)
+ * d — new Dialog after selected (scene editor)
+ * ? — toggle shortcut hint
+ */
+(function (window, document) {
+ 'use strict';
+
+ function isTypingTarget(el) {
+ if (!el) return false;
+ var tag = (el.tagName || '').toLowerCase();
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
+ if (el.isContentEditable) return true;
+ return false;
+ }
+
+ function listItems() {
+ return Array.prototype.slice.call(
+ document.querySelectorAll('#app_container a.list-group-item[href]')
+ );
+ }
+
+ function selectedIndex(items) {
+ var selected = document.getElementById('selected_scope');
+ if (!selected) return -1;
+ for (var i = 0; i < items.length; i++) {
+ if (items[i] === selected || items[i].contains(selected)) return i;
+ }
+ return -1;
+ }
+
+ function go(delta) {
+ var items = listItems();
+ if (!items.length) return;
+ var idx = selectedIndex(items);
+ var next = idx < 0 ? (delta > 0 ? 0 : items.length - 1) : idx + delta;
+ if (next < 0 || next >= items.length) return;
+ var href = items[next].getAttribute('href');
+ if (href) window.location.href = href;
+ }
+
+ function insertAfter(type) {
+ var selected = document.getElementById('selected_scope');
+ if (!selected) return;
+ var href = selected.getAttribute('href') || '';
+ var match = href.match(/\/scene\/(\d+)/);
+ if (!match) return;
+ window.location.href = '/scene/new/' + type + '/' + match[1] + '/1';
+ }
+
+ function ensureHint() {
+ var hint = document.getElementById('ux-kb-hint');
+ if (hint) return hint;
+ hint = document.createElement('div');
+ hint.id = 'ux-kb-hint';
+ hint.className = 'ux-kb-hint';
+ hint.setAttribute('role', 'status');
+ hint.hidden = true;
+ hint.innerHTML =
+ 'Keyboard · j/k nav · ' +
+ 'a action · d dialog · ? hide';
+ document.body.appendChild(hint);
+ return hint;
+ }
+
+ function toggleHint() {
+ var hint = ensureHint();
+ hint.hidden = !hint.hidden;
+ }
+
+ document.addEventListener('keydown', function (ev) {
+ if (ev.defaultPrevented || ev.metaKey || ev.ctrlKey || ev.altKey) return;
+ if (isTypingTarget(ev.target)) return;
+
+ var key = ev.key;
+ if (key === 'j' || key === 'J') {
+ ev.preventDefault();
+ go(1);
+ } else if (key === 'k' || key === 'K') {
+ ev.preventDefault();
+ go(-1);
+ } else if (key === 'a' || key === 'A') {
+ if (document.body.getAttribute('data-ux-page') === 'scene') {
+ ev.preventDefault();
+ insertAfter('A');
+ }
+ } else if (key === 'd' || key === 'D') {
+ if (document.body.getAttribute('data-ux-page') === 'scene') {
+ ev.preventDefault();
+ insertAfter('D');
+ }
+ } else if (key === '?' || (ev.shiftKey && key === '/')) {
+ ev.preventDefault();
+ toggleHint();
+ }
+ });
+})(window, document);
diff --git a/ScriptumX/X/static/X/scripts/ux-presence.js b/ScriptumX/X/static/X/scripts/ux-presence.js
new file mode 100644
index 0000000..f34518a
--- /dev/null
+++ b/ScriptumX/X/static/X/scripts/ux-presence.js
@@ -0,0 +1,77 @@
+/**
+ * Multi-user presence heartbeat (only when server enables it).
+ */
+(function (window, document) {
+ 'use strict';
+
+ function csrfToken() {
+ var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
+ if (el) return el.value;
+ var match = document.cookie.match(/csrftoken=([^;]+)/);
+ return match ? match[1] : '';
+ }
+
+ function render(others) {
+ var host = document.getElementById('ux-presence');
+ if (!host) return;
+ if (!others || !others.length) {
+ host.hidden = true;
+ host.innerHTML = '';
+ return;
+ }
+ host.hidden = false;
+ host.innerHTML = others
+ .map(function (o) {
+ var label = o.label || 'online';
+ return (
+ '' +
+ ' ' +
+ (o.username || '?') +
+ ' · ' +
+ label +
+ ''
+ );
+ })
+ .join(' ');
+ }
+
+ function beat() {
+ var cfg = window.ScriptumXPresence || {};
+ if (!cfg.enabled || !cfg.url) return;
+ var body = JSON.stringify({
+ label: cfg.label || '',
+ scene_id: cfg.sceneId || null,
+ path: window.location.pathname,
+ });
+ if (!window.fetch) return;
+ fetch(cfg.url, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': csrfToken(),
+ },
+ body: body,
+ })
+ .then(function (r) {
+ return r.json();
+ })
+ .then(function (data) {
+ if (!data || !data.enabled) {
+ render([]);
+ return;
+ }
+ render(data.others || []);
+ })
+ .catch(function () { /* ignore */ });
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ var cfg = window.ScriptumXPresence || {};
+ if (!cfg.enabled) return;
+ beat();
+ window.setInterval(beat, 15000);
+ });
+})(window, document);
diff --git a/ScriptumX/X/static/X/scripts/ux-theme.js b/ScriptumX/X/static/X/scripts/ux-theme.js
new file mode 100644
index 0000000..43d27e4
--- /dev/null
+++ b/ScriptumX/X/static/X/scripts/ux-theme.js
@@ -0,0 +1,67 @@
+/**
+ * Optional dark “stage” theme toggle (cookie + body class).
+ */
+(function (window, document) {
+ 'use strict';
+
+ var COOKIE = 'sx_stage_theme';
+
+ function readCookie() {
+ var parts = (';.cookie || '').split(';');
+ for (var i = 0; i < parts.length; i++) {
+ var p = parts[i].replace(/^\s+|\s+$/g, '');
+ if (p.indexOf(COOKIE + '=') === 0) return p.substring(COOKIE.length + 1);
+ }
+ return 'light';
+ }
+
+ function apply(theme) {
+ var dark = theme === 'dark';
+ document.documentElement.classList.toggle('ux-stage-dark', dark);
+ document.body.classList.toggle('ux-stage-dark', dark);
+ var btn = document.getElementById('ux-theme-toggle');
+ if (btn) {
+ btn.setAttribute('aria-pressed', dark ? 'true' : 'false');
+ btn.title = dark ? btn.getAttribute('data-label-light') : btn.getAttribute('data-label-dark');
+ var icon = btn.querySelector('i');
+ if (icon) {
+ icon.className = dark ? 'fa fa-sun-o' : 'fa fa-moon-o';
+ }
+ }
+ }
+
+ function setTheme(theme) {
+ apply(theme);
+ var maxAge = 60 * 60 * 24 * 365;
+ document.cookie = COOKIE + '=' + theme + ';path=/;max-age=' + maxAge + ';SameSite=Lax';
+ var form = document.getElementById('ux-theme-form');
+ if (form) {
+ var input = form.querySelector('input[name="theme"]');
+ if (input) input.value = theme;
+ // fire-and-forget server sync; navigation not required
+ try {
+ var fd = new FormData(form);
+ if (window.fetch) {
+ fetch(form.action, { method: 'POST', body: fd, credentials: 'same-origin' });
+ }
+ } catch (e) { /* ignore */ }
+ }
+ }
+
+ function toggle() {
+ setTheme(readCookie() === 'dark' ? 'light' : 'dark');
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ apply(readCookie());
+ var btn = document.getElementById('ux-theme-toggle');
+ if (btn) {
+ btn.addEventListener('click', function (ev) {
+ ev.preventDefault();
+ toggle();
+ });
+ }
+ });
+
+ window.ScriptumXTheme = { apply: apply, setTheme: setTheme, toggle: toggle };
+})(window, document);
diff --git a/ScriptumX/X/templates/X/layout_X.html b/ScriptumX/X/templates/X/layout_X.html
index 185be35..482a9d8 100644
--- a/ScriptumX/X/templates/X/layout_X.html
+++ b/ScriptumX/X/templates/X/layout_X.html
@@ -1,7 +1,8 @@
{% load static %}
+{% load i18n %}
-
+
@@ -13,6 +14,7 @@
{##}
+
@@ -27,13 +29,16 @@
+
+
+
{% block head %}{% endblock %}
-
+
{#--NAVBAR--#}
@@ -46,7 +51,7 @@
-
+
{% block mobile-toolbar %}{% endblock %}
@@ -61,11 +66,11 @@
{% for tab in tab_list %}
{%if tab.id == tab_active_id %}
-
{{tab.name}}
+
{{tab.name}}
{% else %}
-
{{tab.name}}
{{tab.name}}
+
{{tab.name}}
{{tab.name}}
{% endif %}
{% endfor %}
@@ -104,8 +109,38 @@
{#--FOOTER--#}
-
-
+
+
+
© Scriptum𝓧 {{env.user}} {{env.project}} {{env.script}} {% now "Y-m-d" %} {% now "H:i" %}
@@ -116,6 +151,13 @@
@@ -81,7 +84,7 @@