diff --git a/package-lock.json b/package-lock.json index 93de1a27..e632d8f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "4.0.10", "license": "MIT", "dependencies": { - "basicmodal": "https://github.com/LycheeOrg/basicModal/archive/refs/tags/v3.3.9.tar.gz", + "basicmodal": "LycheeOrg/basicModal#v4.0.1", "jquery": "^3.4.0", "justified-layout": "^4.1.0", "lazysizes": "^5.3.0", @@ -100,9 +100,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.0.tgz", - "integrity": "sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w==", + "version": "18.11.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.2.tgz", + "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==", "dev": true }, "node_modules/aggregate-error": { @@ -1391,9 +1391,9 @@ } }, "node_modules/basicmodal": { - "version": "3.3.9", - "resolved": "https://github.com/LycheeOrg/basicModal/archive/refs/tags/v3.3.9.tar.gz", - "integrity": "sha512-ymfdHb3Ou3HK+A+wOgRuQhIsnYwrzVjH5DbRVVY1YmRRwnTW9E6e/3IqN0X2DkMvN2baxC3Lq7umni1+GAY8ag==", + "name": "@lychee-org/basicmodal", + "version": "4.0.1", + "resolved": "git+ssh://git@github.com/LycheeOrg/basicModal.git#44bbca9b4fd040ab6505c1f3b75abf423e6a153c", "license": "MIT" }, "node_modules/binary-extensions": { @@ -1520,9 +1520,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001421", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001421.tgz", - "integrity": "sha512-Sw4eLbgUJAEhjLs1Fa+mk45sidp1wRn5y6GtDpHGBaNJ9OCDJaVh2tIaWWUnGfuXfKf1JCBaIarak3FkVAvEeA==", + "version": "1.0.30001422", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001422.tgz", + "integrity": "sha512-hSesn02u1QacQHhaxl/kNMZwqVG35Sz/8DgvmgedxSH8z9UUpcDYSPYgsj3x5dQNRcNp6BwpSfQfVzYUTm+fog==", "dev": true, "funding": [ { @@ -3644,9 +3644,9 @@ "dev": true }, "node_modules/is-core-module": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", - "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "dependencies": { "has": "^1.0.3" @@ -9759,9 +9759,9 @@ "dev": true }, "@types/node": { - "version": "18.11.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.0.tgz", - "integrity": "sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w==", + "version": "18.11.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.2.tgz", + "integrity": "sha512-BWN3M23gLO2jVG8g/XHIRFWiiV4/GckeFIqbU/C4V3xpoBBWSMk4OZomouN0wCkfQFPqgZikyLr7DOYDysIkkw==", "dev": true }, "aggregate-error": { @@ -10878,8 +10878,8 @@ } }, "basicmodal": { - "version": "https://github.com/LycheeOrg/basicModal/archive/refs/tags/v3.3.9.tar.gz", - "integrity": "sha512-ymfdHb3Ou3HK+A+wOgRuQhIsnYwrzVjH5DbRVVY1YmRRwnTW9E6e/3IqN0X2DkMvN2baxC3Lq7umni1+GAY8ag==" + "version": "git+ssh://git@github.com/LycheeOrg/basicModal.git#44bbca9b4fd040ab6505c1f3b75abf423e6a153c", + "from": "basicmodal@LycheeOrg/basicModal#v4.0.1" }, "binary-extensions": { "version": "1.13.1", @@ -10981,9 +10981,9 @@ "dev": true }, "caniuse-lite": { - "version": "1.0.30001421", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001421.tgz", - "integrity": "sha512-Sw4eLbgUJAEhjLs1Fa+mk45sidp1wRn5y6GtDpHGBaNJ9OCDJaVh2tIaWWUnGfuXfKf1JCBaIarak3FkVAvEeA==", + "version": "1.0.30001422", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001422.tgz", + "integrity": "sha512-hSesn02u1QacQHhaxl/kNMZwqVG35Sz/8DgvmgedxSH8z9UUpcDYSPYgsj3x5dQNRcNp6BwpSfQfVzYUTm+fog==", "dev": true }, "chalk": { @@ -12737,9 +12737,9 @@ "dev": true }, "is-core-module": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", - "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", + "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", "dev": true, "requires": { "has": "^1.0.3" diff --git a/package.json b/package.json index 55e96472..10ef8bea 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,15 @@ "name": "lychee", "version": "4.0.10", "description": "Self-hosted photo-management done right.", - "authors": "Tobias Reich ", + "authors": [ + "Tobias Reich ", + "The Lychee Organization" + ], "license": "MIT", "private": true, "repository": { "type": "git", - "url": "https://github.com/LycheeOrg/Lychee.git" + "url": "https://github.com/LycheeOrg/Lychee-front.git" }, "scripts": { "start": "gulp watch", @@ -19,7 +22,7 @@ "node": ">= 10" }, "dependencies": { - "basicmodal": "https://github.com/LycheeOrg/basicModal/archive/refs/tags/v3.3.9.tar.gz", + "basicmodal": "LycheeOrg/basicModal#v4.0.1", "jquery": "^3.4.0", "justified-layout": "^4.1.0", "lazysizes": "^5.3.0", diff --git a/scripts/3rd-party/backend.js b/scripts/3rd-party/backend.js index 80704ba0..91e912d7 100644 --- a/scripts/3rd-party/backend.js +++ b/scripts/3rd-party/backend.js @@ -325,7 +325,7 @@ const SmartAlbumID = Object.freeze({ * @property {string} swipe_tolerance_x - actually a number * @property {string} swipe_tolerance_y - actually a number * @property {string} upload_processing_limit - actually a number - * @property {string} version - actually a number + * @property {string} version - a string of 6 digits without separating dots, i.e. version 4.6.3 is reported as `'040603'` */ /** diff --git a/scripts/3rd-party/basicModal.js b/scripts/3rd-party/basicModal.js index c7ebc6f1..032868cf 100644 --- a/scripts/3rd-party/basicModal.js +++ b/scripts/3rd-party/basicModal.js @@ -7,44 +7,130 @@ */ /** - * Returns an associative object containing the values from all `input` and - * `select` elements. + * @typedef ModalDialogConfiguration + * @property {string} [body=''] HTML snippet to be inserted into the content + * area of the dialog + * @property {string[]} [classList=[]] CSS class to be applied to the content area + * of the dialog + * @property {boolean} [closable=true] indicates whether the dialog can be closed + * via {@link close} + * @property {ModalDialogButtonsData} buttons configuration data for the main action and + * cancel button + * @property {ModalDialogReadyCB} [readyCB=null] callback to be called after the dialog + * has become visible and ready for user input + */ + +/** + * @callback ModalDialogReadyCB + * @param {ModalDialogFormElements} htmlElements a dictionary that maps names to form elements + * @param {HTMLDivElement} dialog the DIV element that represents the content area of the dialog + * @returns {void} + */ + +/** + * @callback ModalDialogClosedCB + * @returns {void} + */ + +/** + * @typedef ModalDialogFormElements + * + * A dictionary of names of form elements to those form elements. + * + * @type {Object.} + */ + +/** + * @typedef ModalDialogButtonsData + * @property {ModalDialogButtonData} [action] configuration data for the main action button + * @property {ModalDialogButtonData} [cancel] configuration data for the cancel button + */ + +/** + * @typedef ModalDialogButtonData + * @property {string} [title] the caption of the button + * @property {string[]} [classList=[]] CSS class to be applied to the button + * @property {Object.} [attributes={}] a dictionary of arbitrary HTML attributes and their values + * @property {ModalDialogButtonCB} fn callback to be called upon an "on-click" event + */ + +/** + * @callback ModalDialogButtonCB + * @param {ModalDialogResult} values an associative object with the values of + * all HTML input elements; see {@link getValues} + * @returns {void} + */ + +/** + * A dictionary that maps names of form elements to their values. * - * The properties of the returned object correspond to the `name` attribute - * of the `input` and `select` elements. + * @typedef ModalDialogResult + * @type {Object.} + */ + +/** + * @typedef ModalDialogException + * @property {string} name + * @property {string} message + */ + +/** + * Returns an associative object containing the values from all HTML form + * elements. * * @function getValues * @memberOf basicModal - * @returns {Object} + * @returns {ModalDialogResult} */ /** * Constructs and shows a modal dialog. * - * After the dialog has become ready, the callback `data.callback` is + * After the dialog has become ready, the callback `confData.readyCB` is * invoked. * * @function show * @memberOf basicModal - * @param {ModalDialogData} data configuration data for the dialog - * @returns {boolean} `true` if the dialog became visible + * @param {ModalDialogConfiguration} confData configuration data for the dialog + * @returns {void} + * @throws {ModalDialogException} + */ + +/** + * Scans the dialog for any named form elements and caches them in an internal + * dictionary to avoid repeated DOM queries with CSS selectors for efficiency + * reasons. + * + * The found form elements are those which are included into the dialog result + * set and are enabled/disabled automatically. + * + * Normally, it is not necessary to call this method manually from outside the + * modal dialog as this method is automatically called as part of the dialog + * building process inside `show`. + * However, if the dialog is dynamically modified after `show` has been called + * (e.g. if form elements are removed or added on the fly), then this method + * must be called. + * + * @function cacheFormElements + * @memberOf basicModal + * @returns {void} */ /** * Removes (potentially) old error indicators and highlights the indicated * input element. * - * @function error + * @function focusError * @memberOf basicModal - * @param {string} [nameAttribute] the name of the HTML input element which - * caused the error and shall be highlighted + * @param {string} [name] the name of the HTML input element which + * caused the error and shall be highlighted * @returns {void} */ /** * Determines whether a modal dialog is visible or not. * - * @function visible + * @function isVisible * @memberOf basicModal * @returns {boolean} */ @@ -74,11 +160,12 @@ */ /** - * Removes any (potential) error indicator from the input elements. + * Reactivates buttons and removes any (potential) error indicator from the + * input elements. * * @function reset * @memberOf basicModal - * @returns {boolean} always `true` + * @returns {void} */ /** @@ -87,48 +174,140 @@ * @function close * @memberOf basicModal * @param {boolean} [force=false] - * @returns {boolean} `true`, if the dialog has been visible before and has - * been closed; - * `false`, if no dialog has been visible which could be - * closed + * @param {ModalDialogClosedCB} [onClosedCB] + * @returns {void} */ /** - * @typedef ModalDialogData - * @property {string} [body=''] HTML snippet to be inserted into the content - * area of the dialog - * @property {string} [class=''] CSS class to be applied to the content area - * of the dialog - * @property {boolean} [closable=true] indicates whether the dialog can be closed - * via {@link basicModal.close} - * @property {ModalDialogButtonsData} buttons configuration data for the main action and - * cancel button - * @property {ModalDialogReadyCB} [callback=null] callback to be called after the dialog - * has become visible and ready for user input + * @function isActionButtonBusy + * @memberOf basicModal + * @returns {boolean} */ /** - * @callback ModalDialogReadyCB - * @param {ModalDialogData} data the configuration data which has been used to construct the dialog + * @function markActionButtonAsBusy + * @memberOf basicModal * @returns {void} */ /** - * @typedef ModalDialogButtonsData - * @property {ModalDialogButtonData} [action] configuration data for the main action button - * @property {ModalDialogButtonData} [cancel] configuration data for the cancel button + * @function markActionButtonAsIdle + * @memberOf basicModal + * @returns {void} */ /** - * @typedef ModalDialogButtonData - * @property {string} [title] the caption of the button - * @property {string} [class] CSS class to be applied to the button - * @property {ModalDialogButtonCB} fn callback to be called upon an "on-click" event + * Returns `true`, if the Action button is visible. + * + * @function isActionButtonVisible + * @memberOf basicModal + * @returns {boolean} */ /** - * @callback ModalDialogButtonCB - * @param {Object} [values] an associative object with the values of all HTML - * input elements; see {@link basicModal.getValues} + * Returns `true`, if the Action button is hidden. + * + * Note, this method is not exactly the opposite of + * {@link basicModal#isActionButtonVisible}. + * This method only returns `true` if the dialog owns an Action button which + * can be hidden. + * In other words, both {@link basicModal#isActionButtonVisible} and this method may + * return `false` simultaneously, if there is no Action button at all. + * + * @function isActionButtonHidden + * @memberOf basicModal + * @returns {boolean} + */ + +/** + * Hides the Action button + * + * Note: This does not hide the button by setting the `display` property to + * `none`, but completely removes the button from the DOM. + * This is necessary, as an element which is not displayed is still considered + * when it comes to calculating the first or last child and hence rounding + * of the first/last button does not work as expected, if the button is still + * part of the DOM. + * + * @function hideActionButton + * @memberOf basicModal + * @returns {void} + */ + +/** + * Shows the Action button, if one has been defined + * + * Note: This re-inserts the Action button into the DOM, but only if an Action + * button has previously been defined during the dialog construction. + * + * @function showActionButton + * @memberOf basicModal + * @returns {void} + */ + +/** + * @function isCancelButtonBusy + * @memberOf basicModal + * @returns {boolean} + */ + +/** + * @function markCancelButtonAsBusy + * @memberOf basicModal + * @returns {void} + */ + +/** + * @function markCancelButtonAsIdle + * @memberOf basicModal + * @returns {void} + */ + +/** + * Returns `true`, if the Cancel button is visible. + * + * @function isCancelButtonVisible + * @memberOf basicModal + * @returns {boolean} + */ + +/** + * Returns `true`, if the Cancel button is hidden. + * + * Note, this method is not exactly the opposite of + * {@link basicModal#isCancelButtonVisible}. + * This method only returns `true` if the dialog owns a Cancel button which + * can be hidden. + * In other words, both {@link basicModal#isCancelButtonVisible} and this method may + * return `false` simultaneously, if there is no Cancel button at all. + * + * @function isCancelButtonHidden + * @memberOf basicModal + * @returns {boolean} + */ + +/** + * Hides the Cancel button + * + * Note: This does not hide the button by setting the `display` property to + * `none`, but completely removes the button from the DOM. + * This is necessary, as an element which is not displayed is still considered + * when it comes to calculating the first or last child and hence rounding + * of the first/last button does not work as expected, if the button is still + * part of the DOM. + * + * @function hideCancelButton + * @memberOf basicModal + * @returns {void} + */ + +/** + * Shows the Cancel button, if one has been defined + * + * Note: This re-inserts the Cancel button into the DOM, but only if a Cancel + * button has previously been defined during the dialog construction. + * + * @function showCancelButton + * @memberOf basicModal * @returns {void} */ diff --git a/scripts/main/album.js b/scripts/main/album.js index 2e27d16f..6564773c 100644 --- a/scripts/main/album.js +++ b/scripts/main/album.js @@ -341,10 +341,8 @@ album.add = function (IDs = null, callback = null) { * @returns {void} */ const action = function (data) { - // let title = data.title; - if (!data.title.trim()) { - basicModal.error("title"); + basicModal.focusError("title"); return; } @@ -378,8 +376,27 @@ album.add = function (IDs = null, callback = null) { ); }; + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initAddAlbumDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["TITLE_NEW_ALBUM"]; + formElements.title.placeholder = "Title"; + formElements.title.value = lychee.locale["UNTITLED"]; + }; + + const addAlbumDialogBody = ` +

+
+
+
+ `; + basicModal.show({ - body: lychee.html`

${lychee.locale["TITLE_NEW_ALBUM"]}

`, + body: addAlbumDialogBody, + readyCB: initAddAlbumDialog, buttons: { action: { title: lychee.locale["CREATE_ALBUM"], @@ -400,11 +417,11 @@ album.addByTags = function () { /** @param {{title: string, tags: string}} data */ const action = function (data) { if (!data.title.trim()) { - basicModal.error("title"); + basicModal.focusError("title"); return; } if (!data.tags.trim()) { - basicModal.error("tags"); + basicModal.focusError("tags"); return; } @@ -424,11 +441,28 @@ album.addByTags = function () { ); }; + const addTagAlbumDialogBody = ` +

+
+
+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initAddTagAlbumDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["TITLE_NEW_ALBUM"]; + formElements.title.placeholder = "Title"; + formElements.title.value = lychee.locale["UNTITLED"]; + formElements.tags.placeholder = "Tags"; + }; + basicModal.show({ - body: lychee.html`

${lychee.locale["TITLE_NEW_ALBUM"]} - - -

`, + body: addTagAlbumDialogBody, + readyCB: initAddTagAlbumDialog, buttons: { action: { title: lychee.locale["CREATE_TAG_ALBUM"], @@ -450,7 +484,7 @@ album.setShowTags = function (albumID) { /** @param {{show_tags: string}} data */ const action = function (data) { if (!data.show_tags.trim()) { - basicModal.error("show_tags"); + basicModal.focusError("show_tags"); return; } const new_show_tags = data.show_tags @@ -476,18 +510,26 @@ album.setShowTags = function (albumID) { ); }; + const setShowTagDialogBody = ` +

+
+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initShowTagAlbumDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["ALBUM_NEW_SHOWTAGS"]; + formElements.show_tags.placeholder = "Tags"; + formElements.show_tags.value = album.json.show_tags.sort().join(", "); + }; + basicModal.show({ - body: lychee.html` -

${lychee.locale["ALBUM_NEW_SHOWTAGS"]} - -

`, + body: setShowTagDialogBody, + readyCB: initShowTagAlbumDialog, buttons: { action: { title: lychee.locale["ALBUM_SET_SHOWTAGS"], @@ -525,7 +567,7 @@ album.setTitle = function (albumIDs) { /** @param {{title: string}} data */ const action = function (data) { if (!data.title.trim()) { - basicModal.error("title"); + basicModal.focusError("title"); return; } @@ -567,15 +609,27 @@ album.setTitle = function (albumIDs) { }); }; - const inputHTML = lychee.html``; + const setAlbumTitleDialogBody = ` +

+
+
+
`; - const dialogHTML = - albumIDs.length === 1 - ? lychee.html`

${lychee.locale["ALBUM_NEW_TITLE"]} ${inputHTML}

` - : lychee.html`

${sprintf(lychee.locale["ALBUMS_NEW_TITLE"], albumIDs.length)} ${inputHTML}

`; + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetAlbumTitleDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = + albumIDs.length === 1 ? lychee.locale["ALBUM_NEW_TITLE"] : sprintf(lychee.locale["ALBUMS_NEW_TITLE"], albumIDs.length); + formElements.title.placeholder = lychee.locale["ALBUM_TITLE"]; + formElements.title.value = oldTitle; + }; basicModal.show({ - body: dialogHTML, + body: setAlbumTitleDialogBody, + readyCB: initSetAlbumTitleDialog, buttons: { action: { title: lychee.locale["ALBUM_SET_TITLE"], @@ -594,8 +648,6 @@ album.setTitle = function (albumIDs) { * @returns {void} */ album.setDescription = function (albumID) { - const oldDescription = album.json.description ? album.json.description : ""; - /** @param {{description: string}} data */ const action = function (data) { const description = data.description ? data.description : null; @@ -613,8 +665,26 @@ album.setDescription = function (albumID) { }); }; + const setAlbumDescriptionDialogBody = ` +

+
+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetAlbumDescriptionDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["ALBUM_NEW_DESCRIPTION"]; + formElements.description.placeholder = lychee.locale["ALBUM_DESCRIPTION"]; + formElements.description.value = album.json.description ? album.json.description : ""; + }; + basicModal.show({ - body: lychee.html`

${lychee.locale["ALBUM_NEW_DESCRIPTION"]}

`, + body: setAlbumDescriptionDialogBody, + readyCB: initSetAlbumDescriptionDialog, buttons: { action: { title: lychee.locale["ALBUM_SET_DESCRIPTION"], @@ -653,10 +723,6 @@ album.toggleCover = function (photoID) { * @returns {void} */ album.setLicense = function (albumID) { - const callback = function () { - $("select#license").val(album.json.license === "" ? "none" : album.json.license); - }; - /** @param {{license: string}} data */ const action = function (data) { basicModal.close(); @@ -676,54 +742,65 @@ album.setLicense = function (albumID) { ); }; - let msg = lychee.html` -
-

${lychee.locale["ALBUM_LICENSE"]} - - - -
- ${lychee.locale["ALBUM_LICENSE_HELP"]} -

-
`; + const setAlbumLicenseDialogBody = ` +
+
+ +
+

+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetAlbumLicenseDialog = function (formElements, dialog) { + formElements.license.labels[0].textContent = lychee.locale["ALBUM_LICENSE"]; + formElements.license.item(0).textContent = lychee.locale["ALBUM_LICENSE_NONE"]; + formElements.license.item(1).textContent = lychee.locale["ALBUM_RESERVED"]; + formElements.license.value = album.json.license === "" ? "none" : album.json.license; + dialog.querySelector("p a").textContent = lychee.locale["ALBUM_LICENSE_HELP"]; + }; basicModal.show({ - body: msg, - callback: callback, + body: setAlbumLicenseDialogBody, + readyCB: initSetAlbumLicenseDialog, buttons: { action: { title: lychee.locale["ALBUM_SET_LICENSE"], @@ -742,17 +819,7 @@ album.setLicense = function (albumID) { * @returns {void} */ album.setSorting = function (albumID) { - const callback = function () { - if (album.json.sorting) { - $("select#sortingCol").val(album.json.sorting.column); - $("select#sortingOrder").val(album.json.sorting.order); - } else { - $("select#sortingCol").val(""); - $("select#sortingOrder").val(""); - } - }; - - /** @param {{sortingCol: string, sortingOrder: string}} data */ + /** @param {{sorting_col: string, sorting_order: string}} data */ const action = function (data) { basicModal.close(); @@ -760,8 +827,8 @@ album.setSorting = function (albumID) { "Album::setSorting", { albumID: albumID, - sorting_column: data.sortingCol, - sorting_order: data.sortingOrder, + sorting_column: data.sorting_col, + sorting_order: data.sorting_order, }, function () { if (visible.album()) { @@ -771,35 +838,62 @@ album.setSorting = function (albumID) { ); }; - let msg = lychee.html` -

- ${sprintf( - lychee.locale["SORT_PHOTO_BY"], - ` - - `, - ` - - ` - )} -

`; + const setAlbumSortingDialogBody = ` +
+
+ +
+
+
+ +
+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetAlbumSortingDialog = function (formElements, dialog) { + formElements.sorting_col.labels[0].textContent = lychee.locale["SORT_DIALOG_ATTRIBUTE_LABEL"]; + formElements.sorting_col.item(1).textContent = lychee.locale["SORT_PHOTO_SELECT_1"]; + formElements.sorting_col.item(2).textContent = lychee.locale["SORT_PHOTO_SELECT_2"]; + formElements.sorting_col.item(3).textContent = lychee.locale["SORT_PHOTO_SELECT_3"]; + formElements.sorting_col.item(4).textContent = lychee.locale["SORT_PHOTO_SELECT_4"]; + formElements.sorting_col.item(5).textContent = lychee.locale["SORT_PHOTO_SELECT_5"]; + formElements.sorting_col.item(6).textContent = lychee.locale["SORT_PHOTO_SELECT_6"]; + formElements.sorting_col.item(7).textContent = lychee.locale["SORT_PHOTO_SELECT_7"]; + + formElements.sorting_order.labels[0].textContent = lychee.locale["SORT_DIALOG_ORDER_LABEL"]; + formElements.sorting_order.item(1).textContent = lychee.locale["SORT_ASCENDING"]; + formElements.sorting_order.item(2).textContent = lychee.locale["SORT_DESCENDING"]; + + if (album.json.sorting) { + formElements.sorting_col.value = album.json.sorting.column; + formElements.sorting_order.value = album.json.sorting.order; + } else { + formElements.sorting_col.value = ""; + formElements.sorting_order.value = ""; + } + }; basicModal.show({ - body: msg, - callback: callback, + body: setAlbumSortingDialogBody, + readyCB: initSetAlbumSortingDialog, buttons: { action: { title: lychee.locale["ALBUM_SET_ORDER"], @@ -820,21 +914,20 @@ album.setSorting = function (albumID) { * @returns {void} */ album.setProtectionPolicy = function (albumID) { + /** + * @param {ModalDialogResult} data + */ const action = function (data) { + basicModal.close(); albums.refresh(); - // TODO: If the modal dialog would provide us with proper boolean values for the checkboxes as part of `data` the same way as it does for text inputs, then we would not need these slow and awkward jQeury selectors - album.json.is_nsfw = $('.basicModal .switch input[name="is_nsfw"]:checked').length === 1; - album.json.is_public = $('.basicModal .switch input[name="is_public"]:checked').length === 1; - album.json.grants_full_photo = $('.basicModal .choice input[name="grants_full_photo"]:checked').length === 1; - album.json.requires_link = $('.basicModal .choice input[name="requires_link"]:checked').length === 1; - album.json.is_downloadable = $('.basicModal .choice input[name="is_downloadable"]:checked').length === 1; - album.json.is_share_button_visible = $('.basicModal .choice input[name="is_share_button_visible"]:checked').length === 1; - album.json.has_password = $('.basicModal .choice input[name="has_password"]:checked').length === 1; - const newPassword = $('.basicModal .choice input[name="passwordtext"]').val() || null; - - // Modal input has been processed, now it can be closed - basicModal.close(); + album.json.is_nsfw = data.is_nsfw; + album.json.is_public = data.is_public; + album.json.grants_full_photo = data.grants_full_photo; + album.json.requires_link = data.requires_link; + album.json.is_downloadable = data.is_downloadable; + album.json.is_share_button_visible = data.is_share_button_visible; + album.json.has_password = data.has_password; // Set data and refresh view if (visible.album()) { @@ -856,10 +949,10 @@ album.setProtectionPolicy = function (albumID) { is_share_button_visible: album.json.is_share_button_visible, }; if (album.json.has_password) { - if (newPassword) { + if (data.password) { // We send the password only if there's been a change; that way the // server will keep the current password if it wasn't changed. - params.password = newPassword; + params.password = data.password; } } else { params.password = null; @@ -868,111 +961,150 @@ album.setProtectionPolicy = function (albumID) { api.post("Album::setProtectionPolicy", params); }; - const msg = lychee.html` + const setAlbumProtectionPolicyBody = `
-
- -

${lychee.locale["ALBUM_PUBLIC_EXPL"]}

+
+ + +

-
- -

${lychee.locale["ALBUM_FULL_EXPL"]}

+
+ + +

-
- -

${lychee.locale["ALBUM_HIDDEN_EXPL"]}

+
+ + +

-
- -

${lychee.locale["ALBUM_DOWNLOADABLE_EXPL"]}

+
+ + +

-
- -

${lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE_EXPL"]}

+
+ + +

-
- -

${lychee.locale["ALBUM_PASSWORD_PROT_EXPL"]}

- -
-

-
- -

${lychee.locale["ALBUM_NSFW_EXPL"]}

+
+ + +

+
+ +
- `; +
+
+
+ + +

+
+
`; + + /** + * @typedef ProtectionPolicyDialogFormElements + * @property {HTMLInputElement} is_public + * @property {HTMLInputElement} grants_full_photo + * @property {HTMLInputElement} requires_link + * @property {HTMLInputElement} is_downloadable + * @property {HTMLInputElement} is_share_button_visible + * @property {HTMLInputElement} has_password + * @property {HTMLInputElement} password + * @property {HTMLInputElement} is_nsfw + */ + + /** + * @param {ProtectionPolicyDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initAlbumProtectionPolicyDialog = function (formElements, dialog) { + formElements.is_public.previousElementSibling.textContent = lychee.locale["ALBUM_PUBLIC"]; + formElements.is_public.nextElementSibling.textContent = lychee.locale["ALBUM_PUBLIC_EXPL"]; + formElements.grants_full_photo.previousElementSibling.textContent = lychee.locale["ALBUM_FULL"]; + formElements.grants_full_photo.nextElementSibling.textContent = lychee.locale["ALBUM_FULL_EXPL"]; + formElements.requires_link.previousElementSibling.textContent = lychee.locale["ALBUM_HIDDEN"]; + formElements.requires_link.nextElementSibling.textContent = lychee.locale["ALBUM_HIDDEN_EXPL"]; + formElements.is_downloadable.previousElementSibling.textContent = lychee.locale["ALBUM_DOWNLOADABLE"]; + formElements.is_downloadable.nextElementSibling.textContent = lychee.locale["ALBUM_DOWNLOADABLE_EXPL"]; + formElements.is_share_button_visible.previousElementSibling.textContent = lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE"]; + formElements.is_share_button_visible.nextElementSibling.textContent = lychee.locale["ALBUM_SHARE_BUTTON_VISIBLE_EXPL"]; + formElements.has_password.previousElementSibling.textContent = lychee.locale["ALBUM_PASSWORD_PROT"]; + formElements.has_password.nextElementSibling.textContent = lychee.locale["ALBUM_PASSWORD_PROT_EXPL"]; + formElements.password.placeholder = lychee.locale["PASSWORD"]; + formElements.is_nsfw.previousElementSibling.textContent = lychee.locale["ALBUM_NSFW"]; + formElements.is_nsfw.nextElementSibling.textContent = lychee.locale["ALBUM_NSFW_EXPL"]; + + formElements.is_public.checked = album.json.is_public; + formElements.is_nsfw.checked = album.json.is_nsfw; + + /** + * Array of checkboxes which are enable/disabled wrt. the state of `is_public` + * @type {HTMLInputElement[]} + */ + const tristateCheckboxes = [ + formElements.grants_full_photo, + formElements.requires_link, + formElements.is_downloadable, + formElements.is_share_button_visible, + formElements.has_password, + ]; - const dialogSetupCB = function () { - // TODO: If the modal dialog would provide this callback with proper jQuery objects for all input/select/choice elements, then we would not need these jQuery selectors - $('.basicModal .switch input[name="is_public"]').prop("checked", album.json.is_public); - $('.basicModal .switch input[name="is_nsfw"]').prop("checked", album.json.is_nsfw); if (album.json.is_public) { - $(".basicModal .choice input").attr("disabled", false); + tristateCheckboxes.forEach(function (checkbox) { + checkbox.parentElement.classList.remove("disabled"); + checkbox.disabled = false; + }); // Initialize options based on album settings. - $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", album.json.grants_full_photo); - $('.basicModal .choice input[name="requires_link"]').prop("checked", album.json.requires_link); - $('.basicModal .choice input[name="is_downloadable"]').prop("checked", album.json.is_downloadable); - $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", album.json.is_share_button_visible); - $('.basicModal .choice input[name="has_password"]').prop("checked", album.json.has_password); + formElements.grants_full_photo.checked = album.json.grants_full_photo; + formElements.requires_link.checked = album.json.requires_link; + formElements.is_downloadable.checked = album.json.is_downloadable; + formElements.is_share_button_visible.checked = album.json.is_share_button_visible; + formElements.has_password.checked = album.json.has_password; if (album.json.has_password) { - $('.basicModal .choice input[name="passwordtext"]').show(); + formElements.password.parentElement.classList.remove("hidden"); + } else { + formElements.password.parentElement.classList.add("hidden"); } } else { - $(".basicModal .choice input").attr("disabled", true); + tristateCheckboxes.forEach(function (checkbox) { + checkbox.parentElement.classList.add("disabled"); + checkbox.disabled = true; + }); // Initialize options based on global settings. - $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", lychee.grants_full_photo); - $('.basicModal .choice input[name="requires_link"]').prop("checked", false); - $('.basicModal .choice input[name="is_downloadable"]').prop("checked", lychee.is_downloadable); - $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", lychee.is_share_button_visible); - $('.basicModal .choice input[name="has_password"]').prop("checked", false); - $('.basicModal .choice input[name="passwordtext"]').hide(); + formElements.grants_full_photo.checked = lychee.full_photo; + formElements.requires_link.checked = false; + formElements.is_downloadable.checked = lychee.downloadable; + formElements.is_share_button_visible.checked = lychee.share_button_visible; + formElements.has_password.checked = false; + formElements.password.parentElement.classList.add("hidden"); } - $('.basicModal .switch input[name="is_public"]').on("change", function () { - $(".basicModal .choice input").attr("disabled", $(this).prop("checked") !== true); + formElements.is_public.addEventListener("change", function () { + tristateCheckboxes.forEach(function (checkbox) { + checkbox.parentElement.classList.toggle("disabled"); + checkbox.disabled = !formElements.is_public.checked; + }); }); - $('.basicModal .choice input[name="has_password"]').on("change", function () { - if ($(this).prop("checked") === true) { - $('.basicModal .choice input[name="passwordtext"]').show().focus(); + formElements.has_password.addEventListener("change", function () { + if (formElements.has_password.checked) { + formElements.password.parentElement.classList.remove("hidden"); + formElements.password.focus(); } else { - $('.basicModal .choice input[name="passwordtext"]').hide(); + formElements.password.parentElement.classList.add("hidden"); } }); }; basicModal.show({ - body: msg, - callback: dialogSetupCB, + body: setAlbumProtectionPolicyBody, + readyCB: initAlbumProtectionPolicyDialog, buttons: { action: { title: lychee.locale["SAVE"], @@ -993,81 +1125,72 @@ album.setProtectionPolicy = function (albumID) { * @returns {void} */ album.shareUsers = function (albumID) { + /** + * @param {ModalDialogResult} data + */ const action = function (data) { basicModal.close(); /** @type {number[]} */ - const sharingToAdd = []; - /** @type {number[]} */ - const sharingToDelete = []; - $(".basicModal .choice input").each((_, input) => { - const $input = $(input); - if ($input.is(":checked")) { - if ($input.data("sharingId") === undefined) { - // Input is checked but has no sharing id => new share to create - sharingToAdd.push(Number.parseInt(input.name)); - } - } else { - const sharingId = $input.data("sharingId"); - if (sharingId !== undefined) { - // Input is not checked but has a sharing id => existing share to remove - sharingToDelete.push(Number.parseInt(sharingId)); - } - } - }); + const selectedUserIds = Object.entries(data) + .filter(([userId, isChecked]) => isChecked) + .map(([userId, isChecked]) => parseInt(userId, 10)); - if (sharingToDelete.length > 0) { - api.post("Sharing::delete", { - shareIDs: sharingToDelete, - }); - } - if (sharingToAdd.length > 0) { - api.post("Sharing::add", { - albumIDs: [albumID], - userIDs: sharingToAdd, - }); - } + api.post("Sharing::setByAlbum", { + albumID: albumID, + userIDs: selectedUserIds, + }); }; - const msg = `

${lychee.locale["WAIT_FETCH_DATA"]}

`; + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSharingDialog = function (formElements, dialog) { + /** @type {HTMLParagraphElement} */ + const p = dialog.querySelector("p"); + p.textContent = lychee.locale["WAIT_FETCH_DATA"]; - const dialogSetupCB = function () { /** @param {SharingInfo} data */ const successCallback = function (data) { - const sharingForm = $("#sharing_people_form"); - sharingForm.empty(); - if (data.users.length !== 0) { - sharingForm.append(`

${lychee.locale["SHARING_ALBUM_USERS_LONG_MESSAGE"]}

`); - // Fill with the list of users - data.users.forEach((user) => { - sharingForm.append(lychee.html`
- -

-
`); - }); - data.shared - .filter((val) => val.album_id === albumID) - .forEach((sharing) => { - // Check all the shares that already exist, and store their sharing id on the element - const elem = $(`.basicModal .choice input[name="${sharing.user_id}"]`); - elem.prop("checked", true); - elem.data("sharingId", sharing.id); - }); - } else { - sharingForm.append(`

${lychee.locale["SHARING_ALBUM_USERS_NO_USERS"]}

`); + if (data.users.length === 0) { + p.textContent = lychee.locale["SHARING_ALBUM_USERS_NO_USERS"]; + return; } + + p.textContent = lychee.locale["SHARING_ALBUM_USERS_LONG_MESSAGE"]; + + /** @type {HTMLFormElement} */ + const form = document.createElement("form"); + + const existingShares = new Set(data.shared.map((value) => value.user_id)); + + // Create a list with one checkbox per user + data.users.forEach((user) => { + const div = form.appendChild(document.createElement("div")); + div.classList.add("input-group", "compact-inverse"); + const label = div.appendChild(document.createElement("label")); + label.htmlFor = "share_dialog_user_" + user.id; + label.textContent = user.username; + const input = div.appendChild(document.createElement("input")); + input.type = "checkbox"; + input.id = label.htmlFor; + input.name = user.id.toString(); + input.checked = existingShares.has(user.id); + }); + + // Append the pre-constructed form to the dialog after the paragraph + dialog.appendChild(form); + basicModal.cacheFormElements(); }; - api.post("Sharing::list", {}, successCallback); + api.post("Sharing::list", { albumID: albumID }, successCallback); }; basicModal.show({ - body: msg, - callback: dialogSetupCB, + body: `

`, + readyCB: initSharingDialog, buttons: { action: { title: lychee.locale["SAVE"], @@ -1133,14 +1256,11 @@ album.qrCode = function () { return; } - let msg = lychee.html` -
- `; - basicModal.show({ - body: msg, - callback: function () { - let qrcode = $("#qr-code"); + body: "
", + classList: ["qr-code"], + readyCB: function (formElements, dialog) { + const qrcode = dialog.querySelector("div.qr-code-canvas"); QrCreator.render( { text: location.href, @@ -1148,9 +1268,9 @@ album.qrCode = function () { ecLevel: "H", fill: "#000000", background: "#FFFFFF", - size: qrcode.width(), + size: qrcode.clientWidth, }, - qrcode[0] + qrcode ); }, buttons: { @@ -1175,7 +1295,7 @@ album.getArchive = function (albumIDs) { * @param {?string} albumID * @param {string} op1 * @param {string} ops - * @returns {string} the HTML content of the dialog + * @returns {string} the message */ album.buildMessage = function (albumIDs, albumID, op1, ops) { let targetTitle = lychee.locale["UNTITLED"]; @@ -1199,9 +1319,9 @@ album.buildMessage = function (albumIDs, albumID, op1, ops) { sourceTitle = sourceAlbum.title; } - msg = lychee.html`

${sprintf(lychee.locale[op1], lychee.escapeHTML(sourceTitle), lychee.escapeHTML(targetTitle))}'

`; + msg = sprintf(lychee.locale[op1], sourceTitle, targetTitle); } else { - msg = lychee.html`

${sprintf(lychee.locale[ops], lychee.escapeHTML(targetTitle))}

`; + msg = sprintf(lychee.locale[ops], targetTitle); } return msg; @@ -1212,86 +1332,93 @@ album.buildMessage = function (albumIDs, albumID, op1, ops) { * @returns {void} */ album.delete = function (albumIDs) { - let action = {}; - let cancel = {}; - let msg = ""; - - action.fn = function () { - basicModal.close(); + const isTagAlbum = albumIDs.length === 1 && albums.isTagAlbum(albumIDs[0]); - api.post( - "Album::delete", - { - albumIDs: albumIDs, - }, - function () { - if (visible.albums()) { - albumIDs.forEach(function (id) { - view.albums.content.delete(id); - albums.deleteByID(id); - }); - } else if (visible.album()) { - albums.refresh(); - if (albumIDs.length === 1 && album.getID() === albumIDs[0]) { - lychee.goto(album.getParentID()); - } else { - albumIDs.forEach(function (id) { - album.deleteSubByID(id); - view.album.content.deleteSub(id); - }); - } - } + const handleSuccessfulDeletion = function () { + if (visible.albums()) { + albumIDs.forEach(function (id) { + view.albums.content.delete(id); + albums.deleteByID(id); + }); + } else if (visible.album()) { + albums.refresh(); + if (albumIDs.length === 1 && album.getID() === albumIDs[0]) { + lychee.goto(album.getParentID()); + } else { + albumIDs.forEach(function (id) { + album.deleteSubByID(id); + view.album.content.deleteSub(id); + }); } - ); + } }; - if (albumIDs.length === 1 && albumIDs[0] === "unsorted") { - action.title = lychee.locale["CLEAR_UNSORTED"]; - cancel.title = lychee.locale["KEEP_UNSORTED"]; + const action = function () { + basicModal.close(); + api.post("Album::delete", { albumIDs: albumIDs }, handleSuccessfulDeletion); + }; - msg = `

` + lychee.locale["DELETE_UNSORTED_CONFIRM"] + `

`; - } else if (albumIDs.length === 1) { - let albumTitle = ""; - const isTagAlbum = albums.isTagAlbum(albumIDs[0]); + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + */ + const initConfirmDeletionDialog = function (formElements, dialog) { + /** @type {HTMLParagraphElement} */ + const p = dialog.querySelector("p"); + if (albumIDs.length === 1 && albumIDs[0] === SmartAlbumID.UNSORTED) { + p.textContent = lychee.locale["DELETE_UNSORTED_CONFIRM"]; + } else if (albumIDs.length === 1) { + let albumTitle = ""; + + // Get title + if (album.json) { + if (album.getID() === albumIDs[0]) { + albumTitle = album.json.title; + } else albumTitle = album.getSubByID(albumIDs[0]).title; + } + if (!albumTitle) { + let a = albums.getByID(albumIDs[0]); + if (a) albumTitle = a.title; + } - action.title = lychee.locale[isTagAlbum ? "DELETE_TAG_ALBUM_QUESTION" : "DELETE_ALBUM_QUESTION"]; - cancel.title = lychee.locale["KEEP_ALBUM"]; + // Fallback for album without a title + if (!albumTitle) albumTitle = lychee.locale["UNTITLED"]; - // Get title - if (album.json) { - if (album.getID() === albumIDs[0]) { - albumTitle = album.json.title; - } else albumTitle = album.getSubByID(albumIDs[0]).title; - } - if (!albumTitle) { - let a = albums.getByID(albumIDs[0]); - if (a) albumTitle = a.title; + p.textContent = isTagAlbum + ? sprintf(lychee.locale["DELETE_TAG_ALBUM_CONFIRMATION"], albumTitle) + : sprintf(lychee.locale["DELETE_ALBUM_CONFIRMATION"], albumTitle); + } else { + p.textContent = sprintf(lychee.locale["DELETE_ALBUMS_CONFIRMATION"], albumIDs.length); } + }; - // Fallback for album without a title - if (!albumTitle) albumTitle = lychee.locale["UNTITLED"]; - - msg = lychee.html`

${sprintf( - lychee.locale[isTagAlbum ? "DELETE_TAG_ALBUM_CONFIRMATION" : "DELETE_ALBUM_CONFIRMATION"], - lychee.escapeHTML(albumTitle) - )}

`; - } else { - action.title = lychee.locale["DELETE_ALBUMS_QUESTION"]; - cancel.title = lychee.locale["KEEP_ALBUMS"]; - - msg = lychee.html`

${sprintf(lychee.locale["DELETE_ALBUMS_CONFIRMATION"], albumIDs.length)}

`; - } + const actionButtonLabel = + albumIDs.length === 1 + ? albumIDs[0] === SmartAlbumID.UNSORTED + ? lychee.locale["CLEAR_UNSORTED"] + : isTagAlbum + ? lychee.locale["DELETE_TAG_ALBUM_QUESTION"] + : lychee.locale["DELETE_ALBUM_QUESTION"] + : lychee.locale["DELETE_ALBUMS_QUESTION"]; + + const cancelButtonLabel = + albumIDs.length === 1 + ? albumIDs[0] === SmartAlbumID.UNSORTED + ? lychee.locale["KEEP_UNSORTED"] + : lychee.locale["KEEP_ALBUM"] + : lychee.locale["KEEP_ALBUMS"]; basicModal.show({ - body: msg, + body: "

", + readyCB: initConfirmDeletionDialog, buttons: { action: { - title: action.title, - fn: action.fn, - class: "red", + title: actionButtonLabel, + fn: action, + classList: ["red"], }, cancel: { - title: cancel.title, + title: cancelButtonLabel, fn: basicModal.close, }, }, @@ -1319,12 +1446,14 @@ album.merge = function (albumIDs, albumID, confirm = true) { if (confirm) { basicModal.show({ - body: album.buildMessage(albumIDs, albumID, "ALBUM_MERGE", "ALBUMS_MERGE"), + body: "

", + readyCB: (formElements, dialog) => + (dialog.querySelector("p").textContent = album.buildMessage(albumIDs, albumID, "ALBUM_MERGE", "ALBUMS_MERGE")), buttons: { action: { title: lychee.locale["MERGE_ALBUM"], fn: action, - class: "red", + classList: ["red"], }, cancel: { title: lychee.locale["DONT_MERGE"], @@ -1358,12 +1487,14 @@ album.setAlbum = function (albumIDs, albumID, confirm = true) { if (confirm) { basicModal.show({ - body: album.buildMessage(albumIDs, albumID, "ALBUM_MOVE", "ALBUMS_MOVE"), + body: "

", + readyCB: (formElements, dialog) => + (dialog.querySelector("p").textContent = album.buildMessage(albumIDs, albumID, "ALBUM_MOVE", "ALBUMS_MOVE")), buttons: { action: { title: lychee.locale["MOVE_ALBUMS"], fn: action, - class: "red", + classList: ["red"], }, cancel: { title: lychee.locale["NOT_MOVE_ALBUMS"], diff --git a/scripts/main/build.js b/scripts/main/build.js index fc2d2454..19aa72d4 100644 --- a/scripts/main/build.js +++ b/scripts/main/build.js @@ -495,62 +495,6 @@ build.no_content = function (type) { return html; }; -/** - * @param {string} title the title of the dialog - * @param {(FileList|File[]|DropboxFile[]|{name: string}[])} files a list of file entries to be shown in the dialog - * @returns {string} the HTML fragment for the dialog - */ -build.uploadModal = function (title, files) { - let html = ""; - - html += lychee.html` -

$${title}

-
- `; - - let i = 0; - - while (i < files.length) { - let file = files[i]; - - if (file.name.length > 40) file.name = file.name.substr(0, 17) + "..." + file.name.substr(file.name.length - 20, 20); - - html += lychee.html` -
- $${file.name} - -

-
- `; - - i++; - } - - html += `
`; - - return html; -}; - -/** - * Builds the HTML snippet for a row in the upload dialog. - * - * @param {string} name - * @returns {string} - */ -build.uploadNewFile = function (name) { - if (name.length > 40) { - name = name.substring(0, 17) + "..." + name.substring(name.length - 20, name.length); - } - - return lychee.html` -
- $${name} - -

-
- `; -}; - /** * @param {string[]} tags * @returns {string} return safe HTMl code diff --git a/scripts/main/header.js b/scripts/main/header.js index 774162ab..58b6f089 100644 --- a/scripts/main/header.js +++ b/scripts/main/header.js @@ -190,7 +190,7 @@ header.hideIfLivePhotoNotPlaying = function () { * @returns {void} */ header.hide = function () { - if (visible.photo() && !visible.sidebar() && !visible.contextMenu() && basicModal.visible() === false) { + if (visible.photo() && !visible.sidebar() && !visible.contextMenu() && basicModal.isVisible() === false) { tabindex.saveSettings(header.dom()); tabindex.makeUnfocusable(header.dom()); diff --git a/scripts/main/init.js b/scripts/main/init.js index 0efbb038..fee9fba3 100644 --- a/scripts/main/init.js +++ b/scripts/main/init.js @@ -123,20 +123,20 @@ $(document).ready(function () { }) .bind(["command+backspace", "ctrl+backspace"], function () { if (album.isUploadable()) { - if (visible.photo() && basicModal.visible() === false) { + if (visible.photo() && basicModal.isVisible() === false) { photo.delete([photo.getID()]); return false; - } else if (visible.album() && basicModal.visible() === false) { + } else if (visible.album() && basicModal.isVisible() === false) { album.delete([album.getID()]); return false; } } }) .bind(["command+a", "ctrl+a"], function () { - if (visible.album() && basicModal.visible() === false) { + if (visible.album() && basicModal.isVisible() === false) { multiselect.selectAll(); return false; - } else if (visible.albums() && basicModal.visible() === false) { + } else if (visible.albums() && basicModal.isVisible() === false) { multiselect.selectAll(); return false; } @@ -168,7 +168,7 @@ $(document).ready(function () { }); Mousetrap.bindGlobal("enter", function () { - if (basicModal.visible() === true) { + if (basicModal.isVisible() === true) { // check if any of the input fields is focussed // apply action, other do nothing if ($(".basicModal__content input").is(":focus")) { @@ -210,7 +210,7 @@ $(document).ready(function () { ); Mousetrap.bindGlobal(["esc", "command+up"], function () { - if (basicModal.visible() === true) basicModal.cancel(); + if (basicModal.isVisible() === true) basicModal.cancel(); else if (visible.config() || visible.leftMenu()) leftMenu.close(); else if (visible.contextMenu()) contextMenu.close(); else if (visible.photo()) lychee.goto(album.getID()); @@ -302,12 +302,10 @@ $(document).ready(function () { ) // Upload .on("change", "#upload_files", function () { - basicModal.close(); - upload.start.local(this.files); + basicModal.close(false, () => upload.start.local(this.files)); }) .on("change", "#upload_track_file", function () { - basicModal.close(); - upload.uploadTrack(this.files); + basicModal.close(false, () => upload.uploadTrack(this.files)); }) // Drag and Drop upload .on( @@ -323,7 +321,7 @@ $(document).ready(function () { if ( album.isUploadable() && !visible.contextMenu() && - !basicModal.visible() && + !basicModal.isVisible() && !visible.leftMenu() && !visible.config() && (visible.album() || visible.albums()) @@ -368,7 +366,7 @@ $(document).ready(function () { filesToUpload.length > 0 && album.isUploadable() && !visible.contextMenu() && - !basicModal.visible() && + !basicModal.isVisible() && !visible.leftMenu() && !visible.config() && (visible.album() || visible.albums()) diff --git a/scripts/main/loadingBar.js b/scripts/main/loadingBar.js index fec1bfe4..89b460b5 100644 --- a/scripts/main/loadingBar.js +++ b/scripts/main/loadingBar.js @@ -36,7 +36,7 @@ loadingBar.show = function (status, errorText) { if (visible.header()) header.dom().addClass("header--error"); // Also move down the dark background - if (basicModal.visible()) { + if (basicModal.isVisible()) { $(".basicModalContainer").addClass("basicModalContainer--error"); $(".basicModal").addClass("basicModal--error"); } @@ -68,7 +68,7 @@ loadingBar.show = function (status, errorText) { if (visible.header()) header.dom().addClass("header--error"); // Also move down the dark background - if (basicModal.visible()) { + if (basicModal.isVisible()) { $(".basicModalContainer").addClass("basicModalContainer--error"); $(".basicModal").addClass("basicModal--error"); } diff --git a/scripts/main/lychee.js b/scripts/main/lychee.js index b7bb4624..480538c6 100644 --- a/scripts/main/lychee.js +++ b/scripts/main/lychee.js @@ -4,8 +4,16 @@ const lychee = { title: document.title, + /** + * The version of the backend in human-readable, printable form, e.g. `'4.6.3'`. + * + * TODO: Make format of this attribute and {@link lychee.update_json} consistent. + * + * TODO: Let the backend report the version as a proper object with properties for major, minor and patch level + * + * @type {string} + */ version: "", - versionCode: "", // not really needed anymore updatePath: "https://LycheeOrg.github.io/update.json", updateURL: "https://github.com/LycheeOrg/Lychee/releases", @@ -54,7 +62,7 @@ const lychee = { layout: 1, /** * Display search in public mode. - * @type boolean + * @type {boolean} */ public_search: false, /** @@ -69,17 +77,17 @@ const lychee = { image_overlay_type_default: "exif", /** * Display photo coordinates on map - * @type boolean + * @type {boolean} */ map_display: false, /** * Display photos of public album on map (user not logged in) - * @type boolean + * @type {boolean} */ map_display_public: false, /** * Use the GPS direction data on displayed maps - * @type boolean + * @type {boolean} */ map_display_direction: true, /** @@ -89,12 +97,12 @@ const lychee = { map_provider: "Wikimedia", /** * Include photos of subalbums on map - * @type boolean + * @type {boolean} */ map_include_subalbums: false, /** * Retrieve location name from GPS data - * @type boolean + * @type {boolean} */ location_decoding: false, /** @@ -104,12 +112,12 @@ const lychee = { location_decoding_caching_type: "Harddisk", /** * Show location name - * @type boolean + * @type {boolean} */ location_show: false, /** * Show location name for public albums - * @type boolean + * @type {boolean} */ location_show_public: false, /** @@ -125,7 +133,7 @@ const lychee = { /** * Is landing page enabled? - * @type boolean + * @type {boolean} */ landing_page_enabled: false, delete_imported: false, @@ -162,7 +170,12 @@ const lychee = { checkForUpdates: true, /** - * The most recent, available Lychee version encoded as an integer, e.g. 040506 + * The most recent, available Lychee version encoded as an integer, e.g. 040506. + * + * TODO: Make format of this attribute and {@link lychee.version} consistent. + * + * TODO: Let the backend report the version as a proper object with properties for major, minor and patch level + * * @type {number} */ update_json: 0, @@ -213,15 +226,36 @@ lychee.logs = function () { * @returns {void} */ lychee.aboutDialog = function () { - const msg = lychee.html` -

Lychee ${lychee.version}

- -

${lychee.locale["ABOUT_SUBTITLE"]}

-

${sprintf(lychee.locale["ABOUT_DESCRIPTION"], lychee.website)}

- `; + const aboutDialogBody = ` +

Lychee

+

+

+

`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initAboutDialog = function (formElements, dialog) { + dialog.querySelector("span.version-number").textContent = lychee.version; + const updClassList = dialog.querySelector("p.update-status").classList; + if (lychee.update_available) { + updClassList.remove("up-to-date"); + } + dialog.querySelector("p a").textContent = lychee.locale["UPDATE_AVAILABLE"]; + dialog.querySelector("h2").textContent = lychee.locale["ABOUT_SUBTITLE"]; + // We should not use `innerHTML`, but either hard-code HTML or build it + // programmatically. + // Also, localized strings should not contain HTML tags. + // TODO: Find a better solution for this. + dialog.querySelector("p.about-desc").innerHTML = sprintf(lychee.locale["ABOUT_DESCRIPTION"], lychee.website); + }; basicModal.show({ - body: msg, + body: aboutDialogBody, + readyCB: initAboutDialog, + classList: ["about-dialog"], buttons: { cancel: { title: lychee.locale["CLOSE"], @@ -229,8 +263,6 @@ lychee.aboutDialog = function () { }, }, }); - - if (lychee.checkForUpdates) lychee.getUpdate(); }; /** @@ -288,10 +320,18 @@ lychee.parseInitializationData = function (data) { lychee.update_json = data.update_json; lychee.update_available = data.update_available; + // Here we convert a version string with six digits but without dots + // as reported by the backend, e.g. `'040603'`, into a dot-separated, + // human-readable version string `'4.6.3'`. + // It is ridiculous how many variants we have to represent a version + // number. + // At least there are the following three: + // - a string in human-readable format with dots: `'4.6.3'` + // - a string with six digits, zero-padded, without dots: `'040603'` + // - an integer: `40603` // TODO: Let the backend report the version as a proper object with properties for major, minor and patch level - lychee.versionCode = data.config.version; - if (lychee.versionCode !== "") { - const digits = lychee.versionCode.match(/.{1,2}/g); + if (data.config.version !== "") { + const digits = data.config.version.match(/.{1,2}/g); lychee.version = parseInt(digits[0]).toString() + "." + parseInt(digits[1]).toString() + "." + parseInt(digits[2]).toString(); } @@ -425,55 +465,67 @@ lychee.login = function (data) { * @returns {void} */ lychee.loginDialog = function () { - // Make background unfocusable - tabindex.makeUnfocusable(header.dom()); - tabindex.makeUnfocusable(lychee.content); - tabindex.makeUnfocusable(lychee.imageview); - - const msg = lychee.html` - ${build.iconic("key")} -
- -

Lychee ${lychee.version}${ - lychee.locale["UPDATE_AVAILABLE"] - }

-
- `; + const loginDialogBody = ` + +
+
+ +
+
+ +
+
+

Lychee

+ `; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initLoginDialog = function (formElements, dialog) { + tabindex.makeUnfocusable(header.dom()); + tabindex.makeUnfocusable(lychee.content); + tabindex.makeUnfocusable(lychee.imageview); + tabindex.makeFocusable($(dialog)); + + formElements.username.placeholder = lychee.locale["USERNAME"]; + formElements.password.placeholder = lychee.locale["PASSWORD"]; + dialog.querySelector("span.version-number").textContent = lychee.version; + const updClassList = dialog.querySelector("span.update-status").classList; + if (lychee.update_available) { + updClassList.remove("up-to-date"); + } + dialog.querySelector("span.update-status a").textContent = lychee.locale["UPDATE_AVAILABLE"]; + + // This feels awkward, because this hooks into the modal dialog in some + // unpredictable way. + // It would be better to have a checkbox for password-less login in the + // dialog and then let the action handler of the modal dialog, i.e. + // `lychee.login` handle both cases. + // TODO: Refactor this. + dialog.querySelector("#signInKeyLess").addEventListener("click", u2f.login); + }; basicModal.show({ - body: msg, + body: loginDialogBody, + readyCB: initLoginDialog, + classList: ["login"], buttons: { action: { title: lychee.locale["SIGN_IN"], fn: lychee.login, - attributes: [["data-tabindex", tabindex.get_next_tab_index()]], + attributes: { "data-tabindex": tabindex.get_next_tab_index() }, }, cancel: { title: lychee.locale["CANCEL"], fn: basicModal.close, - attributes: [["data-tabindex", tabindex.get_next_tab_index()]], + attributes: { "data-tabindex": tabindex.get_next_tab_index() }, }, }, }); - - // This feels awkward, because this hooks into the modal dialog in some - // unpredictable way. - // It would be better to have a checkbox for password-less login in the - // dialog and then let the action handler of the modal dialog, i.e. - // `lychee.login` handle both cases. - // TODO: Refactor this. - $("#signInKeyLess").on("click", u2f.login); - - if (lychee.checkForUpdates) lychee.getUpdate(); - - tabindex.makeFocusable($(".basicModal")); }; /** @@ -779,32 +831,6 @@ lychee.load = function (autoplay = true) { } }; -/** - * @returns {void} - */ -lychee.getUpdate = function () { - // console.log(lychee.update_available); - // console.log(lychee.update_json); - - if (lychee.update_json !== 0) { - if (lychee.update_available) { - $(".version span").show(); - } - } else { - /** - * @param {{lychee: {version: number}}} data - */ - const success = function (data) { - if (data.lychee.version > parseInt(lychee.versionCode)) $(".version span").show(); - }; - - $.ajax({ - url: lychee.updatePath, - success: success, - }); - } -}; - /** * Sets the title of the browser window and the title shown in the header bar. * diff --git a/scripts/main/lychee_locale.js b/scripts/main/lychee_locale.js index 86e67368..f4d3c6ef 100644 --- a/scripts/main/lychee_locale.js +++ b/scripts/main/lychee_locale.js @@ -6,8 +6,8 @@ */ lychee.locale = { - USERNAME: "username", - PASSWORD: "password", + USERNAME: "Username", + PASSWORD: "Password", ENTER: "Enter", CANCEL: "Cancel", SIGN_IN: "Sign In", @@ -74,7 +74,7 @@ lychee.locale = { DELETE_ALBUM_QUESTION: "Delete Album and Photos", KEEP_ALBUM: "Keep Album", - DELETE_ALBUM_CONFIRMATION_1: "Are you sure you want to delete the album '%s' and all of the photos it contains? This action can't be undone!", + DELETE_ALBUM_CONFIRMATION: "Are you sure you want to delete the album '%s' and all of the photos it contains? This action can't be undone!", DELETE_TAG_ALBUM_QUESTION: "Delete Album", DELETE_TAG_ALBUM_CONFIRMATION: @@ -85,7 +85,7 @@ lychee.locale = { DELETE_ALBUMS_CONFIRMATION: "Are you sure you want to delete all %d selected albums and all of the photos they contain? This action can't be undone!", - DELETE_UNSORTED_CONFIRM: "Are you sure you want to delete all photos from 'Unsorted'?
This action can't be undone!", + DELETE_UNSORTED_CONFIRM: "Are you sure you want to delete all photos from 'Unsorted'? This action can't be undone!", CLEAR_UNSORTED: "Clear Unsorted", KEEP_UNSORTED: "Keep Unsorted", @@ -319,8 +319,7 @@ lychee.locale = { NEW_PHOTOS_NOTIFICATION: "Send new photos notification emails.", SETTINGS_SUCCESS_NEW_PHOTOS_NOTIFICATION: "New photos notification updated", - USER_EMAIL_INSTRUCTION: - "Add your email below to enable receiving email notifications.
To stop receiving emails, simply remove your email below.", + USER_EMAIL_INSTRUCTION: "Add your email below to enable receiving email notifications. To stop receiving emails, simply remove your email below.", SETTINGS_SUCCESS_CSS: "CSS updated", SETTINGS_SUCCESS_UPDATE: "Settings updated with success", @@ -349,6 +348,9 @@ lychee.locale = { EDIT_SHARING_TEXT: "The sharing-properties of this album will be changed to the following:", SHARE_ALBUM_TEXT: "This album will be shared with the following properties:", + SORT_DIALOG_ATTRIBUTE_LABEL: "Attribute", + SORT_DIALOG_ORDER_LABEL: "Order", + SORT_ALBUM_BY: "Sort albums by %1$s in an %2$s order.", SORT_ALBUM_SELECT_1: "Creation Time", @@ -436,6 +438,7 @@ lychee.locale = { UPLOAD_FAILED_WARNING: "Upload failed. Server returned a warning!", UPLOAD_SKIPPED: "Skipped", UPLOAD_UPDATED: "Updated", + UPLOAD_GENERAL: "General", UPLOAD_IMPORT_SKIPPED_DUPLICATE: "This photo has been skipped because it's already in your library.", UPLOAD_IMPORT_RESYNCED_DUPLICATE: "This photo has been skipped because it's already in your library, but its metadata has been updated.", UPLOAD_ERROR_CONSOLE: "Please take a look at the console of your browser for further details.", diff --git a/scripts/main/password.js b/scripts/main/password.js index 17a84f69..55c9cf3c 100644 --- a/scripts/main/password.js +++ b/scripts/main/password.js @@ -17,12 +17,7 @@ const password = {}; * @param {UnlockSuccessCB} callback - called in case of success */ password.getDialog = function (albumID, callback) { - /** - * @typedef UnlockDialogResult - * @property {string} password - */ - - /** @param {UnlockDialogResult} data */ + /** @param {{password: string}} data */ const action = (data) => { const params = { albumID: albumID, @@ -33,13 +28,12 @@ password.getDialog = function (albumID, callback) { "Album::unlock", params, function () { - basicModal.close(); - callback(); + basicModal.close(false, callback); }, null, function (jqXHR, params2, lycheeException) { if ((jqXHR.status === 401 || jqXHR.status === 403) && lycheeException.message.includes("Password is invalid")) { - basicModal.error("password"); + basicModal.focusError("password"); return true; } basicModal.close(); @@ -49,24 +43,30 @@ password.getDialog = function (albumID, callback) { }; const cancel = function () { - basicModal.close(); - if (!visible.albums() && !visible.album()) lychee.goto(); + basicModal.close(false, function () { + if (!visible.albums() && !visible.album()) lychee.goto(); + }); }; - const msg = - ` -

- ` + - lychee.locale["ALBUM_PASSWORD_REQUIRED"] + - ` - -

- `; + const enterPasswordDialogBody = ` +

+
+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initEnterPasswordDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["ALBUM_PASSWORD_REQUIRED"]; + formElements.password.placeholder = lychee.locale["PASSWORD"]; + }; basicModal.show({ - body: msg, + body: enterPasswordDialogBody, + readyCB: initEnterPasswordDialog, buttons: { action: { title: lychee.locale["ENTER"], diff --git a/scripts/main/photo.js b/scripts/main/photo.js index c3efd3be..d33a8fa7 100644 --- a/scripts/main/photo.js +++ b/scripts/main/photo.js @@ -282,21 +282,7 @@ photo.next = function (animate) { * @returns {boolean} */ photo.delete = function (photoIDs) { - let action = {}; - let cancel = {}; - let msg = ""; - let photoTitle = ""; - - if (photoIDs.length === 1) { - // Get title if only one photo is selected - if (visible.photo()) photoTitle = photo.json.title; - else photoTitle = album.getByID(photoIDs[0]).title; - - // Fallback for photos without a title - if (!photoTitle) photoTitle = lychee.locale["UNTITLED"]; - } - - action.fn = function () { + const deletePhotos = function () { let nextPhotoID = null; let previousPhotoID = null; @@ -341,28 +327,31 @@ photo.delete = function (photoIDs) { api.post("Photo::delete", { photoIDs: photoIDs }); }; - if (photoIDs.length === 1) { - action.title = lychee.locale["PHOTO_DELETE"]; - cancel.title = lychee.locale["PHOTO_KEEP"]; - - msg = lychee.html`

${sprintf(lychee.locale["PHOTO_DELETE_CONFIRMATION"], lychee.escapeHTML(photoTitle))}

`; - } else { - action.title = lychee.locale["PHOTO_DELETE"]; - cancel.title = lychee.locale["PHOTO_KEEP"]; - - msg = lychee.html`

${sprintf(lychee.locale["PHOTO_DELETE_ALL"], photoIDs.length)}

`; - } + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initDeletePhotoDialog = function (formElements, dialog) { + if (photoIDs.length === 1) { + const photoTitle = (visible.photo() ? photo.json.title : album.getByID(photoIDs[0]).title) || lychee.locale["UNTITLED"]; + dialog.querySelector("p").textContent = sprintf(lychee.locale["PHOTO_DELETE_CONFIRMATION"], photoTitle); + } else { + dialog.querySelector("p").textContent = sprintf(lychee.locale["PHOTO_DELETE_ALL"], photoIDs.length); + } + }; basicModal.show({ - body: msg, + body: "

", + readyCB: initDeletePhotoDialog, buttons: { action: { - title: action.title, - fn: action.fn, - class: "red", + title: lychee.locale["PHOTO_DELETE"], + fn: deletePhotos, + classList: ["red"], }, cancel: { - title: cancel.title, + title: lychee.locale["PHOTO_KEEP"], fn: basicModal.close, }, }, @@ -375,22 +364,13 @@ photo.delete = function (photoIDs) { * @returns {void} */ photo.setTitle = function (photoIDs) { - let oldTitle = ""; - let msg = ""; - - if (photoIDs.length === 1) { - // Get old title if only one photo is selected - if (photo.json) oldTitle = photo.json.title; - else if (album.json) oldTitle = album.getByID(photoIDs[0]).title; - } - /** * @param {{title: string}} data * @returns {void} */ const action = function (data) { if (!data.title.trim()) { - basicModal.error("title"); + basicModal.focusError("title"); return; } @@ -415,13 +395,28 @@ photo.setTitle = function (photoIDs) { }); }; - const input = lychee.html``; + const setPhotoTitleDialogBody = ` +

+
+
+
`; - if (photoIDs.length === 1) msg = lychee.html`

${lychee.locale["PHOTO_NEW_TITLE"]} ${input}

`; - else msg = lychee.html`

${sprintf(lychee.locale["PHOTOS_NEW_TITLE"], photoIDs.length)} ${input}

`; + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetPhotoTitleDialog = function (formElements, dialog) { + const oldTitle = photoIDs.length === 1 ? (photo.json ? photo.json.title : album.getByID(photoIDs[0]).title) : ""; + dialog.querySelector("p").textContent = + photoIDs.length === 1 ? lychee.locale["PHOTO_NEW_TITLE"] : sprintf(lychee.locale["PHOTOS_NEW_TITLE"], photoIDs.length); + formElements.title.placeholder = "Title"; + formElements.title.value = oldTitle; + }; basicModal.show({ - body: msg, + body: setPhotoTitleDialogBody, + readyCB: initSetPhotoTitleDialog, buttons: { action: { title: lychee.locale["PHOTO_SET_TITLE"], @@ -564,173 +559,151 @@ photo.setStar = function (photoIDs, isStarred) { * @returns {void} */ photo.setProtectionPolicy = function (photoID) { - const msg_switch = lychee.html` -
- -

${lychee.locale["PHOTO_PUBLIC_EXPL"]}

-
- `; - - const msg_choices = lychee.html` -
- -

${lychee.locale["PHOTO_FULL_EXPL"]}

-
-
- -

${lychee.locale["PHOTO_HIDDEN_EXPL"]}

-
-
- -

${lychee.locale["PHOTO_DOWNLOADABLE_EXPL"]}

-
-
- -

${lychee.locale["PHOTO_SHARE_BUTTON_VISIBLE_EXPL"]}

-
-
- -

${lychee.locale["PHOTO_PASSWORD_PROT_EXPL"]}

-
- `; - - if (photo.json.is_public === 2) { - // Public album. We can't actually change anything, but we will - // display the current settings. - - const msg = lychee.html` -

${lychee.locale["PHOTO_NO_EDIT_SHARING_TEXT"]}

- ${msg_switch} - ${msg_choices} - `; - - basicModal.show({ - body: msg, - buttons: { - cancel: { - title: lychee.locale["CLOSE"], - fn: basicModal.close, - }, - }, - }); + /** + * @param {{is_public: boolean}} data + */ + const action = function (data) { + /** + * Note: `newIsPublic` must be `0` or `1` and no boolean, because + * `photo.is_public` is an integer between `0` and `2`. + */ + const newIsPublic = data.is_public ? 1 : 0; - $('.basicModal .switch input[name="is_public"]').prop("checked", true); - if (album.json) { - if (album.json.grants_full_photo) { - $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); + if (newIsPublic !== photo.json.is_public) { + if (visible.photo()) { + photo.json.is_public = newIsPublic; + view.photo.public(); } - // Photos in public albums are never hidden as such. It's the - // album that's hidden. Or is that distinction irrelevant to end - // users? - if (album.json.is_downloadable) { - $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); - } - if (album.json.has_password) { - $('.basicModal .choice input[name="has_password"]').prop("checked", true); - } - } - $(".basicModal .switch input").attr("disabled", true); - $(".basicModal .switch .label").addClass("label--disabled"); - } else { - // Private album -- each photo can be shared individually. - - const msg = lychee.html` - ${msg_switch} -

${lychee.locale["PHOTO_EDIT_GLOBAL_SHARING_TEXT"]}

- ${msg_choices} - `; - - // TODO: Actually, the action handler receives an object with values of all input fields. There is no need to run use a jQuery-selector - const action = function () { - /** - * Note: `newIsPublic` must be of type `number`, because `photo.is_public` is a number, too - * @type {number} - */ - const newIsPublic = $('.basicModal .switch input[name="is_public"]:checked').length; - - if (newIsPublic !== photo.json.is_public) { - if (visible.photo()) { - photo.json.is_public = newIsPublic; - view.photo.public(); - } + album.getByID(photoID).is_public = newIsPublic; + view.album.content.public(photoID); - album.getByID(photoID).is_public = newIsPublic; - view.album.content.public(photoID); + albums.refresh(); - albums.refresh(); + api.post("Photo::setPublic", { + photoID: photoID, + is_public: newIsPublic !== 0, + }); + } - api.post("Photo::setPublic", { - photoID: photoID, - is_public: newIsPublic !== 0, - }); - } + basicModal.close(); + }; - basicModal.close(); - }; + const setPhotoProtectionPolicyBody = ` +

+
+
+ + +

+
+

+
+ + +

+
+
+ + +

+
+
+ + +

+
+
+ + +

+
+
+ + +

+
+
`; - basicModal.show({ - body: msg, - buttons: { - action: { - title: lychee.locale["SAVE"], - fn: action, - }, - cancel: { - title: lychee.locale["CANCEL"], - fn: basicModal.close, - }, - }, - }); + /** + * @typedef PhotoProtectionPolicyDialogFormElements + * @property {HTMLInputElement} is_public + * @property {HTMLInputElement} grants_full_photo + * @property {HTMLInputElement} requires_link + * @property {HTMLInputElement} is_downloadable + * @property {HTMLInputElement} is_share_button_visible + * @property {HTMLInputElement} has_password + */ - $('.basicModal .switch input[name="is_public"]').on("click", function () { - if ($(this).prop("checked") === true) { - if (lychee.full_photo) { - $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); - } - if (lychee.public_photos_hidden) { - $('.basicModal .choice input[name="requires_link"]').prop("checked", true); - } - if (lychee.downloadable) { - $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); - } - if (lychee.share_button_visible) { - $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", true); - } - // Photos shared individually can't be password-protected. - } else { - $(".basicModal .choice input").prop("checked", false); + /** + * @param {PhotoProtectionPolicyDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initPhotoProtectionPolicyDialog = function (formElements, dialog) { + formElements.is_public.previousElementSibling.textContent = lychee.locale["PHOTO_PUBLIC"]; + formElements.is_public.nextElementSibling.textContent = lychee.locale["PHOTO_PUBLIC_EXPL"]; + formElements.grants_full_photo.previousElementSibling.textContent = lychee.locale["PHOTO_FULL"]; + formElements.grants_full_photo.nextElementSibling.textContent = lychee.locale["PHOTO_FULL_EXPL"]; + formElements.requires_link.previousElementSibling.textContent = lychee.locale["PHOTO_HIDDEN"]; + formElements.requires_link.nextElementSibling.textContent = lychee.locale["PHOTO_HIDDEN_EXPL"]; + formElements.is_downloadable.previousElementSibling.textContent = lychee.locale["PHOTO_DOWNLOADABLE"]; + formElements.is_downloadable.nextElementSibling.textContent = lychee.locale["PHOTO_DOWNLOADABLE_EXPL"]; + formElements.is_share_button_visible.previousElementSibling.textContent = lychee.locale["PHOTO_SHARE_BUTTON_VISIBLE"]; + formElements.is_share_button_visible.nextElementSibling.textContent = lychee.locale["PHOTO_SHARE_BUTTON_VISIBLE_EXPL"]; + formElements.has_password.previousElementSibling.textContent = lychee.locale["PHOTO_PASSWORD_PROT"]; + formElements.has_password.nextElementSibling.textContent = lychee.locale["PHOTO_PASSWORD_PROT_EXPL"]; + + if (photo.json.is_public === 2) { + // Public album. + dialog.querySelector("p#ppp_dialog_no_edit_expl").textContent = lychee.locale["PHOTO_NO_EDIT_SHARING_TEXT"]; + dialog.querySelector("p#ppp_dialog_global_expl").remove(); + // Initialize values of detailed settings according to album + // settings and hide action button as we can't actually change + // anything. + formElements.is_public.checked = true; + formElements.is_public.disabled = true; + formElements.is_public.parentElement.classList.add("disabled"); + if (album.json) { + formElements.grants_full_photo.checked = album.json.grants_full_photo; + // Photos in public albums are never hidden as such. It's the + // album that's hidden. Or is that distinction irrelevant to end + // users? + formElements.requires_link.checked = false; + formElements.is_downloadable.checked = album.json.is_downloadable; + formElements.is_share_button_visible = album.json.is_share_button_visible; + formElements.has_password.checked = album.json.has_password; } - }); - - if (photo.json.is_public === 1) { - $('.basicModal .switch input[name="is_public"]').click(); + basicModal.hideActionButton(); + } else { + // Private album + dialog.querySelector("p#ppp_dialog_no_edit_expl").remove(); + dialog.querySelector("p#ppp_dialog_global_expl").textContent = lychee.locale["PHOTO_EDIT_GLOBAL_SHARING_TEXT"]; + // Initialize values of detailed settings according to global + // configuration. + formElements.is_public.checked = photo.json.is_public !== 0; + formElements.grants_full_photo.checked = lychee.full_photo; + formElements.requires_link.checked = lychee.public_photos_hidden; + formElements.is_downloadable.checked = lychee.downloadable; + formElements.is_share_button_visible = lychee.share_button_visible; + formElements.has_password.checked = false; } - } + }; + + basicModal.show({ + body: setPhotoProtectionPolicyBody, + readyCB: initPhotoProtectionPolicyDialog, + buttons: { + action: { + title: lychee.locale["SAVE"], + fn: action, + }, + cancel: { + title: lychee.locale["CANCEL"], + fn: basicModal.close, + }, + }, + }); }; /** @@ -742,8 +715,6 @@ photo.setProtectionPolicy = function (photoID) { * @returns {void} */ photo.setDescription = function (photoID) { - const oldDescription = photo.json.description ? photo.json.description : ""; - /** * @param {{description: string}} data */ @@ -763,8 +734,26 @@ photo.setDescription = function (photoID) { }); }; + const setPhotoDescriptionDialogBody = ` +

+
+
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetPhotoDescriptionDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["PHOTO_NEW_DESCRIPTION"]; + formElements.description.placeholder = lychee.locale["PHOTO_DESCRIPTION"]; + formElements.description.value = photo.json.description ? photo.json.description : ""; + }; + basicModal.show({ - body: lychee.html`

${lychee.locale["PHOTO_NEW_DESCRIPTION"]}

`, + body: setPhotoDescriptionDialogBody, + readyCB: initSetPhotoDescriptionDialog, buttons: { action: { title: lychee.locale["PHOTO_SET_DESCRIPTION"], @@ -817,15 +806,27 @@ photo.editTags = function (photoIDs) { photo.setTags(photoIDs, newTags); }; - const input = lychee.html``; + const setTagDialogBody = ` +

+
+
+
`; - const msg = - photoIDs.length === 1 - ? lychee.html`

${lychee.locale["PHOTO_NEW_TAGS"]} ${input}

` - : lychee.html`

${sprintf(lychee.locale["PHOTOS_NEW_TAGS"], photoIDs.length)} ${input}

`; + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetTagAlbumDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = + photoIDs.length === 1 ? lychee.locale["PHOTO_NEW_TAGS"] : sprintf(lychee.locale["PHOTOS_NEW_TAGS"], photoIDs.length); + formElements.tags.placeholder = "Tags"; + formElements.tags.value = oldTags.join(", "); + }; basicModal.show({ - body: msg, + body: setTagDialogBody, + readyCB: initSetTagAlbumDialog, buttons: { action: { title: lychee.locale["PHOTO_SET_TAGS"], @@ -921,27 +922,27 @@ photo.setLicense = function (photoID) { */ const action = function (data) { basicModal.close(); - let license = data.license; - let params = { - photoID, - license, - }; - - api.post("Photo::setLicense", params, function () { - // update the photo JSON and reload the license in the sidebar - photo.json.license = params.license; - view.photo.license(); - }); + api.post( + "Photo::setLicense", + { + photoID: photoID, + license: data.license, + }, + function () { + // update the photo JSON and reload the license in the sidebar + photo.json.license = data.license; + view.photo.license(); + } + ); }; - const msg = lychee.html` -
-

${lychee.locale["PHOTO_LICENSE"]} - - + + @@ -973,18 +974,26 @@ photo.setLicense = function (photoID) { - - -
- ${lychee.locale["PHOTO_LICENSE_HELP"]} -

-
`; +
+

+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initSetPhotoLicenseDialog = function (formElements, dialog) { + formElements.license.labels[0].textContent = lychee.locale["PHOTO_LICENSE"]; + formElements.license.item(0).textContent = lychee.locale["PHOTO_LICENSE_NONE"]; + formElements.license.item(1).textContent = lychee.locale["PHOTO_RESERVED"]; + formElements.license.value = photo.json.license === "" ? "none" : photo.json.license; + dialog.querySelector("p a").textContent = lychee.locale["PHOTO_LICENSE_HELP"]; + }; basicModal.show({ - body: msg, - callback: function () { - $("select#license").val(photo.json.license === "" ? "none" : photo.json.license); - }, + body: setPhotoLicenseDialogBody, + readyCB: initSetPhotoLicenseDialog, buttons: { action: { title: lychee.locale["PHOTO_SET_LICENSE"], @@ -1007,109 +1016,92 @@ photo.setLicense = function (photoID) { * @returns {void} */ photo.getArchive = function (photoIDs, kind = null) { - if (photoIDs.length === 1 && kind === null) { - // For a single photo, allow to pick the kind via a dialog box. + if (photoIDs.length !== 1 || kind !== null) { + location.href = "api/Photo::getArchive?photoIDs=" + photoIDs.join() + "&kind=" + kind; + return; + } - let myPhoto; + // For a single photo without a specified kind, allow to pick the kind + // via a dialog box and re-call this method later on. - if (photo.json && photo.json.id === photoIDs[0]) { - myPhoto = photo.json; - } else { - myPhoto = album.getByID(photoIDs[0]); - } + const myPhoto = photo.json && photo.json.id === photoIDs[0] ? photo.json : album.getByID(photoIDs[0]); - /** - * @param {string} id - the ID of the button, same semantics as "kind" - * @param {string} label - the caption on the button - * @returns {string} - HTML - */ - const buildButton = function (id, label) { - return lychee.html` - - ${build.iconic("cloud-download")}${label} + const kind2VariantAndLocalizedLabel = { + FULL: ["original", lychee.locale["PHOTO_FULL"]], + MEDIUM2X: ["medium2x", lychee.locale["PHOTO_MEDIUM_HIDPI"]], + MEDIUM: ["medium", lychee.locale["PHOTO_MEDIUM"]], + SMALL2X: ["small2x", lychee.locale["PHOTO_SMALL_HIDPI"]], + SMALL: ["small", lychee.locale["PHOTO_SMALL"]], + THUMB2X: ["thumb2x", lychee.locale["PHOTO_THUMB_HIDPI"]], + THUMB: ["thumb", lychee.locale["PHOTO_THUMB"]], + }; + + /** + * @param {string} kind - the kind this button is for, used to construct the ID + * @returns {string} - HTML + */ + const buildButton = function (kind) { + return ` + + + `; - }; + }; - let msg = lychee.html` -
- `; + const getPhotoArchiveDialogBody = + Object.entries(kind2VariantAndLocalizedLabel).reduce((html, [kind]) => html + buildButton(kind), "") + buildButton("LIVEPHOTOVIDEO"); - if (myPhoto.size_variants.original.url) { - msg += buildButton( - "FULL", - `${lychee.locale["PHOTO_FULL"]} (${myPhoto.size_variants.original.width}x${myPhoto.size_variants.original.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.original.filesize)})` - ); + /** @param {TouchEvent|MouseEvent} ev */ + const onClickOrTouch = function (ev) { + if (ev.currentTarget instanceof HTMLAnchorElement) { + basicModal.close(); + photo.getArchive(photoIDs, ev.currentTarget.dataset.photoKind); + ev.stopPropagation(); } + }; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + */ + const initGetPhotoArchiveDialog = function (formElements, dialog) { + Object.entries(kind2VariantAndLocalizedLabel).forEach(function ([kind, [variant, lLabel]]) { + /** @type {HTMLAnchorElement} */ + const button = dialog.querySelector('a[data-photo-kind="' + kind + '"]'); + /** @type {?SizeVariant} */ + const sv = myPhoto.size_variants[variant]; + if (sv) { + button.title = lychee.locale["DOWNLOAD"]; + button.addEventListener(lychee.getEventName(), onClickOrTouch); + button.lastElementChild.textContent = + lLabel + " (" + sv.width + "×" + sv.height + ", " + lychee.locale.printFilesizeLocalized(sv.filesize) + ")"; + } else { + button.remove(); + } + }); + /** @type {HTMLAnchorElement} */ + const liveButton = dialog.querySelector('a[data-photo-kind="LIVEPHOTOVIDEO"]'); if (myPhoto.live_photo_url !== null) { - msg += buildButton("LIVEPHOTOVIDEO", `${lychee.locale["PHOTO_LIVE_VIDEO"]}`); - } - if (myPhoto.size_variants.medium2x !== null) { - msg += buildButton( - "MEDIUM2X", - `${lychee.locale["PHOTO_MEDIUM_HIDPI"]} (${myPhoto.size_variants.medium2x.width}x${myPhoto.size_variants.medium2x.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.medium2x.filesize)})` - ); - } - if (myPhoto.size_variants.medium !== null) { - msg += buildButton( - "MEDIUM", - `${lychee.locale["PHOTO_MEDIUM"]} (${myPhoto.size_variants.medium.width}x${myPhoto.size_variants.medium.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.medium.filesize)})` - ); - } - if (myPhoto.size_variants.small2x !== null) { - msg += buildButton( - "SMALL2X", - `${lychee.locale["PHOTO_SMALL_HIDPI"]} (${myPhoto.size_variants.small2x.width}x${myPhoto.size_variants.small2x.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.small2x.filesize)})` - ); - } - if (myPhoto.size_variants.small !== null) { - msg += buildButton( - "SMALL", - `${lychee.locale["PHOTO_SMALL"]} (${myPhoto.size_variants.small.width}x${myPhoto.size_variants.small.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.small.filesize)})` - ); - } - if (myPhoto.size_variants.thumb2x !== null) { - msg += buildButton( - "THUMB2X", - `${lychee.locale["PHOTO_THUMB_HIDPI"]} (${myPhoto.size_variants.thumb2x.width}x${myPhoto.size_variants.thumb2x.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.thumb2x.filesize)})` - ); - } - if (myPhoto.size_variants.thumb !== null) { - msg += buildButton( - "THUMB", - `${lychee.locale["PHOTO_THUMB"]} (${myPhoto.size_variants.thumb.width}x${myPhoto.size_variants.thumb.height}, - ${lychee.locale.printFilesizeLocalized(myPhoto.size_variants.thumb.filesize)})` - ); + liveButton.title = lychee.locale["DOWNLOAD"]; + liveButton.addEventListener(lychee.getEventName(), onClickOrTouch); + liveButton.lastElementChild.textContent = lychee.locale["PHOTO_LIVE_VIDEO"]; + } else { + liveButton.remove(); } + }; - msg += lychee.html` -
- `; - - basicModal.show({ - body: msg, - buttons: { - cancel: { - title: lychee.locale["CLOSE"], - fn: basicModal.close, - }, + basicModal.show({ + body: getPhotoArchiveDialogBody, + readyCB: initGetPhotoArchiveDialog, + classList: ["downloads"], + buttons: { + cancel: { + title: lychee.locale["CLOSE"], + fn: basicModal.close, }, - }); - - $(".downloads .basicModal__button").on(lychee.getEventName(), function () { - const kind = this.id; - basicModal.close(); - photo.getArchive(photoIDs, kind); - }); - } else { - location.href = "api/Photo::getArchive?photoIDs=" + photoIDs.join() + "&kind=" + kind; - } + }, + }); }; /** @@ -1127,14 +1119,11 @@ photo.qrCode = function (photoID) { return; } - let msg = lychee.html` -
- `; - basicModal.show({ - body: msg, - callback: function () { - qrcode = $("#qr-code"); + body: "
", + classList: ["qr-code"], + readyCB: function (formElements, dialog) { + const qrcode = dialog.querySelector("div.qr-code-canvas"); QrCreator.render( { text: photo.getViewLink(myPhoto.id), @@ -1142,9 +1131,9 @@ photo.qrCode = function (photoID) { ecLevel: "H", fill: "#000000", background: "#FFFFFF", - size: qrcode.width(), + size: qrcode.clientWidth, }, - qrcode[0] + qrcode ); }, buttons: { @@ -1183,85 +1172,85 @@ photo.showDirectLinks = function (photoID) { } /** - * @param {string} label - * @param {string} url + * @param {string} name - name of the HTML input element * @returns {string} - HTML */ - const buildLine = function (label, url) { - return lychee.html` -

- ${label} -
- - - ${build.iconic("copy", "ionicons")} - -

- `; + const buildLine = function (name) { + return ` +
+ + + +
`; }; - let msg = lychee.html` - - `; + const showDirectLinksDialogBody = + '

"; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + */ + const initShowDirectLinksDialog = function (formElements, dialog) { + formElements.view.value = photo.getViewLink(photoID); + formElements.view.previousElementSibling.textContent = lychee.locale["PHOTO_VIEW"]; + formElements.view.nextElementSibling.title = lychee.locale["URL_COPY_TO_CLIPBOARD"]; + dialog.querySelector("p").textContent = lychee.locale["PHOTO_DIRECT_LINKS_TO_IMAGES"]; + + for (const type in localizations) { + /** @type {?SizeVariant} */ + const sv = photo.json.size_variants[type]; + if (sv !== null) { + formElements[type].value = lychee.getBaseUrl() + sv.url; + formElements[type].previousElementSibling.textContent = localizations[type] + " (" + sv.width + "×" + sv.height + ")"; + formElements[type].nextElementSibling.title = lychee.locale["URL_COPY_TO_CLIPBOARD"]; + } else { + // The form element is the `` element, the parent + // element is the `
` which binds the label, the input + // and the button together. + // We remove that `
` for non-existing variants. + formElements[type].parentElement.remove(); + } + } + + if (photo.json.live_photo_url !== null) { + formElements.live.value = lychee.getBaseUrl() + photo.json.live_photo_url; + formElements.live.previousElementSibling.textContent = lychee.locale["PHOTO_LIVE_VIDEO"]; + formElements.live.nextElementSibling.title = lychee.locale["URL_COPY_TO_CLIPBOARD"]; + } else { + formElements.live.parentElement.remove(); + } + + /** @param {TouchEvent|MouseEvent} ev */ + const onClickOrTouch = function (ev) { + navigator.clipboard + .writeText(ev.currentTarget.previousElementSibling.value) + .then(() => loadingBar.show("success", lychee.locale["URL_COPIED_TO_CLIPBOARD"])); + ev.stopPropagation(); + }; + dialog.querySelectorAll("a.button").forEach(function (a) { + a.addEventListener(lychee.getEventName(), onClickOrTouch); + }); + }; basicModal.show({ - body: msg, + body: showDirectLinksDialogBody, + readyCB: initShowDirectLinksDialog, buttons: { cancel: { title: lychee.locale["CLOSE"], @@ -1269,11 +1258,4 @@ photo.showDirectLinks = function (photoID) { }, }, }); - - // Ensure that no input line is selected on opening. - $(".basicModal input:focus").blur(); - - $(".directLinks .basicModal__button").on(lychee.getEventName(), function () { - navigator.clipboard.writeText($(this).prev().val()).then(() => loadingBar.show("success", lychee.locale["URL_COPIED_TO_CLIPBOARD"])); - }); }; diff --git a/scripts/main/settings.js b/scripts/main/settings.js index 2d930cf3..34988ef8 100644 --- a/scripts/main/settings.js +++ b/scripts/main/settings.js @@ -19,10 +19,14 @@ settings.createLogin = function () { * @returns {boolean} */ const errorHandler = function (jqXHR, params, lycheeException) { - let htmlBody = "

" + lychee.locale["ERROR_LOGIN"] + "

"; - htmlBody += lycheeException ? "

" + lycheeException.message + "

" : ""; basicModal.show({ - body: htmlBody, + body: "

", + readyCB: (formElement, dialog) => { + /** @type {NodeList} */ + const paragraphs = dialog.querySelectorAll("p"); + paragraphs.item(0).textContent = lychee.locale["ERROR_LOGIN"]; + paragraphs.item(1).textContent = lycheeException ? lycheeException.message : ""; + }, buttons: { action: { title: lychee.locale["RETRY"], @@ -42,57 +46,64 @@ settings.createLogin = function () { }; /** - * @typedef SetLoginDialogResult - * - * @property {string} username - * @property {string} password - * @property {string} confirm - */ - - /** - * @param {SetLoginDialogResult} data + * @param {ModalDialogResult} data * @returns {void} */ const action = function (data) { - const username = data.username; - const password = data.password; - const confirm = data.confirm; - - if (!username.trim()) { - basicModal.error("username"); + if (!data.username.trim()) { + basicModal.focusError("username"); return; } - if (!password.trim()) { - basicModal.error("password"); + if (!data.password.trim()) { + basicModal.focusError("password"); return; } - if (password !== confirm) { - basicModal.error("confirm"); + if (data.password !== data.confirm) { + basicModal.focusError("confirm"); return; } basicModal.close(); - let params = { - username, - password, + const params = { + username: data.username, + password: data.password, }; api.post("Settings::setLogin", params, successHandler, null, errorHandler); }; - const msg = ` -

- ${lychee.locale["LOGIN_TITLE"]} - - - -

`; + const createLoginDialogBody = ` +

+
+
+ +
+
+ +
+
+ +
+
`; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["LOGIN_TITLE"]; + formElements.username.placeholder = lychee.locale["LOGIN_USERNAME"]; + formElements.password.placeholder = lychee.locale["LOGIN_PASSWORD"]; + formElements.confirm.placeholder = lychee.locale["LOGIN_PASSWORD_CONFIRM"]; + }; basicModal.show({ - body: msg, + body: createLoginDialogBody, + readyCB: initDialog, buttons: { action: { title: lychee.locale["LOGIN_CREATE"], @@ -457,32 +468,26 @@ settings.save_enter = function (e) { // We only handle "enter" if (e.which !== 13) return; - // show confirmation box - $(":focus").blur(); - - let action = {}; - let cancel = {}; - - action.title = lychee.locale["ENTER"]; - action.msg = lychee.html`

${lychee.locale["SAVE_RISK"]}

`; - - cancel.title = lychee.locale["CANCEL"]; - - action.fn = function () { - settings.save(settings.getValues("#fullSettings")); - basicModal.close(); - }; + const saveSettingsConfirmationDialogBody = + // TODO: move the style to the style file, where it belongs. + '

'; basicModal.show({ - body: action.msg, + body: saveSettingsConfirmationDialogBody, + readyCB: function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["SAVE_RISK"]; + }, buttons: { action: { - title: action.title, - fn: action.fn, - class: "red", + title: lychee.locale["ENTER"], + fn: function () { + settings.save(settings.getValues("#fullSettings")); + basicModal.close(); + }, + classList: ["red"], }, cancel: { - title: cancel.title, + title: lychee.locale["CANCEL"], fn: basicModal.close, }, }, @@ -493,77 +498,122 @@ settings.save_enter = function (e) { * @returns {void} */ settings.openTokenDialog = function () { - let token = ""; + /** @type {string} */ + let tokenValue = ""; + /** @type {?HTMLAnchorElement} */ + let resetTokenButton = null; + /** @type {?HTMLAnchorElement} */ + let copyTokenButton = null; + /** @type {?HTMLAnchorElement} */ + let disableTokenButton = null; + /** @type {?HTMLInputElement} */ + let tokenInputElement = null; + + const bodyHtml = ` +
+
+ + +
+ + + +
+
+
`; /** * @returns {void} */ const updateTokenDialog = function () { if (lychee.user.has_token) { - $("#button_disable_token").show(); + disableTokenButton.style.display = null; - if (!!token) { - $("#apiToken").text(token); - $("#button_copy_token").show(); + if (!!tokenValue) { + tokenInputElement.value = tokenValue; + tokenInputElement.disabled = false; + copyTokenButton.style.display = null; } else { - $("#apiToken").text(lychee.locale["TOKEN_NOT_AVAILABLE"]); - $("#button_copy_token").hide(); + tokenInputElement.value = lychee.locale["TOKEN_NOT_AVAILABLE"]; + tokenInputElement.disabled = true; + copyTokenButton.style.display = "none"; } } else { - $("#apiToken").text(lychee.locale["DISABLED_TOKEN_STATUS_MSG"]); - $("#button_copy_token").hide(); - $("#button_disable_token").hide(); + tokenInputElement.value = lychee.locale["DISABLED_TOKEN_STATUS_MSG"]; + tokenInputElement.disabled = true; + copyTokenButton.style.display = "none"; + disableTokenButton.style.display = "none"; } }; - const bodyHtml = lychee.html``; - - const initTokenDialog = function () { - updateTokenDialog(); + /** + * @param {MouseEvent|TouchEvent} ev + */ + const onCopyToken = function (ev) { + navigator.clipboard.writeText(tokenValue); + ev.stopPropagation(); + }; - $("#button_copy_token").on(lychee.getEventName(), function () { - navigator.clipboard.writeText(token); - }); + /** + * @param {MouseEvent|TouchEvent} ev + */ + const onResetToken = function (ev) { + tokenInputElement.value = ""; + ev.stopPropagation(); + api.post( + "User::resetToken", + {}, + /** + * @param {{token: string}} data + */ + function (data) { + tokenValue = data.token; + lychee.user.has_token = true; + updateTokenDialog(); + } + ); + }; - $("#button_reset_token").on(lychee.getEventName(), function () { - $("#apiToken").text(lychee.locale["TOKEN_WAIT"]); - api.post( - "User::resetToken", - {}, - /** - * - * @param {{token: string}} data - */ - function (data) { - token = data.token; - lychee.user.has_token = true; - updateTokenDialog(); - } - ); + /** + * @param {MouseEvent|TouchEvent} ev + */ + const onDisableToken = function (ev) { + tokenInputElement.value = ""; + ev.stopPropagation(); + api.post("User::unsetToken", {}, function () { + tokenValue = ""; + lychee.user.has_token = false; + updateTokenDialog(); }); + }; - $("#button_disable_token").on(lychee.getEventName(), function () { - $("#apiToken").text(lychee.locale["TOKEN_WAIT"]); - api.post("User::unsetToken", {}, function () { - token = ""; - lychee.user.has_token = false; - updateTokenDialog(); - }); - }); + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initTokenDialog = function (formElements, dialog) { + resetTokenButton = dialog.querySelector("a#button_reset_token"); + resetTokenButton.title = lychee.locale["RESET"]; + copyTokenButton = dialog.querySelector("a#button_copy_token"); + copyTokenButton.title = lychee.locale["URL_COPY_TO_CLIPBOARD"]; + disableTokenButton = dialog.querySelector("a#button_disable_token"); + disableTokenButton.title = lychee.locale["DISABLE_TOKEN_TOOLTIP"]; + tokenInputElement = formElements.token; + tokenInputElement.placeholder = lychee.locale["TOKEN_WAIT"]; + tokenInputElement.labels[0].textContent = "Token"; + tokenInputElement.blur(); + + updateTokenDialog(); + + copyTokenButton.addEventListener(lychee.getEventName(), onCopyToken); + resetTokenButton.addEventListener(lychee.getEventName(), onResetToken); + disableTokenButton.addEventListener(lychee.getEventName(), onDisableToken); }; basicModal.show({ body: bodyHtml, - callback: initTokenDialog, + readyCB: initTokenDialog, buttons: { cancel: { title: lychee.locale["CLOSE"], diff --git a/scripts/main/u2f.js b/scripts/main/u2f.js index b02e336d..f542a58f 100644 --- a/scripts/main/u2f.js +++ b/scripts/main/u2f.js @@ -8,10 +8,11 @@ const u2f = { */ u2f.is_available = function () { if (!window.isSecureContext && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1") { - const msg = lychee.html`

${lychee.locale["U2F_NOT_SECURE"]}

`; - basicModal.show({ - body: msg, + body: "

", + readyCB: function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["U2F_NOT_SECURE"]; + }, buttons: { cancel: { title: lychee.locale["CLOSE"], diff --git a/scripts/main/upload.js b/scripts/main/upload.js index 7f7baf6a..60a4edc8 100644 --- a/scripts/main/upload.js +++ b/scripts/main/upload.js @@ -2,25 +2,107 @@ * @description Takes care of every action an album can handle and execute. */ -let upload = {}; - -const choiceDeleteSelector = '.basicModal .choice input[name="delete_imported"]'; -const choiceSymlinkSelector = '.basicModal .choice input[name="import_via_symlink"]'; -const choiceDuplicateSelector = '.basicModal .choice input[name="skip_duplicates"]'; -const choiceResyncSelector = '.basicModal .choice input[name="resync_metadata"]'; -const actionSelector = ".basicModal #basicModal__action"; -const cancelSelector = ".basicModal #basicModal__cancel"; -const firstRowStatusSelector = ".basicModal .rows .row .status"; -const firstRowNoticeSelector = ".basicModal .rows .row p.notice"; - -let nRowStatusSelector = function (row) { - return ".basicModal .rows .row:nth-child(" + row + ") .status"; +/** + * @typedef ProgressReportDialogRow + * @property {HTMLLIElement} listEntry + * @property {HTMLHeadingElement} header + * @property {HTMLParagraphElement} status + * @property {HTMLParagraphElement} notice + */ + +const upload = { + SCROLL_OPTIONS: { + inline: "nearest", + block: "nearest", + behavior: "smooth", + }, + + _dom: { + /** + * Holds the ordered list (`
    `) with the individual reports + * of a Progress Report dialog. + * + * @type {HTMLOListElement|null} + */ + reportList: null, + + /** + * Maps a path (as the unique identifier) to a tuple of UI elements + * which visualize the report row for that path. + * + * Note, rows for event reports which are not associated to a + * particular file or directory are not kept in this map, but + * of course they are visualized inside the list of reports. + * + * This map allows fast access to the rows without running + * (inefficient) CSS selector queries and/or relying on a specific + * order (i.e. no need for `nth-child`-selector). + * + * @type {Map|null} + */ + progressRowsByPath: null, + }, +}; + +upload.showProgressReportCloseButton = function () { + basicModal.showActionButton(); + basicModal.hideCancelButton(); + // Re-activate cancel button to close modal panel if needed + basicModal.markActionButtonAsIdle(); +}; + +upload.closeProgressReportDialog = function () { + basicModal.close(); + upload._dom.reportList = null; + upload._dom.progressRowsByPath = null; }; -let showCloseButton = function () { - $(actionSelector).show(); - // re-activate cancel button to close modal panel if needed - $(cancelSelector).removeClass("basicModal__button--active").hide(); +/** + * Builds the HTML snippet for a single entry in the Progress Report dialog. + * + * Constructs an entry for the list of reports made up of a caption, + * a status and a notice. + * + * @param {string} caption the caption of the list entry; for reports about + * files this is typically the filename + * @returns {ProgressReportDialogRow} + */ +upload.buildReportRow = function (caption) { + const listEntry = document.createElement("li"); + + const header = listEntry.appendChild(document.createElement("h2")); + header.textContent = caption.length <= 40 ? caption : caption.substring(0, 19) + "…" + caption.substring(caption.length - 20, caption.length); + const status = listEntry.appendChild(document.createElement("p")); + status.classList.add("status"); + const notice = listEntry.appendChild(document.createElement("p")); + notice.classList.add("notice"); + + return { listEntry, header, status, notice }; +}; + +/** + * Builds the HTML snippet for the list of reports in the Progress Report dialog. + * + * The list is initially filled with the given list of files. + * More items to this list may be added on-the-fly during an ongoing import. + * + * Note: This is used for downloading files from a remote URL, importing from + * Dropbox or uploading, i.e. whenever the list is known in advance. + * For importing from server, the list initially only contains the selected + * server directory and more items are added while the backend scans the + * directory on the server. + * + * @param {(FileList|File[]|DropboxFile[]|{name: string}[])} files + * @returns {void} + */ +upload.buildReportList = function (files) { + upload._dom.reportList = document.createElement("ol"); + upload._dom.progressRowsByPath = new Map(); + for (let idx = 0; idx !== files.length; idx++) { + const row = upload.buildReportRow(files[idx].name); + upload._dom.progressRowsByPath.set(files[idx].name, row); + upload._dom.reportList.appendChild(row.listEntry); + } }; /** @@ -29,38 +111,66 @@ let showCloseButton = function () { * @param {ModalDialogReadyCB} run_callback * @param {?ModalDialogButtonCB} cancel_callback */ -upload.show = function (title, files, run_callback, cancel_callback = null) { +upload.showProgressReportDialog = function (title, files, run_callback, cancel_callback = null) { + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initImportProgressReportDialog = function (formElements, dialog) { + // Initially, the normal Action (aka "Close") button is hidden and + // remains hidden as long as an import is running. + // Users must use the Cancel button to interrupt an ongoing import. + // The Action button becomes visible after the import has been + // terminated (either successfully, with error or due to interruption). + basicModal.hideActionButton(); + + const caption = dialog.querySelector("h1"); + caption.textContent = title; + upload.buildReportList(files); + dialog.appendChild(upload._dom.reportList); + + setTimeout(() => run_callback(formElements, dialog), 0); + }; + basicModal.show({ - body: build.uploadModal(title, files), + body: "

    ", + classList: ["import"], + readyCB: initImportProgressReportDialog, buttons: { action: { title: lychee.locale["CLOSE"], - class: "hidden", - fn: function () { - if ($(actionSelector).is(":visible")) basicModal.close(); - }, + fn: () => upload.closeProgressReportDialog(), }, cancel: { title: lychee.locale["CANCEL"], - class: "red hidden", - fn: function () { - // close modal if close button is displayed - if ($(actionSelector).is(":visible")) basicModal.close(); - if (cancel_callback) { - $(cancelSelector).addClass("busy"); - cancel_callback(); + classList: ["red"], + fn: function (resultData) { + // If Action button is visible, the Cancel button behaves + // like the Close button; otherwise the button only calls + // the callback to cancel the import + if (basicModal.isActionButtonVisible()) { + upload.closeProgressReportDialog(); + } else { + if (cancel_callback) { + cancel_callback(resultData); + } } }, }, }, - callback: run_callback, }); }; -upload.notify = function (title, text) { - if (text == null || text === "") text = lychee.locale["UPLOAD_MANAGE_NEW_PHOTOS"]; +/** + * @param {string} title + * @param {string} [text=""] + * @returns {void} + */ +upload.notify = function (title, text = "") { + if (text === "") text = lychee.locale["UPLOAD_MANAGE_NEW_PHOTOS"]; - if (!window.webkitNotifications) return false; + if (!window.webkitNotifications) return; if (window.webkitNotifications.checkPermission() !== 0) window.webkitNotifications.requestPermission(); @@ -132,19 +242,20 @@ upload.start = { if (!hasErrorOccurred && !hasWarningOccurred) { // Success - basicModal.close(); + upload.closeProgressReportDialog(); upload.notify(lychee.locale["UPLOAD_COMPLETE"]); } else if (!hasErrorOccurred && hasWarningOccurred) { // Warning - showCloseButton(); + upload.showProgressReportCloseButton(); upload.notify(lychee.locale["UPLOAD_COMPLETE"]); } else { // Error - showCloseButton(); + upload.showProgressReportCloseButton(); if (shallCancelUpload) { - $(".basicModal .rows .row:nth-child(n+" + (latestFileIdx + 2).toString() + ") .status") - .html(lychee.locale["UPLOAD_CANCELLED"]) - .addClass("warning"); + const row = upload.buildReportRow(lychee.locale["UPLOAD_GENERAL"]); + row.status.textContent = lychee.locale["UPLOAD_CANCELLED"]; + row.status.classList.add("warning"); + upload._dom.reportList.appendChild(row.listEntry); } upload.notify(lychee.locale["UPLOAD_COMPLETE"], lychee.locale["UPLOAD_COMPLETE_FAILED"]); } @@ -202,16 +313,13 @@ upload.start = { // Set progress when progress has changed if (progress > uploadProgress) { uploadProgress = progress; - /** @type {?jQuery} */ - const jqStatusMsg = $(nRowStatusSelector(fileIdx + 1)); - jqStatusMsg.html(uploadProgress + "%"); + const row = upload._dom.progressRowsByPath.get(files[fileIdx].name); + row.listEntry.scrollIntoView(upload.SCROLL_OPTIONS); + row.status.textContent = "" + uploadProgress + "%"; if (progress >= 100) { - jqStatusMsg.html(lychee.locale["UPLOAD_PROCESSING"]); + row.status.textContent = lychee.locale["UPLOAD_PROCESSING"]; isUploadRunning = false; - let scrollPos = 0; - if (fileIdx + 1 > 4) scrollPos = (fileIdx + 1 - 4) * 40; - $(".basicModal .rows").scrollTop(scrollPos); // Start a new upload, if there are still pending // files @@ -236,52 +344,38 @@ upload.start = { * @this XMLHttpRequest */ const onLoaded = function () { + const row = upload._dom.progressRowsByPath.get(files[fileIdx].name); /** @type {?LycheeException} */ const lycheeException = this.status >= 400 ? this.response : null; - let errorText = ""; - let statusText; - let statusClass; switch (this.status) { case 200: case 201: case 204: - statusText = lychee.locale["UPLOAD_FINISHED"]; - statusClass = "success"; + row.status.textContent = lychee.locale["UPLOAD_FINISHED"]; + row.status.classList.add("success"); break; case 409: - statusText = lychee.locale["UPLOAD_SKIPPED"]; - errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_ERROR_UNKNOWN"]; + row.status.textContent = lychee.locale["UPLOAD_SKIPPED"]; + row.status.classList.add("warning"); + row.notice.textContent = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_ERROR_UNKNOWN"]; hasWarningOccurred = true; - statusClass = "warning"; break; case 413: - statusText = lychee.locale["UPLOAD_FAILED"]; - errorText = lychee.locale["UPLOAD_ERROR_POSTSIZE"]; + row.status.textContent = lychee.locale["UPLOAD_FAILED"]; + row.status.classList.add("error"); + row.notice.textContent = lychee.locale["UPLOAD_ERROR_POSTSIZE"]; hasErrorOccurred = true; - statusClass = "error"; + api.onError(this, { albumID: albumID }, lycheeException); break; default: - statusText = lychee.locale["UPLOAD_FAILED"]; - errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_ERROR_UNKNOWN"]; + row.status.textContent = lychee.locale["UPLOAD_FAILED"]; + row.status.classList.add("error"); + row.notice.textContent = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_ERROR_UNKNOWN"]; hasErrorOccurred = true; - statusClass = "error"; + api.onError(this, { albumID: albumID }, lycheeException); break; } - - $(nRowStatusSelector(fileIdx + 1)) - .html(statusText) - .addClass(statusClass); - - if (statusClass === "error") { - api.onError(this, { albumID: albumID }, lycheeException); - } - - if (errorText !== "") { - $(".basicModal .rows .row:nth-child(" + (fileIdx + 1) + ") p.notice") - .html(errorText) - .show(); - } }; /** @@ -358,12 +452,12 @@ upload.start = { return lychee.locale["UPLOAD_IN_PROGRESS"]; }; - upload.show( + upload.showProgressReportDialog( lychee.locale["UPLOAD_UPLOADING"], files, function () { // Upload first file - $(cancelSelector).show(); + basicModal.showCancelButton(); process(0); }, function () { @@ -379,19 +473,12 @@ upload.start = { url: function (preselectedUrl = "") { const albumID = album.getID(); - /** - * @typedef UrlDialogResult - * @property {string} url - */ - - /** @param {UrlDialogResult} data */ - const action = function (data) { + /** @param {{url: string}} data */ + const importFromUrl = function (data) { const runImport = function () { - $(firstRowStatusSelector).html(lychee.locale["UPLOAD_IMPORTING"]); - const successHandler = function () { // Same code as in import.dropbox() - basicModal.close(); + upload.closeProgressReportDialog(); upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"]); album.reload(); }; @@ -404,32 +491,31 @@ upload.start = { */ const errorHandler = function (jqXHR, params, lycheeException) { // Same code as in import.dropbox() - let errorText; - let statusText; - let statusClass; + /** @type {ProgressReportDialogRow} */ + const row = upload._dom.progressRowsByPath.get(data.url); switch (jqXHR.status) { case 409: - statusText = lychee.locale["UPLOAD_SKIPPED"]; - errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; - statusClass = "warning"; + row.status.textContent = lychee.locale["UPLOAD_SKIPPED"]; + row.status.classList.add("warning"); + row.notice.textContent = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; break; default: - statusText = lychee.locale["UPLOAD_FAILED"]; - errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; - statusClass = "error"; + row.status.textContent = lychee.locale["UPLOAD_FAILED"]; + row.status.classList.add("error"); + row.notice.textContent = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; break; } - $(firstRowNoticeSelector).html(errorText).show(); - $(firstRowStatusSelector).html(statusText).addClass(statusClass); // Show close button - $(".basicModal #basicModal__action.hidden").show(); + basicModal.showActionButton(); upload.notify(lychee.locale["UPLOAD_IMPORT_WARN_ERR"]); album.reload(); return true; }; + upload._dom.progressRowsByPath.get(data.url).status.textContent = lychee.locale["UPLOAD_IMPORTING"]; + // In theory, the backend is prepared to download a list of // URLs (note that `data.url`) is wrapped into an array. // However, we need a better dialog which allows input of a @@ -459,21 +545,40 @@ upload.start = { ); }; + upload.showProgressReportDialog(lychee.locale["UPLOAD_IMPORTING_URL"], [{ name: data.url }], runImport); + }; + + /** @param {{url: string}} data */ + const processImportFromUrlDialog = function (data) { if (data.url && data.url.trim().length > 3) { - basicModal.close(); - upload.show(lychee.locale["UPLOAD_IMPORTING_URL"], [{ name: data.url }], runImport); - } else basicModal.error("link"); + basicModal.close(false, () => importFromUrl(data)); + } else basicModal.focusError("url"); + }; + + const importFromUrlDialogBody = ` +

    +
    +
    +
    `; + + /** + * @param {ModalDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initImportFromUrlDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["UPLOAD_IMPORT_INSTR"]; + formElements.url.placeholder = "https://"; + formElements.url.value = preselectedUrl; }; basicModal.show({ - body: - lychee.html`

    ` + - lychee.locale["UPLOAD_IMPORT_INSTR"] + - `

    `, + body: importFromUrlDialogBody, + readyCB: initImportFromUrlDialog, buttons: { action: { title: lychee.locale["UPLOAD_IMPORT"], - fn: action, + fn: processImportFromUrlDialog, }, cancel: { title: lychee.locale["CANCEL"], @@ -486,27 +591,93 @@ upload.start = { server: function () { const albumID = album.getID(); - const importDialogSetupCB = function () { - const $delete = $(choiceDeleteSelector); - const $symlinks = $(choiceSymlinkSelector); - const $duplicates = $(choiceDuplicateSelector); - const $resync = $(choiceResyncSelector); + /** + * @typedef ImportFromServerDialogFormElements + * + * @property {HTMLInputElement} paths + * @property {HTMLInputElement} delete_imported + * @property {HTMLInputElement} import_via_symlink + * @property {HTMLInputElement} skip_duplicates + * @property {HTMLInputElement} resync_metadata + */ + /** + * @param {ImportFromServerDialogFormElements} formElements + * @param {HTMLDivElement} dialog + * @returns {void} + */ + const initImportFromServerDialog = function (formElements, dialog) { + dialog.querySelector("p").textContent = lychee.locale["UPLOAD_IMPORT_SERVER_INSTR"]; + formElements.paths.placeholder = lychee.locale["UPLOAD_ABSOLUTE_PATH"]; + formElements.paths.value = lychee.location + "uploads/import/"; + formElements.delete_imported.previousElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_DELETE_ORIGINALS"]; + formElements.delete_imported.nextElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_DELETE_ORIGINALS_EXPL"]; + formElements.import_via_symlink.previousElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_VIA_SYMLINK"]; + formElements.import_via_symlink.nextElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_VIA_SYMLINK_EXPL"]; + formElements.skip_duplicates.previousElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_SKIP_DUPLICATES"]; + formElements.skip_duplicates.nextElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_SKIP_DUPLICATES_EXPL"]; + formElements.resync_metadata.previousElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_RESYNC_METADATA"]; + formElements.resync_metadata.nextElementSibling.textContent = lychee.locale["UPLOAD_IMPORT_RESYNC_METADATA_EXPL"]; + + // Initialize form elements (and dependent form elements) based on + // global configuration settings. if (lychee.delete_imported) { - $delete.prop("checked", true); - $symlinks.prop("checked", false).prop("disabled", true); + formElements.delete_imported.checked = true; + formElements.import_via_symlink.checked = false; + formElements.import_via_symlink.disabled = true; + formElements.import_via_symlink.parentElement.classList.add("disabled"); } else { if (lychee.import_via_symlink) { - $symlinks.prop("checked", true); - $delete.prop("checked", false).prop("disabled", true); + formElements.delete_imported.checked = false; + formElements.delete_imported.disabled = true; + formElements.delete_imported.parentElement.classList.add("disabled"); + formElements.import_via_symlink.checked = true; } } + if (lychee.skip_duplicates) { - $duplicates.prop("checked", true); - if (lychee.resync_metadata) $resync.prop("checked", true); + formElements.skip_duplicates.checked = true; + formElements.resync_metadata.checked = lychee.resync_metadata; } else { - $resync.prop("disabled", true); + formElements.skip_duplicates.checked = false; + formElements.resync_metadata.checked = false; + formElements.resync_metadata.disabled = true; + formElements.resync_metadata.parentElement.classList.add("disabled"); } + + // Checkbox action handler to visualize contradictory settings + formElements.delete_imported.addEventListener("change", function () { + if (formElements.delete_imported.checked) { + formElements.import_via_symlink.checked = false; + formElements.import_via_symlink.disabled = true; + formElements.import_via_symlink.parentElement.classList.add("disabled"); + } else { + formElements.import_via_symlink.disabled = false; + formElements.import_via_symlink.parentElement.classList.remove("disabled"); + } + }); + + formElements.import_via_symlink.addEventListener("change", function () { + if (formElements.import_via_symlink.checked) { + formElements.delete_imported.checked = false; + formElements.delete_imported.disabled = true; + formElements.delete_imported.parentElement.classList.add("disabled"); + } else { + formElements.delete_imported.disabled = false; + formElements.delete_imported.parentElement.classList.remove("disabled"); + } + }); + + formElements.skip_duplicates.addEventListener("change", function () { + if (formElements.skip_duplicates.checked) { + formElements.resync_metadata.disabled = false; + formElements.resync_metadata.parentElement.classList.remove("disabled"); + } else { + formElements.resync_metadata.checked = false; + formElements.resync_metadata.disabled = true; + formElements.resync_metadata.parentElement.classList.add("disabled"); + } + }); }; /** @@ -519,27 +690,7 @@ upload.start = { */ /** @param {ServerImportDialogResult} data */ - const action = function (data) { - if (!data.paths.trim()) { - basicModal.error("paths"); - return; - } else { - // Consolidate `data` before we close the modal dialog - // TODO: We should fix the modal dialog to properly return the values of all input fields, incl. check boxes - // We split the given path string at unescaped spaces into an - // array or more precisely we create an array whose entries - // matches strings with non-space characters or escaped spaces. - // After splitting, the escaped spaces must be replaced by - // proper spaces as escaping of spaces is a GUI-only thing to - // allow input of several paths into a single input field. - data.paths = data.paths.match(/(?:\\ |\S)+/g).map((path) => path.replaceAll("\\ ", " ")); - data.delete_imported = !!$(choiceDeleteSelector).prop("checked"); - data.import_via_symlink = !!$(choiceSymlinkSelector).prop("checked"); - data.skip_duplicates = !!$(choiceDuplicateSelector).prop("checked"); - data.resync_metadata = !!$(choiceResyncSelector).prop("checked"); - basicModal.close(); - } - + const importFromServer = function (data) { let isUploadCancelled = false; const cancelUpload = function () { @@ -551,16 +702,12 @@ upload.start = { }; const runUpload = function () { - $(cancelSelector).show(); + basicModal.showCancelButton(); // Variables holding state across the invocations of // processIncremental(). - const jqRows = $(".basicModal .rows"); let lastReadIdx = 0; - let currentPath = null; - let jqCurrentRow = null; // the jQuery object of the current row let encounteredProblems = false; - let topSkip = 0; /** * Worker function invoked from both the response progress @@ -571,44 +718,71 @@ upload.start = { const processIncremental = function (reports) { reports.slice(lastReadIdx).forEach(function (report) { if (report.type === "progress") { - if (currentPath !== report.path) { - // New directory. Add a new line to the dialog box at the end - currentPath = report.path; - jqCurrentRow = $(build.uploadNewFile(currentPath)).appendTo(jqRows); - topSkip += jqCurrentRow.outerHeight(); - } + // Gets existing row for the current path or creates a new one + /** @type {ProgressReportDialogRow} */ + const row = upload._dom.progressRowsByPath.get(report.path) || upload.buildReportRow(report.path); + upload._dom.progressRowsByPath.set(report.path, row); + // Always unconditionally append the list entry to + // the end of the list even if the `reportList` + // already contains `listEntry`. + // 1. If `listEntry` is not yet an element of + // `reportList` (e.g. this happens for + // new directories), then appending the + // element does the obvious thing + // 2. If `listEntry` is already an element + // of `reportList` (e.g. this happens for + // follow-up reports), then `appendChild` + // *moves* `listEntry` the end of the list. + // We don't need to take care of accidentally + // duplicating the entry, the DOM tree is + // clever enough. + // Moving `listEntry` is an intended effect, + // as we always want the most recent entry at + // the end of the list. + upload._dom.reportList.appendChild(row.listEntry); + row.listEntry.scrollIntoView(upload.SCROLL_OPTIONS); if (report.progress !== 100) { - $(".status", jqCurrentRow).text("" + report.progress + "%"); + row.status.textContent = "" + report.progress + "%"; } else { // Final status report for this directory. - $(".status", jqCurrentRow).text(lychee.locale["UPLOAD_FINISHED"]).addClass("success"); + row.status.textContent = lychee.locale["UPLOAD_FINISHED"]; + row.status.classList.add("success"); } } else if (report.type === "event") { - let jqEventRow; - if (jqCurrentRow) { - if (currentPath !== report.path) { - // If we already have a current row (for - // progress reports) and the event does - // not refer to that directory, we - // insert the event row _before_ the - // current row, so that the progress - // report stays in sight. - jqEventRow = $(build.uploadNewFile(report.path || "General")).insertBefore(jqCurrentRow); - topSkip += jqEventRow.outerHeight(); - } else { - // The problem is with the directory - // itself, so alter its existing line. - jqEventRow = jqCurrentRow; - } + let row; + if (!!report.path) { + // The event report refers to a specific path, + // hence get the existing row for that path + // or create a new one. + /** @type {ProgressReportDialogRow} */ + row = upload._dom.progressRowsByPath.get(report.path) || upload.buildReportRow(report.path); + upload._dom.progressRowsByPath.set(report.path, row); + // Always unconditionally append the list entry to + // the end of the list even if the `reportList` + // already contains `listEntry`. + // 1. If `listEntry` is not yet an element of + // `reportList` (e.g. this happens for + // new directories), then appending the + // element does the obvious thing + // 2. If `listEntry` is already an element + // of `reportList` (e.g. this happens for + // follow-up reports), then `appendChild` + // *moves* `listEntry` the end of the list. + // We don't need to take care of accidentally + // duplicating the entry, the DOM tree is + // clever enough. + // Moving `listEntry` is an intended effect, + // as we always want the most recent entry at + // the end of the list. + upload._dom.reportList.appendChild(row.listEntry); } else { - // If we do not have a current row yet, we - // simply append it to the list of rows - // (this might happen if the event occurs - // before the first progress report) - jqEventRow = $(build.uploadNewFile(report.path || "General")).appendTo(jqRows); - topSkip += jqEventRow.outerHeight(); + // The event report does not refer to a + // specific directory. + row = upload.buildReportRow(lychee.locale["UPLOAD_GENERAL"]); + upload._dom.reportList.appendChild(row.listEntry); } + row.listEntry.scrollIntoView(upload.SCROLL_OPTIONS); let severityClass = ""; let statusText = ""; @@ -669,14 +843,14 @@ upload.start = { break; } - $(".status", jqEventRow).text(statusText).addClass(severityClass); - $(".notice", jqEventRow).text(noteText).show(); + row.notice.textContent = noteText; + row.status.textContent = statusText; + row.status.classList.add(severityClass); encounteredProblems = true; } }); // forEach (resp) lastReadIdx = reports.length; - $(jqRows).scrollTop(topSkip); }; // processIncremental /** @@ -690,8 +864,8 @@ upload.start = { album.reload(); - if (encounteredProblems) showCloseButton(); - else basicModal.close(); + if (encounteredProblems) upload.showProgressReportCloseButton(); + else upload.closeProgressReportDialog(); }; /** @@ -728,7 +902,7 @@ upload.start = { album.reload(); - showCloseButton(); + upload.showProgressReportCloseButton(); return; } @@ -751,63 +925,62 @@ upload.start = { api.post("Import::server", params, successHandler, progressHandler); }; - upload.show(lychee.locale["UPLOAD_IMPORT_SERVER"], [], runUpload, cancelUpload); - }; // action - - const msg = lychee.html` -

    - ${lychee.locale["UPLOAD_IMPORT_SERVER_INSTR"]} - -

    -
    - -

    - ${lychee.locale["UPLOAD_IMPORT_DELETE_ORIGINALS_EXPL"]} -

    -
    -
    - -

    - ${lychee.locale["UPLOAD_IMPORT_VIA_SYMLINK_EXPL"]} -

    -
    -
    - -

    - ${lychee.locale["UPLOAD_IMPORT_SKIP_DUPLICATES_EXPL"]} -

    -
    -
    - -

    - ${lychee.locale["UPLOAD_IMPORT_RESYNC_METADATA_EXPL"]} -

    -
    - `; + upload.showProgressReportDialog(lychee.locale["UPLOAD_IMPORT_SERVER"], [], runUpload, cancelUpload); + }; // importFromServer + + /** @param {ServerImportDialogResult} data */ + const processImportFromServerDialog = function (data) { + if (!data.paths.trim()) { + basicModal.focusError("paths"); + return; + } + + // Consolidate `data` before we close the modal dialog + // We split the given path string at unescaped spaces into an + // array or more precisely we create an array whose entries + // match strings with non-space characters or escaped spaces. + // After splitting, the escaped spaces must be replaced by + // proper spaces as escaping of spaces is a GUI-only thing to + // allow input of several paths into a single input field. + data.paths = data.paths.match(/(?:\\ |\S)+/g).map((path) => path.replaceAll("\\ ", " ")); + basicModal.close(false, () => importFromServer(data)); + }; + + const importFromServerDialogBody = ` +

    +
    +
    + +
    +
    + + +

    +
    +
    + + +

    +
    +
    + + +

    +
    +
    + + +

    +
    +
    `; basicModal.show({ - body: msg, - callback: importDialogSetupCB, + body: importFromServerDialogBody, + readyCB: initImportFromServerDialog, buttons: { action: { title: lychee.locale["UPLOAD_IMPORT"], - fn: action, + fn: processImportFromServerDialog, }, cancel: { title: lychee.locale["CANCEL"], @@ -827,7 +1000,7 @@ upload.start = { const runImport = function () { const successHandler = function () { // Same code as in import.url() - basicModal.close(); + upload.closeProgressReportDialog(); upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"]); album.reload(); }; @@ -840,33 +1013,34 @@ upload.start = { */ const errorHandler = function (jqXHR, params, lycheeException) { // Same code as in import.url() - let errorText; - let statusText; - let statusClass; + // Note, this is complete rubbish: + // Dropbox allows to import several photos at once, but + // here we assume that `files` has only a single entry. + // This seems to be a long-standing, open bug + /** @type {ProgressReportDialogRow} */ + const row = upload._dom.progressRowsByPath.get(files[0].link); switch (jqXHR.status) { case 409: - statusText = lychee.locale["UPLOAD_SKIPPED"]; - errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; - statusClass = "warning"; + row.status.textContent = lychee.locale["UPLOAD_SKIPPED"]; + row.status.classList.add("warning"); + row.notice.textContent = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; break; default: - statusText = lychee.locale["UPLOAD_FAILED"]; - errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; - statusClass = "error"; + row.status.textContent = lychee.locale["UPLOAD_FAILED"]; + row.status.classList.add("error"); + row.notice.textContent = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; break; } - $(firstRowNoticeSelector).html(errorText).show(); - $(firstRowStatusSelector).html(statusText).addClass(statusClass); // Show close button - $(".basicModal #basicModal__action.hidden").show(); + basicModal.showActionButton(); upload.notify(lychee.locale["UPLOAD_IMPORT_WARN_ERR"]); album.reload(); return true; }; - $(firstRowStatusSelector).html(lychee.locale["UPLOAD_IMPORTING"]); + upload._dom.progressRowsByPath.get(files[0].link).status.textContent = lychee.locale["UPLOAD_IMPORTING"]; // TODO: Use a streamed response; see long comment in `import.url()` for the reasons api.post( @@ -882,7 +1056,7 @@ upload.start = { }; files.forEach((file) => (file.name = file.link)); - upload.show("Importing from Dropbox", files, runImport); + upload.showProgressReportDialog("Importing from Dropbox", files, runImport); }; lychee.loadDropbox(function () { @@ -895,31 +1069,6 @@ upload.start = { }, }; -upload.check = function () { - let $delete = $(choiceDeleteSelector); - let $symlinks = $(choiceSymlinkSelector); - - if ($delete.prop("checked")) { - $symlinks.prop("checked", false).prop("disabled", true); - } else { - $symlinks.prop("disabled", false); - if ($symlinks.prop("checked")) { - $delete.prop("checked", false).prop("disabled", true); - } else { - $delete.prop("disabled", false); - } - } - - let $duplicates = $(choiceDuplicateSelector); - let $resync = $(choiceResyncSelector); - - if ($duplicates.prop("checked")) { - $resync.prop("disabled", false); - } else { - $resync.prop("checked", false).prop("disabled", true); - } -}; - /** * @param {(FileList|File[])} files * @@ -930,6 +1079,10 @@ upload.uploadTrack = function (files) { if (files.length <= 0 || albumID === null) return; const runUpload = function () { + // Only a single track can be uploaded at once, hence the only + // file is at position 0. + const row = upload._dom.progressRowsByPath.get(files[0].name); + /** * A function to be called when a response has been received. * @@ -966,23 +1119,23 @@ upload.uploadTrack = function (files) { break; } - $(firstRowStatusSelector).html(statusText).addClass(statusClass); + row.status.textContent = statusText; if (errorText !== "") { - $(firstRowNoticeSelector).html(errorText).show(); + row.notice.textContent = errorText; api.onError(this, { albumID: albumID }, lycheeException); - showCloseButton(); + upload.showProgressReportCloseButton(); upload.notify(lychee.locale["UPLOAD_COMPLETE"], lychee.locale["UPLOAD_COMPLETE_FAILED"]); } else { - basicModal.close(); + upload.closeProgressReportDialog(); upload.notify(lychee.locale["UPLOAD_COMPLETE"]); } album.reload(); }; // finish - $(firstRowStatusSelector).html(lychee.locale["UPLOAD_UPLOADING"]); + row.status.textContent = lychee.locale["UPLOAD_UPLOADING"]; const formData = new FormData(); const xhr = new XMLHttpRequest(); @@ -999,5 +1152,5 @@ upload.uploadTrack = function (files) { xhr.send(formData); }; // runUpload - upload.show(lychee.locale["UPLOAD_UPLOADING"], files, runUpload); + upload.showProgressReportDialog(lychee.locale["UPLOAD_UPLOADING"], files, runUpload); }; diff --git a/styles/main/_basicModal.custom.scss b/styles/main/_basicModal.custom.scss index 46c9974c..5be1605a 100644 --- a/styles/main/_basicModal.custom.scss +++ b/styles/main/_basicModal.custom.scss @@ -1,45 +1,136 @@ -.basicModal .select select option { - background: #333333 !important; - color: #ffffff !important; +div.basicModalContainer { + background-color: $colorDialogContainerBg; + + &--error { + transform: translateY(40px); + } +} + +div.basicModal { + background: $colorDialogBg; + box-shadow: 0 1px 4px black(0.2), inset 0 1px 0 white(0.05); + + font-size: 14px; + // Most browser use a default line height roughly about 120%. + // This yields 1.2 * 14px = 16.8px and makes it difficult to align + // certain elements (e.g. checkboxes) "pixel-perfect" due to annoying + // rounding issue. + // So we enforce an integer line height here. + line-height: 17px; + + &--error { + transform: translateY(-40px); + } +} + +div.basicModal__buttons { + box-shadow: none; +} + +.basicModal__button { + padding: 13px 0 15px; + background: transparent; + color: $colorDialogMainButtonFont; + border-top: 1px solid black(0.2); + box-shadow: inset 0 1px 0 white(0.02); + cursor: default; + + &:active, + &--busy { + transition: none; + background: black(0.1); + cursor: wait; + } + + &#basicModal__action { + color: $colorDialogMainActionButtonFont; + box-shadow: inset 0 1px 0 white(0.02), inset 1px 0 0 black(0.2); + } + + &#basicModal__action.red, + &#basicModal__cancel.red { + color: $colorDialogMainButtonWarningFont; + } + + &.hidden { + display: none; + } } -.basicModal .switch:last-child { - padding-bottom: 42px; +// restrict hover features to devices that support it +@media (hover: hover) { + .basicModal__button:hover { + background: white(0.02); + } } -.basicModal .hr { - padding: 0 30px 15px; - width: 100%; +div.basicModal__content { + padding: 36px; + color: $colorDialogDefaultFg; + text-align: left; + + // the expected elements of a modal dialog are either: p, hr, form + > * { + display: block; + width: 100%; + margin: 24px 0; + padding: 0; + + &:first-child, + &.force-first-child { + margin-top: 0; + } + + &:last-child, + &.force-last-child { + margin-bottom: 0; + } + } - hr { + .disabled { + color: $colorDialogDisabledFg; + } + + b { + font-weight: bold; + color: $colorDialogEmphasizedFg; + } + + a { + color: inherit; + text-decoration: none; + border-bottom: 1px dashed $colorDialogDefaultFg; + } + + a.button { + display: inline-block; + margin: 0 6px; + padding: 3px 12px; + color: $colorFormElementAccent; + text-align: center; + border-radius: 5px; border: none; - border-top: 1px solid rgba(0, 0, 0, 0.2); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); + + .iconic { + fill: $colorFormElementAccent; + } + } + + > hr { + border: none; + border-top: 1px solid black(0.3); } } -// responsive web design for smaller screens -@media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { - .basicModal { - max-width: 90%; - - .basicModal__content, - .basicModal__content .choice, - .basicModal__content .switch { - h1, - p { - padding-left: 20px; - padding-right: 20px; - } - - p { - font-size: 12px; - line-height: 14px; - } - - h1 { - font-size: 14px; - line-height: 16px; - } +// restrict hover features to devices that support it +@media (hover: hover) { + div.basicModal__content a.button:hover { + color: $colorDialogEmphasizedFg; + background: $colorFormElementAccent; + + .iconic { + fill: $colorDialogEmphasizedFg; } } } diff --git a/styles/main/_dialog_about.scss b/styles/main/_dialog_about.scss new file mode 100644 index 00000000..fdce2976 --- /dev/null +++ b/styles/main/_dialog_about.scss @@ -0,0 +1,20 @@ +div.basicModal.about-dialog div.basicModal__content { + h1 { + font-size: 120%; + font-weight: bold; + text-align: center; + color: $colorDialogEmphasizedFg; + } + h2 { + font-weight: bold; + color: $colorDialogEmphasizedFg; + } + p.update-status { + &.up-to-date { + display: none; + } + } + p.about-desc { + line-height: 1.4em; + } +} diff --git a/styles/main/_dialog_downloads.scss b/styles/main/_dialog_downloads.scss new file mode 100644 index 00000000..3a14180c --- /dev/null +++ b/styles/main/_dialog_downloads.scss @@ -0,0 +1,22 @@ +div.basicModal.downloads div.basicModal__content a.button { + display: block; + margin: 12px 0; + padding: 12px; + font-weight: bold; + box-shadow: inset 0 1px 0 white(0.02); + border: solid 1px black(0.2); + + .iconic { + width: 12px; + height: 12px; + margin-right: 12px; + } +} + +div.basicModal.qr-code { + width: 300px; + + div.basicModal__content { + padding: 12px; + } +} diff --git a/styles/main/_dialog_import.scss b/styles/main/_dialog_import.scss new file mode 100644 index 00000000..58286473 --- /dev/null +++ b/styles/main/_dialog_import.scss @@ -0,0 +1,92 @@ +.basicModal.import div.basicModal__content { + padding: 12px 8px; + + // Title -------------------------------------------------------------- // + h1 { + margin-bottom: 12px; + color: $colorDialogEmphasizedFg; + font-size: 16px; + line-height: 19px; + font-weight: bold; + text-align: center; + } + + // Rows -------------------------------------------------------------- // + ol { + margin-top: 12px; + height: 300px; + background-color: $colorFormElementBg; + overflow: hidden; + overflow-y: auto; + border-radius: 3px; + box-shadow: inset 0 0 3px black(0.4); + } + + // Row -------------------------------------------------------------- // + ol li { + float: left; + padding: 8px 0; + width: 100%; + background-color: white(0.02); + + &:nth-child(2n) { + background-color: white(0); + } + + h2 { + float: left; + padding: 5px 10px; + width: 70%; + color: $colorDialogEmphasizedFg; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + } + + p.status { + float: left; + padding: 5px 10px; + width: 30%; + color: $colorDialogMainButtonFont; + font-size: 14px; + text-align: right; + + animation-name: pulse; + animation-duration: 2s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + + &.error, + &.warning, + &.success { + animation: none; + } + + &.error { + color: $colorImportError; + } + + &.warning { + color: $colorImportWarning; + } + + &.success { + color: $colorImportSuccess; + } + } + + p.notice { + float: left; + padding: 2px 10px 5px; + width: 100%; + color: $colorDialogMainButtonFont; + font-size: 12px; + overflow: hidden; + line-height: 16px; + } + + p.notice:empty { + display: none; + } + } +} diff --git a/styles/main/_dialog_login.scss b/styles/main/_dialog_login.scss new file mode 100644 index 00000000..c12af42b --- /dev/null +++ b/styles/main/_dialog_login.scss @@ -0,0 +1,48 @@ +div.basicModal.login div.basicModal__content { + a.button#signInKeyLess { + position: absolute; + display: block; + color: $colorDialogMainButtonFont; + top: 8px; + left: 8px; + width: 30px; + height: 30px; + margin: 0; + padding: 5px; + cursor: pointer; + box-shadow: inset 1px 1px 0 white(0.02); + border: solid 1px black(0.2); + + .iconic { + width: 100%; + height: 100%; + fill: $colorDialogMainButtonFont; + } + } + + p.version { + font-size: 12px; + text-align: right; + + span.update-status { + &.up-to-date { + display: none; + } + } + } +} + +// restrict hover features to devices that support it +// for some unknown reason the button for keyless sign-in of the login dialog +// uses another color-scheme then all other buttons +// in particular, it does not use the blue accent color +@media (hover: hover) { + div.basicModal.login div.basicModal__content a.button#signInKeyLess:hover { + color: $colorDialogEmphasizedFg; + background: inherit; + + .iconic { + fill: $colorDialogEmphasizedFg; + } + } +} diff --git a/styles/main/_dialog_photo_links.scss b/styles/main/_dialog_photo_links.scss new file mode 100644 index 00000000..996c626d --- /dev/null +++ b/styles/main/_dialog_photo_links.scss @@ -0,0 +1,22 @@ +form.photo-links div.input-group { + padding-right: 30px; + + a.button { + display: block; + position: absolute; + margin: 0; + padding: 4px; + right: 0; + bottom: 0; + width: 26px; + height: 26px; + cursor: pointer; + box-shadow: inset 1px 1px 0 white(0.02); + border: solid 1px black(0.2); + + .iconic { + width: 100%; + height: 100%; + } + } +} diff --git a/styles/main/_dialog_token.scss b/styles/main/_dialog_token.scss new file mode 100644 index 00000000..dbc36e0f --- /dev/null +++ b/styles/main/_dialog_token.scss @@ -0,0 +1,36 @@ +form.token div.input-group { + padding-right: 82px; + + input[disabled], + input.disabled { + color: $colorDialogDisabledFg; + } + + div.button-group { + display: block; + position: absolute; + margin: 0; + padding: 0; + right: 0; + bottom: 0; + width: 78px; // 3 buttons à 26px + + a.button { + display: block; + float: right; + margin: 0; + padding: 4px; + bottom: 4px; // compensation for bottom padding (3px) and bottom border (1px) of input element + width: 26px; + height: 26px; + cursor: pointer; + box-shadow: inset 1px 1px 0 white(0.02); + border: solid 1px black(0.2); + + .iconic { + width: 100%; + height: 100%; + } + } + } +} diff --git a/styles/main/_form.scss b/styles/main/_form.scss new file mode 100644 index 00000000..1597650d --- /dev/null +++ b/styles/main/_form.scss @@ -0,0 +1,379 @@ +// A HTML `
    ` is expected to consist of a sequence of one or more +// `
    ` elements. +// Each of this `
    ` binds together a label, an input element and some +// explanatory text. +// This kind of `
    ` is called an input group. +// There are several classes of input groups which define how their children +// are arranged. +// +// First, this file defines styles for form elements which always apply +// and which are independent of the class of the input group. +// Second, this file defines the different input groups and adjusts some +// form elements acc. to the class of input group. + +// 1. Form elements (general styling, independent of input group) + +input, +div.select, +select, +textarea, +output { + display: inline-block; + /* we must position this element so that it becomes the nearest + * positioned ancestor for ::before, ::after and :checked::before */ + position: relative; +} + +div.select > select { + // The element `` needs to be wrapped into an additional `
    ` and + // most styles are applied to this `
    `. + // As the `` element + top: 3px; // must match the top padding of the ``, +// -- `