diff --git a/assets/locales/af/default.po b/assets/locales/af/default.po index 3960c84d2..fda442d49 100644 --- a/assets/locales/af/default.po +++ b/assets/locales/af/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/ar/default.po b/assets/locales/ar/default.po index 7cc7bf026..a940e0c4b 100644 --- a/assets/locales/ar/default.po +++ b/assets/locales/ar/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: Google Cloud Translation Basic \n" diff --git a/assets/locales/bg/default.po b/assets/locales/bg/default.po index 86319c4d8..1feaaf157 100644 --- a/assets/locales/bg/default.po +++ b/assets/locales/bg/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/ca/default.po b/assets/locales/ca/default.po index 4c8b5da22..16bec0e66 100644 --- a/assets/locales/ca/default.po +++ b/assets/locales/ca/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: Google Cloud Translation Basic \n" diff --git a/assets/locales/da/default.po b/assets/locales/da/default.po index 6d3981c1a..d89dda9b9 100644 --- a/assets/locales/da/default.po +++ b/assets/locales/da/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/de/default.po b/assets/locales/de/default.po index bb4a4b2ee..6d1001d46 100644 --- a/assets/locales/de/default.po +++ b/assets/locales/de/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/el/default.po b/assets/locales/el/default.po index 36b5d9f7c..a4abcbc15 100644 --- a/assets/locales/el/default.po +++ b/assets/locales/el/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/es/default.po b/assets/locales/es/default.po index 8d478c304..9b329bd5a 100644 --- a/assets/locales/es/default.po +++ b/assets/locales/es/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/et/default.po b/assets/locales/et/default.po index 64e825703..8f298ff7d 100644 --- a/assets/locales/et/default.po +++ b/assets/locales/et/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/eu/default.po b/assets/locales/eu/default.po index f078aec19..c9e2a8bb0 100644 --- a/assets/locales/eu/default.po +++ b/assets/locales/eu/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/fa/default.po b/assets/locales/fa/default.po index 77eb3a645..33ca8779d 100644 --- a/assets/locales/fa/default.po +++ b/assets/locales/fa/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: Google Cloud Translation Basic \n" diff --git a/assets/locales/fr/default.po b/assets/locales/fr/default.po index 95e62e3fe..3476325ad 100644 --- a/assets/locales/fr/default.po +++ b/assets/locales/fr/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-28 15:07+0000\n" "Last-Translator: jean-louis67 \n" diff --git a/assets/locales/ga/default.po b/assets/locales/ga/default.po index 583861874..89438350d 100644 --- a/assets/locales/ga/default.po +++ b/assets/locales/ga/default.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" @@ -15,8 +15,8 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=5; plural=n==1 ? 0 : n==2 ? 1 : (n>2 && n<7) ? 2 :(n>" -"6 && n<11) ? 3 : 4;\n" +"Plural-Forms: nplurals=5; plural=n==1 ? 0 : n==2 ? 1 : (n>2 && n<7) ? 2 :" +"(n>6 && n<11) ? 3 : 4;\n" "X-Generator: Weblate 2026.5\n" #: messages.go:112 diff --git a/assets/locales/he/default.po b/assets/locales/he/default.po index eda754df6..79846fb27 100644 --- a/assets/locales/he/default.po +++ b/assets/locales/he/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/hi/default.po b/assets/locales/hi/default.po index d425262f9..cd32b24e6 100644 --- a/assets/locales/hi/default.po +++ b/assets/locales/hi/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: Google Cloud Translation Basic \n" diff --git a/assets/locales/ja/default.po b/assets/locales/ja/default.po index b8e8b4a19..c30334c9e 100644 --- a/assets/locales/ja/default.po +++ b/assets/locales/ja/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/ko/default.po b/assets/locales/ko/default.po index e1d9b71de..f9b005989 100644 --- a/assets/locales/ko/default.po +++ b/assets/locales/ko/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: DeepL \n" diff --git a/assets/locales/ku/default.po b/assets/locales/ku/default.po index 3008b22bb..8fa037d9a 100644 --- a/assets/locales/ku/default.po +++ b/assets/locales/ku/default.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: Google Cloud Translation Basic \n" diff --git a/assets/locales/lv/default.po b/assets/locales/lv/default.po index 0bafcbb9c..c50319237 100644 --- a/assets/locales/lv/default.po +++ b/assets/locales/lv/default.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: ci@photoprism.app\n" +"Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2026-06-27 09:13+0000\n" "PO-Revision-Date: 2026-06-27 10:28+0000\n" "Last-Translator: Google Cloud Translation Basic { + if ($session.loginRequired()) { + next({ name: loginRoute }); + } else if (!$config.feature("services") || $config.deny("services", "manage")) { + next({ name: $session.getDefaultRoute() }); + } else { + next(); + } + }, }, { name: "settings_account", diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js index a47fc5244..51666d88d 100644 --- a/frontend/src/common/config.js +++ b/frontend/src/common/config.js @@ -149,6 +149,8 @@ export default class Config { } else { this.setTheme("default"); } + + this.setReduceMotion(this.getSettings()?.ui.reduceMotion); } loading() { @@ -209,6 +211,7 @@ export default class Config { this.setBatchSize(values.settings); await this.setLanguage(this.getLanguageLocale(), true); this.setTheme(values.settings.ui.theme); + this.setReduceMotion(values.settings.ui.reduceMotion); } // Adjust album counts by access level. @@ -591,6 +594,16 @@ export default class Config { } } + // setReduceMotion toggles the "reduce-motion" class on the element so CSS can + // suppress interface animations and transitions when the accessibility setting is enabled. + setReduceMotion(enabled) { + if (document && document.documentElement) { + document.documentElement.classList.toggle("reduce-motion", !!enabled); + } + + return this; + } + // getSettings returns the current user's configuration settings. getSettings() { return this.values.settings; @@ -612,6 +625,7 @@ export default class Config { this.setBatchSize(settings); this.setLanguage(settings.ui.language, false); this.setTheme(settings.ui.theme); + this.setReduceMotion(settings.ui.reduceMotion); return this; } diff --git a/frontend/src/common/links.js b/frontend/src/common/links.js index 554506d7f..30adf654b 100644 --- a/frontend/src/common/links.js +++ b/frontend/src/common/links.js @@ -1,6 +1,7 @@ export default { docs: "https://link.photoprism.app/docs", userGuide: "https://docs.photoprism.app/user-guide/", + syncSettings: "https://docs.photoprism.app/user-guide/settings/sync/", developerGuide: "https://docs.photoprism.app/developer-guide/", developerSetup: "https://docs.photoprism.app/developer-guide/setup/", firstSteps: "https://docs.photoprism.app/user-guide/first-steps/", diff --git a/frontend/src/common/util.js b/frontend/src/common/util.js index 478fd96dd..60a1bc698 100644 --- a/frontend/src/common/util.js +++ b/frontend/src/common/util.js @@ -476,6 +476,21 @@ export default class $util { return ($config.getSettings()?.ui?.openOnHover ?? true) && !$util.hasTouch(); } + // mapAnimateDuration returns the map fly-to animation duration in milliseconds, forcing 0 + // (no animation) when the Reduce Motion accessibility setting is enabled. A non-negative + // overrideMs takes precedence over the global Maps.Animate preference. + static mapAnimateDuration(settings, overrideMs = -1) { + if (settings?.ui?.reduceMotion) { + return 0; + } + + if (overrideMs >= 0) { + return overrideMs; + } + + return settings?.maps?.animate ?? 0; + } + // isMobile returns true when the current user agent or touch capability indicates a mobile device. // The `> 2` touch check covers iPads in desktop mode, where the user agent omits the mobile hint. static isMobile() { diff --git a/frontend/src/common/view.js b/frontend/src/common/view.js index 9a30af08c..f2948479f 100644 --- a/frontend/src/common/view.js +++ b/frontend/src/common/view.js @@ -5,6 +5,15 @@ import { getAppSessionStorage, getAppStorage } from "common/storage"; const TouchStartEvent = "touchstart"; const TouchMoveEvent = "touchmove"; +// Selector for the document's viewport meta tag. +const ViewportMetaSelector = 'meta[name="viewport"]'; + +// Viewport content that disables the browser's native pinch-zoom of the whole page. +const ViewportNoZoom = "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"; + +// Token in a viewport content string indicating native zoom is already disabled. +const ViewportNoZoomToken = "user-scalable=no"; + // True if debug and/or trace logs should be recorded. const debug = window.__CONFIG__?.debug; const trace = window.__CONFIG__?.trace; @@ -405,6 +414,7 @@ export class View { this.scopes = []; this.hideScrollbar = false; this.preventNavigation = false; + this.savedViewportContent = ""; this.focusScopes = new Map(); // Tracks the most recent history position and derived navigation direction so components can @@ -555,6 +565,7 @@ export class View { let hideScrollbar = this.len() > 2 ? this.hideScrollbar : false; let disableScrolling = false; let disableNavigationGestures = false; + let disableViewportZoom = false; let preventNavigation = uid > 0 && !name.startsWith("PPage"); switch (name) { @@ -582,6 +593,7 @@ export class View { hideScrollbar = true; disableScrolling = true; disableNavigationGestures = true; + disableViewportZoom = true; preventNavigation = true; break; } @@ -661,6 +673,14 @@ export class View { } } + // Lock native pinch-zoom while a flagged overlay is active, restore it otherwise. + // Both helpers are idempotent, so re-applying the same state is a no-op. + if (disableViewportZoom) { + this.disableNativeZoom(); + } else { + this.restoreNativeZoom(); + } + if (debug) { console.groupEnd(); } @@ -818,6 +838,47 @@ export class View { return setFocus(el, selector, scroll); } + // disableNativeZoom locks the viewport so the browser can't pinch-zoom the whole page + // while an overlay (e.g. the lightbox) handles zoom itself; restoreNativeZoom restores + // the saved content. Idempotent; assumes one active overlay (savedViewportContent is + // app-global), which View.apply() upholds by locking only for the top-of-stack view. + disableNativeZoom() { + if (this.savedViewportContent) { + return; + } + + const viewport = document.querySelector(ViewportMetaSelector); + + if (!viewport) { + return; + } + + const content = viewport.getAttribute("content") || ""; + + // Nothing to restore if the base viewport already blocks zoom (e.g. the Zoom setting is off). + if (content.includes(ViewportNoZoomToken)) { + return; + } + + this.savedViewportContent = content; + viewport.setAttribute("content", ViewportNoZoom); + } + + // restoreNativeZoom restores the viewport content saved by disableNativeZoom, if any. + restoreNativeZoom() { + if (!this.savedViewportContent) { + return; + } + + const viewport = document.querySelector(ViewportMetaSelector); + + if (viewport) { + viewport.setAttribute("content", this.savedViewportContent); + } + + this.savedViewportContent = ""; + } + // Navigates to the specified URL, optionally with a delay set in milliseconds and a blocked user interface. redirect(url, delay, blockUI) { // Return if no URL was passed. diff --git a/frontend/src/component/action/menu.vue b/frontend/src/component/action/menu.vue index f1f316c6c..fb8d1608c 100644 --- a/frontend/src/component/action/menu.vue +++ b/frontend/src/component/action/menu.vue @@ -63,9 +63,15 @@ export default { return { visible: false, actions: [], - openOnHover: this.$util.shouldOpenOnHover(), }; }, + computed: { + // openOnHover reactively reflects the user's menu open-on-hover preference so a settings + // change applies without a page reload (persistent menus keep no mount-time snapshot). + openOnHover() { + return this.$util.shouldOpenOnHover(); + }, + }, methods: { show() { this.actions = this.items().filter((action) => action.visible); diff --git a/frontend/src/component/album/edit/dialog.vue b/frontend/src/component/album/edit/dialog.vue index bae854e20..f8d0acb4b 100644 --- a/frontend/src/component/album/edit/dialog.vue +++ b/frontend/src/component/album/edit/dialog.vue @@ -96,6 +96,7 @@ diff --git a/frontend/src/page/settings/content.vue b/frontend/src/page/settings/content.vue index 29a0d444d..d55bddd53 100644 --- a/frontend/src/page/settings/content.vue +++ b/frontend/src/page/settings/content.vue @@ -164,16 +164,22 @@ - + {{ $gettext(`Download`) }} - + - + - + + + + + @@ -232,10 +255,16 @@ export default { PAboutFooter, }, data() { + const user = this.$session.getUser(); + const role = user ? user.Role : ""; + const canChangeDownloads = + this.$session.isAdmin() || (this.$session.isUser() && !this.$session.hasScope() && (role === "user" || role === "manager")); + return { isDemo: this.$config.isDemo(), isAdmin: this.$session.isAdmin(), isSuperAdmin: this.$session.isSuperAdmin(), + canChangeDownloads: canChangeDownloads, readonly: this.$config.get("readonly"), experimental: this.$config.get("experimental"), config: this.$config.values, diff --git a/frontend/src/page/settings/general.vue b/frontend/src/page/settings/general.vue index de9454503..f7bb5da1e 100644 --- a/frontend/src/page/settings/general.vue +++ b/frontend/src/page/settings/general.vue @@ -72,6 +72,66 @@ + + + + + + + + + + + + + + + + + + + + + + + {{ $gettext(`Accessibility`) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ $gettext(`Places`) }} @@ -398,6 +528,7 @@ + @@ -517,6 +648,30 @@ export default { }) .finally(() => (this.busy = false)); }, + // saveAndReload persists the settings and reloads the page so changes baked into the + // server-rendered HTML (viewport meta for zoom, hide-scrollbar body class) take effect. + saveAndReload() { + this.busy = true; + this.settings + .save() + .then(() => { + this.$config.setSettings(this.settings); + this.$notify.info(this.$gettext("Reloading…")); + this.$notify.blockUI(); + setTimeout(() => window.location.reload(), 100); + }) + .finally(() => (this.busy = false)); + }, + // onChangeZoom saves the page-zoom setting and reloads to refresh the viewport meta tag. + onChangeZoom() { + this.saveAndReload(); + }, + // onChangeScrollbar maps the inverted "Hide Scrollbar" checkbox to the stored ui.scrollbar + // flag and reloads so the server-rendered hide-scrollbar body class is refreshed. + onChangeScrollbar(hidden) { + this.settings.ui.scrollbar = !hidden; + this.saveAndReload(); + }, }, }; diff --git a/frontend/tests/acceptance/acceptance-auth/settings/general.js b/frontend/tests/acceptance/acceptance-auth/settings/general.js index 53786b2f2..0429b1c2d 100644 --- a/frontend/tests/acceptance/acceptance-auth/settings/general.js +++ b/frontend/tests/acceptance/acceptance-auth/settings/general.js @@ -9,6 +9,7 @@ import Page from "../../page-model/page"; import Photo from "../../page-model/photo"; import PhotoEdit from "../../page-model/photo-edit"; import Album from "../../page-model/album"; +import AlbumDialog from "../../page-model/dialog-album"; import Settings from "../../page-model/settings"; import Library from "../../page-model/library"; import Notifies from "../../page-model/notifications"; @@ -25,6 +26,7 @@ const page = new Page(); const photo = new Photo(); const photoedit = new PhotoEdit(); const album = new Album(); +const albumdialog = new AlbumDialog(); const settings = new Settings(); const library = new Library(); const getPageUrl = ClientFunction(() => window.location.href); @@ -150,7 +152,7 @@ test.meta("testID", "settings-general-003").meta({ type: "short", mode: "auth" } await menu.openPage("settings"); - await t.expect(settings.accountTab.visible).ok().expect(settings.servicesTab.visible).ok(); + await t.expect(settings.accountTab.visible).ok(); await menu.checkMenuItemAvailability("originals", true); await menu.checkMenuItemAvailability("folders", true); await menu.checkMenuItemAvailability("moments", true); @@ -213,7 +215,7 @@ test.meta("testID", "settings-general-003").meta({ type: "short", mode: "auth" } await menu.openPage("settings"); - await t.expect(settings.accountTab.visible).notOk().expect(settings.servicesTab.visible).notOk().expect(settings.generalTab.visible).ok(); + await t.expect(settings.accountTab.visible).notOk().expect(settings.generalTab.visible).ok(); await t .click(settings.importCheckbox) @@ -236,7 +238,7 @@ test.meta("testID", "settings-general-003").meta({ type: "short", mode: "auth" } await menu.openPage("albums"); await menu.openPage("settings"); - await t.expect(settings.accountTab.visible).ok().expect(settings.servicesTab.visible).ok().expect(settings.generalTab.visible).ok(); + await t.expect(settings.accountTab.visible).ok().expect(settings.generalTab.visible).ok(); if (t.browser.platform === "mobile") { if (await toolbar.openMobileToolbar.visible) { @@ -667,3 +669,329 @@ test.meta("testID", "settings-general-007").meta({ type: "short", mode: "auth" } await page.login("admin", "photoprism"); await t.expect(getPageUrl()).contains("browse"); }); + +test.meta("testID", "settings-general-008").meta({ type: "short", mode: "auth" })( + "Common: Disable albums, favorites, folders, and media", + async (t) => { + await menu.checkMenuItemAvailability("albums", true); + await menu.checkMenuItemAvailability("favorites", true); + await menu.checkMenuItemAvailability("folders", true); + await menu.checkMenuItemAvailability("media", true); + + await menu.openPage("settings"); + await t + .click(settings.albumsCheckbox) + .click(settings.favoritesCheckbox) + .click(settings.foldersCheckbox) + .click(settings.videosCheckbox); + await t.eval(() => location.reload()); + + await menu.checkMenuItemAvailability("albums", false); + await menu.checkMenuItemAvailability("favorites", false); + await menu.checkMenuItemAvailability("folders", false); + await menu.checkMenuItemAvailability("media", false); + + await menu.openPage("settings"); + await t + .click(settings.albumsCheckbox) + .click(settings.favoritesCheckbox) + .click(settings.foldersCheckbox) + .click(settings.videosCheckbox); + + await menu.checkMenuItemAvailability("albums", true); + await menu.checkMenuItemAvailability("favorites", true); + await menu.checkMenuItemAvailability("folders", true); + await menu.checkMenuItemAvailability("media", true); + } +); + + +test.meta("testID", "settings-general-009").meta({ type: "short", mode: "auth" })( + "Common: Disable album downloads only - file downloads still work", + async (t) => { + await menu.openPage("albums"); + await notifies.waitForAlbumsToLoad(7000); + + await album.toggleSelectNthAlbum(0, "all"); + await contextmenu.checkContextMenuActionAvailability("download", true); + await contextmenu.clearSelection(); + + await album.openNthAlbum(0); + await toolbar.checkToolbarActionAvailability("download", true); + + await t.navigateTo("/library/browse"); + await toolbar.search("photo:true stack:true"); + await photo.triggerHoverAction("nth", 0, "select"); + + await contextmenu.checkContextMenuActionAvailability("download", true); + await contextmenu.clearSelection(); + + await toolbar.search("photo:true"); + await photoviewer.openPhotoViewer("nth", 0); + await photoviewer.checkPhotoViewerActionAvailability("download", true); + await photoviewer.triggerPhotoViewerAction("close-button"); + await t.expect(Selector("div.p-lightbox__pswp").visible).notOk(); + + await toolbar.search("photo:true stack:true"); + await photo.triggerHoverAction("nth", 0, "select"); + await contextmenu.triggerContextMenuAction("edit", ""); + await t.click(photoedit.filesTab); + await t.expect(photoedit.downloadFile.nth(0).visible).ok(); + await t.click(photoedit.dialogClose); + await contextmenu.clearSelection(); + + await menu.openPage("settings"); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + await t.click(settings.albumDownloadDisabledCheckbox); + await t.wait(500); + + await menu.openPage("albums"); + await notifies.waitForAlbumsToLoad(7000); + + await album.toggleSelectNthAlbum(0, "all"); + await contextmenu.checkContextMenuActionAvailability("download", false); + await contextmenu.clearSelection(); + + await album.openNthAlbum(0); + await toolbar.checkToolbarActionAvailability("download", false); + + await t.navigateTo("/library/browse"); + await toolbar.search("photo:true stack:true"); + await photo.triggerHoverAction("nth", 0, "select"); + + await contextmenu.checkContextMenuActionAvailability("download", true); + + await photo.triggerHoverAction("nth", 1, "select"); + await contextmenu.checkContextMenuActionAvailability("download", true); + await contextmenu.clearSelection(); + + await toolbar.search("photo:true"); + await photoviewer.openPhotoViewer("nth", 0); + await photoviewer.checkPhotoViewerActionAvailability("download", true); + await photoviewer.triggerPhotoViewerAction("close-button"); + await t.expect(Selector("div.p-lightbox__pswp").visible).notOk(); + + await toolbar.search("photo:true stack:true"); + await photo.triggerHoverAction("nth", 0, "select"); + await contextmenu.triggerContextMenuAction("edit", ""); + await t.click(photoedit.filesTab); + await t.expect(photoedit.downloadFile.nth(0).visible).ok(); + await t.click(photoedit.dialogClose); + await contextmenu.clearSelection(); + + await menu.openPage("settings"); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + await t.click(settings.albumDownloadDisabledCheckbox); + await t.wait(500); + + await menu.openPage("albums"); + await notifies.waitForAlbumsToLoad(7000); + await album.toggleSelectNthAlbum(0, "all"); + await contextmenu.checkContextMenuActionAvailability("download", true); + await contextmenu.clearSelection(); + } +); + +test.meta("testID", "settings-general-011").meta({ type: "short", mode: "auth" })( + "Common: Change file download name setting and verify persistence", + async (t) => { + await menu.openPage("settings"); + await t.click(Selector(settings.libraryTab)); + await t.wait(500); + + await t.click(settings.downloadNameSelect); + await t.click(Selector(".v-list-item").withText("Original Name")); + await t.wait(500); + + await t.expect(Selector(".input-download-name .v-field__input").innerText).contains("Original"); + + await t.eval(() => location.reload()); + await t.click(Selector(settings.libraryTab)); + await t.wait(500); + await t.expect(Selector(".input-download-name .v-field__input").innerText).contains("Original"); + + await t.click(settings.downloadNameSelect); + await t.click(Selector(".v-list-item").withText("Share Friendly")); + await t.wait(500); + + await t.expect(Selector(".input-download-name .v-field__input").innerText).contains("Share"); + + await t.click(settings.downloadNameSelect); + await t.click(Selector(".v-list-item").withText("Current Name")); + await t.wait(500); + + await t.expect(Selector(".input-download-name .v-field__input").innerText).contains("Current"); + } +); + +test.meta("testID", "settings-general-012").meta({ type: "short", mode: "auth" })( + "Common: Change album download name setting and verify persistence", + async (t) => { + await menu.openPage("settings"); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + + await t.click(settings.albumDownloadNameSelect); + await t.click(Selector(".v-list-item").withText("Original Name")); + await t.wait(500); + + await t.expect(Selector(".input-album-download-name .v-field__input").innerText).contains("Original"); + + await t.eval(() => location.reload()); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + await t.expect(Selector(".input-album-download-name .v-field__input").innerText).contains("Original"); + + await t.click(settings.albumDownloadNameSelect); + await t.click(Selector(".v-list-item").withText("Share Friendly")); + await t.wait(500); + + await t.expect(Selector(".input-album-download-name .v-field__input").innerText).contains("Share"); + + await t.click(settings.albumDownloadNameSelect); + await t.click(Selector(".v-list-item").withText("Current Name")); + await t.wait(500); + + await t.expect(Selector(".input-album-download-name .v-field__input").innerText).contains("Current"); + } +); + +test.meta("testID", "settings-general-013").meta({ type: "short", mode: "auth" })( + "Common: Default album sort order is applied to newly created albums", + async (t) => { + await menu.openPage("settings"); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + + await t.click(settings.albumOrderSelect); + await t.click(Selector(".v-list-item").withText("Oldest First")); + await t.wait(500); + await t.expect(Selector(".input-album-order .v-field__input").innerText).contains("Oldest"); + + await menu.openPage("albums"); + await notifies.waitForAlbumsToLoad(7000); + const initialAlbumCount = await album.getAlbumCount("all"); + + await toolbar.triggerToolbarAction("add"); + await t.wait(1000); + + const albumCountAfterCreate = await album.getAlbumCount("all"); + await t.expect(albumCountAfterCreate).eql(initialAlbumCount + 1); + + const newAlbumUid = await album.getNthAlbumUid("all", 0); + + await album.openAlbumWithUid(newAlbumUid); + await t.wait(500); + + await toolbar.triggerToolbarAction("edit"); + await t.wait(500); + + await t.expect(albumdialog.sortOrderValue.innerText).contains("Oldest"); + + await t.click(albumdialog.dialogCancel); + await t.wait(300); + + await toolbar.triggerToolbarAction("delete"); + await t.wait(500); + + await menu.openPage("settings"); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + + await t.click(settings.albumOrderSelect); + await t.click(Selector(".v-list-item").withText("Newest First")); + await t.wait(500); + await t.expect(Selector(".input-album-order .v-field__input").innerText).contains("Newest"); + } +); + +test.meta("testID", "settings-general-014").meta({ type: "short", mode: "auth" })( + "Common: Change default album order settings and verify persistence", + async (t) => { + await menu.openPage("settings"); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + + await t.click(settings.albumOrderSelect); + await t.click(Selector(".v-list-item").withText("Oldest First")); + await t.wait(500); + await t.expect(Selector(".input-album-order .v-field__input").innerText).contains("Oldest"); + + await t.click(settings.folderOrderSelect); + await t.click(Selector(".v-list-item").withText("File Name")); + await t.wait(500); + await t.expect(Selector(".input-folder-order .v-field__input").innerText).contains("File Name"); + + await t.eval(() => location.reload()); + await t.click(Selector(settings.collectionsTab)); + await t.wait(500); + await t.expect(Selector(".input-album-order .v-field__input").innerText).contains("Oldest"); + await t.expect(Selector(".input-folder-order .v-field__input").innerText).contains("File Name"); + + await t.click(settings.albumOrderSelect); + await t.click(Selector(".v-list-item").withText("Newest First")); + await t.wait(500); + + await t.click(settings.folderOrderSelect); + await t.click(Selector(".v-list-item").withText("Newest First")); + await t.wait(500); + + await t.expect(Selector(".input-album-order .v-field__input").innerText).contains("Newest"); + await t.expect(Selector(".input-folder-order .v-field__input").innerText).contains("Newest"); + } +); + +test.meta("testID", "settings-general-015").meta({ type: "short", mode: "auth" })( + "Common: Toggle accessibility settings and verify persistence", + async (t) => { + const hasReduceMotionClass = ClientFunction(() => document.documentElement.classList.contains("reduce-motion")); + const reloadPage = ClientFunction(() => window.location.reload()); + const setNoReloadSentinel = ClientFunction(() => { + window.__ppNoReload = true; + }); + const noReloadSentinel = ClientFunction(() => window.__ppNoReload === true); + + await menu.openPage("settings"); + await t.click(Selector(settings.generalTab)); + await t.wait(500); + + // A window sentinel detects an unexpected full-page reload: the live toggles below + // (Reduce Motion, Open on Hover) must NOT reload, so the sentinel must survive them. + await setNoReloadSentinel(); + + // Reduce Motion applies a live class without reloading the page. + await t.expect(Selector(".input-reduce-motion input").checked).notOk(); + await t.expect(hasReduceMotionClass()).notOk(); + await t.click(settings.reduceMotionCheckbox); + await t.wait(500); + await t.expect(Selector(".input-reduce-motion input").checked).ok(); + await t.expect(hasReduceMotionClass()).ok(); + await t.expect(noReloadSentinel()).ok(); + + // Open on Hover persists as a plain preference (unchecking the default). + await t.click(settings.openOnHoverCheckbox); + await t.wait(500); + await t.expect(Selector(".input-open-on-hover input").checked).notOk(); + await t.expect(noReloadSentinel()).ok(); + + // An explicit reload clears the sentinel; the settings must persist across it. + await reloadPage(); + await t.expect(noReloadSentinel()).notOk(); + await t.click(Selector(settings.generalTab)); + await t.wait(500); + await t.expect(Selector(".input-reduce-motion input").checked).ok(); + await t.expect(hasReduceMotionClass()).ok(); + await t.expect(Selector(".input-open-on-hover input").checked).notOk(); + + // Restore defaults so the shared fixture state is left unchanged. + await t.click(settings.reduceMotionCheckbox); + await t.wait(500); + await t.click(settings.openOnHoverCheckbox); + await t.wait(500); + await t.expect(Selector(".input-reduce-motion input").checked).notOk(); + await t.expect(hasReduceMotionClass()).notOk(); + await t.expect(Selector(".input-open-on-hover input").checked).ok(); + } +); diff --git a/frontend/tests/acceptance/page-model/dialog-album.js b/frontend/tests/acceptance/page-model/dialog-album.js index b86a5d4a4..c754dc280 100644 --- a/frontend/tests/acceptance/page-model/dialog-album.js +++ b/frontend/tests/acceptance/page-model/dialog-album.js @@ -8,5 +8,7 @@ export default class Page { this.description = Selector(".input-description textarea", { timeout: 15000 }); this.category = Selector(".input-category input", { timeout: 15000 }); this.location = Selector(".input-location input", { timeout: 15000 }); + this.sortOrderSelect = Selector(".form-album-edit .v-select", { timeout: 15000 }); + this.sortOrderValue = Selector(".form-album-edit .v-select .v-field__input", { timeout: 15000 }); } } diff --git a/frontend/tests/acceptance/page-model/settings.js b/frontend/tests/acceptance/page-model/settings.js index b0b1346a4..8095a9cb7 100644 --- a/frontend/tests/acceptance/page-model/settings.js +++ b/frontend/tests/acceptance/page-model/settings.js @@ -25,19 +25,44 @@ export default class Page { this.peopleCheckbox = Selector(".input-people div.v-selection-control__input"); this.deleteCheckbox = Selector(".input-delete div.v-selection-control__input"); this.libraryCheckbox = Selector(".input-library div.v-selection-control__input"); + this.albumsCheckbox = Selector(".input-albums div.v-selection-control__input"); + this.favoritesCheckbox = Selector(".input-favorites div.v-selection-control__input"); + this.foldersCheckbox = Selector(".input-folders div.v-selection-control__input"); + this.videosCheckbox = Selector(".input-videos div.v-selection-control__input"); + this.zoomCheckbox = Selector(".input-zoom div.v-selection-control__input"); + this.openOnHoverCheckbox = Selector(".input-open-on-hover div.v-selection-control__input"); + this.hideScrollbarCheckbox = Selector(".input-hide-scrollbar div.v-selection-control__input"); + this.reduceMotionCheckbox = Selector(".input-reduce-motion div.v-selection-control__input"); this.libraryTab = Selector("#tab-settings_content"); + this.collectionsTab = Selector("#tab-settings_collections"); this.reviewCheckbox = Selector(".input-review div.v-selection-control__input"); this.convertCheckbox = Selector(".input-convert div.v-selection-control__input"); this.estimatesCheckbox = Selector(".input-estimates div.v-selection-control__input"); this.dateTimeStacksCheckbox = Selector(".input-stack-meta div.v-selection-control__input"); this.uuidStacksCheckbox = Selector(".input-stack-uuid div.v-selection-control__input"); this.nameStacksCheckbox = Selector(".input-stack-name div.v-selection-control__input"); - this.downloadRawCheckbox = Selector(".input-download-raw div.v-selection-control__input"); this.hideTitlesCheckbox = Selector(".input-search-titles div.v-selection-control__input"); this.hideCaptionsCheckbox = Selector(".input-search-captions div.v-selection-control__input"); this.hideListViewCheckbox = Selector(".input-search-listview div.v-selection-control__input"); + this.downloadNameSelect = Selector(".input-download-name div.v-input__control"); + this.downloadOriginalsCheckbox = Selector(".input-download-originals div.v-selection-control__input"); + this.downloadRawCheckbox = Selector(".input-download-raw div.v-selection-control__input"); + this.downloadSidecarCheckbox = Selector(".input-download-sidecar div.v-selection-control__input"); + + this.albumDownloadDisabledCheckbox = Selector(".input-album-download-disabled div.v-selection-control__input"); + this.albumDownloadNameSelect = Selector(".input-album-download-name div.v-input__control"); + this.albumDownloadOriginalsCheckbox = Selector(".input-album-download-originals div.v-selection-control__input"); + this.albumDownloadRawCheckbox = Selector(".input-album-download-raw div.v-selection-control__input"); + this.albumDownloadSidecarCheckbox = Selector(".input-album-download-sidecar div.v-selection-control__input"); + + this.albumOrderSelect = Selector(".input-album-order div.v-input__control"); + this.folderOrderSelect = Selector(".input-folder-order div.v-input__control"); + this.momentOrderSelect = Selector(".input-moment-order div.v-input__control"); + this.stateOrderSelect = Selector(".input-state-order div.v-input__control"); + this.monthOrderSelect = Selector(".input-month-order div.v-input__control"); + this.advancedTab = Selector("#tab-settings_advanced"); this.debugCheckbox = Selector("label").withText("Debug Logs"); this.backupCheckbox = Selector("label").withText("Disable Backups"); @@ -47,6 +72,5 @@ export default class Page { this.readOnlyCheckbox = Selector("label").withText("Read-Only Mode"); this.accountTab = Selector("#tab-settings_account"); - this.servicesTab = Selector("#tab-settings_services"); } } diff --git a/frontend/tests/vitest/common/config.test.js b/frontend/tests/vitest/common/config.test.js index 8657796ec..f4200240b 100644 --- a/frontend/tests/vitest/common/config.test.js +++ b/frontend/tests/vitest/common/config.test.js @@ -618,4 +618,42 @@ describe("common/config", () => { } }); }); + + describe("setReduceMotion", () => { + afterEach(() => { + document.documentElement.classList.remove("reduce-motion"); + }); + + it("toggles the reduce-motion class on based on the argument", () => { + const cfg = createTestConfig(); + cfg.setReduceMotion(true); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(true); + cfg.setReduceMotion(false); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(false); + }); + it("coerces truthy and falsy values and returns the config instance", () => { + const cfg = createTestConfig(); + cfg.setReduceMotion("yes"); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(true); + cfg.setReduceMotion(undefined); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(false); + expect(cfg.setReduceMotion(true)).toBe(cfg); + }); + it("applies the class on construction when settings enable reduced motion", () => { + const values = JSON.parse(JSON.stringify(window.__CONFIG__)); + values.settings.ui.reduceMotion = true; + new Config(new StorageShim(), values); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(true); + }); + it("updates the class when settings change via setSettings", () => { + const cfg = createTestConfig(); + const settings = JSON.parse(JSON.stringify(cfg.getSettings())); + settings.ui.reduceMotion = true; + cfg.setSettings(settings); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(true); + settings.ui.reduceMotion = false; + cfg.setSettings(settings); + expect(document.documentElement.classList.contains("reduce-motion")).toBe(false); + }); + }); }); diff --git a/frontend/tests/vitest/common/util.test.js b/frontend/tests/vitest/common/util.test.js index 453afbc45..522efdc82 100644 --- a/frontend/tests/vitest/common/util.test.js +++ b/frontend/tests/vitest/common/util.test.js @@ -402,4 +402,25 @@ describe("common/util", () => { expect($util.shouldOpenOnHover()).toBe(false); }); }); + + describe("mapAnimateDuration", () => { + it("returns the global Maps.Animate duration by default", () => { + expect($util.mapAnimateDuration({ maps: { animate: 400 } })).toBe(400); + }); + it("prefers a non-negative override over the global setting", () => { + expect($util.mapAnimateDuration({ maps: { animate: 400 } }, 0)).toBe(0); + expect($util.mapAnimateDuration({ maps: { animate: 400 } }, 250)).toBe(250); + }); + it("ignores a negative override and falls back to the global setting", () => { + expect($util.mapAnimateDuration({ maps: { animate: 400 } }, -1)).toBe(400); + }); + it("forces 0 when Reduce Motion is enabled, overriding both sources", () => { + expect($util.mapAnimateDuration({ ui: { reduceMotion: true }, maps: { animate: 400 } })).toBe(0); + expect($util.mapAnimateDuration({ ui: { reduceMotion: true }, maps: { animate: 400 } }, 250)).toBe(0); + }); + it("returns 0 when no settings or animation value is available", () => { + expect($util.mapAnimateDuration(undefined)).toBe(0); + expect($util.mapAnimateDuration({})).toBe(0); + }); + }); }); diff --git a/frontend/tests/vitest/common/view.test.js b/frontend/tests/vitest/common/view.test.js index 3440c78e1..af4217f14 100644 --- a/frontend/tests/vitest/common/view.test.js +++ b/frontend/tests/vitest/common/view.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { $view, preventNavigationTouchEvent } from "common/view"; import { buildNamespace } from "common/storage"; @@ -320,4 +320,106 @@ describe("common/view", () => { }); }); }); + + describe("viewport zoom", () => { + let viewport; + let createdMeta; + let originalContent; + + beforeEach(() => { + $view.savedViewportContent = ""; + viewport = document.querySelector('meta[name="viewport"]'); + createdMeta = false; + originalContent = null; + if (!viewport) { + viewport = document.createElement("meta"); + viewport.setAttribute("name", "viewport"); + document.head.appendChild(viewport); + createdMeta = true; + } else { + originalContent = viewport.getAttribute("content"); + } + viewport.setAttribute("content", "width=device-width, initial-scale=1.0"); + }); + + afterEach(() => { + $view.savedViewportContent = ""; + // apply()-driven cases toggle these body classes; clear them so state doesn't leak. + document.body.classList.remove("hide-scrollbar", "disable-scrolling", "disable-navigation-gestures"); + const current = document.querySelector('meta[name="viewport"]'); + if (createdMeta) { + current?.remove(); + } else { + if (!current) { + document.head.appendChild(viewport); + } + if (originalContent === null) { + viewport.removeAttribute("content"); + } else { + viewport.setAttribute("content", originalContent); + } + } + }); + + it("disableNativeZoom saves the current content and blocks zoom", () => { + $view.disableNativeZoom(); + expect($view.savedViewportContent).toBe("width=device-width, initial-scale=1.0"); + expect(viewport.getAttribute("content")).toContain("user-scalable=no"); + }); + + it("restoreNativeZoom restores the saved content", () => { + $view.disableNativeZoom(); + $view.restoreNativeZoom(); + expect(viewport.getAttribute("content")).toBe("width=device-width, initial-scale=1.0"); + expect($view.savedViewportContent).toBe(""); + }); + + it("disableNativeZoom is idempotent and keeps the first saved content", () => { + $view.disableNativeZoom(); + // A second call while already locked must not overwrite the saved original. + $view.disableNativeZoom(); + expect($view.savedViewportContent).toBe("width=device-width, initial-scale=1.0"); + $view.restoreNativeZoom(); + expect(viewport.getAttribute("content")).toBe("width=device-width, initial-scale=1.0"); + }); + + it("disableNativeZoom does nothing when the base viewport already blocks zoom", () => { + viewport.setAttribute("content", "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"); + $view.disableNativeZoom(); + expect($view.savedViewportContent).toBe(""); + // restore is then a no-op — the base viewport is left untouched. + $view.restoreNativeZoom(); + expect(viewport.getAttribute("content")).toContain("user-scalable=no"); + }); + + it("restoreNativeZoom does nothing when nothing was saved", () => { + $view.restoreNativeZoom(); + expect(viewport.getAttribute("content")).toBe("width=device-width, initial-scale=1.0"); + }); + + it("disableNativeZoom does nothing when there is no viewport meta", () => { + viewport.remove(); + expect(() => $view.disableNativeZoom()).not.toThrow(); + expect($view.savedViewportContent).toBe(""); + }); + + it("apply() locks the viewport for the lightbox and restores it for a non-overlay view", () => { + const fakeComponent = (name, uid) => ({ + $: { uid }, + $el: document.createElement("div"), + $options: { name }, + $refs: {}, + }); + + // A PLightbox view is flagged disableViewportZoom, so apply() locks the viewport. + $view.apply(fakeComponent("PLightbox", 991)); + expect(viewport.getAttribute("content")).toContain("user-scalable=no"); + expect($view.savedViewportContent).toBe("width=device-width, initial-scale=1.0"); + + // Applying a non-overlay view (no flag) restores the saved viewport. + $view.apply(fakeComponent("PPageAlbums", 992)); + expect(viewport.getAttribute("content")).toBe("width=device-width, initial-scale=1.0"); + expect($view.savedViewportContent).toBe(""); + }); + }); }); diff --git a/frontend/tests/vitest/component/settings/general.test.js b/frontend/tests/vitest/component/settings/general.test.js new file mode 100644 index 000000000..f817de40a --- /dev/null +++ b/frontend/tests/vitest/component/settings/general.test.js @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { shallowMount, flushPromises } from "@vue/test-utils"; +import PSettingsGeneral from "page/settings/general.vue"; + +function makeSettings() { + return { + ui: { + theme: "default", + language: "en", + timeZone: "Local", + startPage: "default", + scrollbar: true, + zoom: false, + openOnHover: true, + reduceMotion: false, + }, + maps: { style: "", animate: 0 }, + features: { places: false, download: true }, + search: { batchSize: -1 }, + }; +} + +function mountGeneral() { + return shallowMount(PSettingsGeneral, { + global: { + mocks: { + $config: { + values: { experimental: false, disable: {}, readonly: false }, + getSettings: () => makeSettings(), + get: () => false, + isDemo: () => false, + isPortal: () => false, + isPublic: () => false, + loading: () => false, + themeName: "default", + load: () => Promise.resolve(), + setSettings: vi.fn(), + }, + $session: { isAdmin: () => true, isSuperAdmin: () => true, hasScope: () => false }, + $event: { subscribe: vi.fn(() => 1), unsubscribe: vi.fn() }, + $notify: { info: vi.fn(), success: vi.fn(), blockUI: vi.fn() }, + $gettext: (s) => s, + $pgettext: (_ctx, s) => s, + $sponsorFeatures: () => Promise.resolve(), + }, + }, + }); +} + +describe("page/settings/general accessibility handlers", () => { + let wrapper; + beforeEach(() => { + wrapper = mountGeneral(); + }); + afterEach(() => { + wrapper.unmount(); + vi.useRealTimers(); + }); + + it("onChangeScrollbar maps the inverted Hide Scrollbar checkbox to ui.scrollbar", () => { + const reload = vi.spyOn(wrapper.vm, "saveAndReload").mockImplementation(() => {}); + wrapper.vm.settings.ui.scrollbar = true; + // "Hide Scrollbar" checked → hide → scrollbar stored as false. + wrapper.vm.onChangeScrollbar(true); + expect(wrapper.vm.settings.ui.scrollbar).toBe(false); + // Unchecked → show → scrollbar stored as true. + wrapper.vm.onChangeScrollbar(false); + expect(wrapper.vm.settings.ui.scrollbar).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it("onChangeZoom triggers a save-and-reload without changing other flags", () => { + const reload = vi.spyOn(wrapper.vm, "saveAndReload").mockImplementation(() => {}); + wrapper.vm.onChangeZoom(); + expect(reload).toHaveBeenCalledTimes(1); + expect(wrapper.vm.settings.ui.scrollbar).toBe(true); + }); + + it("saveAndReload persists the settings and updates the client config", async () => { + // Fake timers keep the deferred window.location.reload() from firing during the test. + vi.useFakeTimers(); + const save = vi.spyOn(wrapper.vm.settings, "save").mockResolvedValue(undefined); + wrapper.vm.saveAndReload(); + expect(wrapper.vm.busy).toBe(true); + await flushPromises(); + expect(save).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$config.setSettings).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$notify.info).toHaveBeenCalled(); + expect(wrapper.vm.$notify.blockUI).toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/vitest/options/options.test.js b/frontend/tests/vitest/options/options.test.js index 9a60c2b0a..87eaa053c 100644 --- a/frontend/tests/vitest/options/options.test.js +++ b/frontend/tests/vitest/options/options.test.js @@ -3,7 +3,9 @@ import "../fixtures"; import * as options from "options/options"; import { AccountTypes, + AlbumSortOrder, Colors, + DownloadName, Expires, FallbackLocale, FeedbackCategories, @@ -18,6 +20,7 @@ import { PhotoTypes, RetryLimits, SetDefaultLocale, + SortOrderOptions, StartPages, ThumbSizes, Timeouts, @@ -258,4 +261,44 @@ describe("options/options", () => { expect(AccountTypes()[0].value).toBe("webdav"); expect(AccountTypes().length).toBe(1); }); + + it("should return download name options", () => { + const downloadNames = DownloadName(); + expect(downloadNames).toHaveLength(3); + expect(downloadNames[0].value).toBe("file"); + expect(downloadNames[0].text).toBe("Current Name"); + expect(downloadNames[1].value).toBe("original"); + expect(downloadNames[1].text).toBe("Original Name"); + expect(downloadNames[2].value).toBe("share"); + expect(downloadNames[2].text).toBe("Share Friendly"); + }); + + it("should return album sort order options", () => { + const sortOrders = AlbumSortOrder(); + expect(sortOrders).toHaveLength(8); + expect(sortOrders[0].value).toBe("newest"); + expect(sortOrders[0].text).toBe("Newest First"); + expect(sortOrders[1].value).toBe("oldest"); + expect(sortOrders[1].text).toBe("Oldest First"); + expect(sortOrders[2].value).toBe("added"); + expect(sortOrders[3].value).toBe("title"); + expect(sortOrders[4].value).toBe("name"); + expect(sortOrders[5].value).toBe("size"); + expect(sortOrders[6].value).toBe("duration"); + expect(sortOrders[7].value).toBe("relevance"); + }); + + it("should build sort order options for the requested keys, in order", () => { + const opts = SortOrderOptions(["relevance", "newest", "archived"]); + expect(opts).toHaveLength(3); + expect(opts[0]).toEqual({ value: "relevance", text: "Most Relevant" }); + expect(opts[1]).toEqual({ value: "newest", text: "Newest First" }); + expect(opts[2]).toEqual({ value: "archived", text: "Recently Archived" }); + }); + + it("should skip unknown sort order keys", () => { + const opts = SortOrderOptions(["newest", "bogus", "similar"]); + expect(opts.map((o) => o.value)).toEqual(["newest", "similar"]); + expect(opts[1].text).toBe("Visual Similarity"); + }); }); diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 92e901457..d3a6a4d59 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -1139,6 +1139,9 @@ "openOnHover": { "type": "boolean" }, + "reduceMotion": { + "type": "boolean" + }, "scrollbar": { "type": "boolean" }, diff --git a/internal/config/customize/settings.go b/internal/config/customize/settings.go index 852cab302..e6ddbac59 100644 --- a/internal/config/customize/settings.go +++ b/internal/config/customize/settings.go @@ -53,13 +53,14 @@ func NewSettings(theme, language, timeZone string) *Settings { return &Settings{ UI: UISettings{ - Scrollbar: true, - Zoom: false, - OpenOnHover: true, - Theme: theme, - Language: language, - TimeZone: timeZone, - StartPage: DefaultStartPage, + Scrollbar: true, + Zoom: false, + OpenOnHover: true, + ReduceMotion: false, + Theme: theme, + Language: language, + TimeZone: timeZone, + StartPage: DefaultStartPage, }, Search: SearchSettings{ BatchSize: -1, diff --git a/internal/config/customize/settings_test.go b/internal/config/customize/settings_test.go index 2609424c3..52750766d 100644 --- a/internal/config/customize/settings_test.go +++ b/internal/config/customize/settings_test.go @@ -15,6 +15,10 @@ func TestNewSettings(t *testing.T) { assert.Equal(t, DefaultLanguage, s.UI.Language) assert.Equal(t, DefaultTimeZone, s.UI.TimeZone) assert.Equal(t, DefaultStartPage, s.UI.StartPage) + assert.Equal(t, true, s.UI.Scrollbar) + assert.Equal(t, false, s.UI.Zoom) + assert.Equal(t, true, s.UI.OpenOnHover) + assert.Equal(t, false, s.UI.ReduceMotion) assert.Equal(t, DefaultMapsStyle, s.Maps.Style) }) t.Run("Custom", func(t *testing.T) { @@ -95,6 +99,9 @@ func TestSettings_Save(t *testing.T) { s := NewDefaultSettings() s.UI.Theme = "onyx" s.UI.Language = "de" + // Set the UI booleans to non-default values so the round-trip proves their yaml tags map. + s.UI.Scrollbar = false + s.UI.ReduceMotion = true assert.Equal(t, "onyx", s.UI.Theme) assert.Equal(t, "de", s.UI.Language) @@ -103,6 +110,13 @@ func TestSettings_Save(t *testing.T) { t.Fatal(err) } + reloaded := NewDefaultSettings() + if err := reloaded.Load("testdata/settings_tmp.yml"); err != nil { + t.Fatal(err) + } + assert.Equal(t, false, reloaded.UI.Scrollbar) + assert.Equal(t, true, reloaded.UI.ReduceMotion) + if err := os.Remove("testdata/settings_tmp.yml"); err != nil { t.Fatal(err) } diff --git a/internal/config/customize/testdata/settings.yml b/internal/config/customize/testdata/settings.yml index 7539fe337..840b2d73b 100755 --- a/internal/config/customize/testdata/settings.yml +++ b/internal/config/customize/testdata/settings.yml @@ -2,6 +2,7 @@ UI: Scrollbar: true Zoom: false OpenOnHover: true + ReduceMotion: false Theme: onyx Language: de TimeZone: Local diff --git a/internal/config/customize/ui.go b/internal/config/customize/ui.go index e0fbe30bc..38d8c3461 100644 --- a/internal/config/customize/ui.go +++ b/internal/config/customize/ui.go @@ -2,11 +2,12 @@ package customize // UISettings represents user interface settings. type UISettings struct { - Scrollbar bool `json:"scrollbar" yaml:"Scrollbar"` - Zoom bool `json:"zoom" yaml:"Zoom"` - OpenOnHover bool `json:"openOnHover" yaml:"OpenOnHover"` - Theme string `json:"theme" yaml:"Theme"` - Language string `json:"language" yaml:"Language"` - TimeZone string `json:"timeZone" yaml:"TimeZone"` - StartPage string `json:"startPage" yaml:"StartPage"` + Scrollbar bool `json:"scrollbar" yaml:"Scrollbar"` + Zoom bool `json:"zoom" yaml:"Zoom"` + OpenOnHover bool `json:"openOnHover" yaml:"OpenOnHover"` + ReduceMotion bool `json:"reduceMotion" yaml:"ReduceMotion"` + Theme string `json:"theme" yaml:"Theme"` + Language string `json:"language" yaml:"Language"` + TimeZone string `json:"timeZone" yaml:"TimeZone"` + StartPage string `json:"startPage" yaml:"StartPage"` } diff --git a/internal/entity/search/accounts.go b/internal/entity/search/accounts.go index d74f66f33..c17fdbc66 100644 --- a/internal/entity/search/accounts.go +++ b/internal/entity/search/accounts.go @@ -9,6 +9,10 @@ import ( func Accounts(frm form.SearchServices) (result entity.Services, err error) { s := Db().Where(&entity.Service{}) + if frm.Query != "" { + s = s.Where("acc_name LIKE ?", SqlParam(frm.Query, "%", "%")) + } + if frm.Share { s = s.Where("acc_share = 1") } diff --git a/internal/entity/search/accounts_test.go b/internal/entity/search/accounts_test.go index 0f9ef35ed..667078a54 100644 --- a/internal/entity/search/accounts_test.go +++ b/internal/entity/search/accounts_test.go @@ -80,4 +80,52 @@ func TestAccounts(t *testing.T) { assert.IsType(t, entity.Service{}, r) } }) + t.Run("QueryMatchesAccountName", func(t *testing.T) { + f := form.SearchServices{ + Query: "Account2", + Count: 10, + } + r, err := Accounts(f) + + if err != nil { + t.Fatal(err) + } + + assert.NotEmpty(t, r) + + found := false + for _, s := range r { + assert.Contains(t, s.AccName, "Account2") + if s.AccName == "Test Account2" { + found = true + } + } + assert.True(t, found) + }) + t.Run("QueryMatchesMultiple", func(t *testing.T) { + f := form.SearchServices{ + Query: "Test Account", + Count: 10, + } + r, err := Accounts(f) + + if err != nil { + t.Fatal(err) + } + + assert.GreaterOrEqual(t, len(r), 2) + }) + t.Run("QueryNoMatch", func(t *testing.T) { + f := form.SearchServices{ + Query: "NoSuchServiceName", + Count: 10, + } + r, err := Accounts(f) + + if err != nil { + t.Fatal(err) + } + + assert.Empty(t, r) + }) }