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 @@ - + ScriptumX
{% 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--#} -