From 8ba73216d3bd2842a364c44075d571423d5e42d4 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Tue, 19 May 2026 23:38:17 +0100 Subject: [PATCH 1/4] Phase 12c: Dual-UI integration (default UI setting, user preference, navbar toggle) - Dockerfile: split into parallel build_v2/build_v3 stages (BuildKit builds them concurrently); Python stage copies assets from both via --from directives - CI: add V3 install + build steps to build-server.yml frontend job - Backend: add default_ui system setting (choice: old/new, default old); when set to "new" the root "/" redirects to "/ui-new/" - Backend: add preferred_ui field to UserSettings model + CheckConstraint; Alembic migration 11311df29aa4 - V2 App.vue: "Switch to New UI" navbar link; post-login redirect to /ui-new/ when preferred_ui == "new" - V3 App.vue: "Switch to Classic UI" navbar link; post-login redirect to / when preferred_ui == "old" - V2 + V3 user settings pages: preferred_ui dropdown (system default / Classic / New) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-server.yml | 17 ++++++++ Dockerfile | 20 +++++++--- client-v3/src/App.vue | 6 +++ .../user/settings/UserSettingsConfig.vue | 17 ++++++++ client-v3/src/types/api/user.ts | 1 + client/src/App.vue | 5 +++ .../vue_components/user/settings/Settings.vue | 19 +++++++++ ...29aa4_add_preferred_ui_to_user_settings.py | 39 +++++++++++++++++++ server/controllers/controllers.py | 7 +++- server/digi_server/settings.py | 10 +++++ server/models/user.py | 5 +++ 11 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 server/alembic_config/versions/11311df29aa4_add_preferred_ui_to_user_settings.py diff --git a/.github/workflows/build-server.yml b/.github/workflows/build-server.yml index 939ed434..560ae243 100644 --- a/.github/workflows/build-server.yml +++ b/.github/workflows/build-server.yml @@ -34,6 +34,23 @@ jobs: cd client npm run build + - name: Setup Node.js (V3) + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: 'client-v3/package-lock.json' + + - name: Install dependencies (V3) + run: | + cd client-v3 + npm ci + + - name: Build frontend (V3) + run: | + cd client-v3 + npm run build + - name: Upload frontend build uses: actions/upload-artifact@v4 with: diff --git a/Dockerfile b/Dockerfile index abe78178..f1203f4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ -FROM node:24-bookworm AS node_build +FROM node:24-bookworm AS build_v2 -# npm 11 bundled with Node 24, no separate install needed RUN mkdir -p /server/static - COPY /client/package.json /client/package.json COPY /client/package-lock.json /client/package-lock.json COPY /client/.npmrc /client/.npmrc @@ -12,7 +10,15 @@ COPY /client /client COPY /docs /docs RUN npm run build -COPY /server /server +FROM node:24-bookworm AS build_v3 + +RUN mkdir -p /server/static/ui-new +COPY /client-v3/package.json /client-v3/package.json +COPY /client-v3/package-lock.json /client-v3/package-lock.json +WORKDIR /client-v3 +RUN npm ci +COPY /client-v3 /client-v3 +RUN npm run build FROM python:3.13-bookworm @@ -22,8 +28,10 @@ RUN pip install -r requirements.txt RUN apt update RUN apt install -y nano -COPY --from=node_build /server /server +COPY /server /server +COPY --from=build_v2 /server/static /server/static +COPY --from=build_v3 /server/static/ui-new /server/static/ui-new WORKDIR /server RUN mkdir conf EXPOSE 8080 -ENTRYPOINT ["python3", "main.py"] \ No newline at end of file +ENTRYPOINT ["python3", "main.py"] diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index da462fa4..22024e02 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -94,6 +94,7 @@ + Switch to Classic UI Help About @@ -193,6 +194,7 @@ import { useConfirm } from '@/composables/useConfirm'; import ConfirmDialog from '@/components/common/ConfirmDialog.vue'; import CreateUser from '@/components/user/CreateUser.vue'; import { useUserStore } from '@/stores/user'; +import type { UserSettings } from '@/types/api/user'; import { useSystemStore } from '@/stores/system'; import { useShowStore } from '@/stores/show'; import { useWebSocketStore } from '@/stores/websocket'; @@ -275,6 +277,10 @@ async function awaitWSConnect(): Promise { if (userStore.authToken) { await userStore.getCurrentUser(); await Promise.all([userStore.getCurrentRbac(), userStore.getUserSettings()]); + if ((userStore.userSettings as UserSettings).preferred_ui === 'old') { + window.location.href = '/'; + return; + } } if (systemStore.currentShow != null) { await showStore.getShowSessionData(); diff --git a/client-v3/src/components/user/settings/UserSettingsConfig.vue b/client-v3/src/components/user/settings/UserSettingsConfig.vue index 6039c09f..0430f769 100644 --- a/client-v3/src/components/user/settings/UserSettingsConfig.vue +++ b/client-v3/src/components/user/settings/UserSettingsConfig.vue @@ -101,6 +101,15 @@ /> + + + + Reset @@ -143,6 +152,7 @@ const defaultState = (): UserSettings => ({ console_log_level: 'WARN', character_mru_sort: false, character_combined_dropdown: false, + preferred_ui: null, }); const state = ref(defaultState()); @@ -162,6 +172,12 @@ const consoleLogLevelOptions = [ { value: 'SILENT', text: 'SILENT' }, ]; +const preferredUiOptions = [ + { value: null, text: 'Use system default' }, + { value: 'old', text: 'Classic UI' }, + { value: 'new', text: 'New UI' }, +]; + const rules = computed(() => ({ enable_script_auto_save: {}, script_auto_save_interval: { @@ -176,6 +192,7 @@ const rules = computed(() => ({ console_log_level: { required }, character_mru_sort: {}, character_combined_dropdown: {}, + preferred_ui: {}, })); const v$ = useVuelidate(rules, state); diff --git a/client-v3/src/types/api/user.ts b/client-v3/src/types/api/user.ts index 98e204a2..e37f8f3b 100644 --- a/client-v3/src/types/api/user.ts +++ b/client-v3/src/types/api/user.ts @@ -22,4 +22,5 @@ export interface UserSettings { console_log_level: string; character_mru_sort: boolean; character_combined_dropdown: boolean; + preferred_ui: string | null; } diff --git a/client/src/App.vue b/client/src/App.vue index e4067e9e..765ab62c 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -91,6 +91,7 @@ + Switch to New UI Help About @@ -323,6 +324,10 @@ export default defineComponent({ if ((this as any).AUTH_TOKEN) { await (this as any).GET_CURRENT_USER(); await Promise.all([(this as any).GET_CURRENT_RBAC(), (this as any).GET_USER_SETTINGS()]); + if ((this as any).USER_SETTINGS?.preferred_ui === 'new') { + window.location.href = '/ui-new/'; + return; + } } if ((this as any).SETTINGS.current_show != null) { diff --git a/client/src/vue_components/user/settings/Settings.vue b/client/src/vue_components/user/settings/Settings.vue index 6dabe84f..0290232d 100644 --- a/client/src/vue_components/user/settings/Settings.vue +++ b/client/src/vue_components/user/settings/Settings.vue @@ -102,6 +102,18 @@ :switch="true" /> + + + Reset @@ -141,6 +153,7 @@ export default defineComponent({ console_log_level: 'WARN', character_mru_sort: false, character_combined_dropdown: false, + preferred_ui: null as string | null, }, textAlignmentOptions: [ { value: TEXT_ALIGNMENT.LEFT, text: 'Left' }, @@ -155,6 +168,11 @@ export default defineComponent({ { value: 'ERROR', text: 'ERROR' }, { value: 'SILENT', text: 'SILENT' }, ], + preferredUiOptions: [ + { value: null, text: 'Use system default' }, + { value: 'old', text: 'Classic UI' }, + { value: 'new', text: 'New UI' }, + ], toggle: 0, }; }, @@ -184,6 +202,7 @@ export default defineComponent({ console_log_level: { required }, character_mru_sort: {}, character_combined_dropdown: {}, + preferred_ui: {}, }, }, mounted(): void { diff --git a/server/alembic_config/versions/11311df29aa4_add_preferred_ui_to_user_settings.py b/server/alembic_config/versions/11311df29aa4_add_preferred_ui_to_user_settings.py new file mode 100644 index 00000000..277b0238 --- /dev/null +++ b/server/alembic_config/versions/11311df29aa4_add_preferred_ui_to_user_settings.py @@ -0,0 +1,39 @@ +"""add preferred_ui to user_settings + +Revision ID: 11311df29aa4 +Revises: 4fbfeaba60ae +Create Date: 2026-05-19 23:24:51.376973 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '11311df29aa4' +down_revision: Union[str, None] = '4fbfeaba60ae' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('preferred_ui', sa.String(), nullable=True)) + batch_op.create_check_constraint( + 'ck_user_settings_preferred_ui', + "preferred_ui IS NULL OR preferred_ui IN ('old', 'new')", + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user_settings', schema=None) as batch_op: + batch_op.drop_constraint('ck_user_settings_preferred_ui', type_='check') + batch_op.drop_column('preferred_ui') + + # ### end Alembic commands ### diff --git a/server/controllers/controllers.py b/server/controllers/controllers.py index 8a8fed4a..9aa2a8d7 100644 --- a/server/controllers/controllers.py +++ b/server/controllers/controllers.py @@ -23,7 +23,12 @@ def import_all_controllers(): class RootController(BaseController): - def get(self, _path): + def get(self, path): + if not path: + default_ui = self.application.digi_settings.get("default_ui") + if default_ui == "new": + self.redirect("/ui-new/") + return if is_frozen(): # In PyInstaller mode, use resource path full_path = get_resource_path(os.path.join("static", "index.html")) diff --git a/server/digi_server/settings.py b/server/digi_server/settings.py index 8d1274f8..0fb13092 100644 --- a/server/digi_server/settings.py +++ b/server/digi_server/settings.py @@ -206,6 +206,16 @@ def init_settings(self): hide_from_ui=True, ) self.define("debug_mode", bool, False, True, display_name="Enable Debug Mode") + self.define( + "default_ui", + str, + "old", + True, + display_name="Default UI Version", + help_text="Which UI version users are directed to by default. User preferences override this.", + choice_options=["old", "new"], + category="General", + ) self.define( "log_level", str, diff --git a/server/models/user.py b/server/models/user.py index def17546..089080ac 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -89,12 +89,17 @@ class UserSettings(db.Model): console_log_level: Mapped[str] = mapped_column(default="WARN") character_mru_sort: Mapped[bool] = mapped_column(default=False) character_combined_dropdown: Mapped[bool] = mapped_column(default=False) + preferred_ui: Mapped[str | None] = mapped_column(default=None) __table_args__ = ( CheckConstraint( "console_log_level IN ('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'SILENT')", name="ck_user_settings_console_log_level", ), + CheckConstraint( + "preferred_ui IS NULL OR preferred_ui IN ('old', 'new')", + name="ck_user_settings_preferred_ui", + ), ) # Hidden Properties (None user editable, marked with _) From 145e36132eef0c1ae2fd78af5bcf74f2d9395361 Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Wed, 20 May 2026 08:50:29 +0100 Subject: [PATCH 2/4] Fix Phase 12c dual-UI integration bugs - RootController.get(): make async and await digi_settings.get() so the default_ui redirect actually evaluates; add _switch bypass - Both navbar toggle links now include ?_switch=1 so clicking them bypasses server-side and client-side redirects, preventing loops - V2 awaitWSConnect: also check system default_ui when preferred_ui is null, matching the full redirect logic from the plan - V2 Settings: add real isValidUi validator to preferred_ui so Vuelidate tracks it as a dirty-able field and enables Submit - V3 UserSettingsConfig: replace v$.$anyDirty with computed formDirty (JSON.stringify comparison) to avoid Vue 3 watcher async race - V2 vite.config: add cleanV2StaticPlugin to preserve server/static/ui-new/ when rebuilding V2; set emptyOutDir: false Co-Authored-By: Claude Sonnet 4.6 --- client-v3/src/App.vue | 5 ++-- .../user/settings/UserSettingsConfig.vue | 11 +++++++-- client/src/App.vue | 13 +++++++---- .../vue_components/user/settings/Settings.vue | 2 +- client/vite.config.ts | 23 +++++++++++++++++-- ...29aa4_add_preferred_ui_to_user_settings.py | 19 +++++++-------- server/controllers/controllers.py | 6 ++--- 7 files changed, 56 insertions(+), 23 deletions(-) diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index 22024e02..134a48ec 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -94,7 +94,7 @@ - Switch to Classic UI + Switch to Classic UI Help About @@ -277,7 +277,8 @@ async function awaitWSConnect(): Promise { if (userStore.authToken) { await userStore.getCurrentUser(); await Promise.all([userStore.getCurrentRbac(), userStore.getUserSettings()]); - if ((userStore.userSettings as UserSettings).preferred_ui === 'old') { + const switching = new URLSearchParams(window.location.search).has('_switch'); + if (!switching && (userStore.userSettings as UserSettings).preferred_ui === 'old') { window.location.href = '/'; return; } diff --git a/client-v3/src/components/user/settings/UserSettingsConfig.vue b/client-v3/src/components/user/settings/UserSettingsConfig.vue index 0430f769..3cf69f8e 100644 --- a/client-v3/src/components/user/settings/UserSettingsConfig.vue +++ b/client-v3/src/components/user/settings/UserSettingsConfig.vue @@ -111,8 +111,8 @@ - Reset - + Reset + Submit @@ -128,6 +128,7 @@