diff --git a/gulpfile.js b/gulpfile.js index f9eb0c14..1fcaec8f 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -16,7 +16,6 @@ const catchError = function (err) { paths.view = { php: ["../view.php"], js: [ - "./scripts/_gup.js", "./scripts/api.js", "./scripts/csrf_protection.js", "./scripts/view/main.js", @@ -27,6 +26,7 @@ paths.view = { "./scripts/main/mapview.js", "./scripts/main/lychee_locale.js", "./scripts/main/tabindex.js", + "./scripts/3rd-party/backend.js", "./deps/basiccontext/scripts/basicContext.js", ], scripts: ["node_modules/jquery/dist/jquery.min.js", "node_modules/lazysizes/lazysizes.min.js", "../dist/_view--javascript.js"], @@ -78,7 +78,7 @@ gulp.task("view--svg", function () { paths.main = { html: ["../index.html"], - js: ["./scripts/*.js", "./scripts/main/*.js", "./deps/basiccontext/scripts/basicContext.js"], + js: ["./scripts/*.js", "./scripts/main/*.js", "./scripts/3rd-party/backend.js", "./deps/basiccontext/scripts/basicContext.js"], scripts: [ "node_modules/jquery/dist/jquery.min.js", "node_modules/lazysizes/lazysizes.min.js", @@ -165,7 +165,7 @@ gulp.task("main--svg", function () { /* Frame ----------------------------------------- */ paths.frame = { - js: ["./scripts/_gup.js", "./scripts/api.js", "./scripts/csrf_protection.js", "./scripts/frame/main.js"], + js: ["./scripts/api.js", "./scripts/csrf_protection.js", "./scripts/frame/main.js", "./scripts/3rd-party/backend.js"], scss: ["./styles/frame/*.scss"], styles: ["./styles/frame/frame.scss"], scripts: [ @@ -217,7 +217,7 @@ gulp.task( /* Landing ----------------------------------------- */ paths.landing = { - js: ["./scripts/_gup.js", "./scripts/landing/*.js"], + js: ["./scripts/landing/*.js"], scripts: ["node_modules/jquery/dist/jquery.min.js", "node_modules/lazysizes/lazysizes.min.js", "../dist/_landing--javascript.js"], scss: [ "./styles/landing/*.scss", diff --git a/package.json b/package.json index f7d98713..502c5a20 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "gulp-sass": "^4.0.2", "gulp-uglify": "^3.0.2", "node-sass": "^4.13.0", - "npm": "^6.14.11", + "npm": "^6.14.16", "prettier": "2.2.1", "uglify-js": "^3.12.4" } diff --git a/scripts/3rd-party/backend.js b/scripts/3rd-party/backend.js new file mode 100644 index 00000000..31c45afd --- /dev/null +++ b/scripts/3rd-party/backend.js @@ -0,0 +1,386 @@ +/** + * @typedef {Object} LycheeException + * @property {string} message the message of the exception + * @property {string} exception the (base) name of the exception class; in developer mode the backend reports the full class name, in productive mode only the base name + * @property {string} [file] the file name where the exception has been thrown; only in developer mode + * @property {number} [line] the line number where the exception has been thrown; only in developer mode + * @property {Array} [trace] the backtrace; only in developer mode + * @property {?LycheeException} [previous_exception] the previous exception, if any; only in developer mode + */ + +/** + * @typedef Photo + * + * @property {string} id + * @property {string} title + * @property {?string} description + * @property {string[]} tags + * @property {number} is_public + * @property {?string} type + * @property {?string} iso + * @property {?string} aperture + * @property {?string} make + * @property {?string} model + * @property {?string} lens + * @property {?string} shutter + * @property {?string} focal + * @property {?number} latitude + * @property {?number} longitude + * @property {?number} altitude + * @property {?number} img_direction + * @property {?string} location + * @property {?string} taken_at + * @property {?string} taken_at_orig_tz + * @property {boolean} is_starred + * @property {?string} live_photo_url + * @property {?string} album_id + * @property {string} checksum + * @property {string} license + * @property {string} created_at + * @property {string} updated_at + * @property {?string} live_photo_content_id + * @property {?string} live_photo_checksum + * @property {SizeVariants} size_variants + * @property {boolean} is_downloadable + * @property {boolean} is_share_button_visible + * @property {?string} [next_photo_id] + * @property {?string} [previous_photo_id] + */ + +/** + * @typedef SizeVariants + * + * @property {SizeVariant} original + * @property {?SizeVariant} medium2x + * @property {?SizeVariant} medium + * @property {?SizeVariant} small2x + * @property {?SizeVariant} small + * @property {?SizeVariant} thumb2x + * @property {?SizeVariant} thumb + */ + +/** + * @typedef SizeVariant + * + * @property {number} type + * @property {string} url + * @property {number} width + * @property {number} height + * @property {number} filesize + */ + +/** + * @typedef SortingCriterion + * + * @property {string} column + * @property {string} order + */ + +/** + * @typedef Album + * + * @property {string} id + * @property {string} parent_id + * @property {string} created_at + * @property {string} updated_at + * @property {string} title + * @property {?string} description + * @property {string} license + * @property {Photo[]} photos + * @property {Album[]} [albums] + * @property {?string} cover_id + * @property {?Thumb} thumb + * @property {string} [owner_name] optional, only shown in authenticated mode + * @property {boolean} is_public + * @property {boolean} is_downloadable + * @property {boolean} is_share_button_visible + * @property {boolean} is_nsfw + * @property {boolean} grants_full_photo + * @property {boolean} requires_link + * @property {boolean} has_password + * @property {boolean} has_albums + * @property {?string} min_taken_at + * @property {?string} max_taken_at + * @property {?SortingCriterion} sorting + */ + +/** + * @typedef TagAlbum + * + * @property {string} id + * @property {string} created_at + * @property {string} updated_at + * @property {string} title + * @property {?string} description + * @property {string[]} show_tags + * @property {Photo[]} photos + * @property {?Thumb} thumb + * @property {string} [owner_name] optional, only shown in authenticated mode + * @property {boolean} is_public + * @property {boolean} is_downloadable + * @property {boolean} is_share_button_visible + * @property {boolean} is_nsfw + * @property {boolean} grants_full_photo + * @property {boolean} requires_link + * @property {boolean} has_password + * @property {?string} min_taken_at + * @property {?string} max_taken_at + * @property {?SortingCriterion} sorting + * @property {boolean} is_tag_album always true + */ + +/** + * @typedef SmartAlbum + * + * @property {string} id + * @property {string} title + * @property {Photo[]} photos + * @property {?Thumb} thumb + * @property {boolean} is_public + * @property {boolean} is_downloadable + * @property {boolean} is_share_button_visible + */ + +/** + * @typedef Thumb + * + * @property {string} id + * @property {string} type + * @property {string} thumb + * @property {?string} thumb2x + */ + +/** + * @typedef SharingInfo + * + * DTO returned by `Sharing::list` + * + * @property {{id: number, album_id: string, user_id: number, username: string, title: string}[]} shared + * @property {{id: string, title: string}[]} albums + * @property {{id: number, username: string}[]} users + */ + +/** + * @typedef SearchResult + * + * DTO returned by `Search::run` + * + * @property {Album[]} albums + * @property {TagAlbum[]} tag_albums + * @property {Photo[]} photos + * @property {string} checksum - checksum of the search result to + * efficiently determine if the result has + * changed since the last time + */ + +/** + * @typedef Albums + * + * @property {SmartAlbums} smart_albums + * @property {TagAlbum[]} tag_albums + * @property {Album[]} albums + * @property {Album[]} shared_albums + */ + +/** + * @typedef SmartAlbums + * + * @property {?SmartAlbum} unsorted + * @property {?SmartAlbum} starred + * @property {?SmartAlbum} public + * @property {?SmartAlbum} recent + */ + +/** + * The IDs of the built-in, smart albums. + * + * @type {Readonly<{RECENT: string, STARRED: string, PUBLIC: string, UNSORTED: string}>} + */ +const SmartAlbumID = Object.freeze({ + UNSORTED: "unsorted", + STARRED: "starred", + PUBLIC: "public", + RECENT: "recent", +}); + +/** + * @typedef User + * + * @property {number} id + * @property {string} username + * @property {?string} email + * @property {boolean} may_upload + * @property {boolean} is_locked + */ + +/** + * @typedef WebAuthnCredential + * + * @property {string} id + */ + +/** + * @typedef PositionData + * + * @property {?string} id - album ID + * @property {?string} title - album title + * @property {Photo[]} photos + */ + +/** + * @typedef EMailData + * + * @property {?string} email + */ + +/** + * @typedef ConfigSetting + * + * @property {number} id + * @property {string} key + * @property {?string} value - TODO: this should have the correct type depending on `type_range` + * @property {string} cat + * @property {string} type_range + * @property {number} confidentiality - `0`: public setting, `2`: informational, `3`: admin only + * @property {string} description + */ + +/** + * @typedef LogEntry + * + * @property {number} id + * @property {string} created_at + * @property {string} updated_at + * @property {string} type + * @property {string} function + * @property {number} line + * @property {string} text + */ + +/** + * @typedef DiagnosticInfo + * + * @property {string[]} errors + * @property {string[]} infos + * @property {string[]} configs + * @property {number} update - `0`: not on master branch; `1`: up-to-date; `2`: not up-to-date; `3`: requires migration + */ + +/** + * @typedef FrameSettings + * + * @property {number} refresh + */ + +/** + * @typedef InitializationData + * + * @property {number} status - `1`: unauthenticated, `2`: authenticated + * @property {boolean} admin + * @property {boolean} may_upload + * @property {boolean} is_locked + * @property {number} update_json - version number of latest available update + * @property {boolean} update_available + * @property {Object.} locale + * @property {string} [username] - only if user is not the admin; TODO: Change that + * @property {ConfigurationData} config + * @property {DeviceConfiguration} config_device + */ + +/** + * @typedef ConfigurationData + * + * @property {string} album_subtitle_type + * @property {string} check_for_updates - actually a boolean + * @property {string} [default_license] + * @property {string} [delete_imported] - actually a boolean + * @property {string} downloadable - actually a boolean + * @property {string} [dropbox_key] + * @property {string} editor_enabled - actually a boolean + * @property {string} full_photo - actually a boolean + * @property {string} image_overlay_type + * @property {string} landing_page_enable - actually a boolean + * @property {string} lang + * @property {string[]} lang_available + * @property {string} layout - actually a number: `0`, `1` or `2` + * @property {string} [location] + * @property {string} location_decoding - actually a boolean + * @property {string} location_show - actually a boolean + * @property {string} location_show_public - actually a boolean + * @property {string} map_display - actually a boolean + * @property {string} map_display_direction - actually a boolean + * @property {string} map_display_public - actually a boolean + * @property {string} map_include_subalbums - actually a boolean + * @property {string} map_provider + * @property {string} new_photos_notification - actually a boolean + * @property {string} nsfw_blur - actually a boolean + * @property {string} nsfw_visible - actually a boolean + * @property {string} nsfw_warning - actually a boolean + * @property {string} nsfw_warning_admin - actually a boolean + * @property {string} public_photos_hidden - actually a boolean + * @property {string} public_search - actually a boolean + * @property {string} share_button_visible - actually a boolean + * @property {string} [skip_duplicates] - actually a boolean + * @property {SortingCriterion} sorting_albums + * @property {SortingCriterion} sorting_photos + * @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 + */ + +/** + * @typedef DeviceConfiguration + * + * @property {string} device_type + * @property {boolean} header_auto_hide + * @property {boolean} active_focus_on_page_load + * @property {boolean} enable_button_visibility + * @property {boolean} enable_button_share + * @property {boolean} enable_button_archive + * @property {boolean} enable_button_move + * @property {boolean} enable_button_trash + * @property {boolean} enable_button_fullscreen + * @property {boolean} enable_button_download + * @property {boolean} enable_button_add + * @property {boolean} enable_button_more + * @property {boolean} enable_button_rotate + * @property {boolean} enable_close_tab_on_esc + * @property {boolean} enable_contextmenu_header + * @property {boolean} hide_content_during_imgview + * @property {boolean} enable_tabindex + */ + +/** + * The JSON object for incremental reports sent by the + * back-end within a streamed response. + * + * @typedef ImportReport + * + * @property {string} type - indicates the type of report; + * `'progress'`: {@link ImportProgressReport}, + * `'event'`: {@link ImportEventReport} + */ + +/** + * The JSON object for cumulative progress reports sent by the + * back-end within a streamed response. + * + * @typedef ImportProgressReport + * + * @property {string} type - `'progress'` + * @property {string} path + * @property {number} progress + */ + +/** + * The JSON object for events sent by the back-end within a streamed response. + * + * @typedef ImportEventReport + * + * @property {string} type - `'event'` + * @property {string} subtype - the subtype of event; equals the base name of the exception class which caused this event on the back-end + * @property {number} severity - either `'debug'`, `'info'`, `'notice'`, `'warning'`, `'error'`, `'critical'` or `'emergency'` + * @property {?string} path - the path to the affected file or directory + * @property {string} message - a message text + */ diff --git a/scripts/3rd-party/basicModal.js b/scripts/3rd-party/basicModal.js new file mode 100644 index 00000000..c7ebc6f1 --- /dev/null +++ b/scripts/3rd-party/basicModal.js @@ -0,0 +1,134 @@ +/** + * The Basic Model component. + * + * See: {@link https://github.com/LycheeOrg/basicModal} + * + * @namespace basicModal + */ + +/** + * Returns an associative object containing the values from all `input` and + * `select` elements. + * + * The properties of the returned object correspond to the `name` attribute + * of the `input` and `select` elements. + * + * @function getValues + * @memberOf basicModal + * @returns {Object} + */ + +/** + * Constructs and shows a modal dialog. + * + * After the dialog has become ready, the callback `data.callback` is + * invoked. + * + * @function show + * @memberOf basicModal + * @param {ModalDialogData} data configuration data for the dialog + * @returns {boolean} `true` if the dialog became visible + */ + +/** + * Removes (potentially) old error indicators and highlights the indicated + * input element. + * + * @function error + * @memberOf basicModal + * @param {string} [nameAttribute] 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 + * @memberOf basicModal + * @returns {boolean} + */ + +/** + * Triggers a virtual "on-click" event on the main action button. + * + * The method closes the dialog and calls the registered callback for the main + * action. + * + * @function action + * @memberOf basicModal + * @returns {boolean} `true`, if the main action button exists and a click + * event has been triggered; `false` otherwise + */ + +/** + * Triggers a virtual "on-click" event on the cancel button. + * + * The method closes the dialog and calls the registered callback for the + * cancel action. + * + * @function cancel + * @memberOf basicModal + * @returns {boolean} `true`, if the main action button exists and a click + * event has been triggered; `false` otherwise + */ + +/** + * Removes any (potential) error indicator from the input elements. + * + * @function reset + * @memberOf basicModal + * @returns {boolean} always `true` + */ + +/** + * Closes the dialog without triggering any action. + * + * @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 + */ + +/** + * @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 + */ + +/** + * @callback ModalDialogReadyCB + * @param {ModalDialogData} data the configuration data which has been used to construct the dialog + * @returns {void} + */ + +/** + * @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} [class] CSS class to be applied to the button + * @property {ModalDialogButtonCB} fn callback to be called upon an "on-click" event + */ + +/** + * @callback ModalDialogButtonCB + * @param {Object} [values] an associative object with the values of all HTML + * input elements; see {@link basicModal.getValues} + * @returns {void} + */ diff --git a/scripts/3rd-party/dropbox.js b/scripts/3rd-party/dropbox.js new file mode 100644 index 00000000..951c8c47 --- /dev/null +++ b/scripts/3rd-party/dropbox.js @@ -0,0 +1,68 @@ +/** + * The Dropbox JS component. + * + * It is "dynamically" loaded by {@link lychee.loadDropbox}. + * See: + * + * - {@link https://www.dropbox.com/developers/documentation} + * - {@link https://www.dropbox.com/developers/chooser} + * + * @namespace Dropbox + */ + +/** + * Shows the Dropbox Chooser component and allows the user to pick files. + * + * @function choose + * @param {DropboxChooserOptions} options + * @memberOf Dropbox + */ + +/** + * See {@link https://www.dropbox.com/developers/chooser}. + * + * @typedef DropboxChooserOptions + * @property {DropboxChooserSuccessCB} success Called when a user has selected files. + * @property {DropboxChooserCancelCB} [cancel] Called when the user cancels the + * chooser without having selected + * files. + * @property {string} [linkType=preview] `"preview"` (default) is a preview link + * to the document for sharing, `"direct"` + * is an expiring link to download the + * contents of the file. + * @property {boolean} [multiselect=false] A value of `false` (default) limits + * selection to a single file, while + * `true` enables multiple file selection. + * @property {string[]} [extensions] a list of file extensions which the + * user is able to select + * @property {boolean} [folderselect=false] determines whether the user is able to + * select folders, too + * @property {number} [sizeLimit] a limit on the size of each file which + * may be selected + */ + +/** + * Callback if users have successfully selected files from their Dropbox. + * + * @callback DropboxChooserSuccessCB + * @param {DropboxFile[]} files + * @returns {void} + */ + +/** + * Callback if users cancelled selecting files from their Dropbox. + * + * @callback DropboxChooserCancelCB + * @returns {void} + */ + +/** + * @typedef DropboxFile + * @property {string} id unique ID + * @property {string} name name of the file, e.g. `"filename.txt`" + * @property {string} link URL to access the file + * @property {number} bytes size of file in bytes + * @property {string} icon URL to a 64x64px icon based on the file type + * @property {string} [thumbnailLink] a thumbnail link for image and video files + * @property {boolean} isDir indicates whether the file is actually a directory + */ diff --git a/scripts/_gup.js b/scripts/_gup.js deleted file mode 100644 index ba5d665f..00000000 --- a/scripts/_gup.js +++ /dev/null @@ -1,10 +0,0 @@ -function gup(b) { - b = b.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); - - let a = "[\\?&]" + b + "=([^&#]*)"; - let d = new RegExp(a); - let c = d.exec(window.location.href); - - if (c === null) return ""; - else return c[1]; -} diff --git a/scripts/api.js b/scripts/api.js index 20696d8a..3582b584 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -2,68 +2,125 @@ * @description This module communicates with Lychee's API */ +/** + * @callback APISuccessCB + * @param {Object} data the decoded JSON response + * @returns {void} + */ + +/** + * @callback APIErrorCB + * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. + * @param {Object} params the original JSON parameters of the request + * @param {?LycheeException} lycheeException the Lychee exception + * @returns {boolean} + */ + +/** + * @callback APIProgressCB + * @param {ProgressEvent} event the progress event + * @returns {void} + */ + +/** + * The main API object + */ let api = { + /** + * Global, default error handler + * + * @type {?APIErrorCB} + */ onError: null, }; -api.isTimeout = function (errorThrown, jqXHR) { - if ( - errorThrown && - ((errorThrown === "Bad Request" && - jqXHR && - jqXHR.responseJSON && - jqXHR.responseJSON.error && - jqXHR.responseJSON.error === "Session timed out") || - (errorThrown === "unknown status" && - jqXHR && - jqXHR.status && - jqXHR.status === 419 && - jqXHR.responseJSON && - jqXHR.responseJSON.message && - jqXHR.responseJSON.message === "CSRF token mismatch.")) - ) { - return true; - } - - return false; +/** + * Checks whether the returned error is probably due to an expired HTTP session. + * + * There seem to be two variants how an expired session may be reported: + * + * 1. The web-application has already been loaded, is fully initialized + * and a user tries to navigate to another part of the gallery. + * In this case, the AJAX request sends the previous, expired CSRF token + * and the backend responds with a 419 status code. + * 2. The user completely reloads the website (e.g. typically be hitting + * F5 in most browsers). + * In this case, the CSRF token is re-generated by the backend, so no + * CSRF mismatch occurs, but the user is no longer authenticated. and the + * backend responds with a 401 status code. + * + * Note, case 2 also happens if a user directly navigates to a link + * of the form `#/album-id/` or `#/album-id/photo-id` unless the album is + * public, but password protected. + * In that case, the backend also sends a 401 status code, but with a + * special "Password Required" exception which is handled specially in + * `album.js`. + * + * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. + * @param {?LycheeException} lycheeException the Lychee exception + * + * @returns {boolean} + */ +api.hasSessionExpired = function (jqXHR, lycheeException) { + return ( + (jqXHR.status === 419 && !!lycheeException && lycheeException.exception.endsWith("SessionExpiredException")) || + (jqXHR.status === 401 && !!lycheeException && lycheeException.exception.endsWith("UnauthenticatedException")) + ); }; -api.post = function (fn, params, successCallback, responseProgressCB = null, errorCallback = null) { +/** + * + * @param {string} fn + * @param {Object} params + * @param {?APISuccessCB} successCallback + * @param {?APIProgressCB} responseProgressCB + * @param {?APIErrorCB} errorCallback + * @returns {void} + */ +api.post = function (fn, params, successCallback = null, responseProgressCB = null, errorCallback = null) { loadingBar.show(); - params = $.extend({ function: fn }, params); - - let api_url = "api/" + fn; - - const success = (data) => { + /** + * The success handler + * @param {Object} data the decoded JSON object of the response + */ + const successHandler = (data) => { setTimeout(loadingBar.hide, 100); - - // Catch errors - if (typeof data === "string" && data.substring(0, 7) === "Error: ") { - api.onError(data.substring(7, data.length), params, data); - return false; - } - if (successCallback) successCallback(data); }; - const error = (jqXHR, textStatus, errorThrown) => { + /** + * The error handler + * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. + */ + const errorHandler = (jqXHR) => { + /** + * @type {?LycheeException} + */ + const lycheeException = jqXHR.responseJSON; + if (errorCallback) { - let isHandled = errorCallback(jqXHR); - if (isHandled) return; + let isHandled = errorCallback(jqXHR, params, lycheeException); + if (isHandled) { + setTimeout(loadingBar.hide, 100); + return; + } } // Call global error handler for unhandled errors - api.onError(api.isTimeout(errorThrown, jqXHR) ? "Session timed out." : "Server error or API not found.", params, errorThrown); + api.onError(jqXHR, params, lycheeException); }; let ajaxParams = { type: "POST", - url: api_url, + url: "api/" + fn, contentType: "application/json", data: JSON.stringify(params), dataType: "json", - success, - error, + headers: { + "X-XSRF-TOKEN": csrf.getCSRFCookieValue(), + }, + success: successHandler, + error: errorHandler, }; if (responseProgressCB !== null) { @@ -75,23 +132,31 @@ api.post = function (fn, params, successCallback, responseProgressCB = null, err $.ajax(ajaxParams); }; -api.get = function (url, callback) { +/** + * + * @param {string} url + * @param {APISuccessCB} callback + * @returns {void} + */ +api.getCSS = function (url, callback) { loadingBar.show(); - const success = (data) => { + /** + * The success handler + * @param {Object} data the decoded JSON object of the response + */ + const successHandler = (data) => { setTimeout(loadingBar.hide, 100); - // Catch errors - if (typeof data === "string" && data.substring(0, 7) === "Error: ") { - api.onError(data.substring(7, data.length), params, data); - return false; - } - callback(data); }; - const error = (jqXHR, textStatus, errorThrown) => { - api.onError(api.isTimeout(errorThrown, jqXHR) ? "Session timed out." : "Server error or API not found.", {}, errorThrown); + /** + * The error handler + * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. + */ + const errorHandler = (jqXHR) => { + api.onError(jqXHR, {}, null); }; $.ajax({ @@ -99,40 +164,10 @@ api.get = function (url, callback) { url: url, data: {}, dataType: "text", - success, - error, - }); -}; - -api.post_raw = function (fn, params, callback) { - loadingBar.show(); - - params = $.extend({ function: fn }, params); - - let api_url = "api/" + fn; - - const success = (data) => { - setTimeout(loadingBar.hide, 100); - - // Catch errors - if (typeof data === "string" && data.substring(0, 7) === "Error: ") { - api.onError(data.substring(7, data.length), params, data); - return false; - } - - callback(data); - }; - - const error = (jqXHR, textStatus, errorThrown) => { - api.onError(api.isTimeout(errorThrown, jqXHR) ? "Session timed out." : "Server error or API not found.", params, errorThrown); - }; - - $.ajax({ - type: "POST", - url: api_url, - data: params, - dataType: "text", - success, - error, + headers: { + "X-XSRF-TOKEN": csrf.getCSRFCookieValue(), + }, + success: successHandler, + error: errorHandler, }); }; diff --git a/scripts/csrf_protection.js b/scripts/csrf_protection.js index 6003a88d..745ed55d 100644 --- a/scripts/csrf_protection.js +++ b/scripts/csrf_protection.js @@ -1,21 +1,25 @@ -let csrf = {}; +const csrf = {}; -csrf.addLaravelCSRF = function (event, jqxhr, settings) { - if (settings.url !== lychee.updatePath) { - jqxhr.setRequestHeader("X-XSRF-TOKEN", csrf.getCookie("XSRF-TOKEN")); - } -}; - -csrf.escape = function (s) { - return s.replace(/([.*+?\^${}()|\[\]\/\\])/g, "\\$1"); -}; - -csrf.getCookie = function (name) { - // we stop the selection at = (default json) but also at % to prevent any %3D at the end of the string - var match = document.cookie.match(RegExp("(?:^|;\\s*)" + csrf.escape(name) + "=([^;^%]*)")); - return match ? match[1] : null; -}; - -csrf.bind = function () { - $(document).on("ajaxSend", csrf.addLaravelCSRF); +/** + * Returns the value of the CSRF token. + * + * Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2 + * + * @returns {?string} + */ +csrf.getCSRFCookieValue = function () { + const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row)); + // We must remove all '%3D' from the end of the string. + // Background: + // The actual binary value of the CSFR value is encoded in Base64. + // If the length of original, binary value is not a multiple of 3 bytes, + // the encoding gets padded with `=` on the right; i.e. there might be + // zero, one or two `=` at the end of the encoded value. + // If the value is sent from the server to the client as part of a cookie, + // the `=` character is URL-encoded as `%3D`, because `=` is already used + // to separate a cookie key from its value. + // When we send back the value to the server as part of an AJAX request, + // Laravel expects an unpadded value. + // Hence, we must remove the `%3D`. + return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null; }; diff --git a/scripts/frame/main.js b/scripts/frame/main.js index 987905e2..2bfa7ceb 100644 --- a/scripts/frame/main.js +++ b/scripts/frame/main.js @@ -1,11 +1,38 @@ +/** + * @description Used as an alternative `main` to view photos with "frame mode" + * + * Note, the build script picks a subset of the JS files to build a variant + * of the JS code for the special "frame mode". + * As this variant does not include all JS files, some objects are missing. + * Hence, we must partially re-implement these objects to the extent which is + * required by the methods we call. + * + * This approach is very tedious and error-prone, because we actually + * duplicate code. + * This variant of a sub-implementation only exists, because it saves some + * AJAX calls. + * For example certain meta-data about the viewed photo (e.g. tags) is not + * fetch via AJAX, but inlined by the backend into the eventual HTML page. + */ + // Sub-implementation of lychee -------------------------------------------------------------- // -let lychee = { - api_V2: true, -}; +const lychee = {}; lychee.content = $(".content"); +/** + * DON'T USE THIS METHOD. + * + * TODO: Find all invocations of this method and nuke them. + * + * This method does not cover all potentially dangerous characters and this + * method should not be required on the first place. + * jQuery and even native JS has better methods for this in the year 2022! + * + * @param {string} [html=""] + * @returns {string} + */ lychee.escapeHTML = function (html = "") { // Ensure that html is a string html += ""; @@ -22,6 +49,20 @@ lychee.escapeHTML = function (html = "") { return html; }; +/** + * Creates a HTML string with some fancy variable substitution. + * + * Actually, this method should not be required in the year 2022. + * jQuery and even native JS should probably provide a suitable alternative. + * But this method is used so ubiquitous that it might be difficult to get + * rid of it. + * + * TODO: Try it nonetheless. + * + * @param literalSections + * @param substs + * @returns {string} + */ lychee.html = function (literalSections, ...substs) { // Use raw literal sections: we don’t want // backslashes (\n etc.) to be interpreted @@ -52,26 +93,38 @@ lychee.html = function (literalSections, ...substs) { return result; }; +/** + * @returns {string} - either `"touchend"` or `"click"` + */ lychee.getEventName = function () { - let touchendSupport = + const touchendSupport = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent || navigator.vendor || window.opera) && "ontouchend" in document.documentElement; return touchendSupport === true ? "touchend" : "click"; }; // Sub-implementation of lychee -------------------------------------------------------------- // -let frame = { +const frame = { + /** @type {number} */ refresh: 30000, + /** @type {?Photo} */ + photo: null, }; +/** + * @returns {void} + */ frame.start_blur = function () { - let img = document.getElementById("background"); - let canvas = document.getElementById("background_canvas"); + const img = document.getElementById("background"); + const canvas = document.getElementById("background_canvas"); StackBlur.image(img, canvas, 20); canvas.style.width = "100%"; canvas.style.height = "100%"; }; +/** + * @returns {void} + */ frame.next = function () { $("body").removeClass("loaded"); setTimeout(function () { @@ -79,89 +132,114 @@ frame.next = function () { }, 1000); }; +/** + * @returns {void} + */ frame.refreshPicture = function () { - api.post("Photo::getRandom", {}, function (data) { - if (data.size_variants === null || (data.size_variants.original === null && data.size_variants.medium === null)) { - console.log("URL not found"); - } - if (data.size_variants.thumb === null) console.log("Thumb not found"); - - $("#background").attr("src", data.size_variants.thumb.url); - - let srcset = ""; - let src = ""; - this.frame.photo = null; - if (data.size_variants.medium !== null) { - src = data.size_variants.medium.url; + api.post( + "Photo::getRandom", + {}, + /** @param {Photo} data */ + function (data) { + if (data.size_variants.thumb) { + $("#background").attr("src", data.size_variants.thumb.url); + } else { + $("#background").removeAttr("src"); + console.log("Thumb not found"); + } - if (data.size_variants.medium2x !== null) { - srcset = `${data.size_variants.medium.url} ${data.size_variants.medium.width}w, ${data.size_variants.medium2x.url} ${data.size_variants.medium2x.width}w`; - // We use it in the resize callback. - this.frame.photo = data; + let srcset = ""; + let src = ""; + frame.photo = null; + if (data.size_variants.medium !== null) { + src = data.size_variants.medium.url; + + if (data.size_variants.medium2x !== null) { + srcset = `${data.size_variants.medium.url} ${data.size_variants.medium.width}w, ${data.size_variants.medium2x.url} ${data.size_variants.medium2x.width}w`; + // We use it in the resize callback. + this.frame.photo = data; + } + } else { + src = data.size_variants.original.url; } - } else { - src = data.size_variants.original.url; - } - $("#picture").attr("srcset", srcset); - frame.resize(); - $("#picture").attr("src", src).css("display", "inline"); + $("#picture").attr("srcset", srcset); + frame.resize(); + $("#picture").attr("src", src).css("display", "inline"); - setTimeout(function () { - frame.next(); - }, frame.refresh); - }); + setTimeout(function () { + frame.next(); + }, frame.refresh); + } + ); }; +/** + * @param {FrameSettings} data + * @returns {void} + */ frame.set = function (data) { - // console.log(data.refresh); - frame.refresh = data.refresh ? parseInt(data.refresh, 10) + 1000 : 31000; // 30 sec + 1 sec of blackout - // console.log(frame.refresh); + frame.refresh = data.refresh + 1000; // + 1 sec of blackout frame.refreshPicture(); }; +/** + * @returns {void} + */ frame.resize = function () { if (this.photo) { - let ratio = + const ratio = this.photo.size_variants.original.height > 0 ? this.photo.size_variants.original.width / this.photo.size_variants.original.height : 1; - let winWidth = $(window).width(); - let winHeight = $(window).height(); + const winWidth = $(window).width(); + const winHeight = $(window).height(); // Our math assumes that the image occupies the whole frame. That's // not quite the case (the default css sets it to 95%) but it's close // enough. - let width = winWidth / ratio > winHeight ? winHeight * ratio : winWidth; + const width = winWidth / ratio > winHeight ? winHeight * ratio : winWidth; $("#picture").attr("sizes", width + "px"); } }; -frame.error = function (errorThrown, params = "", data = "") { - loadingBar.show("error", errorThrown); - - console.error({ - description: errorThrown, +/** + * + * @param {XMLHttpRequest} jqXHR + * @param {Object} params the original JSON parameters of the request + * @param {?LycheeException} lycheeException the Lychee Exception + * @returns {boolean} + */ +frame.handleAPIError = function (jqXHR, params, lycheeException) { + const msg = jqXHR.statusText + (lycheeException ? " - " + lycheeException.message : ""); + loadingBar.show("error", msg); + console.error("The server returned an error response", { + description: msg, params: params, - response: data, + response: lycheeException, }); - alert(errorThrown); + alert(msg); + return true; }; // Main -------------------------------------------------------------- // -let loadingBar = { - show() {}, - hide() {}, +const loadingBar = { + /** + * @param {?string} status the status, either `null`, `"error"` or `"success"` + * @param {?string} errorText the error text to show + * @returns {void} + */ + show(status, errorText) {}, + + /** + * @param {?boolean} force + */ + hide(force) {}, }; -let imageview = $("#imageview"); - $(function () { - // set CSRF protection (Laravel) - csrf.bind(); - // Set API error handler - api.onError = frame.error; + api.onError = frame.handleAPIError; $(window).on("resize", function () { frame.resize(); @@ -175,7 +253,12 @@ $(function () { $("body").addClass("loaded"); }); - api.post("Frame::getSettings", {}, function (data) { - frame.set(data); - }); + api.post( + "Frame::getSettings", + {}, + /** @param {FrameSettings} data */ + function (data) { + frame.set(data); + } + ); }); diff --git a/scripts/landing/landing.js b/scripts/landing/landing.js index 90132c01..7f782e67 100644 --- a/scripts/landing/landing.js +++ b/scripts/landing/landing.js @@ -1,98 +1,51 @@ -let landing = { - galleryGrid: null, - loaderPerc: null, - load_wrap: null, -}; - -landing.init = function () { - this.load_wrap = $("#load_wrap"); -}; - -landing.endLoader = function () { - clearInterval(loaderPerc); -}; +const landing = {}; +/** + * @returns {void} + */ landing.runInitAnimations = function () { - if ($("#loader_wrap").length > 0) { - $("#loader_wrap").fadeOut(1000); - } + $("#loader_wrap").fadeOut(1000); - if ($(".animate-down").length > 0) { - $(".animate-down").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } + $(".animate-down").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); + }); - if ($(".animate-up").length > 0) { - $(".animate-up").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } + $(".animate-up").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); + }); - if ($(".pop-in").length > 0) { - $(".pop-in").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } + $(".pop-in").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); + }); - if ($(".pop-out").length > 0) { - $(".pop-out").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } + $(".pop-out").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); + }); }; +/** + * @returns {void} + */ landing.runInitAnimationsHome = function () { - if ($(".pop-in").length > 0) { - $(".pop-in").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); + $(".pop-in").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); + }); + + const onFadedOut = function () { + $(".pop-in-last").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); }); - } - setTimeout(function () { - $("#intro").fadeOut(1000, function () { - if ($(".pop-in-last").length > 0) { - $(".pop-in-last").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } - if ($(".animate-down").length > 0) { - $(".animate-down").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } + $(".animate-down").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); + }); - if ($(".animate-up").length > 0) { - $(".animate-up").each(function (index) { - var $this = $(this); - setTimeout(function () { - $this.addClass("toggled"); - }, 100 * index); - }); - } + $(".animate-up").each(function (index) { + setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); }); - }, 2500); + }; + + setTimeout(() => $("#intro").fadeOut(1000, onFadedOut), 2500); }; $(document).ready(function () { diff --git a/scripts/main/_swipe.jquery.js b/scripts/main/_swipe.jquery.js index fc9b81cf..8872a450 100644 --- a/scripts/main/_swipe.jquery.js +++ b/scripts/main/_swipe.jquery.js @@ -1,6 +1,6 @@ (function ($) { - var Swipe = function (el) { - var self = this; + const Swipe = function (el) { + const self = this; this.el = $(el); this.pos = { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } }; @@ -22,19 +22,19 @@ Swipe.prototype = { touchStart: function (e) { - var touch = e.originalEvent.touches[0]; + const touch = e.originalEvent.touches[0]; this.swipeStart(e, touch.pageX, touch.pageY); }, touchMove: function (e) { - var touch = e.originalEvent.touches[0]; + const touch = e.originalEvent.touches[0]; this.swipeMove(e, touch.pageX, touch.pageY); }, mouseDown: function (e) { - var self = this; + const self = this; this.swipeStart(e, e.pageX, e.pageY); diff --git a/scripts/main/album.js b/scripts/main/album.js index 984b9abf..44da36c0 100644 --- a/scripts/main/album.js +++ b/scripts/main/album.js @@ -2,34 +2,55 @@ * @description Takes care of every action an album can handle and execute. */ -let album = { +const album = { + /** @type {(?Album|?TagAlbum|?SearchAlbum)} */ json: null, }; +/** + * @param {?string} id + * @returns {boolean} + */ album.isSmartID = function (id) { - return id === "unsorted" || id === "starred" || id === "public" || id === "recent"; + return id === SmartAlbumID.UNSORTED || id === SmartAlbumID.STARRED || id === SmartAlbumID.PUBLIC || id === SmartAlbumID.RECENT; +}; + +/** + * @param {?string} id + * @returns {boolean} + */ +album.isSearchID = function (id) { + return id === SearchAlbumID; }; +/** + * @param {?string} id + * @returns {boolean} + */ album.isModelID = function (id) { - return typeof id === "string" && id.length === 24; + return typeof id === "string" && /^[-_0-9a-zA-Z]{24}$/.test(id); }; +/** + * @returns {?string} + */ album.getParentID = function () { - if (album.json == null || album.isSmartID(album.json.id) === true || !album.json.parent_id) { + if (album.json === null || album.isSmartID(album.json.id) || album.isSearchID(album.json.id) || !album.json.parent_id) { return null; } return album.json.parent_id; }; /** - * @return {?string} + * @returns {?string} the album ID */ album.getID = function () { + /** @type {?string} */ let id = null; // this is a Lambda let isID = (_id) => { - return album.isSmartID(_id) || album.isModelID(_id); + return album.isSmartID(_id) || /*album.isSearchID(_id) || */ album.isModelID(_id); }; if (photo.json) id = photo.json.album_id; @@ -44,15 +65,20 @@ album.getID = function () { else return null; }; +/** + * @returns {boolean} + */ album.isTagAlbum = function () { return album.json && album.json.is_tag_album && album.json.is_tag_album === true; }; +/** + * @param {?string} photoID + * @returns {?Photo} the photo model + */ album.getByID = function (photoID) { - // Function returns the JSON of a photo - if (photoID == null || !album.json || !album.json.photos) { - lychee.error("Error: Album json not found !"); + loadingBar.show("error", "Error: Album json not found !"); return null; } @@ -64,54 +90,65 @@ album.getByID = function (photoID) { i++; } - lychee.error("Error: photo " + photoID + " not found !"); + loadingBar.show("error", "Error: photo " + photoID + " not found !"); return null; }; +/** + * Returns the sub-album of the current album by ID, if found. + * + * Note: If the current album is the special {@link SearchAlbum}, then + * also {@link TagAlbum} may be returned as a "sub album". + * + * @param {?string} albumID + * @returns {(?Album|?TagAlbum)} the sub-album model + */ album.getSubByID = function (albumID) { - // Function returns the JSON of a subalbum + // The special `SearchAlbum` may also contain `TagAlbum` as sub-albums + if (albumID == null || !album.json || (!album.json.albums && !album.json.tag_albums)) { + loadingBar.show("error", "Error: Album json not found!"); + return null; + } - if (albumID == null || !album.json || !album.json.albums) { - lychee.error("Error: Album json not found!"); - return undefined; + const subAlbum = album.json.albums ? album.json.albums.find((a) => a.id === albumID) : null; + if (subAlbum) { + return subAlbum; } - let i = 0; - while (i < album.json.albums.length) { - if (album.json.albums[i].id === albumID) { - return album.json.albums[i]; - } - i++; + const subTagAlbum = album.json.tag_albums ? album.json.tag_albums.find((a) => a.id === albumID) : null; + if (subTagAlbum) { + return subTagAlbum; } - lychee.error("Error: album " + albumID + " not found!"); - return undefined; + loadingBar.show("error", "Error: album " + albumID + " not found!"); + return null; }; -// noinspection DuplicatedCode +/** + * @param {string} photoID + * @returns {void} + */ album.deleteByID = function (photoID) { if (photoID == null || !album.json || !album.json.photos) { - lychee.error("Error: Album json not found !"); - return false; + loadingBar.show("error", "Error: Album json not found !"); + return; } - let deleted = false; - $.each(album.json.photos, function (i) { if (album.json.photos[i].id === photoID) { album.json.photos.splice(i, 1); - deleted = true; return false; } }); - - return deleted; }; -// noinspection DuplicatedCode +/** + * @param {string} albumID + * @returns {boolean} + */ album.deleteSubByID = function (albumID) { if (albumID == null || !album.json || !album.json.albums) { - lychee.error("Error: Album json not found !"); + loadingBar.show("error", "Error: Album json not found !"); return false; } @@ -128,29 +165,41 @@ album.deleteSubByID = function (albumID) { return deleted; }; -album.load = function (albumID, refresh = false) { - let params = { - albumID, - password: "", - }; +/** + * @callback AlbumLoadedCB + * @param {boolean} accessible - `true`, if the album has successfully been + * loaded and parsed; `false`, if the album is + * private or public, but unlocked + * @returns {void} + */ - const processData = function (data) { +/** + * @param {string} albumID + * @param {?AlbumLoadedCB} [albumLoadedCB=null] + * + * @returns {void} + */ +album.load = function (albumID, albumLoadedCB = null) { + /** + * @param {Album} data + */ + const processAlbum = function (data) { album.json = data; - if (refresh === false) { - lychee.animate(".content", "contentZoomOut"); + if (albumLoadedCB === null) { + lychee.animate(lychee.content, "contentZoomOut"); } let waitTime = 300; - // Skip delay when refresh is true + // Skip delay when we have a callback `albumLoadedCB` // Skip delay when opening a blank Lychee - if (refresh === true) waitTime = 0; + if (albumLoadedCB) waitTime = 0; if (!visible.albums() && !visible.photo() && !visible.album()) waitTime = 0; setTimeout(() => { view.album.init(); - if (refresh === false) { + if (albumLoadedCB === null) { lychee.animate(lychee.content, "contentZoomIn"); header.setMode("album"); } @@ -162,7 +211,7 @@ album.load = function (albumID, refresh = false) { if (first_album.length !== 0) { first_album.focus(); } else { - first_photo = $(".photo:first"); + const first_photo = $(".photo:first"); if (first_photo.length !== 0) { first_photo.focus(); } @@ -171,55 +220,97 @@ album.load = function (albumID, refresh = false) { }, waitTime); }; - api.post( - "Album::get", - params, - function (data) { - processData(data); + /** + * @param {Album} data + */ + const successHandler = function (data) { + processAlbum(data); - tabindex.makeFocusable(lychee.content); + tabindex.makeFocusable(lychee.content); - if (lychee.active_focus_on_page_load) { - // Put focus on first element - either album or photo - first_album = $(".album:first"); - if (first_album.length !== 0) { - first_album.focus(); - } else { - first_photo = $(".photo:first"); - if (first_photo.length !== 0) { - first_photo.focus(); - } + if (lychee.active_focus_on_page_load) { + // Put focus on first element - either album or photo + const first_album = $(".album:first"); + if (first_album.length !== 0) { + first_album.focus(); + } else { + const first_photo = $(".photo:first"); + if (first_photo.length !== 0) { + first_photo.focus(); } } - }, - null, - function (jqXHR) { - if (jqXHR.status === 403) { - password.getDialog(albumID, function () { - params.password = password.value; - - api.post("Album::get", params, function (_data) { - albums.refresh(); - processData(_data); - }); - }); - return true; - } + } + + if (albumLoadedCB) albumLoadedCB(true); + }; + + /** + * @param {XMLHttpRequest} jqXHR + * @param {Object} params the original JSON parameters of the request + * @param {?LycheeException} lycheeException the Lychee exception + * @returns {boolean} + */ + const errorHandler = function (jqXHR, params, lycheeException) { + if (jqXHR.status !== 401 && jqXHR.status !== 403) { + // Any other error then unauthenticated or unauthorized + // shall be handled by the global error handler. return false; } - ); -}; -album.parse = function () { - if (!album.json.title) album.json.title = lychee.locale["UNTITLED"]; + if (lycheeException.exception.endsWith("PasswordRequiredException")) { + // If a password is required, then try to unlock the album + // and in case of success, try again to load album with same + // parameters + password.getDialog(albumID, function () { + albums.refresh(); + album.load(albumID, albumLoadedCB); + }); + return true; + } else if (albumLoadedCB) { + // In case we could not successfully load and unlock the album, + // but we have a callback, we call that and consider the error + // handled. + // Note: This case occurs for a single public photo on an + // otherwise non-public album. + album.json = null; + albumLoadedCB(false); + return true; + } else { + // In any other case, let the global error handler deal with the + // problem. + return false; + } + }; + + api.post("Album::get", { albumID: albumID }, successHandler, null, errorHandler); }; +/** + * Creates a new album. + * + * The method optionally calls the provided callback after the new album + * has been created and passes the ID of the newly created album plus the + * provided `IDs`. + * + * Actually, the callback should enclose all additional parameter it needs. + * The parameter `IDs` is not needed by this method itself. + * TODO: Refactor callbacks. + * Also see comments for {@link TargetAlbumSelectedCB} and + * {@link contextMenu.move}. + * + * @param {string[]} [IDs=null] some IDs which are passed on to the callback + * @param {TargetAlbumSelectedCB} [callback=null] called upon successful creation of the album + * + * @returns {void} + */ album.add = function (IDs = null, callback = null) { + /** + * @param {{title: string}} data + * @returns {void} + */ const action = function (data) { // let title = data.title; - const isModelID = (albumID) => typeof albumID === "string" && albumID.length === 24; - if (!data.title.trim()) { basicModal.error("title"); return; @@ -227,12 +318,12 @@ album.add = function (IDs = null, callback = null) { basicModal.close(); - let params = { + const params = { title: data.title, parent_id: null, }; - if (visible.albums() || album.isSmartID(album.json.id)) { + if (visible.albums() || album.isSmartID(album.json.id) || album.isSearchID(album.json.id)) { params.parent_id = null; } else if (visible.album()) { params.parent_id = album.json.id; @@ -240,22 +331,23 @@ album.add = function (IDs = null, callback = null) { params.parent_id = photo.json.album_id; } - api.post("Album::add", params, function (_data) { - if (_data && isModelID(_data.id)) { + api.post( + "Album::add", + params, + /** @param {Album} _data */ + function (_data) { if (IDs != null && callback != null) { - callback(IDs, _data, false); // we do not confirm + callback(IDs, _data.id, false); // we do not confirm } else { albums.refresh(); lychee.goto(_data.id); } - } else { - lychee.error(null, params, _data); } - }); + ); }; basicModal.show({ - body: lychee.html`

${lychee.locale["TITLE_NEW_ALBUM"]}

`, + body: lychee.html`

${lychee.locale["TITLE_NEW_ALBUM"]}

`, buttons: { action: { title: lychee.locale["CREATE_ALBUM"], @@ -269,7 +361,11 @@ album.add = function (IDs = null, callback = null) { }); }; +/** + * @returns {void} + */ album.addByTags = function () { + /** @param {{title: string, tags: string}} data */ const action = function (data) { if (!data.title.trim()) { basicModal.error("title"); @@ -282,25 +378,23 @@ album.addByTags = function () { basicModal.close(); - let params = { - title: data.title, - tags: data.tags, - }; - - api.post("Album::addByTags", params, function (_data) { - const isModelID = (albumID) => typeof albumID === "string" && albumID.length === 24; - if (_data && isModelID(_data.id)) { + api.post( + "Album::addByTags", + { + title: data.title, + tags: data.tags.split(","), + }, + /** @param {TagAlbum} _data */ + function (_data) { albums.refresh(); lychee.goto(_data.id); - } else { - lychee.error(null, params, _data); } - }); + ); }; basicModal.show({ body: lychee.html`

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

`, buttons: { @@ -316,40 +410,52 @@ album.addByTags = function () { }); }; +/** + * @param {string} albumID + * @returns {void} + */ album.setShowTags = function (albumID) { - let oldShowTags = album.json.show_tags; - + /** @param {{show_tags: string}} data */ const action = function (data) { if (!data.show_tags.trim()) { basicModal.error("show_tags"); return; } + const new_show_tags = data.show_tags + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag !== "" && tag.indexOf(",") === -1) + .sort(); - let show_tags = data.show_tags; basicModal.close(); if (visible.album()) { - album.json.show_tags = show_tags; + album.json.show_tags = new_show_tags; view.album.show_tags(); } - let params = { - albumID: albumID, - show_tags: show_tags, - }; - api.post("Album::setShowTags", params, function (_data) { - if (_data) { - lychee.error(null, params, _data); - } else { - album.reload(); - } - }); + api.post( + "Album::setShowTags", + { + albumID: albumID, + show_tags: new_show_tags, + }, + () => album.reload() + ); }; basicModal.show({ - body: lychee.html`

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

`, + body: lychee.html` +

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

`, buttons: { action: { title: lychee.locale["ALBUM_SET_SHOWTAGS"], @@ -363,14 +469,13 @@ album.setShowTags = function (albumID) { }); }; +/** + * + * @param {string[]} albumIDs + * @returns {boolean} + */ album.setTitle = function (albumIDs) { let oldTitle = ""; - let msg = ""; - - if (!albumIDs) return false; - if (!(albumIDs instanceof Array)) { - albumIDs = [albumIDs]; - } if (albumIDs.length === 1) { // Get old title if only one album is selected @@ -380,11 +485,12 @@ album.setTitle = function (albumIDs) { } else oldTitle = album.getSubByID(albumIDs[0]).title; } if (!oldTitle) { - let a = albums.getByID(albumIDs[0]); + const a = albums.getByID(albumIDs[0]); if (a) oldTitle = a.title; } } + /** @param {{title: string}} data */ const action = function (data) { if (!data.title.trim()) { basicModal.error("title"); @@ -393,7 +499,7 @@ album.setTitle = function (albumIDs) { basicModal.close(); - let newTitle = data.title; + const newTitle = data.title; if (visible.album()) { if (albumIDs.length === 1 && album.getID() === albumIDs[0]) { @@ -402,7 +508,7 @@ album.setTitle = function (albumIDs) { album.json.title = newTitle; view.album.title(); - let a = albums.getByID(albumIDs[0]); + const a = albums.getByID(albumIDs[0]); if (a) a.title = newTitle; } else { albumIDs.forEach(function (id) { @@ -417,31 +523,27 @@ album.setTitle = function (albumIDs) { // Rename all albums albumIDs.forEach(function (id) { - let a = albums.getByID(id); + const a = albums.getByID(id); if (a) a.title = newTitle; view.albums.content.title(id); }); } - let params = { - albumIDs: albumIDs.join(), + api.post("Album::setTitle", { + albumIDs: albumIDs, title: newTitle, - }; - - api.post("Album::setTitle", params, function (_data) { - if (_data) { - lychee.error(null, params, _data); - } }); }; - let input = lychee.html``; + const inputHTML = lychee.html``; - if (albumIDs.length === 1) msg = lychee.html`

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

`; - else msg = lychee.html`

${lychee.locale["ALBUMS_NEW_TITLE_1"]} $${albumIDs.length} ${lychee.locale["ALBUMS_NEW_TITLE_2"]} ${input}

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

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

` + : lychee.html`

${lychee.locale["ALBUMS_NEW_TITLE_1"]} $${albumIDs.length} ${lychee.locale["ALBUMS_NEW_TITLE_2"]} ${inputHTML}

`; basicModal.show({ - body: msg, + body: dialogHTML, buttons: { action: { title: lychee.locale["ALBUM_SET_TITLE"], @@ -455,11 +557,16 @@ album.setTitle = function (albumIDs) { }); }; +/** + * @param {string} albumID + * @returns {void} + */ album.setDescription = function (albumID) { - let oldDescription = album.json.description ? album.json.description : ""; + const oldDescription = album.json.description ? album.json.description : ""; + /** @param {{description: string}} data */ const action = function (data) { - let description = data.description ? data.description : null; + const description = data.description ? data.description : null; basicModal.close(); @@ -468,15 +575,9 @@ album.setDescription = function (albumID) { view.album.description(); } - let params = { - albumID, - description, - }; - - api.post("Album::setDescription", params, function (_data) { - if (_data) { - lychee.error(null, params, _data); - } + api.post("Album::setDescription", { + albumID: albumID, + description: description, }); }; @@ -495,54 +596,52 @@ album.setDescription = function (albumID) { }); }; +/** + * @param {string} photoID + * @returns {void} + */ album.toggleCover = function (photoID) { - if (!photoID) return false; - album.json.cover_id = album.json.cover_id === photoID ? null : photoID; - let params = { + const params = { albumID: album.json.id, photoID: album.json.cover_id, }; - api.post("Album::setCover", params, function (data) { - if (data) { - lychee.error(null, params, data); - } else { - view.album.content.cover(photoID); - if (!album.getParentID()) { - albums.refresh(); - } + api.post("Album::setCover", params, function () { + view.album.content.cover(photoID); + if (!album.getParentID()) { + albums.refresh(); } }); }; +/** + * @param {string} albumID + * @returns {void} + */ album.setLicense = function (albumID) { const callback = function () { $("select#license").val(album.json.license === "" ? "none" : album.json.license); - return false; }; + /** @param {{license: string}} data */ const action = function (data) { - let license = data.license; - basicModal.close(); - let params = { - albumID, - license, - }; - - api.post("Album::setLicense", params, function (_data) { - if (_data) { - lychee.error(null, params, _data); - } else { + api.post( + "Album::setLicense", + { + albumID: albumID, + license: data.license, + }, + function () { if (visible.album()) { - album.json.license = params.license; + album.json.license = data.license; view.album.license(); } } - }); + ); }; let msg = lychee.html` @@ -606,82 +705,65 @@ album.setLicense = function (albumID) { }); }; +/** + * @param {string} albumID + * @returns {void} + */ album.setSorting = function (albumID) { const callback = function () { - $("select#sortingCol").val(album.json.sorting_col); - $("select#sortingOrder").val(album.json.sorting_order === null ? "ASC" : album.json.sorting_order); - return false; + 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 */ const action = function (data) { - let sortingCol = data.sortingCol; - let sortingOrder = data.sortingOrder; - basicModal.close(); - let params = { - albumID, - sortingCol, - sortingOrder, - }; - - api.post("Album::setSorting", params, function (_data) { - if (visible.album()) { - album.reload(); + api.post( + "Album::setSorting", + { + albumID: albumID, + sorting_column: data.sortingCol, + sorting_order: data.sortingOrder, + }, + function () { + if (visible.album()) { + album.reload(); + } } - }); + ); }; - let msg = - lychee.html` -
-

` + - lychee.locale["SORT_PHOTO_BY_1"] + - ` - - - - ` + - lychee.locale["SORT_PHOTO_BY_2"] + - ` - - - - ` + - lychee.locale["SORT_PHOTO_BY_3"] + - ` -

-
`; + let msg = lychee.html` +

+ ${lychee.locale["SORT_PHOTO_BY_1"]} + + + + ${lychee.locale["SORT_PHOTO_BY_2"]} + + + + ${lychee.locale["SORT_PHOTO_BY_3"]} +

`; basicModal.show({ body: msg, @@ -699,326 +781,304 @@ album.setSorting = function (albumID) { }); }; -album.setPublic = function (albumID, e) { - let password = ""; - - if (!basicModal.visible()) { - let msg = lychee.html` -
-
- -

${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"]}

-
-
- `; +/** + * Sets the accessibility attributes of an album. + * + * @param {string} albumID + * @returns {void} + */ +album.setProtectionPolicy = function (albumID) { + const action = function (data) { + 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(); - basicModal.show({ - body: msg, - buttons: { - action: { - title: lychee.locale["ALBUM_SHARING_CONFIRM"], - // Call setPublic function without showing the modal - fn: () => album.setPublic(albumID, e), - }, - cancel: { - title: lychee.locale["CANCEL"], - fn: basicModal.close, - }, - }, - }); + // Set data and refresh view + if (visible.album()) { + view.album.nsfw(); + view.album.public(); + view.album.requiresLink(); + view.album.downloadable(); + view.album.shareButtonVisible(); + view.album.password(); + } - $('.basicModal .switch input[name="is_public"]').on("click", function () { - if ($(this).prop("checked") === true) { - $(".basicModal .choice input").attr("disabled", false); - - if (album.json.is_public) { - // Initialize options based on album settings. - if (album.json.grants_full_photo) $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); - if (album.json.requires_link) $('.basicModal .choice input[name="requires_link"]').prop("checked", true); - if (album.json.is_downloadable) $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); - if (album.json.is_share_button_visible) $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", true); - if (album.json.has_password) { - $('.basicModal .choice input[name="has_password"]').prop("checked", true); - $('.basicModal .choice input[name="passwordtext"]').show(); - } - } else { - // Initialize options based on global settings. - if (lychee.grants_full_photo) { - $('.basicModal .choice input[name="grants_full_photo"]').prop("checked", true); - } - if (lychee.is_downloadable) { - $('.basicModal .choice input[name="is_downloadable"]').prop("checked", true); - } - if (lychee.is_share_button_visible) { - $('.basicModal .choice input[name="is_share_button_visible"]').prop("checked", true); - } - } - } else { - $(".basicModal .choice input").prop("checked", false).attr("disabled", true); - $('.basicModal .choice input[name="passwordtext"]').hide(); + const params = { + albumID: albumID, + grants_full_photo: album.json.grants_full_photo, + is_public: album.json.is_public, + is_nsfw: album.json.is_nsfw, + requires_link: album.json.requires_link, + is_downloadable: album.json.is_downloadable, + is_share_button_visible: album.json.is_share_button_visible, + }; + if (album.json.has_password) { + if (newPassword) { + // 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; } - }); - - if (album.json.is_nsfw) { - $('.basicModal .switch input[name="is_nsfw"]').prop("checked", true); } else { - $('.basicModal .switch input[name="is_nsfw"]').prop("checked", false); + params.password = null; } + api.post("Album::setProtectionPolicy", params); + }; + + const msg = lychee.html` +
+
+ +

${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"]}

+
+
+ `; + + 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 .switch input[name="is_public"]').click(); + $(".basicModal .choice input").attr("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); + if (album.json.has_password) { + $('.basicModal .choice input[name="passwordtext"]').show(); + } } else { $(".basicModal .choice input").attr("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(); } - $('.basicModal .choice input[name="has_password"]').on("change", function () { - if ($(this).prop("checked") === true) $('.basicModal .choice input[name="passwordtext"]').show().focus(); - else $('.basicModal .choice input[name="passwordtext"]').hide(); + $('.basicModal .switch input[name="is_public"]').on("change", function () { + $(".basicModal .choice input").attr("disabled", $(this).prop("checked") !== true); }); - return true; - } - - albums.refresh(); - - // Set public - album.json.is_nsfw = $('.basicModal .switch input[name="is_nsfw"]:checked').length === 1; - - // Set public - album.json.is_public = $('.basicModal .switch input[name="is_public"]:checked').length === 1; - - // Set full photo - album.json.grants_full_photo = $('.basicModal .choice input[name="grants_full_photo"]:checked').length === 1; - - // Set visible - album.json.requires_link = $('.basicModal .choice input[name="requires_link"]:checked').length === 1; - - // Set downloadable - album.json.is_downloadable = $('.basicModal .choice input[name="is_downloadable"]:checked').length === 1; + $('.basicModal .choice input[name="has_password"]').on("change", function () { + if ($(this).prop("checked") === true) { + $('.basicModal .choice input[name="passwordtext"]').show().focus(); + } else { + $('.basicModal .choice input[name="passwordtext"]').hide(); + } + }); + }; - // Set share_button_visible - album.json.is_share_button_visible = $('.basicModal .choice input[name="is_share_button_visible"]:checked').length === 1; + basicModal.show({ + body: msg, + callback: dialogSetupCB, + buttons: { + action: { + title: lychee.locale["ALBUM_SHARING_CONFIRM"], + fn: action, + }, + cancel: { + title: lychee.locale["CANCEL"], + fn: basicModal.close, + }, + }, + }); +}; - // Set password - let oldPassword = album.json.password; - if ($('.basicModal .choice input[name="has_password"]:checked').length === 1) { - password = $('.basicModal .choice input[name="passwordtext"]').val(); - album.json.has_password = true; - } else { - password = ""; - album.json.has_password = false; - } +/** + * Lets a user update the sharing settings of an album. + * + * @param {string} albumID + * @returns {void} + */ +album.shareUsers = function (albumID) { + const action = function (data) { + basicModal.close(); - // Modal input has been processed, now it can be closed - basicModal.close(); - - // Set data and refresh view - if (visible.album()) { - view.album.nsfw(); - view.album.public(); - view.album.requiresLink(); - view.album.downloadable(); - view.album.shareButtonVisible(); - view.album.password(); - } + /** @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)); + } + } + }); - let params = { - albumID, - grants_full_photo: album.json.grants_full_photo, - is_public: album.json.is_public, - is_nsfw: album.json.is_nsfw, - requires_link: album.json.requires_link, - is_downloadable: album.json.is_downloadable, - is_share_button_visible: album.json.is_share_button_visible, + if (sharingToDelete.length > 0) { + api.post("Sharing::delete", { + shareIDs: sharingToDelete, + }); + } + if (sharingToAdd.length > 0) { + api.post("Sharing::add", { + albumIDs: [albumID], + userIDs: sharingToAdd, + }); + } }; - if (oldPassword !== album.json.password || password.length > 0) { - // 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 = password; - } - - api.post("Album::setPublic", params, null); -}; -album.shareUsers = function (albumID, e) { - if (!basicModal.visible()) { - let msg = `
-

${lychee.locale["WAIT_FETCH_DATA"]}

-
`; + const msg = `

${lychee.locale["WAIT_FETCH_DATA"]}

`; - api.post("Sharing::List", {}, (data) => { + const dialogSetupCB = function () { + /** @param {SharingInfo} data */ + const successCallback = function (data) { const sharingForm = $("#sharing_people_form"); sharingForm.empty(); - if (data !== undefined) { - if (data.users !== undefined) { - sharingForm.append(`

${lychee.locale["SHARING_ALBUM_USERS_LONG_MESSAGE"]}

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

-
`); - }); - var sharingOfAlbum = data.shared !== undefined ? data.shared.filter((val) => val.album_id === albumID) : []; - sharingOfAlbum.forEach((sharing) => { - // Check all the shares who already exists, and store their sharing id on the element - var elem = $(`.basicModal .choice input[name="${sharing.user_id}"]`); + 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"]}

`); - } + } else { + sharingForm.append(`

${lychee.locale["SHARING_ALBUM_USERS_NO_USERS"]}

`); } - }); - - basicModal.show({ - body: msg, - buttons: { - action: { - title: lychee.locale["ALBUM_SHARING_CONFIRM"], - fn: (data) => { - album.shareUsers(albumID, e); - }, - }, - cancel: { - title: lychee.locale["CANCEL"], - fn: basicModal.close, - }, - }, - }); - return true; - } + }; - basicModal.close(); + api.post("Sharing::list", {}, successCallback); + }; - var sharingToAdd = []; - var sharingToDelete = []; - $(".basicModal .choice input").each((_, input) => { - var $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(input.name); - } - } else { - var sharingId = $input.data("sharingId"); - if (sharingId !== undefined) { - // Input is not checked but has a sharing id => existing share to remove - sharingToDelete.push(sharingId); - } - } + basicModal.show({ + body: msg, + callback: dialogSetupCB, + buttons: { + action: { + title: lychee.locale["ALBUM_SHARING_CONFIRM"], + fn: action, + }, + cancel: { + title: lychee.locale["CANCEL"], + fn: basicModal.close, + }, + }, }); - - if (sharingToDelete.length > 0) { - var params = { ShareIDs: sharingToDelete.join(",") }; - api.post("Sharing::Delete", params, function (data) { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } - }); - } - if (sharingToAdd.length > 0) { - var params = { - albumIDs: albumID, - UserIDs: sharingToAdd.join(","), - }; - api.post("Sharing::Add", params, (data) => { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", "Sharing updated!"); - } - }); - } - - return true; }; -album.setNSFW = function (albumID, e) { +/** + * Toggles the NSFW attribute of the currently loaded album. + * + * @returns {void} + */ +album.toggleNSFW = function () { album.json.is_nsfw = !album.json.is_nsfw; view.album.nsfw(); - let params = { - albumID: albumID, - }; - - api.post("Album::setNSFW", params, function (data) { - if (data) { - lychee.error(null, params, data); - } else { - albums.refresh(); - } - }); + api.post( + "Album::setNSFW", + { + albumID: album.json.id, + is_nsfw: album.json.is_nsfw, + }, + () => albums.refresh() + ); }; +/** + * @param {string} service - either `"twitter"`, `"facebook"` or `"mail"` + * @returns {void} + */ album.share = function (service) { if (album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { return; } - let url = location.href; + const url = location.href; switch (service) { case "twitter": @@ -1033,40 +1093,49 @@ album.share = function (service) { } }; +/** + * @param {string[]} albumIDs + * @returns {void} + */ album.getArchive = function (albumIDs) { - location.href = "api/Album::getArchive" + lychee.html`?albumIDs=${albumIDs.join()}`; + location.href = "api/Album::getArchive?albumIDs=" + albumIDs.join(); }; +/** + * @param {string[]} albumIDs + * @param {?string} albumID + * @param {string} op1 + * @param {string} op2 + * @param {string} ops + * @returns {string} the HTML content of the dialog + */ album.buildMessage = function (albumIDs, albumID, op1, op2, ops) { let title = ""; let sTitle = ""; let msg = ""; - if (!albumIDs) return false; - if (!(albumIDs instanceof Array)) albumIDs = [albumIDs]; - // Get title of first album if (albumID === null) { title = lychee.locale["ROOT"]; } else { - album1 = albums.getByID(albumID); + const album1 = albums.getByID(albumID); if (album1) { title = album1.title; } } // Fallback for first album without a title - if (title === "") title = lychee.locale["UNTITLED"]; + if (!title) title = lychee.locale["UNTITLED"]; if (albumIDs.length === 1) { // Get title of second album - album2 = albums.getByID(albumIDs[0]); + const album2 = albums.getByID(albumIDs[0]); if (album2) { sTitle = album2.title; } // Fallback for second album without a title - if (sTitle === "") sTitle = lychee.locale["UNTITLED"]; + if (!sTitle) sTitle = lychee.locale["UNTITLED"]; msg = lychee.html`

${lychee.locale[op1]} '$${sTitle}' ${lychee.locale[op2]} '$${title}'?

`; } else { @@ -1076,33 +1145,32 @@ album.buildMessage = function (albumIDs, albumID, op1, op2, ops) { return msg; }; +/** + * @param {string[]} albumIDs + * @returns {void} + */ album.delete = function (albumIDs) { let action = {}; let cancel = {}; let msg = ""; - if (!albumIDs) return false; - if (!(albumIDs instanceof Array)) albumIDs = [albumIDs]; - action.fn = function () { basicModal.close(); - let params = { - albumIDs: albumIDs.join(), - }; - - api.post("Album::delete", params, function (data) { - if (visible.albums()) { - albumIDs.forEach(function (id) { - view.albums.content.delete(id); - albums.deleteByID(id); - }); - } else if (visible.album()) { - if (albumIDs.toString() === "unsorted") { - album.reload(); - } else { + 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]) { + if (albumIDs.length === 1 && album.getID() === albumIDs[0]) { lychee.goto(album.getParentID()); } else { albumIDs.forEach(function (id) { @@ -1112,12 +1180,10 @@ album.delete = function (albumIDs) { } } } - - if (typeof data !== "undefined") lychee.error(null, params, data); - }); + ); }; - if (albumIDs.toString() === "unsorted") { + if (albumIDs.length === 1 && albumIDs[0] === "unsorted") { action.title = lychee.locale["CLEAR_UNSORTED"]; cancel.title = lychee.locale["KEEP_UNSORTED"]; @@ -1140,7 +1206,7 @@ album.delete = function (albumIDs) { } // Fallback for album without a title - if (albumTitle === "") albumTitle = lychee.locale["UNTITLED"]; + if (!albumTitle) albumTitle = lychee.locale["UNTITLED"]; msg = lychee.html`

${lychee.locale["DELETE_ALBUM_CONFIRMATION_1"]} '$${albumTitle}' ${lychee.locale["DELETE_ALBUM_CONFIRMATION_2"]}

`; } else { @@ -1166,22 +1232,23 @@ album.delete = function (albumIDs) { }); }; +/** + * @param {string[]} albumIDs + * @param {string} albumID + * @param {boolean} confirm + */ album.merge = function (albumIDs, albumID, confirm = true) { const action = function () { basicModal.close(); - let params = { - albumID: albumID, - albumIDs: albumIDs.join(), - }; - - api.post("Album::merge", params, function (data) { - if (data) { - lychee.error(null, params, data); - } else { - album.reload(); - } - }); + api.post( + "Album::merge", + { + albumID: albumID, + albumIDs: albumIDs, + }, + () => album.reload() + ); }; if (confirm) { @@ -1204,22 +1271,23 @@ album.merge = function (albumIDs, albumID, confirm = true) { } }; +/** + * @param {string[]} albumIDs source IDs + * @param {string} albumID target ID + * @param {boolean} confirm show confirmation dialog? + */ album.setAlbum = function (albumIDs, albumID, confirm = true) { const action = function () { basicModal.close(); - let params = { - albumID: albumID, - albumIDs: albumIDs.join(), - }; - - api.post("Album::move", params, function (data) { - if (data) { - lychee.error(null, params, data); - } else { - album.reload(); - } - }); + api.post( + "Album::move", + { + albumID: albumID, + albumIDs: albumIDs, + }, + () => album.reload() + ); }; if (confirm) { @@ -1242,6 +1310,9 @@ album.setAlbum = function (albumIDs, albumID, confirm = true) { } }; +/** + * @returns {void} + */ album.apply_nsfw_filter = function () { if (lychee.nsfw_visible) { $('.album[data-nsfw="1"]').show(); @@ -1250,12 +1321,14 @@ album.apply_nsfw_filter = function () { } }; -album.toggle_nsfw_filter = function () { - lychee.nsfw_visible = !lychee.nsfw_visible; - album.apply_nsfw_filter(); - return false; -}; - +/** + * Determines whether the user can upload to the currently active album. + * + * For special cases of no album / smart album / etc. we return true. + * It's only for regular, non-matching albums that we return false. + * + * @returns {boolean} + */ album.isUploadable = function () { if (lychee.admin) { return true; @@ -1264,59 +1337,52 @@ album.isUploadable = function () { return false; } - // For special cases of no album / smart album / etc. we return true. - // It's only for regular non-matching albums that we return false. - if (album.json === null || !album.json.owner_name) { - return true; - } - - return album.json.owner_name === lychee.username; + // TODO: Comparison of numeric user IDs (instead of names) should be more robust + return album.json === null || !album.json.owner_name || album.json.owner_name === lychee.username; }; +/** + * @param {Photo} data + */ album.updatePhoto = function (data) { + /** + * @param {?SizeVariant} src + * @returns {?SizeVariant} + */ let deepCopySizeVariant = function (src) { if (src === undefined || src === null) return null; - let result = {}; - result.url = src.url; - result.width = src.width; - result.height = src.height; - result.filesize = src.filesize; - return result; + return { + type: src.type, + url: src.url, + width: src.width, + height: src.height, + filesize: src.filesize, + }; }; - if (album.json) { - $.each(album.json.photos, function () { - if (this.id === data.id) { - // Deep copy size variants - this.size_variants = { - thumb: null, - thumb2x: null, - small: null, - small2x: null, - medium: null, - medium2x: null, - original: null, - }; - if (data.size_variants !== undefined && data.size_variants !== null) { - this.size_variants.thumb = deepCopySizeVariant(data.size_variants.thumb); - this.size_variants.thumb2x = deepCopySizeVariant(data.size_variants.thumb2x); - this.size_variants.small = deepCopySizeVariant(data.size_variants.small); - this.size_variants.small2x = deepCopySizeVariant(data.size_variants.small2x); - this.size_variants.medium = deepCopySizeVariant(data.size_variants.medium); - this.size_variants.medium2x = deepCopySizeVariant(data.size_variants.medium2x); - this.size_variants.original = deepCopySizeVariant(data.size_variants.original); - } - view.album.content.updatePhoto(this); - albums.refresh(); - return false; - } - return true; - }); + if (album.json && album.json.photos) { + const photo = album.json.photos.find((p) => p.id === data.id); + + // Deep copy size variants + photo.size_variants = { + thumb: deepCopySizeVariant(data.size_variants.thumb), + thumb2x: deepCopySizeVariant(data.size_variants.thumb2x), + small: deepCopySizeVariant(data.size_variants.small), + small2x: deepCopySizeVariant(data.size_variants.small2x), + medium: deepCopySizeVariant(data.size_variants.medium), + medium2x: deepCopySizeVariant(data.size_variants.medium2x), + original: deepCopySizeVariant(data.size_variants.original), + }; + view.album.content.updatePhoto(photo); + albums.refresh(); } }; +/** + * @returns {void} + */ album.reload = function () { - let albumID = album.getID(); + const albumID = album.getID(); album.refresh(); albums.refresh(); @@ -1325,6 +1391,9 @@ album.reload = function () { else lychee.goto(); }; +/** + * @returns {void} + */ album.refresh = function () { album.json = null; }; diff --git a/scripts/main/albums.js b/scripts/main/albums.js index 3cbf2beb..edfda321 100644 --- a/scripts/main/albums.js +++ b/scripts/main/albums.js @@ -2,58 +2,62 @@ * @description Takes care of every action albums can handle and execute. */ -let albums = { +const albums = { + /** @type {?Albums} */ json: null, }; +/** + * @returns {void} + */ albums.load = function () { let startTime = new Date().getTime(); - lychee.animate(".content", "contentZoomOut"); + lychee.animate(lychee.content, "contentZoomOut"); - if (albums.json === null) { - api.post("Albums::get", {}, function (data) { - let waitTime; + /** + * @param {Albums} data + */ + const successCallback = function (data) { + // Smart Albums + if (data.smart_albums.length > 0) albums.localizeSmartAlbums(data.smart_albums); - // Smart Albums - if (data.smart_albums != null) albums._createSmartAlbums(data.smart_albums); + albums.json = data; - albums.json = data; + // Skip delay when opening a blank Lychee + const skipDelay = (!visible.albums() && !visible.photo() && !visible.album()) || (visible.album() && lychee.content.html() === ""); + // Calculate delay + const durationTime = new Date().getTime() - startTime; + const waitTime = durationTime > 300 || skipDelay ? 0 : 300 - durationTime; - // Calculate delay - let durationTime = new Date().getTime() - startTime; - if (durationTime > 300) waitTime = 0; - else waitTime = 300 - durationTime; + setTimeout(() => { + header.setMode("albums"); + view.albums.init(); + lychee.animate(lychee.content, "contentZoomIn"); - // Skip delay when opening a blank Lychee - if (!visible.albums() && !visible.photo() && !visible.album()) waitTime = 0; - if (visible.album() && lychee.content.html() === "") waitTime = 0; + tabindex.makeFocusable(lychee.content); - setTimeout(() => { - header.setMode("albums"); - view.albums.init(); - lychee.animate(lychee.content, "contentZoomIn"); - - tabindex.makeFocusable(lychee.content); - - if (lychee.active_focus_on_page_load) { - // Put focus on first element - either album or photo - let first_album = $(".album:first"); - if (first_album.length !== 0) { - first_album.focus(); - } else { - let first_photo = $(".photo:first"); - if (first_photo.length !== 0) { - first_photo.focus(); - } + if (lychee.active_focus_on_page_load) { + // Put focus on first element - either album or photo + let first_album = $(".album:first"); + if (first_album.length !== 0) { + first_album.focus(); + } else { + let first_photo = $(".photo:first"); + if (first_photo.length !== 0) { + first_photo.focus(); } } + } + + setTimeout(() => { + lychee.footer_show(); + }, 300); + }, waitTime); + }; - setTimeout(() => { - lychee.footer_show(); - }, 300); - }, waitTime); - }); + if (albums.json === null) { + api.post("Albums::get", {}, successCallback); } else { setTimeout(() => { header.setMode("albums"); @@ -64,11 +68,11 @@ albums.load = function () { if (lychee.active_focus_on_page_load) { // Put focus on first element - either album or photo - first_album = $(".album:first"); + const first_album = $(".album:first"); if (first_album.length !== 0) { first_album.focus(); } else { - first_photo = $(".photo:first"); + const first_photo = $(".photo:first"); if (first_photo.length !== 0) { first_photo.focus(); } @@ -78,60 +82,52 @@ albums.load = function () { } }; +/** + * @param {(Album|TagAlbum|SmartAlbum)} album + * @returns {void} + */ albums.parse = function (album) { if (!album.thumb) { - album.thumb = {}; - album.thumb.id = ""; - album.thumb.thumb = album.has_password ? "img/password.svg" : "img/no_images.svg"; - album.thumb.type = ""; - album.thumb.thumb2x = null; + album.thumb = { + id: "", + thumb: album.has_password ? "img/password.svg" : "img/no_images.svg", + type: "image/svg+xml", + thumb2x: null, + }; } }; -// TODO: REFACTOR THIS -albums._createSmartAlbums = function (data) { +/** + * Normalizes the built-in smart albums. + * + * @param {SmartAlbums} data + * @returns {void} + */ +albums.localizeSmartAlbums = function (data) { if (data.unsorted) { - data.unsorted = { - id: "unsorted", - title: lychee.locale["UNSORTED"], - created_at: null, - is_unsorted: true, - thumb: data.unsorted.thumb, - }; + data.unsorted.title = lychee.locale["UNSORTED"]; } if (data.starred) { - data.starred = { - id: "starred", - title: lychee.locale["STARRED"], - created_at: null, - is_starred: true, - thumb: data.starred.thumb, - }; + data.starred.title = lychee.locale["STARRED"]; } if (data.public) { - data.public = { - id: "public", - title: lychee.locale["PUBLIC"], - created_at: null, - is_public: true, - requires_link: true, - thumb: data.public.thumb, - }; + data.public.title = lychee.locale["PUBLIC"]; + // TODO: Why do we need to set these two attributes? What component relies upon them, what happens if we don't set them? Is it legacy? + data.public.is_public = true; + data.public.requires_link = true; } if (data.recent) { - data.recent = { - id: "recent", - title: lychee.locale["RECENT"], - created_at: null, - is_recent: true, - thumb: data.recent.thumb, - }; + data.recent.title = lychee.locale["RECENT"]; } }; +/** + * @param {?string} albumID + * @returns {boolean} + */ albums.isShared = function (albumID) { if (albumID == null) return false; if (!albums.json) return false; @@ -139,7 +135,11 @@ albums.isShared = function (albumID) { let found = false; - let func = function () { + /** + * @this {Album} + * @returns {boolean} + */ + const func = function () { if (this.id === albumID) { found = true; return false; // stop the loop @@ -154,78 +154,68 @@ albums.isShared = function (albumID) { return found; }; +/** + * @param {?string} albumID + * @returns {(null|Album|TagAlbum|SmartAlbum)} + */ albums.getByID = function (albumID) { - // Function returns the JSON of an album + if (albumID == null) return null; + if (!albums.json) return null; + if (!albums.json.albums) return null; - if (albumID == null) return undefined; - if (!albums.json) return undefined; - if (!albums.json.albums) return undefined; - - let json = undefined; - - let func = function () { - if (this.id === albumID) { - json = this; - return false; // stop the loop - } - if (this.albums) { - $.each(this.albums, func); - } - }; + if (albums.json.smart_albums.hasOwnProperty(albumID)) { + return albums.json.smart_albums[albumID]; + } - $.each(albums.json.albums, func); + let result = albums.json.tag_albums.find((tagAlbum) => tagAlbum.id === albumID); + if (result) { + return result; + } - if (json === undefined && albums.json.shared_albums !== null) $.each(albums.json.shared_albums, func); + result = albums.json.albums.find((album) => album.id === albumID); + if (result) { + return result; + } - if (json === undefined && albums.json.smart_albums !== null) $.each(albums.json.smart_albums, func); + result = albums.json.shared_albums.find((album) => album.id === albumID); + if (result) { + return result; + } - return json; + return null; }; +/** + * Deletes a top-level album by ID from the cached JSON for albums. + * + * The method is called by {@link album.delete} after a top-level album has + * successfully been deleted at the server-side. + * + * @param {?string} albumID + * @returns {void} + */ albums.deleteByID = function (albumID) { - // Function returns the JSON of an album - // This function is only ever invoked for top-level albums so it - // doesn't need to descend down the albums tree. + if (albumID == null) return; + if (!albums.json) return; + if (!albums.json.albums) return; - if (albumID == null) return false; - if (!albums.json) return false; - if (!albums.json.albums) return false; + let idx = albums.json.albums.findIndex((a) => a.id === albumID); + albums.json.albums.splice(idx, 1); - let deleted = false; + if (idx !== -1) return; - $.each(albums.json.albums, function (i) { - if (albums.json.albums[i].id === albumID) { - albums.json.albums.splice(i, 1); - deleted = true; - return false; // stop the loop - } - }); - - if (deleted === false) { - if (!albums.json.shared_albums) return undefined; - $.each(albums.json.shared_albums, function (i) { - if (albums.json.shared_albums[i].id === albumID) { - albums.json.shared_albums.splice(i, 1); - deleted = true; - return false; // stop the loop - } - }); - } + idx = albums.json.shared_albums.findIndex((a) => a.id === albumID); + albums.json.shared_albums.splice(idx, 1); - if (deleted === false) { - if (!albums.json.smart_albums) return undefined; - $.each(albums.json.smart_albums, function (i) { - if (albums.json.smart_albums[i].id === albumID) { - delete albums.json.smart_albums[i]; - deleted = true; - return false; // stop the loop - } - }); - } + if (idx !== -1) return; - return deleted; + idx = albums.json.tag_albums.findIndex((a) => a.id === albumID); + albums.json.tag_albums.splice(idx, 1); }; +/** + * @returns {void} + */ albums.refresh = function () { albums.json = null; }; diff --git a/scripts/main/build.js b/scripts/main/build.js index 322a41d2..6b167832 100644 --- a/scripts/main/build.js +++ b/scripts/main/build.js @@ -1,48 +1,58 @@ +//noinspection HtmlUnknownTarget + /** * @description This module is used to generate HTML-Code. */ -let build = {}; +const build = {}; +/** + * @param {string} icon + * @param {string} [classes=""] + * + * @returns {string} + */ build.iconic = function (icon, classes = "") { - let html = ""; - - html += lychee.html``; - - return html; + return lychee.html``; }; +/** + * @param {string} title + * @returns {string} + */ build.divider = function (title) { - let html = ""; - - html += lychee.html`

${title}

`; - - return html; + return lychee.html`

${title}

`; }; +/** + * @param {string} id + * @returns {string} + */ build.editIcon = function (id) { - let html = ""; - - html += lychee.html`
${build.iconic("pencil")}
`; - - return html; + return lychee.html`
${build.iconic("pencil")}
`; }; +/** + * @param {number} top + * @param {number} left + * @returns {string} + */ build.multiselect = function (top, left) { return lychee.html`
`; }; -// two additional images that are barely visible seems a bit overkill - use same image 3 times -// if this simplification comes to pass data.types, data.thumbs and data.thumbs2x no longer need to be arrays +/** + * Returns HTML for the thumb of an album. + * + * @param {(Album|TagAlbum)} data + * + * @returns {string} + */ build.getAlbumThumb = function (data) { - let isVideo; - let isRaw; - let thumb; - - isVideo = data.thumb.type && data.thumb.type.indexOf("video") > -1; - isRaw = data.thumb.type && data.thumb.type.indexOf("raw") > -1; - thumb = data.thumb.thumb; - var thumb2x = ""; + const isVideo = data.thumb.type && data.thumb.type.indexOf("video") > -1; + const isRaw = data.thumb.type && data.thumb.type.indexOf("raw") > -1; + const thumb = data.thumb.thumb; + const thumb2x = data.thumb.thumb2x; if (thumb === "uploads/thumb/" && isVideo) { return `Photo thumbnail`; @@ -51,13 +61,17 @@ build.getAlbumThumb = function (data) { return `Photo thumbnail`; } - thumb2x = data.thumb.thumb2x; - return `Photo thumbnail`; }; +/** + * @param {(Album|TagAlbum|SmartAlbum)} data + * @param {boolean} disabled + * + * @returns {string} HTML for the album + */ build.album = function (data, disabled = false) { const formattedCreationTs = lychee.locale.printMonthYear(data.created_at); const formattedMinTs = lychee.locale.printMonthYear(data.min_taken_at); @@ -85,14 +99,13 @@ build.album = function (data, disabled = false) { break; case "oldstyle": default: - if (lychee.sortingAlbums !== "" && data.min_taken_at && data.max_taken_at) { - let sortingAlbums = lychee.sortingAlbums.replace("ORDER BY ", "").split(" "); - if (sortingAlbums[0] === "max_taken_at" || sortingAlbums[0] === "min_taken_at") { + if (lychee.sorting_albums && data.min_taken_at && data.max_taken_at) { + if (lychee.sorting_albums.column === "max_taken_at" || lychee.sorting_albums.column === "min_taken_at") { if (formattedMinTs !== "" && formattedMaxTs !== "") { subtitle = formattedMinTs === formattedMaxTs ? formattedMaxTs : formattedMinTs + " - " + formattedMaxTs; - } else if (formattedMinTs !== "" && sortingAlbums[0] === "min_taken_at") { + } else if (formattedMinTs !== "" && lychee.sorting_albums.column === "min_taken_at") { subtitle = formattedMinTs; - } else if (formattedMaxTs !== "" && sortingAlbums[0] === "max_taken_at") { + } else if (formattedMaxTs !== "" && lychee.sorting_albums.column === "max_taken_at") { subtitle = formattedMaxTs; } } @@ -118,12 +131,12 @@ build.album = function (data, disabled = false) { html += lychee.html`
${build.iconic("warning")} - ${build.iconic("star")} - ${build.iconic("clock")} - ${build.iconic( - "eye" - )} - ${build.iconic("list")} + ${build.iconic("star")} + ${build.iconic("clock")} + ${build.iconic("eye")} + ${build.iconic("list")} ${build.iconic("lock-locked")} ${build.iconic("tag")} ${build.iconic("folder-cover")} @@ -131,7 +144,7 @@ build.album = function (data, disabled = false) { `; } - if ((data.albums && data.albums.length > 0) || (data.hasOwnProperty("has_albums") && data.has_albums === true)) { + if ((data.albums && data.albums.length > 0) || data.has_albums) { html += lychee.html`
${build.iconic("layers")} @@ -143,15 +156,24 @@ build.album = function (data, disabled = false) { return html; }; +/** + * @param {Photo} data + * @param {boolean} disabled + * + * @returns {string} HTML for the photo + */ build.photo = function (data, disabled = false) { let html = ""; let thumbnail = ""; - var thumb2x = ""; - let isCover = data.id === album.json.cover_id; + let thumb2x = ""; + // Note, album.json might not be loaded, if + // a) the photo is a single public photo in a private album + // b) the photo is part of a search result + const isCover = album.json && album.json.cover_id === data.id; - let isVideo = data.type && data.type.indexOf("video") > -1; - let isRaw = data.type && data.type.indexOf("raw") > -1; - let isLivePhoto = data.live_photo_url !== "" && data.live_photo_url !== null; + const isVideo = data.type && data.type.indexOf("video") > -1; + const isRaw = data.type && data.type.indexOf("raw") > -1; + const isLivePhoto = data.live_photo_url !== "" && data.live_photo_url !== null; if (data.size_variants.thumb === null) { if (isLivePhoto) { @@ -162,7 +184,7 @@ build.photo = function (data, disabled = false) { } else if (isRaw) { thumbnail = `Photo thumbnail`; } - } else if (lychee.layout === "0") { + } else if (lychee.layout === 0) { if (data.size_variants.thumb2x !== null) { thumb2x = data.size_variants.thumb2x.url; } @@ -242,10 +264,17 @@ build.photo = function (data, disabled = false) { html += `
`; if (album.isUploadable()) { + // Note, `album.json` might be null, if the photo is displayed as + // part of a search result and therefore the actual parent album + // is not loaded. (The "parent" album is the virtual "search album" + // in this case). + // This also means that the displayed variant of the public badge of + // a photo depends on the availability of the parent album. + // This seems to be an undesired but unavoidable side effect. html += lychee.html` `; @@ -256,6 +285,13 @@ build.photo = function (data, disabled = false) { return html; }; +/** + * @param {Photo} data + * @param {string} overlay_type + * @param {boolean} [next=false] + * + * @returns {string} + */ build.check_overlay_type = function (data, overlay_type, next = false) { let types = ["desc", "date", "exif", "none"]; let idx = types.indexOf(overlay_type); @@ -271,6 +307,10 @@ build.check_overlay_type = function (data, overlay_type, next = false) { } }; +/** + * @param {Photo} data + * @returns {string} + */ build.overlay_image = function (data) { let overlay = ""; switch (build.check_overlay_type(data, lychee.image_overlay_type)) { @@ -308,7 +348,7 @@ build.overlay_image = function (data) { return ( lychee.html`
-

$${data.title}

+

$${data.title ? data.title : lychee.locale["UNTITLED"]}

` + (overlay !== "" ? `

${overlay}

` : ``) + ` @@ -317,19 +357,25 @@ build.overlay_image = function (data) { ); }; -build.imageview = function (data, visibleControls, autoplay) { +/** + * @param {Photo} data + * @param {boolean} areControlsVisible + * @param {boolean} autoplay + * @returns {{thumb: string, html: string}} + */ +build.imageview = function (data, areControlsVisible, autoplay) { let html = ""; let thumb = ""; if (data.type.indexOf("video") > -1) { - html += lychee.html``; } else if (data.type.indexOf("raw") > -1 && data.size_variants.medium === null) { html += lychee.html`big`; } else { let img = ""; @@ -339,7 +385,7 @@ build.imageview = function (data, visibleControls, autoplay) { // See if we have the thumbnail loaded... $(".photo").each(function () { - if ($(this).attr("data-id") && $(this).attr("data-id") == data.id) { + if ($(this).attr("data-id") && $(this).attr("data-id") === data.id) { let thumbimg = $(this).find("img"); if (thumbimg.length > 0) { thumb = thumbimg[0].currentSrc ? thumbimg[0].currentSrc : thumbimg[0].src; @@ -355,11 +401,11 @@ build.imageview = function (data, visibleControls, autoplay) { medium = `srcset='${data.size_variants.medium.url} ${data.size_variants.medium.width}w, ${data.size_variants.medium2x.url} ${data.size_variants.medium2x.width}w'`; } img = - `medium`; } else { - img = `big`; } @@ -396,12 +442,16 @@ build.imageview = function (data, visibleControls, autoplay) { return { html, thumb }; }; -build.no_content = function (typ) { +/** + * @param {string} type - either `"magnifying-glass"`, `"eye"`, `"cog"` or `"question-marks"` + * @returns {string} + */ +build.no_content = function (type) { let html = ""; - html += lychee.html`
${build.iconic(typ)}`; + html += lychee.html`
${build.iconic(type)}`; - switch (typ) { + switch (type) { case "magnifying-glass": html += lychee.html`

${lychee.locale["VIEW_NO_RESULT"]}

`; break; @@ -421,6 +471,11 @@ build.no_content = function (typ) { 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 = ""; @@ -452,9 +507,15 @@ build.uploadModal = function (title, files) { 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.substr(0, 17) + "..." + name.substr(name.length - 20, 20); + name = name.substring(0, 17) + "..." + name.substring(name.length - 20, name.length); } return lychee.html` @@ -466,22 +527,21 @@ build.uploadNewFile = function (name) { `; }; +/** + * @param {string[]} tags + * @returns {string} + */ build.tags = function (tags) { let html = ""; - let editable = typeof album !== "undefined" ? album.isUploadable() : false; + const editable = album.isUploadable(); - // Search is enabled if logged in (not publicMode) or public seach is enabled - let searchable = lychee.publicMode === false || lychee.public_search === true; + // Search is enabled if logged in (not publicMode) or public search is enabled + const searchable = !lychee.publicMode || lychee.public_search; // build class_string for tag - let a_class = "tag"; - if (searchable) { - a_class = a_class + " search"; - } - - if (typeof tags === "string" && tags !== "") { - tags = tags.split(","); + const a_class = searchable ? "tag search" : "tag"; + if (tags.length !== 0) { tags.forEach(function (tag, index) { if (editable) { html += lychee.html`$${tag}${build.iconic("x")}`; @@ -496,21 +556,25 @@ build.tags = function (tags) { return html; }; +/** + * @param {User} user + * @returns {string} + */ build.user = function (user) { - let html = lychee.html`
+ return lychee.html`

- + @@ -519,24 +583,26 @@ build.user = function (user) { Delete

`; - - return html; }; +/** + * @param {WebAuthnCredential} credential + * @returns {string} + */ build.u2f = function (credential) { return lychee.html`

- + ${credential.id.slice(0, 30)} diff --git a/scripts/main/contextMenu.js b/scripts/main/contextMenu.js index 69598ae6..57023bc1 100644 --- a/scripts/main/contextMenu.js +++ b/scripts/main/contextMenu.js @@ -2,23 +2,26 @@ * @description This module is used for the context menu. */ -let contextMenu = {}; +const contextMenu = {}; +/** + * @param {jQuery.Event} e + */ contextMenu.add = function (e) { let items = [ { title: build.iconic("image") + lychee.locale["UPLOAD_PHOTO"], fn: () => $("#upload_files").click() }, {}, - { title: build.iconic("link-intact") + lychee.locale["IMPORT_LINK"], fn: upload.start.url }, - { title: build.iconic("dropbox", "ionicons") + lychee.locale["IMPORT_DROPBOX"], fn: upload.start.dropbox }, - { title: build.iconic("terminal") + lychee.locale["IMPORT_SERVER"], fn: upload.start.server }, + { title: build.iconic("link-intact") + lychee.locale["IMPORT_LINK"], fn: () => upload.start.url() }, + { title: build.iconic("dropbox", "ionicons") + lychee.locale["IMPORT_DROPBOX"], fn: () => upload.start.dropbox() }, + { title: build.iconic("terminal") + lychee.locale["IMPORT_SERVER"], fn: () => upload.start.server() }, {}, - { title: build.iconic("folder") + lychee.locale["NEW_ALBUM"], fn: album.add }, + { title: build.iconic("folder") + lychee.locale["NEW_ALBUM"], fn: () => album.add() }, ]; if (visible.albums()) { - items.push({ title: build.iconic("tags") + lychee.locale["NEW_TAG_ALBUM"], fn: album.addByTags }); - } else if (album.isSmartID(album.getID())) { - // remove Import and New album if smart album + items.push({ title: build.iconic("tags") + lychee.locale["NEW_TAG_ALBUM"], fn: () => album.addByTags() }); + } else if (album.isSmartID(album.getID()) || album.isSearchID(album.getID())) { + // remove Import and New album if smart album or search results items.splice(1); } @@ -37,14 +40,14 @@ contextMenu.add = function (e) { // For tag albums the context menu is normally not used. items = []; } - if (albumID.length === 24 || albumID === "unsorted") { - if (albumID !== "unsorted") { + if (albumID.length === 24 || albumID === SmartAlbumID.UNSORTED) { + if (albumID !== SmartAlbumID.UNSORTED) { let button_visibility_album = $("#button_visibility_album"); if (button_visibility_album && button_visibility_album.css("display") === "none") { items.unshift({ title: build.iconic("eye") + lychee.locale["VISIBILITY_ALBUM"], visible: lychee.enable_button_visibility, - fn: (event) => album.setPublic(albumID, event), + fn: () => album.setProtectionPolicy(albumID), }); } } @@ -56,7 +59,7 @@ contextMenu.add = function (e) { fn: () => album.delete([albumID]), }); } - if (albumID !== "unsorted") { + if (albumID !== SmartAlbumID.UNSORTED) { if (!album.isTagAlbum()) { let button_move_album = $("#button_move_album"); if (button_move_album && button_move_album.css("display") === "none") { @@ -72,7 +75,7 @@ contextMenu.add = function (e) { items.unshift({ title: build.iconic("warning") + lychee.locale["ALBUM_MARK_NSFW"], visible: true, - fn: () => album.setNSFW(albumID), + fn: () => album.toggleNSFW(), }); } } @@ -84,19 +87,25 @@ contextMenu.add = function (e) { upload.notify(); }; +/** + * @param {string} albumID + * @param {jQuery.Event} e + * + * @returns {void} + */ contextMenu.album = function (albumID, e) { // Notice for 'Merge': // fn must call basicContext.close() first, // in order to keep the selection - if (album.isSmartID(albumID)) return false; + if (album.isSmartID(albumID) || album.isSearchID(albumID)) return; // Show merge-item when there's more than one album // Commented out because it doesn't consider subalbums or shared albums. // let showMerge = (albums.json && albums.json.albums && Object.keys(albums.json.albums).length>1); - let showMerge = true; + const showMerge = true; - let items = [ + const items = [ { title: build.iconic("pencil") + lychee.locale["RENAME"], fn: () => album.setTitle([albumID]) }, { title: build.iconic("collapse-left") + lychee.locale["MERGE"], @@ -108,7 +117,7 @@ contextMenu.album = function (albumID, e) { }, { title: build.iconic("folder") + lychee.locale["MOVE"], - visible: lychee.sub_albums, + visible: true, fn: () => { basicContext.close(); contextMenu.move([albumID], e, album.setAlbum, "ROOT"); @@ -120,7 +129,7 @@ contextMenu.album = function (albumID, e) { if (visible.album()) { // not top level - let myalbum = album.getSubByID(albumID); + const myalbum = album.getSubByID(albumID); if (myalbum.thumb.id) { let coverActive = myalbum.thumb.id === album.json.cover_id; // prepend context menu item @@ -136,17 +145,23 @@ contextMenu.album = function (albumID, e) { basicContext.show(items, e.originalEvent, contextMenu.close); }; +/** + * @param {string[]} albumIDs + * @param {jQuery.Event} e + * + * @returns {void} + */ contextMenu.albumMulti = function (albumIDs, e) { multiselect.stopResize(); // Automatically merge selected albums when albumIDs contains more than one album // Show list of albums otherwise - let autoMerge = albumIDs.length > 1; + const autoMerge = albumIDs.length > 1; // Show merge-item when there's more than one album // Commented out because it doesn't consider subalbums or shared albums. // let showMerge = (albums.json && albums.json.albums && Object.keys(albums.json.albums).length>1); - let showMerge = true; + const showMerge = true; let items = [ { title: build.iconic("pencil") + lychee.locale["RENAME_ALL"], fn: () => album.setTitle(albumIDs) }, @@ -168,7 +183,7 @@ contextMenu.albumMulti = function (albumIDs, e) { }, { title: build.iconic("folder") + lychee.locale["MOVE_ALL"], - visible: lychee.sub_albums, + visible: true, fn: () => { basicContext.close(); contextMenu.move(albumIDs, e, album.setAlbum, "ROOT"); @@ -181,88 +196,104 @@ contextMenu.albumMulti = function (albumIDs, e) { basicContext.show(items, e.originalEvent, contextMenu.close); }; -contextMenu.buildList = function (lists, exclude, action, parent = 0, layer = 0) { - const find = function (excl, id) { - for (let i = 0; i < excl.length; i++) { - if (excl[i] === id) return true; - } - return false; - }; +/** + * @callback ContextMenuActionCB + * + * @param {(Photo|Album)} entity + */ + +/** + * @callback ContextMenuEventCB + * + * @param {jQuery.Event} [e] + * @returns {void} + */ +/** + * @param {(Photo|Album)[]} lists + * @param {string[]} exclude list of IDs to exclude + * @param {ContextMenuActionCB} action + * @param {?string} [parentID=null] parentID + * @param {number} [layer=0] + * + * @returns {{title: string, disabled: boolean, fn: ContextMenuEventCB}[]} + */ +contextMenu.buildList = function (lists, exclude, action, parentID = null, layer = 0) { let items = []; - let i = 0; - while (i < lists.length) { - if ((layer === 0 && !lists[i].parent_id) || lists[i].parent_id === parent) { - let item = lists[i]; + lists.forEach(function (item) { + if ((layer !== 0 || item.parent_id) && item.parent_id !== parentID) return; - let thumb = "img/no_cover.svg"; - if (item.thumb && item.thumb.thumb) { - if (item.thumb.thumb === "uploads/thumb/") { - if (item.thumb.type && item.thumb.type.indexOf("video") > -1) { - thumb = "img/play-icon.png"; - } - } else { - thumb = item.thumb.thumb; + let thumb = "img/no_cover.svg"; + if (item.thumb && item.thumb.thumb) { + if (item.thumb.thumb === "uploads/thumb/") { + if (item.thumb.type && item.thumb.type.indexOf("video") > -1) { + thumb = "img/play-icon.png"; } - } else if (item.size_variants) { - if (item.size_variants.thumb === null) { - if (item.type && item.type.indexOf("video") > -1) { - thumb = "img/play-icon.png"; - } - } else { - thumb = item.size_variants.thumb.url; + } else { + thumb = item.thumb.thumb; + } + } else if (item.size_variants) { + if (item.size_variants.thumb === null) { + if (item.type && item.type.indexOf("video") > -1) { + thumb = "img/play-icon.png"; } + } else { + thumb = item.size_variants.thumb.url; } + } - if (item.title === "") item.title = lychee.locale["UNTITLED"]; + if (!item.title) item.title = lychee.locale["UNTITLED"]; - let prefix = layer > 0 ? "  ".repeat(layer - 1) + "└ " : ""; + let prefix = layer > 0 ? "  ".repeat(layer - 1) + "└ " : ""; - let html = lychee.html` + let html = lychee.html` ${prefix} - + thumbnail

$${item.title}
`; - items.push({ - title: html, - disabled: find(exclude, item.id), - fn: () => action(item), - }); + items.push({ + title: html, + disabled: exclude.findIndex((id) => id === item.id) !== -1, + fn: () => action(item), + }); - if (item.albums && item.albums.length > 0) { - items = items.concat(contextMenu.buildList(item.albums, exclude, action, item.id, layer + 1)); - } else { - // Fallback for flat tree representation. Should not be - // needed anymore but shouldn't hurt either. - items = items.concat(contextMenu.buildList(lists, exclude, action, item.id, layer + 1)); - } + if (item.albums && item.albums.length > 0) { + items = items.concat(contextMenu.buildList(item.albums, exclude, action, item.id, layer + 1)); + } else { + // Fallback for flat tree representation. Should not be + // needed anymore but shouldn't hurt either. + items = items.concat(contextMenu.buildList(lists, exclude, action, item.id, layer + 1)); } - - i++; - } + }); return items; }; +/** + * @param {?string} albumID + * @param {jQuery.Event} e + * + * @returns {void} + */ contextMenu.albumTitle = function (albumID, e) { api.post("Albums::tree", {}, function (data) { let items = []; - items = items.concat({ title: lychee.locale["ROOT"], disabled: albumID === false, fn: () => lychee.goto() }); + items = items.concat({ title: lychee.locale["ROOT"], disabled: albumID === null, fn: () => lychee.goto() }); if (data.albums && data.albums.length > 0) { items = items.concat({}); - items = items.concat(contextMenu.buildList(data.albums, albumID !== false ? [parseInt(albumID, 10)] : [], (a) => lychee.goto(a.id))); + items = items.concat(contextMenu.buildList(data.albums, albumID !== null ? [albumID] : [], (a) => lychee.goto(a.id))); } if (data.shared_albums && data.shared_albums.length > 0) { items = items.concat({}); - items = items.concat(contextMenu.buildList(data.shared_albums, albumID !== false ? [albumID] : [], (a) => lychee.goto(a.id))); + items = items.concat(contextMenu.buildList(data.shared_albums, albumID !== null ? [albumID] : [], (a) => lychee.goto(a.id))); } - if (albumID !== false && !album.isSmartID(albumID) && album.isUploadable()) { + if (albumID !== null && !album.isSmartID(albumID) && !album.isSearchID(albumID) && album.isUploadable()) { if (items.length > 0) { items.unshift({}); } @@ -274,11 +305,22 @@ contextMenu.albumTitle = function (albumID, e) { }); }; +/** + * @param {string} photoID + * @param {jQuery.Event} e + * + * @returns {void} + */ contextMenu.photo = function (photoID, e) { - let coverActive = photoID === album.json.cover_id; + const coverActive = photoID === album.json.cover_id; - let items = [ - { title: build.iconic("star") + lychee.locale["STAR"], fn: () => photo.setStar([photoID]) }, + const isPhotoStarred = album.getByID(photoID).is_starred; + + const items = [ + { + title: build.iconic("star") + (isPhotoStarred ? lychee.locale["UNSTAR"] : lychee.locale["STAR"]), + fn: () => photo.setStar([photoID], !isPhotoStarred), + }, { title: build.iconic("tag") + lychee.locale["TAGS"], fn: () => photo.editTags([photoID]) }, // for future work, use a list of all the ancestors. { @@ -307,8 +349,8 @@ contextMenu.photo = function (photoID, e) { { title: build.iconic("trash") + lychee.locale["DELETE"], fn: () => photo.delete([photoID]) }, { title: build.iconic("cloud-download") + lychee.locale["DOWNLOAD"], fn: () => photo.getArchive([photoID]) }, ]; - if (album.isSmartID(album.getID()) || album.isTagAlbum()) { - // Cover setting not supported for smart or tag albums. + if (album.isSmartID(album.getID()) || album.isSearchID(album.getID) || album.isTagAlbum()) { + // Cover setting not supported for smart or tag albums and search results. items.splice(2, 1); } @@ -317,47 +359,31 @@ contextMenu.photo = function (photoID, e) { basicContext.show(items, e.originalEvent, contextMenu.close); }; -contextMenu.countSubAlbums = function (photoIDs) { - let count = 0; - - let i, j; - - if (album.albums) { - for (i = 0; i < photoIDs.length; i++) { - for (j = 0; j < album.albums.length; j++) { - if (album.albums[j].id === photoIDs[i]) { - count++; - break; - } - } - } - } - - return count; -}; - +/** + * @param {string[]} photoIDs + * @param {jQuery.Event} e + */ contextMenu.photoMulti = function (photoIDs, e) { - // Notice for 'Move All': - // fn must call basicContext.close() first, - // in order to keep the selection and multiselect - let subcount = contextMenu.countSubAlbums(photoIDs); - let photocount = photoIDs.length - subcount; - - if (subcount && photocount) { - multiselect.deselect(".photo.active, .album.active"); - multiselect.close(); - lychee.error("Please select either albums or photos!"); - return; - } - if (subcount) { - contextMenu.albumMulti(photoIDs, e); - return; - } - multiselect.stopResize(); + let arePhotosStarred = false; + let arePhotosNotStarred = false; + photoIDs.forEach(function (id) { + if (album.getByID(id).is_starred) { + arePhotosStarred = true; + } else { + arePhotosNotStarred = true; + } + }); + let items = [ - { title: build.iconic("star") + lychee.locale["STAR_ALL"], fn: () => photo.setStar(photoIDs) }, + // Only show the star/unstar menu item when the selected photos are + // consistently either all starred or all not starred. + { + title: build.iconic("star") + (arePhotosNotStarred ? lychee.locale["STAR_ALL"] : lychee.locale["UNSTAR_ALL"]), + visible: !(arePhotosStarred && arePhotosNotStarred), + fn: () => photo.setStar(photoIDs, arePhotosNotStarred), + }, { title: build.iconic("tag") + lychee.locale["TAGS_ALL"], fn: () => photo.editTags(photoIDs) }, {}, { title: build.iconic("pencil") + lychee.locale["RENAME_ALL"], fn: () => photo.setTitle(photoIDs) }, @@ -382,15 +408,22 @@ contextMenu.photoMulti = function (photoIDs, e) { basicContext.show(items, e.originalEvent, contextMenu.close); }; +/** + * @param {string} albumID + * @param {string} photoID + * @param {jQuery.Event} e + */ contextMenu.photoTitle = function (albumID, photoID, e) { let items = [{ title: build.iconic("pencil") + lychee.locale["RENAME"], fn: () => photo.setTitle([photoID]) }]; - let data = album.json; + // Note: We can also have a photo without its parent album being loaded + // if the photo is a public photo within a private album + const photos = album.json ? album.json.photos : []; - if (data.photos !== false && data.photos.length > 0) { + if (photos.length > 0) { items.push({}); - items = items.concat(contextMenu.buildList(data.photos, [photoID], (a) => lychee.goto(albumID + "/" + a.id))); + items = items.concat(contextMenu.buildList(photos, [photoID], (a) => lychee.goto(albumID + "/" + a.id))); } if (!album.isUploadable()) { @@ -401,33 +434,34 @@ contextMenu.photoTitle = function (albumID, photoID, e) { basicContext.show(items, e.originalEvent, contextMenu.close); }; +/** + * @param {string} photoID + * @param {jQuery.Event} e + */ contextMenu.photoMore = function (photoID, e) { // Show download-item when // a) We are allowed to upload to the album // b) the photo is explicitly marked as downloadable (v4-only) // c) or, the album is explicitly marked as downloadable - let showDownload = - album.isUploadable() || - (photo.json.hasOwnProperty("is_downloadable") - ? photo.json.is_downloadable - : album.json && album.json.is_downloadable && album.json.is_downloadable); - let showFull = photo.json.size_variants.original.url && photo.json.size_variants.original.url !== ""; - let items = [ - { title: build.iconic("fullscreen-enter") + lychee.locale["FULL_PHOTO"], visible: !!showFull, fn: () => window.open(photo.getDirectLink()) }, - { title: build.iconic("cloud-download") + lychee.locale["DOWNLOAD"], visible: !!showDownload, fn: () => photo.getArchive([photoID]) }, + const showDownload = album.isUploadable() || photo.json.is_downloadable; + const showFull = !!(photo.json.size_variants.original.url && photo.json.size_variants.original.url !== ""); + + const items = [ + { title: build.iconic("fullscreen-enter") + lychee.locale["FULL_PHOTO"], visible: showFull, fn: () => window.open(photo.getDirectLink()) }, + { title: build.iconic("cloud-download") + lychee.locale["DOWNLOAD"], visible: showDownload, fn: () => photo.getArchive([photoID]) }, ]; if (album.isUploadable()) { // prepend further buttons if menu bar is reduced on small screens - let button_visibility = $("#button_visibility"); + const button_visibility = $("#button_visibility"); if (button_visibility && button_visibility.css("display") === "none") { items.unshift({ title: build.iconic("eye") + lychee.locale["VISIBILITY_PHOTO"], visible: lychee.enable_button_visibility, - fn: (event) => photo.setPublic(photo.getID(), event), + fn: () => photo.setProtectionPolicy(photo.getID()), }); } - let button_trash = $("#button_trash"); + const button_trash = $("#button_trash"); if (button_trash && button_trash.css("display") === "none") { items.unshift({ title: build.iconic("trash") + lychee.locale["DELETE"], @@ -435,7 +469,7 @@ contextMenu.photoMore = function (photoID, e) { fn: () => photo.delete([photo.getID()]), }); } - let button_move = $("#button_move"); + const button_move = $("#button_move"); if (button_move && button_move.css("display") === "none") { items.unshift({ title: build.iconic("folder") + lychee.locale["MOVE"], @@ -450,7 +484,7 @@ contextMenu.photoMore = function (photoID, e) { (photo.json.live_photo_url !== "" && photo.json.live_photo_url !== null) ) ) { - let button_rotate_cwise = $("#button_rotate_cwise"); + const button_rotate_cwise = $("#button_rotate_cwise"); if (button_rotate_cwise && button_rotate_cwise.css("display") === "none") { items.unshift({ title: build.iconic("clockwise") + lychee.locale["PHOTO_EDIT_ROTATECWISE"], @@ -458,7 +492,7 @@ contextMenu.photoMore = function (photoID, e) { fn: () => photoeditor.rotate(photo.getID(), 1), }); } - let button_rotate_ccwise = $("#button_rotate_ccwise"); + const button_rotate_ccwise = $("#button_rotate_ccwise"); if (button_rotate_ccwise && button_rotate_ccwise.css("display") === "none") { items.unshift({ title: build.iconic("counterclockwise") + lychee.locale["PHOTO_EDIT_ROTATECCWISE"], @@ -472,23 +506,90 @@ contextMenu.photoMore = function (photoID, e) { basicContext.show(items, e.originalEvent); }; +/** + * @param {Album[]} albums + * @param {string} albumID + * + * @returns {string[]} + */ contextMenu.getSubIDs = function (albums, albumID) { let ids = [albumID]; - let a; - for (a = 0; a < albums.length; a++) { - if (albums[a].parent_id === albumID) { - ids = ids.concat(contextMenu.getSubIDs(albums, albums[a].id)); + albums.forEach(function (album) { + if (album.parent_id === albumID) { + ids = ids.concat(contextMenu.getSubIDs(albums, album.id)); } - if (albums[a].albums && albums[a].albums.length > 0) { - ids = ids.concat(contextMenu.getSubIDs(albums[a].albums, albumID)); + if (album.albums && album.albums.length > 0) { + ids = ids.concat(contextMenu.getSubIDs(album.albums, albumID)); } - } + }); return ids; }; +/** + * @callback TargetAlbumSelectedCB + * + * Called by {@link contextMenu.move} after the user has selected a target ID. + * In most cases, {@link album.setAlbum} or {@link photo.setAlbum} are + * directly used as the callback. + * This design decision is the only reason, why this callback gets more + * parameters than the selected target ID. + * The parameter signature of this callback matches {@link album.setAlbum}. + * + * However, the callback should actually enclose all other parameters it + * needs and only receive the target ID. + * + * TODO: Re-factor callbacks. + * + * @param {string[]} IDs the source IDs + * @param {?string} targetID the ID of the target album + * @param {boolean} [confirm] indicates whether the callback shall show a + * confirmation dialog to the user for whatever to + * callback is going to do + * @returns {void} + */ + +/** + * Shows the context menu with the album tree and allows the user to select a target album. + * + * **ATTENTION:** The name `move` of this method is very badly chosen. + * The method does not move anything, but only shows the menu and reports + * the selected album. + * In particular, the method is used by any operation which needs a target + * album (i.e. merge, copy-to, etc.) + * + * TODO: Find a better name for this function. + * + * The method calls the provided callback after the user has selected a + * target album and passes the ID of the target album together with the + * source `IDs` and the event `e` to the callback. + * + * TODO: Actually the callbacks should enclose all additional parameters (e.g., `IDs`) they need. Refactor the callbacks. + * + * The name of the root node in the context menu may be provided by the caller + * depending on the use-case. + * Keep in mind, that the root album is not visible to the user during normal + * browsing. + * Photos on the root level are stashed away into a virtual album called + * "Unsorted". + * Albums on the root level are shown as siblings, but the root node itself + * is invisible. + * So the user actually sees a forest. + * Hence, the root node should be named differently to meet the user's + * expectations. + * When the user moves/copies/merges photos, then the root node should be + * called "Unsorted". + * When the user moves/copies/merges albums, then the root node should be + * called "Root". + * + * @param {string[]} IDs - IDs of source objects (either album or photo IDs) + * @param {jQuery.Event} e - Some (?) event + * @param {TargetAlbumSelectedCB} callback - to be called after the user has selected a target ID + * @param {string} [kind=UNSORTED] - Name of root album; either "UNSORTED" or "ROOT" + * @param {boolean} [display_root=true] - Whether the root (aka unsorted) album shall be shown + */ contextMenu.move = function (IDs, e, callback, kind = "UNSORTED", display_root = true) { let items = []; @@ -546,15 +647,20 @@ contextMenu.move = function (IDs, e, callback, kind = "UNSORTED", display_root = }); }; +/** + * @param {string} photoID + * @param {jQuery.Event} e + * + * @returns {void} + */ contextMenu.sharePhoto = function (photoID, e) { - // v4+ only - if (photo.json.hasOwnProperty("is_share_button_visible") && !photo.json.is_share_button_visible) { + if (!photo.json.is_share_button_visible) { return; } - let iconClass = "ionicons"; + const iconClass = "ionicons"; - let items = [ + const items = [ { title: build.iconic("twitter", iconClass) + "Twitter", fn: () => photo.share(photoID, "twitter") }, { title: build.iconic("facebook", iconClass) + "Facebook", fn: () => photo.share(photoID, "facebook") }, { title: build.iconic("envelope-closed") + "Mail", fn: () => photo.share(photoID, "mail") }, @@ -565,15 +671,20 @@ contextMenu.sharePhoto = function (photoID, e) { basicContext.show(items, e.originalEvent); }; +/** + * @param {string} albumID + * @param {jQuery.Event} e + * + * @returns {void} + */ contextMenu.shareAlbum = function (albumID, e) { - // v4+ only - if (album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { + if (!album.json.is_share_button_visible) { return; } - let iconClass = "ionicons"; + const iconClass = "ionicons"; - let items = [ + const items = [ { title: build.iconic("twitter", iconClass) + "Twitter", fn: () => album.share("twitter") }, { title: build.iconic("facebook", iconClass) + "Facebook", fn: () => album.share("facebook") }, { title: build.iconic("envelope-closed") + "Mail", fn: () => album.share("mail") }, @@ -585,9 +696,7 @@ contextMenu.shareAlbum = function (albumID, e) { // Copy the url with prefilled password param url += "?password="; } - if (lychee.clipboardCopy(url)) { - loadingBar.show("success", lychee.locale["URL_COPIED_TO_CLIPBOARD"]); - } + navigator.clipboard.writeText(url).then(() => loadingBar.show("success", lychee.locale["URL_COPIED_TO_CLIPBOARD"])); }, }, ]; @@ -595,8 +704,11 @@ contextMenu.shareAlbum = function (albumID, e) { basicContext.show(items, e.originalEvent); }; +/** + * @returns {void} + */ contextMenu.close = function () { - if (!visible.contextMenu()) return false; + if (!visible.contextMenu()) return; basicContext.close(); @@ -606,6 +718,10 @@ contextMenu.close = function () { } }; +/** + * @param {jQuery.Event} e + * @returns {void} + */ contextMenu.config = function (e) { let items = [{ title: build.iconic("cog") + lychee.locale["SETTINGS"], fn: settings.open }]; if (lychee.new_photos_notification) { diff --git a/scripts/main/header.js b/scripts/main/header.js index 64f0ee53..489511d6 100644 --- a/scripts/main/header.js +++ b/scripts/main/header.js @@ -2,15 +2,26 @@ * @description This module takes care of the header. */ -let header = { +/** + * @namespace + * @property {jQuery} _dom + */ +const header = { _dom: $(".header"), }; +/** + * @param {?string} [selector=null] + * @returns {jQuery} + */ header.dom = function (selector) { if (selector == null || selector === "") return header._dom; return header._dom.find(selector); }; +/** + * @returns {void} + */ header.bind = function () { // Event Name let eventName = lychee.getEventName(); @@ -24,19 +35,19 @@ header.bind = function () { else contextMenu.albumTitle(album.getID(), e); }); - header.dom("#button_visibility").on(eventName, function (e) { - photo.setPublic(photo.getID(), e); + header.dom("#button_visibility").on(eventName, function () { + photo.setProtectionPolicy(photo.getID()); }); header.dom("#button_share").on(eventName, function (e) { contextMenu.sharePhoto(photo.getID(), e); }); - header.dom("#button_visibility_album").on(eventName, function (e) { - album.setPublic(album.getID(), e); + header.dom("#button_visibility_album").on(eventName, function () { + album.setProtectionPolicy(album.getID()); }); - header.dom("#button_sharing_album_users").on(eventName, function (e) { - album.shareUsers(album.getID(), e); + header.dom("#button_sharing_album_users").on(eventName, function () { + album.shareUsers(album.getID()); }); header.dom("#button_share_album").on(eventName, function (e) { @@ -82,8 +93,8 @@ header.bind = function () { header.dom("#button_move_album").on(eventName, function (e) { contextMenu.move([album.getID()], e, album.setAlbum, "ROOT", album.getParentID() != null); }); - header.dom("#button_nsfw_album").on(eventName, function (e) { - album.setNSFW(album.getID()); + header.dom("#button_nsfw_album").on(eventName, function () { + album.toggleNSFW(); }); header.dom("#button_move").on(eventName, function (e) { contextMenu.move([photo.getID()], e, photo.setAlbum); @@ -101,7 +112,7 @@ header.bind = function () { album.getArchive([album.getID()]); }); header.dom("#button_star").on(eventName, function () { - photo.setStar([photo.getID()]); + photo.toggleStar(); }); header.dom("#button_rotate_ccwise").on(eventName, function () { photoeditor.rotate(photo.getID(), -1); @@ -128,7 +139,7 @@ header.bind = function () { header.dom(".header__search").on("keyup click", function () { if ($(this).val().length > 0) { lychee.goto("search/" + encodeURIComponent($(this).val())); - } else if (search.hash !== null) { + } else if (search.json !== null) { search.reset(); } }); @@ -137,13 +148,14 @@ header.bind = function () { }); header.bind_back(); - - return true; }; +/** + * @returns {void} + */ header.bind_back = function () { // Event Name - let eventName = lychee.getEventName(); + const eventName = lychee.getEventName(); header.dom(".header__title").on(eventName, function () { if (lychee.landing_page_enable && visible.albums()) { @@ -154,6 +166,9 @@ header.bind_back = function () { }); }; +/** + * @returns {void} + */ header.show = function () { lychee.imageview.removeClass("full"); header.dom().removeClass("header--hidden"); @@ -161,16 +176,19 @@ header.show = function () { tabindex.restoreSettings(header.dom()); photo.updateSizeLivePhotoDuringAnimation(); - - return true; }; +/** + * @returns {void} + */ header.hideIfLivePhotoNotPlaying = function () { // Hides the header, if current live photo is not playing - if (photo.isLivePhotoPlaying() == true) return false; - return header.hide(); + if (!photo.isLivePhotoPlaying()) header.hide(); }; +/** + * @returns {void} + */ header.hide = function () { if (visible.photo() && !visible.sidebar() && !visible.contextMenu() && basicModal.visible() === false) { tabindex.saveSettings(header.dom()); @@ -180,22 +198,26 @@ header.hide = function () { header.dom().addClass("header--hidden"); photo.updateSizeLivePhotoDuringAnimation(); - - return true; } - - return false; }; -header.setTitle = function (title = "Untitled") { +/** + * @param {string} title + * @returns {void} + */ +header.setTitle = function (title) { let $title = header.dom(".header__title"); let html = lychee.html`$${title}${build.iconic("caret-bottom")}`; $title.html(html); - - return true; }; +/** + * + * @param {string} mode either one out of `"public"`, `"albums"`, `"album"`, + * `"photo"`, `"map"` or `"config"` + * @returns {void} + */ header.setMode = function (mode) { if (mode === "albums" && lychee.publicMode === true) mode = "public"; @@ -214,22 +236,22 @@ header.setMode = function (mode) { ); if (lychee.public_search) { - let e = $(".header__search, .header__clear", ".header__toolbar--public"); + const e = $(".header__search, .header__clear", ".header__toolbar--public"); e.show(); tabindex.makeFocusable(e); } else { - let e = $(".header__search, .header__clear", ".header__toolbar--public"); + const e = $(".header__search, .header__clear", ".header__toolbar--public"); e.hide(); tabindex.makeUnfocusable(e); } // Set icon in Public mode if (lychee.map_display_public) { - let e = $(".button--map-albums", ".header__toolbar--public"); + const e = $(".button--map-albums", ".header__toolbar--public"); e.show(); tabindex.makeFocusable(e); } else { - let e = $(".button--map-albums", ".header__toolbar--public"); + const e = $(".button--map-albums", ".header__toolbar--public"); e.hide(); tabindex.makeUnfocusable(e); } @@ -238,7 +260,7 @@ header.setMode = function (mode) { if (lychee.active_focus_on_page_load) { $("#button_signin").focus(); } - return true; + return; case "albums": header.dom().removeClass("header--view"); @@ -256,28 +278,28 @@ header.setMode = function (mode) { // If map is disabled, we should hide the icon if (lychee.map_display) { - let e = $(".button--map-albums", ".header__toolbar--albums"); + const e = $(".button--map-albums", ".header__toolbar--albums"); e.show(); tabindex.makeFocusable(e); } else { - let e = $(".button--map-albums", ".header__toolbar--albums"); + const e = $(".button--map-albums", ".header__toolbar--albums"); e.hide(); tabindex.makeUnfocusable(e); } if (lychee.enable_button_add && lychee.may_upload) { - let e = $(".button_add", ".header__toolbar--albums"); + const e = $(".button_add", ".header__toolbar--albums"); e.show(); tabindex.makeFocusable(e); } else { - let e = $(".button_add", ".header__toolbar--albums"); + const e = $(".button_add", ".header__toolbar--albums"); e.remove(); } - return true; + return; case "album": - let albumID = album.getID(); + const albumID = album.getID(); header.dom().removeClass("header--view"); header @@ -299,37 +321,37 @@ header.setMode = function (mode) { (album.json.photos.length === 0 && album.json.albums && album.json.albums.length === 0) || (!album.isUploadable() && !album.json.is_downloadable) ) { - let e = $("#button_archive"); + const e = $("#button_archive"); e.hide(); tabindex.makeUnfocusable(e); } else { - let e = $("#button_archive"); + const e = $("#button_archive"); e.show(); tabindex.makeFocusable(e); } if (album.json && album.json.hasOwnProperty("is_share_button_visible") && !album.json.is_share_button_visible) { - let e = $("#button_share_album"); + const e = $("#button_share_album"); e.hide(); tabindex.makeUnfocusable(e); } else { - let e = $("#button_share_album"); + const e = $("#button_share_album"); e.show(); tabindex.makeFocusable(e); } // If map is disabled, we should hide the icon if (lychee.publicMode === true ? lychee.map_display_public : lychee.map_display) { - let e = $("#button_map_album"); + const e = $("#button_map_album"); e.show(); tabindex.makeFocusable(e); } else { - let e = $("#button_map_album"); + const e = $("#button_map_album"); e.hide(); tabindex.makeUnfocusable(e); } - if (albumID === "starred" || albumID === "public" || albumID === "recent") { + if (albumID === SmartAlbumID.STARRED || albumID === SmartAlbumID.PUBLIC || albumID === SmartAlbumID.RECENT) { $( "#button_nsfw_album, #button_info_album, #button_trash_album, #button_visibility_album, #button_sharing_album_users, #button_move_album" ).hide(); @@ -345,7 +367,7 @@ header.setMode = function (mode) { "#button_nsfw_album, #button_info_album, #button_trash_album, #button_visibility_album, #button_sharing_album_users, #button_move_album" ) ); - } else if (albumID === "unsorted") { + } else if (albumID === SmartAlbumID.UNSORTED) { $("#button_nsfw_album, #button_info_album, #button_visibility_album, #button_sharing_album_users, #button_move_album").hide(); $("#button_trash_album, .button_add, .header__divider", ".header__toolbar--album").show(); tabindex.makeFocusable($("#button_trash_album, .button_add, .header__divider", ".header__toolbar--album")); @@ -405,35 +427,35 @@ header.setMode = function (mode) { // Remove buttons if needed if (!lychee.enable_button_visibility) { - let e = $("#button_visibility_album", "#button_sharing_album_users", ".header__toolbar--album"); + const e = $("#button_visibility_album", "#button_sharing_album_users", ".header__toolbar--album"); e.remove(); } if (!lychee.enable_button_share) { - let e = $("#button_share_album", ".header__toolbar--album"); + const e = $("#button_share_album", ".header__toolbar--album"); e.remove(); } if (!lychee.enable_button_archive) { - let e = $("#button_archive", ".header__toolbar--album"); + const e = $("#button_archive", ".header__toolbar--album"); e.remove(); } if (!lychee.enable_button_move) { - let e = $("#button_move_album", ".header__toolbar--album"); + const e = $("#button_move_album", ".header__toolbar--album"); e.remove(); } if (!lychee.enable_button_trash) { - let e = $("#button_trash_album", ".header__toolbar--album"); + const e = $("#button_trash_album", ".header__toolbar--album"); e.remove(); } if (!lychee.enable_button_fullscreen || !lychee.fullscreenAvailable()) { - let e = $("#button_fs_album_enter", ".header__toolbar--album"); + const e = $("#button_fs_album_enter", ".header__toolbar--album"); e.remove(); } if (!lychee.enable_button_add) { - let e = $(".button_add", ".header__toolbar--album"); + const e = $(".button_add", ".header__toolbar--album"); e.remove(); } - return true; + return; case "photo": header.dom().addClass("header--view"); @@ -450,31 +472,31 @@ header.setMode = function (mode) { ); // If map is disabled, we should hide the icon if (lychee.publicMode === true ? lychee.map_display_public : lychee.map_display) { - let e = $("#button_map"); + const e = $("#button_map"); e.show(); tabindex.makeFocusable(e); } else { - let e = $("#button_map"); + const e = $("#button_map"); e.hide(); tabindex.makeUnfocusable(e); } if (album.isUploadable()) { - let e = $("#button_trash, #button_move, #button_visibility, #button_star"); + const e = $("#button_trash, #button_move, #button_visibility, #button_star"); e.show(); tabindex.makeFocusable(e); } else { - let e = $("#button_trash, #button_move, #button_visibility, #button_star"); + const e = $("#button_trash, #button_move, #button_visibility, #button_star"); e.hide(); tabindex.makeUnfocusable(e); } if (photo.json && photo.json.hasOwnProperty("is_share_button_visible") && !photo.json.is_share_button_visible) { - let e = $("#button_share"); + const e = $("#button_share"); e.hide(); tabindex.makeUnfocusable(e); } else { - let e = $("#button_share"); + const e = $("#button_share"); e.show(); tabindex.makeFocusable(e); } @@ -489,34 +511,34 @@ header.setMode = function (mode) { ) && !(photo.json.size_variants.original.url && photo.json.size_variants.original.url !== "") ) { - let e = $("#button_more"); + const e = $("#button_more"); e.hide(); tabindex.makeUnfocusable(e); } // Remove buttons if needed if (!lychee.enable_button_visibility) { - let e = $("#button_visibility", ".header__toolbar--photo"); + const e = $("#button_visibility", ".header__toolbar--photo"); e.remove(); } if (!lychee.enable_button_share) { - let e = $("#button_share", ".header__toolbar--photo"); + const e = $("#button_share", ".header__toolbar--photo"); e.remove(); } if (!lychee.enable_button_move) { - let e = $("#button_move", ".header__toolbar--photo"); + const e = $("#button_move", ".header__toolbar--photo"); e.remove(); } if (!lychee.enable_button_trash) { - let e = $("#button_trash", ".header__toolbar--photo"); + const e = $("#button_trash", ".header__toolbar--photo"); e.remove(); } if (!lychee.enable_button_fullscreen || !lychee.fullscreenAvailable()) { - let e = $("#button_fs_enter", ".header__toolbar--photo"); + const e = $("#button_fs_enter", ".header__toolbar--photo"); e.remove(); } if (!lychee.enable_button_more) { - let e = $("#button_more", ".header__toolbar--photo"); + const e = $("#button_more", ".header__toolbar--photo"); e.remove(); } if (!lychee.enable_button_rotate) { @@ -526,7 +548,7 @@ header.setMode = function (mode) { e = $("#button_rotate_ccwise", ".header__toolbar--photo"); e.remove(); } - return true; + return; case "map": header.dom().removeClass("header--view"); header @@ -540,27 +562,28 @@ header.setMode = function (mode) { ".header__toolbar--public, .header__toolbar--album, .header__toolbar--albums, .header__toolbar--photo, .header__toolbar--config" ) ); - return true; + return; case "config": header.dom().addClass("header--view"); header .dom(".header__toolbar--public, .header__toolbar--albums, .header__toolbar--album, .header__toolbar--photo, .header__toolbar--map") .removeClass("header__toolbar--visible"); header.dom(".header__toolbar--config").addClass("header__toolbar--visible"); - return true; + return; } - - return false; }; -// Note that the pull-down menu is now enabled not only for editable -// items but for all of public/albums/album/photo views, so 'editable' is a -// bit of a misnomer at this point... +/** + * Note that the pull-down menu is now enabled not only for editable + * items but for all of public/albums/album/photo views, so 'editable' is a + * bit of a misnomer at this point... + * + * @param {boolean} editable + * @returns {void} + */ header.setEditable = function (editable) { - let $title = header.dom(".header__title"); + const $title = header.dom(".header__title"); if (editable) $title.addClass("header__title--editable"); else $title.removeClass("header__title--editable"); - - return true; }; diff --git a/scripts/main/init.js b/scripts/main/init.js index f9ef2c3e..8f7feabe 100644 --- a/scripts/main/init.js +++ b/scripts/main/init.js @@ -6,13 +6,10 @@ $(document).ready(function () { $("#sensitive_warning").hide(); // Event Name - let eventName = lychee.getEventName(); - - // set CSRF protection (Laravel) - csrf.bind(); + const eventName = lychee.getEventName(); // Set API error handler - api.onError = lychee.error; + api.onError = lychee.handleAPIError; $("html").css("visibility", "visible"); @@ -24,9 +21,9 @@ $(document).ready(function () { // Image View lychee.imageview - .on(eventName, ".arrow_wrapper--previous", photo.previous) - .on(eventName, ".arrow_wrapper--next", photo.next) - .on(eventName, "img, #livephoto", photo.cycle_display_overlay); + .on(eventName, ".arrow_wrapper--previous", () => photo.previous(false)) + .on(eventName, ".arrow_wrapper--next", () => photo.next(false)) + .on(eventName, "img, #livephoto", () => photo.cycle_display_overlay()); // Keyboard Mousetrap.addKeycodes({ @@ -88,7 +85,7 @@ $(document).ready(function () { .bind(["r"], function () { if (album.isUploadable()) { if (visible.album()) { - album.setTitle(album.getID()); + album.setTitle([album.getID()]); return false; } else if (visible.photo()) { photo.setTitle([photo.getID()]); @@ -96,7 +93,11 @@ $(document).ready(function () { } } }) - .bind(["h"], album.toggle_nsfw_filter) + .bind(["h"], function () { + lychee.nsfw_visible = !lychee.nsfw_visible; + album.apply_nsfw_filter(); + return false; + }) .bind(["d"], function () { if (album.isUploadable()) { if (visible.photo()) { @@ -215,7 +216,7 @@ $(document).ready(function () { else if (visible.photo()) lychee.goto(album.getID()); else if (visible.album() && !album.json.parent_id) lychee.goto(); else if (visible.album()) lychee.goto(album.getParentID()); - else if (visible.albums() && search.hash !== null) search.reset(); + else if (visible.albums() && search.json !== null) search.reset(); else if (visible.mapview()) mapview.close(); else if (visible.albums() && lychee.enable_close_tab_on_esc) { window.open("", "_self").close(); @@ -225,18 +226,18 @@ $(document).ready(function () { $(document) // Fullscreen on mobile - .on("touchend", "#imageview #image", function (e) { + .on("touchend", "#imageview #image", function () { // prevent triggering event 'mousemove' // why? this also prevents 'click' from firing which results in unexpected behaviour // unable to reproduce problems arising from 'mousemove' on iOS devices // e.preventDefault(); - if (typeof swipe.obj === "undefined" || (Math.abs(swipe.offsetX) <= 5 && Math.abs(swipe.offsetY) <= 5)) { + if (typeof swipe.obj === null || (Math.abs(swipe.offsetX) <= 5 && Math.abs(swipe.offsetY) <= 5)) { // Toggle header only if we're not moving to next/previous photo; // In this case, swipe.preventNextHeaderToggle is set to true - if (typeof swipe.preventNextHeaderToggle === "undefined" || !swipe.preventNextHeaderToggle) { + if (!swipe.preventNextHeaderToggle) { if (visible.header()) { - header.hide(e); + header.hide(); } else { header.show(); } @@ -253,30 +254,52 @@ $(document).ready(function () { if (visible.photo()) swipe.start($("#imageview #image, #imageview #livephoto")); }) .swipe() - .on("swipeMove", function (e) { - if (visible.photo()) swipe.move(e.swipe); - }) + .on( + "swipeMove", + /** @param {jQuery.Event} e */ function (e) { + if (visible.photo()) swipe.move(e.swipe); + } + ) .swipe() - .on("swipeEnd", function (e) { - if (visible.photo()) swipe.stop(e.swipe, photo.previous, photo.next); - }); + .on( + "swipeEnd", + /** @param {jQuery.Event} e */ function (e) { + if (visible.photo()) swipe.stop(e.swipe, photo.previous, photo.next); + } + ); // Document $(document) // Navigation - .on("click", ".album", function (e) { - multiselect.albumClick(e, $(this)); - }) - .on("click", ".photo", function (e) { - multiselect.photoClick(e, $(this)); - }) + .on( + "click", + ".album", + /** @param {jQuery.Event} e */ function (e) { + multiselect.albumClick(e, $(this)); + } + ) + .on( + "click", + ".photo", + /** @param {jQuery.Event} e */ function (e) { + multiselect.photoClick(e, $(this)); + } + ) // Context Menu - .on("contextmenu", ".photo", function (e) { - multiselect.photoContextMenu(e, $(this)); - }) - .on("contextmenu", ".album", function (e) { - multiselect.albumContextMenu(e, $(this)); - }) + .on( + "contextmenu", + ".photo", + /** @param {jQuery.Event} e */ function (e) { + multiselect.photoContextMenu(e, $(this)); + } + ) + .on( + "contextmenu", + ".album", + /** @param {jQuery.Event} e */ function (e) { + multiselect.albumContextMenu(e, $(this)); + } + ) // Upload .on("change", "#upload_files", function () { basicModal.close(); @@ -290,45 +313,51 @@ $(document).ready(function () { }, false ) - .on("drop", function (e) { - if ( - !album.isUploadable() || - visible.contextMenu() || - basicModal.visible() || - visible.leftMenu() || - visible.config() || - !(visible.album() || visible.albums()) - ) { + .on( + "drop", + /** @param {jQuery.Event} e */ function (e) { + if ( + album.isUploadable() && + !visible.contextMenu() && + !basicModal.visible() && + !visible.leftMenu() && + !visible.config() && + (visible.album() || visible.albums()) + ) { + // Detect if dropped item is a file or a link + if (e.originalEvent.dataTransfer.files.length > 0) { + upload.start.local(e.originalEvent.dataTransfer.files); + } else if (e.originalEvent.dataTransfer.getData("Text").length > 3) { + upload.start.url(e.originalEvent.dataTransfer.getData("Text")); + } + } + return false; } - - // Detect if dropped item is a file or a link - if (e.originalEvent.dataTransfer.files.length > 0) upload.start.local(e.originalEvent.dataTransfer.files); - else if (e.originalEvent.dataTransfer.getData("Text").length > 3) upload.start.url(e.originalEvent.dataTransfer.getData("Text")); - - return false; - }) + ) // click on thumbnail on map - .on("click", ".image-leaflet-popup", function (e) { + .on("click", ".image-leaflet-popup", function () { mapview.goto($(this)); }) // Paste upload - .on("paste", function (e) { - if (e.originalEvent.clipboardData.items) { - const items = e.originalEvent.clipboardData.items; - let filesToUpload = []; - - // Search clipboard items for an image - for (let i = 0; i < items.length; i++) { - if (items[i].type.indexOf("image") !== -1 || items[i].type.indexOf("video") !== -1) { - filesToUpload.push(items[i].getAsFile()); + .on( + "paste", + /** @param {jQuery.Event} e */ function (e) { + if (e.originalEvent.clipboardData.items) { + const items = e.originalEvent.clipboardData.items; + let filesToUpload = []; + + // Search clipboard items for an image + for (let i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image") !== -1 || items[i].type.indexOf("video") !== -1) { + filesToUpload.push(items[i].getAsFile()); + } } - } - if (filesToUpload.length > 0) { // We perform the check so deep because we don't want to // prevent the paste from working in text input fields, etc. if ( + filesToUpload.length > 0 && album.isUploadable() && !visible.contextMenu() && !basicModal.visible() && @@ -337,18 +366,24 @@ $(document).ready(function () { (visible.album() || visible.albums()) ) { upload.start.local(filesToUpload); - } - return false; + return false; + } else { + return true; + } } } - }); + ); // Fullscreen if (lychee.fullscreenAvailable()) $(document).on("fullscreenchange mozfullscreenchange webkitfullscreenchange msfullscreenchange", lychee.fullscreenUpdate); $("#sensitive_warning").on("click", view.album.nsfw_warning.next); + /** + * @param {number} scrollPos + * @returns {void} + */ const rememberScrollPage = function (scrollPos) { if ((visible.albums() && !visible.search()) || visible.album()) { let urls = JSON.parse(localStorage.getItem("scroll")); @@ -372,7 +407,8 @@ $(document).ready(function () { $(window) // resize .on("resize", function () { - if (visible.album() || visible.search()) view.album.content.justify(); + if (visible.album()) view.album.content.justify(album.json ? album.json.photos : []); + if (visible.search()) view.album.content.justify(search.json.photos); if (visible.photo()) view.photo.onresize(); }) // remember scroll positions diff --git a/scripts/main/leftMenu.js b/scripts/main/leftMenu.js index 9843bfa9..c59deb97 100644 --- a/scripts/main/leftMenu.js +++ b/scripts/main/leftMenu.js @@ -2,17 +2,29 @@ * @description This module is used for the context menu. */ -let leftMenu = { +/** + * @namespace + * @property {jQuery} _dom + */ +const leftMenu = { _dom: $(".leftMenu"), }; +/** + * @param {?string} [selector=null] + * @returns {jQuery} + */ leftMenu.dom = function (selector) { if (selector == null || selector === "") return leftMenu._dom; return leftMenu._dom.find(selector); }; -// Note: on mobile we use a context menu instead; please make sure that -// contextMenu.config is kept in sync with any changes here! +/** + * Note: on mobile we use a context menu instead; please make sure that + * contextMenu.config is kept in sync with any changes here! + * + * @returns {void} + */ leftMenu.build = function () { let html = lychee.html` ${lychee.locale["CLOSE"]} @@ -40,7 +52,10 @@ leftMenu.build = function () { leftMenu._dom.html(html); }; -/* Set the width of the side navigation to 250px and the left margin of the page content to 250px */ +/** Set the width of the side navigation to 250px and the left margin of the page content to 250px + * + * @returns {void} + */ leftMenu.open = function () { leftMenu._dom.addClass("leftMenu__visible"); lychee.content.addClass("leftMenu__open"); @@ -57,7 +72,11 @@ leftMenu.open = function () { multiselect.unbind(); }; -/* Set the width of the side navigation to 0 and the left margin of the page content to 0 */ +/** + * Set the width of the side navigation to 0 and the left margin of the page content to 0 + * + * @returns {void} + */ leftMenu.close = function () { leftMenu._dom.removeClass("leftMenu__visible"); lychee.content.removeClass("leftMenu__open"); @@ -74,6 +93,9 @@ leftMenu.close = function () { lychee.load(); }; +/** + * @returns {void} + */ leftMenu.bind = function () { // Event Name let eventName = lychee.getEventName(); @@ -90,34 +112,53 @@ leftMenu.bind = function () { leftMenu.dom("#button_u2f").on(eventName, leftMenu.u2f); leftMenu.dom("#button_sharing").on(eventName, leftMenu.Sharing); leftMenu.dom("#button_update").on(eventName, leftMenu.Update); - - return true; }; +/** + * @returns {void} + */ leftMenu.Logs = function () { view.logs.init(); }; +/** + * @returns {void} + */ leftMenu.Diagnostics = function () { view.diagnostics.init(); }; +/** + * @returns {void} + */ leftMenu.Update = function () { view.update.init(); }; +/** + * @returns {void} + */ leftMenu.Notifications = function () { notifications.load(); }; +/** + * @returns {void} + */ leftMenu.Users = function () { users.list(); }; +/** + * @returns {void} + */ leftMenu.u2f = function () { u2f.list(); }; +/** + * @returns {void} + */ leftMenu.Sharing = function () { sharing.list(); }; diff --git a/scripts/main/loadingBar.js b/scripts/main/loadingBar.js index 8c882d31..fec1bfe4 100644 --- a/scripts/main/loadingBar.js +++ b/scripts/main/loadingBar.js @@ -2,16 +2,27 @@ * @description This module is used to show and hide the loading bar. */ -let loadingBar = { +const loadingBar = { + /** @type {?string} */ status: null, + /** @type {jQuery} */ _dom: $("#loading"), }; +/** + * @param {string} [selector=""] + * @returns {jQuery} + */ loadingBar.dom = function (selector) { if (selector == null || selector === "") return loadingBar._dom; return loadingBar._dom.find(selector); }; +/** + * @param {?string} status the status, either `null`, `"error"` or `"success"` + * @param {?string} errorText the error text to show + * @returns {void} + */ loadingBar.show = function (status, errorText) { if (status === "error") { // Set status @@ -42,7 +53,7 @@ loadingBar.show = function (status, errorText) { clearTimeout(loadingBar._timeout); loadingBar._timeout = setTimeout(() => loadingBar.hide(true), 3000); - return true; + return; } if (status === "success") { @@ -74,7 +85,7 @@ loadingBar.show = function (status, errorText) { clearTimeout(loadingBar._timeout); loadingBar._timeout = setTimeout(() => loadingBar.hide(true), 2000); - return true; + return; } if (loadingBar.status === null) { @@ -90,11 +101,13 @@ loadingBar.show = function (status, errorText) { // Modify loading loadingBar.dom().removeClass("loading uploading error").html("").addClass("loading").show(); }, 1000); - - return true; } }; +/** + * @param {boolean} force + * @returns {void} + */ loadingBar.hide = function (force) { if ((loadingBar.status !== "error" && loadingBar.status !== "success" && loadingBar.status != null) || force) { // Remove status diff --git a/scripts/main/lychee.js b/scripts/main/lychee.js index 5eb9a538..fc167dc7 100644 --- a/scripts/main/lychee.js +++ b/scripts/main/lychee.js @@ -2,7 +2,7 @@ * @description This module provides the basic functions of Lychee. */ -let lychee = { +const lychee = { title: document.title, version: "", versionCode: "", // not really needed anymore @@ -16,30 +16,110 @@ let lychee = { full_photo: true, downloadable: false, public_photos_hidden: true, - share_button_visible: false, // enable only v4+ - api_V2: false, // enable api_V2 - sub_albums: false, // enable sub_albums features - admin: false, // enable admin mode (multi-user) - may_upload: false, // enable possibility to upload (multi-user) - is_locked: false, // locked user (multi-user) + share_button_visible: false, + /** + * Enable admin mode (multi-user) + * @type boolean + */ + admin: false, + /** + * Enable possibility to upload (multi-user) + * @type boolean + */ + may_upload: false, + /** + * Locked user (multi-user) + * @type boolean + */ + is_locked: false, + /** @type {?string} */ username: null, - layout: "1", // 0: Use default, "square" layout. 1: Use Flickr-like "justified" layout. 2: Use Google-like "unjustified" layout - public_search: false, // display Search in publicMode - image_overlay_type: "exif", // current Overlay display type - image_overlay_type_default: "exif", // image overlay type default type - map_display: false, // display photo coordinates on map - map_display_public: false, // display photos of public album on map (user not logged in) - map_display_direction: true, // use the GPS direction data on displayed maps - map_provider: "Wikimedia", // Provider of OSM Tiles - map_include_subalbums: false, // include photos of subalbums on map - location_decoding: false, // retrieve location name from GPS data - location_decoding_caching_type: "Harddisk", // caching mode for GPS data decoding - location_show: false, // show location name - location_show_public: false, // show location name for public albums - swipe_tolerance_x: 150, // tolerance for navigating when swiping images to the left and right on mobile - swipe_tolerance_y: 250, // tolerance for navigating when swiping images up and down - - landing_page_enabled: false, // is landing page enabled ? + /** + * Values: + * + * - `0`: Use default, "square" layout. + * - `1`: Use Flickr-like "justified" layout. + * - `2`: Use Google-like "unjustified" layout + * + * @type {number} + */ + layout: 1, + /** + * Display search in public mode. + * @type boolean + */ + public_search: false, + /** + * Overlay display type + * @type {string} + */ + image_overlay_type: "exif", + /** + * Image overlay type default type + * @type {string} + */ + image_overlay_type_default: "exif", + /** + * Display photo coordinates on map + * @type boolean + */ + map_display: false, + /** + * Display photos of public album on map (user not logged in) + * @type boolean + */ + map_display_public: false, + /** + * Use the GPS direction data on displayed maps + * @type boolean + */ + map_display_direction: true, + /** + * Provider of OSM Tiles + * @type {string} + */ + map_provider: "Wikimedia", + /** + * Include photos of subalbums on map + * @type boolean + */ + map_include_subalbums: false, + /** + * Retrieve location name from GPS data + * @type boolean + */ + location_decoding: false, + /** + * Caching mode for GPS data decoding + * @type {string} + */ + location_decoding_caching_type: "Harddisk", + /** + * Show location name + * @type boolean + */ + location_show: false, + /** + * Show location name for public albums + * @type boolean + */ + location_show_public: false, + /** + * Tolerance for navigating when swiping images to the left and right on mobile + * @type {number} + */ + swipe_tolerance_x: 150, + /** + * Tolerance for navigating when swiping images up and down + * @type {number} + */ + swipe_tolerance_y: 250, + + /** + * Is landing page enabled? + * @type boolean + */ + landing_page_enabled: false, delete_imported: false, import_via_symlink: false, skip_duplicates: false, @@ -69,19 +149,30 @@ let lychee = { enable_close_tab_on_esc: false, enable_tabindex: false, enable_contextmenu_header: true, - hide_content_during_imageview: false, + hide_content_during_imgview: false, device_type: "desktop", - checkForUpdates: "1", + checkForUpdates: true, + /** + * The most recent, available Lychee version encoded as an integer, e.g. 040506 + * @type {number} + */ update_json: 0, update_available: false, new_photos_notification: false, - sortingPhotos: "", - sortingAlbums: "", + /** @type {?SortingCriterion} */ + sorting_photos: null, + /** @type {?SortingCriterion} */ + sorting_albums: null, + /** + * The absolute path of the server-side installation directory of Lychee, e.g. `/var/www/lychee` + * @type {string} + */ location: "", lang: "", - lang_available: {}, + /** @type {string[]} */ + lang_available: [], dropbox: false, dropboxKey: "", @@ -90,21 +181,31 @@ let lychee = { imageview: $("#imageview"), footer: $("#footer"), + /** @type {Locale} */ locale: {}, nsfw_unlocked_albums: [], }; +/** + * @returns {string} + */ lychee.diagnostics = function () { return "/Diagnostics"; }; +/** + * @returns {string} + */ lychee.logs = function () { return "/Logs"; }; +/** + * @returns {void} + */ lychee.aboutDialog = function () { - let msg = lychee.html` + const msg = lychee.html`

Lychee ${lychee.version}

${lychee.locale["ABOUT_SUBTITLE"]}

@@ -121,7 +222,7 @@ lychee.aboutDialog = function () { }, }); - if (lychee.checkForUpdates === "1") lychee.getUpdate(); + if (lychee.checkForUpdates) lychee.getUpdate(); }; /** @@ -129,232 +230,212 @@ lychee.aboutDialog = function () { * for re-initialization to prevent * multiple registrations of global * event handlers + * @returns {void} */ lychee.init = function (isFirstInitialization = true) { lychee.adjustContentHeight(); - api.post("Session::init", {}, function (data) { - if (data.status === 0) { - // No configuration - - lychee.setMode("public"); - - header.dom().hide(); - lychee.content.hide(); - $("body").append(build.no_content("cog")); - settings.createConfig(); + api.post( + "Session::init", + {}, + /** @param {InitializationData} data */ + function (data) { + lychee.parseInitializationData(data); + + if (data.status === 2) { + // Logged in + leftMenu.build(); + leftMenu.bind(); + lychee.setMode("logged_in"); + + // Show dialog when there is no username and password + // TODO: Refactor this. At least rename the flag `login` to something more understandable like `isAdminUserConfigured`, but rather re-factor the whole logic, i.e. the initial user should be created as part of the installation routine. + // In particular it is completely insane to build the UI as if the admin user was successfully authenticated. + // This might leak confidential photos to anybody if the DB is filled + // with photos and the admin password reset to `null`. + if (data.config.login === false) settings.createLogin(); + } else if (data.status === 1) { + lychee.setMode("public"); + } else { + loadingBar.show("error", "Error: Unexpected status"); + return; + } - return true; + if (isFirstInitialization) { + $(window).on("popstate", function () { + const autoplay = history.state && history.state.hasOwnProperty("autoplay") ? history.state.autoplay : true; + lychee.load(autoplay); + }); + lychee.load(); + } } + ); +}; - lychee.sub_albums = data.sub_albums || false; - lychee.update_json = data.update_json; - lychee.update_available = data.update_available; - lychee.landing_page_enable = (data.config.landing_page_enable && data.config.landing_page_enable === "1") || false; - lychee.new_photos_notification = false; +/** + * @param {InitializationData} data + * @returns {void} + */ +lychee.parseInitializationData = function (data) { + lychee.update_json = data.update_json; + lychee.update_available = data.update_available; + + // 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); + lychee.version = parseInt(digits[0]).toString() + "." + parseInt(digits[1]).toString() + "." + parseInt(digits[2]).toString(); + } - lychee.versionCode = data.config.version; - if (lychee.versionCode !== "") { - let digits = lychee.versionCode.match(/.{1,2}/g); - lychee.version = parseInt(digits[0]).toString() + "." + parseInt(digits[1]).toString() + "." + parseInt(digits[2]).toString(); - } + // we copy the locale that exists only. + // This ensures forward and backward compatibility. + // e.g. if the front localization is unfinished in a language + // or if we need to change some locale string + for (let key in data.locale) { + lychee.locale[key] = data.locale[key]; + } - // we copy the locale that exists only. - // This ensure forward and backward compatibility. - // e.g. if the front localization is unfished in a language - // or if we need to change some locale string - for (let key in data.locale) { - lychee.locale[key] = data.locale[key]; - } + // Check status + // 0 = No configuration + // 1 = Logged out + // 2 = Logged in + if (data.status === 2) { + // Logged in + lychee.parsePublicInitializationData(data); + lychee.parseProtectedInitializationData(data); + + lychee.may_upload = data.admin || data.may_upload; + lychee.admin = data.admin; + lychee.is_locked = data.is_locked; + lychee.username = data.username; + } else if (data.status === 1) { + lychee.parsePublicInitializationData(data); + } else { + // should not happen. + } +}; - const validatedSwipeToleranceX = - (data.config.swipe_tolerance_x && !isNaN(parseInt(data.config.swipe_tolerance_x)) && parseInt(data.config.swipe_tolerance_x)) || 150; - const validatedSwipeToleranceY = - (data.config.swipe_tolerance_y && !isNaN(parseInt(data.config.swipe_tolerance_y)) && parseInt(data.config.swipe_tolerance_y)) || 250; - - // Check status - // 0 = No configuration - // 1 = Logged out - // 2 = Logged in - if (data.status === 2) { - // Logged in - - lychee.sortingPhotos = data.config.sorting_Photos || data.config.sortingPhotos || ""; - lychee.sortingAlbums = data.config.sorting_Albums || data.config.sortingAlbums || ""; - lychee.album_subtitle_type = data.config.album_subtitle_type || "oldstyle"; - lychee.dropboxKey = data.config.dropbox_key || data.config.dropboxKey || ""; - lychee.location = data.config.location || ""; - lychee.checkForUpdates = data.config.check_for_updates || data.config.checkForUpdates || "1"; - lychee.lang = data.config.lang || ""; - lychee.lang_available = data.config.lang_available || {}; - lychee.layout = data.config.layout || "1"; - lychee.public_search = (data.config.public_search && data.config.public_search === "1") || false; - lychee.image_overlay_type = !data.config.image_overlay_type ? "exif" : data.config.image_overlay_type; - lychee.image_overlay_type_default = lychee.image_overlay_type; - lychee.map_display = (data.config.map_display && data.config.map_display === "1") || false; - lychee.map_display_public = (data.config.map_display_public && data.config.map_display_public === "1") || false; - lychee.map_display_direction = (data.config.map_display_direction && data.config.map_display_direction === "1") || false; - lychee.map_provider = !data.config.map_provider ? "Wikimedia" : data.config.map_provider; - lychee.map_include_subalbums = (data.config.map_include_subalbums && data.config.map_include_subalbums === "1") || false; - lychee.location_decoding = (data.config.location_decoding && data.config.location_decoding === "1") || false; - lychee.location_decoding_caching_type = !data.config.location_decoding_caching_type - ? "Harddisk" - : data.config.location_decoding_caching_type; - lychee.location_show = (data.config.location_show && data.config.location_show === "1") || false; - lychee.location_show_public = (data.config.location_show_public && data.config.location_show_public === "1") || false; - lychee.swipe_tolerance_x = validatedSwipeToleranceX; - lychee.swipe_tolerance_y = validatedSwipeToleranceY; - - lychee.default_license = data.config.default_license || "none"; - lychee.css = data.config.css || ""; - lychee.full_photo = data.config.full_photo == null || data.config.full_photo === "1"; - lychee.downloadable = (data.config.downloadable && data.config.downloadable === "1") || false; - lychee.public_photos_hidden = data.config.public_photos_hidden == null || data.config.public_photos_hidden === "1"; - lychee.share_button_visible = (data.config.share_button_visible && data.config.share_button_visible === "1") || false; - lychee.delete_imported = data.config.delete_imported && data.config.delete_imported === "1"; - lychee.import_via_symlink = data.config.import_via_symlink && data.config.import_via_symlink === "1"; - lychee.skip_duplicates = data.config.skip_duplicates && data.config.skip_duplicates === "1"; - lychee.nsfw_visible = (data.config.nsfw_visible && data.config.nsfw_visible === "1") || false; - lychee.nsfw_blur = (data.config.nsfw_blur && data.config.nsfw_blur === "1") || false; - lychee.nsfw_warning = (data.config.nsfw_warning_admin && data.config.nsfw_warning_admin === "1") || false; - - lychee.header_auto_hide = data.config_device.header_auto_hide; - lychee.active_focus_on_page_load = data.config_device.active_focus_on_page_load; - lychee.enable_button_visibility = data.config_device.enable_button_visibility; - lychee.enable_button_share = data.config_device.enable_button_share; - lychee.enable_button_archive = data.config_device.enable_button_archive; - lychee.enable_button_move = data.config_device.enable_button_move; - lychee.enable_button_trash = data.config_device.enable_button_trash; - lychee.enable_button_fullscreen = data.config_device.enable_button_fullscreen; - lychee.enable_button_download = data.config_device.enable_button_download; - lychee.enable_button_add = data.config_device.enable_button_add; - lychee.enable_button_more = data.config_device.enable_button_more; - lychee.enable_button_rotate = data.config_device.enable_button_rotate; - lychee.enable_close_tab_on_esc = data.config_device.enable_close_tab_on_esc; - lychee.enable_tabindex = data.config_device.enable_tabindex; - lychee.enable_contextmenu_header = data.config_device.enable_contextmenu_header; - lychee.hide_content_during_imgview = data.config_device.hide_content_during_imgview; - lychee.device_type = data.config_device.device_type || "desktop"; // we set default as Desktop - - lychee.editor_enabled = (data.config.editor_enabled && data.config.editor_enabled === "1") || false; - - lychee.nsfw_visible_saved = lychee.nsfw_visible; - - lychee.new_photos_notification = (data.config.new_photos_notification && data.config.new_photos_notification === "1") || false; - - lychee.upload_processing_limit = parseInt(data.config.upload_processing_limit); - // when null or any non stringified numeric value is sent from the server we get NaN. - // we fix this. - if (isNaN(lychee.upload_processing_limit)) lychee.upload_processing_limit = 4; - - // leftMenu - leftMenu.build(); - leftMenu.bind(); - - lychee.may_upload = data.admin || data.may_upload; - lychee.admin = data.admin; - lychee.is_locked = data.is_locked; - lychee.username = data.username; - lychee.setMode("logged_in"); - - // Show dialog when there is no username and password - if (data.config.login === false) settings.createLogin(); - } else if (data.status === 1) { - // Logged out - - // TODO remove sortingPhoto once the v4 is out - lychee.sortingPhotos = data.config.sorting_Photos || data.config.sortingPhotos || ""; - lychee.sortingAlbums = data.config.sorting_Albums || data.config.sortingAlbums || ""; - lychee.album_subtitle_type = data.config.album_subtitle_type || "oldstyle"; - lychee.checkForUpdates = data.config.check_for_updates || data.config.checkForUpdates || "1"; - lychee.layout = data.config.layout || "1"; - lychee.public_search = (data.config.public_search && data.config.public_search === "1") || false; - lychee.image_overlay_type = !data.config.image_overlay_type ? "exif" : data.config.image_overlay_type; - lychee.image_overlay_type_default = lychee.image_overlay_type; - lychee.map_display = (data.config.map_display && data.config.map_display === "1") || false; - lychee.map_display_public = (data.config.map_display_public && data.config.map_display_public === "1") || false; - lychee.map_display_direction = (data.config.map_display_direction && data.config.map_display_direction === "1") || false; - lychee.map_provider = !data.config.map_provider ? "Wikimedia" : data.config.map_provider; - lychee.map_include_subalbums = (data.config.map_include_subalbums && data.config.map_include_subalbums === "1") || false; - lychee.location_show = (data.config.location_show && data.config.location_show === "1") || false; - lychee.location_show_public = (data.config.location_show_public && data.config.location_show_public === "1") || false; - lychee.swipe_tolerance_x = validatedSwipeToleranceX; - lychee.swipe_tolerance_y = validatedSwipeToleranceY; - - lychee.nsfw_visible = (data.config.nsfw_visible && data.config.nsfw_visible === "1") || false; - lychee.nsfw_blur = (data.config.nsfw_blur && data.config.nsfw_blur === "1") || false; - lychee.nsfw_warning = (data.config.nsfw_warning && data.config.nsfw_warning === "1") || false; - - lychee.header_auto_hide = data.config_device.header_auto_hide; - lychee.active_focus_on_page_load = data.config_device.active_focus_on_page_load; - lychee.enable_button_visibility = data.config_device.enable_button_visibility; - lychee.enable_button_share = data.config_device.enable_button_share; - lychee.enable_button_archive = data.config_device.enable_button_archive; - lychee.enable_button_move = data.config_device.enable_button_move; - lychee.enable_button_trash = data.config_device.enable_button_trash; - lychee.enable_button_fullscreen = data.config_device.enable_button_fullscreen; - lychee.enable_button_download = data.config_device.enable_button_download; - lychee.enable_button_add = data.config_device.enable_button_add; - lychee.enable_button_more = data.config_device.enable_button_more; - lychee.enable_button_rotate = data.config_device.enable_button_rotate; - lychee.enable_close_tab_on_esc = data.config_device.enable_close_tab_on_esc; - lychee.enable_tabindex = data.config_device.enable_tabindex; - lychee.enable_contextmenu_header = data.config_device.enable_contextmenu_header; - lychee.hide_content_during_imgview = data.config_device.hide_content_during_imgview; - lychee.device_type = data.config_device.device_type || "desktop"; // we set default as Desktop - lychee.nsfw_visible_saved = lychee.nsfw_visible; - - // console.log(lychee.full_photo); - lychee.setMode("public"); - } else { - // should not happen. - } +/** + * Parses the configuration settings which are always available. + * + * TODO: If configuration management is re-factored on the backend, remember to use proper types in the first place + * + * @param {InitializationData} data + * @returns {void} + */ +lychee.parsePublicInitializationData = function (data) { + lychee.sorting_photos = data.config.sorting_photos; + lychee.sorting_albums = data.config.sorting_albums; + lychee.album_subtitle_type = data.config.album_subtitle_type || "oldstyle"; + lychee.checkForUpdates = data.config.check_for_updates; + lychee.layout = Number.parseInt(data.config.layout, 10) || 1; + lychee.landing_page_enable = data.config.landing_page_enable === "1"; + lychee.public_search = data.config.public_search === "1"; + lychee.image_overlay_type = data.config.image_overlay_type || "exif"; + lychee.image_overlay_type_default = lychee.image_overlay_type; + lychee.map_display = data.config.map_display === "1"; + lychee.map_display_public = data.config.map_display_public === "1"; + lychee.map_display_direction = data.config.map_display_direction === "1"; + lychee.map_provider = data.config.map_provider || "Wikimedia"; + lychee.map_include_subalbums = data.config.map_include_subalbums === "1"; + lychee.location_show = data.config.location_show === "1"; + lychee.location_show_public = data.config.location_show_public === "1"; + lychee.swipe_tolerance_x = Number.parseInt(data.config.swipe_tolerance_x, 10) || 150; + lychee.swipe_tolerance_y = Number.parseInt(data.config.swipe_tolerance_y, 10) || 250; + + lychee.nsfw_visible = data.config.nsfw_visible === "1"; + lychee.nsfw_visible_saved = lychee.nsfw_visible; + lychee.nsfw_blur = data.config.nsfw_blur === "1"; + lychee.nsfw_warning = data.config.nsfw_warning === "1"; + + lychee.header_auto_hide = data.config_device.header_auto_hide; + lychee.active_focus_on_page_load = data.config_device.active_focus_on_page_load; + lychee.enable_button_visibility = data.config_device.enable_button_visibility; + lychee.enable_button_share = data.config_device.enable_button_share; + lychee.enable_button_archive = data.config_device.enable_button_archive; + lychee.enable_button_move = data.config_device.enable_button_move; + lychee.enable_button_trash = data.config_device.enable_button_trash; + lychee.enable_button_fullscreen = data.config_device.enable_button_fullscreen; + lychee.enable_button_download = data.config_device.enable_button_download; + lychee.enable_button_add = data.config_device.enable_button_add; + lychee.enable_button_more = data.config_device.enable_button_more; + lychee.enable_button_rotate = data.config_device.enable_button_rotate; + lychee.enable_close_tab_on_esc = data.config_device.enable_close_tab_on_esc; + lychee.enable_tabindex = data.config_device.enable_tabindex; + lychee.enable_contextmenu_header = data.config_device.enable_contextmenu_header; + lychee.hide_content_during_imgview = data.config_device.hide_content_during_imgview; + lychee.device_type = data.config_device.device_type || "desktop"; // we set default as Desktop +}; - if (isFirstInitialization) { - $(window).on("popstate", function () { - const autoplay = history.state && history.state.hasOwnProperty("autoplay") ? history.state.autoplay : true; - lychee.load(autoplay); - }); - lychee.load(); - } - }); +/** + * Parses the configuration settings which are only available, if a user is authenticated. + * + * TODO: If configuration management is re-factored on the backend, remember to use proper types in the first place + * + * @param {InitializationData} data + * @returns {void} + */ +lychee.parseProtectedInitializationData = function (data) { + lychee.dropboxKey = data.config.dropbox_key || ""; + lychee.location = data.config.location || ""; + lychee.checkForUpdates = data.config.check_for_updates === "1"; + lychee.lang = data.config.lang || ""; + lychee.lang_available = data.config.lang_available || []; + lychee.location_decoding = data.config.location_decoding === "1"; + lychee.default_license = data.config.default_license || "none"; + lychee.css = data.config.css || ""; + lychee.full_photo = data.config.full_photo === "1"; + lychee.downloadable = data.config.downloadable === "1"; + lychee.public_photos_hidden = data.config.public_photos_hidden === "1"; + lychee.share_button_visible = data.config.share_button_visible === "1"; + lychee.delete_imported = data.config.delete_imported === "1"; + lychee.import_via_symlink = data.config.import_via_symlink === "1"; + lychee.skip_duplicates = data.config.skip_duplicates === "1"; + lychee.editor_enabled = data.config.editor_enabled === "1"; + lychee.new_photos_notification = data.config.new_photos_notification === "1"; + lychee.upload_processing_limit = Number.parseInt(data.config.upload_processing_limit, 10) || 4; }; +/** + * @param {{username: string, password: string}} data + * @returns {void} + */ lychee.login = function (data) { - let username = data.username; - let password = data.password; - - if (!username.trim()) { + if (!data.username.trim()) { basicModal.error("username"); return; } - if (!password.trim()) { + if (!data.password.trim()) { basicModal.error("password"); return; } - let params = { - username, - password, - }; - - api.post("Session::login", params, function (_data) { - if (typeof _data === "undefined") { - window.location.reload(); - } else { - // Show error and reactive button - basicModal.error("password"); + api.post( + "Session::login", + data, + () => window.location.reload(), + null, + function (jqXHR) { + if (jqXHR.status === 401) { + basicModal.error("password"); + return true; + } else { + return false; + } } - }); + ); }; +/** + * @returns {void} + */ lychee.loginDialog = function () { - // Make background make unfocusable + // Make background unfocusable tabindex.makeUnfocusable(header.dom()); tabindex.makeUnfocusable(lychee.content); tabindex.makeUnfocusable(lychee.imageview); - let msg = lychee.html` + const msg = lychee.html` ${build.iconic("key")}

` + diff --git a/scripts/main/photo.js b/scripts/main/photo.js index 4e750f0b..e8969728 100644 --- a/scripts/main/photo.js +++ b/scripts/main/photo.js @@ -2,48 +2,44 @@ * @description Takes care of every action a photo can handle and execute. */ -let photo = { +const photo = { + /** @type {?Photo} */ json: null, cache: null, + /** @type {?boolean} indicates whether the browser supports prefetching of images; `null` if support hasn't been determined yet */ supportsPrefetch: null, - LivePhotosObject: null, + /** @type {?LivePhotosKit.Player} */ + livePhotosObject: null, }; +/** + * @returns {?string} - the photo ID + */ photo.getID = function () { - let id = null; - - if (photo.json) id = photo.json.id; - else id = $(".photo:hover, .photo.active").attr("data-id"); + let id = photo.json ? photo.json.id : $(".photo:hover, .photo.active").attr("data-id"); + id = typeof id === "string" && /^[-_0-9a-zA-Z]{24}$/.test(id) ? id : null; - if (typeof id === "string" && id.length === 24) return id; - else return null; + return id; }; +/** + * + * @param {string} photoID + * @param {string} albumID + * @param {boolean} autoplay - automatically start playback, if the photo is a video or live photo + * + * @returns {void} + */ photo.load = function (photoID, albumID, autoplay) { - const checkContent = function () { - if (album.json != null && album.json.photos) photo.load(photoID, albumID, autoplay); - else setTimeout(checkContent, 100); - }; - - const checkPasswd = function () { - if (password.value !== "") photo.load(photoID, albumID, autoplay); - else setTimeout(checkPasswd, 200); - }; - - // we need to check the album.json.photos because otherwise the script is too fast and this raise an error. - if (album.json == null || album.json.photos == null) { - checkContent(); - return false; - } - - let params = { - photoID, - password: password.value, - }; - - api.post("Photo::get", params, function (data) { + /** + * @param {Photo} data + * @returns {void} + */ + const successHandler = function (data) { photo.json = data; + // TODO: `photo.json.original_album_id` is set only, but never read; do we need it? photo.json.original_album_id = photo.json.album_id; + // TODO: Why do we overwrite the true album ID of a photo, by the externally provided one? I guess we need it, because the album which the user came from might also be a smart album or a tag album. However, in this case I would prefer to leave the `album_id untouched (don't rename it to `original_album_id`) and call this one `effective_album_id` instead. photo.json.album_id = albumID; if (!visible.photo()) view.photo.show(); @@ -56,194 +52,248 @@ photo.load = function (photoID, albumID, autoplay) { tabindex.makeUnfocusable(lychee.content); }, 300); } - }); + }; + + api.post( + "Photo::get", + { + photoID: photoID, + }, + successHandler + ); }; +/** + * @returns {boolean} + */ photo.hasExif = function () { - let exifHash = photo.json.make + photo.json.model + photo.json.shutter + photo.json.aperture + photo.json.focal + photo.json.iso; - - return exifHash !== ""; + return !!photo.json.make || !!photo.json.model || !!photo.json.shutter || !!photo.json.aperture || !!photo.json.focal || !!photo.json.iso; }; +/** + * @returns {boolean} + */ photo.hasTakestamp = function () { - return photo.json.taken_at !== null; + return !!photo.json.taken_at; }; +/** + * @returns {boolean} + */ photo.hasDesc = function () { - return photo.json.description && photo.json.description !== ""; + return !!photo.json.description; }; +/** + * @returns {boolean} + */ photo.isLivePhoto = function () { - if (!photo.json) return false; // In case it's called, but not initialized - return photo.json.live_photo_url && photo.json.live_photo_url !== ""; + return ( + !!photo.json && // In case it's called, but not initialized + !!photo.json.live_photo_url + ); }; -photo.isLivePhotoInitizalized = function () { - return photo.LivePhotosObject !== null; +/** + * @returns {boolean} + */ +photo.isLivePhotoInitialized = function () { + return !!photo.livePhotosObject; }; +/** + * @returns {boolean} + */ photo.isLivePhotoPlaying = function () { - if (photo.isLivePhotoInitizalized() === false) return false; - return photo.LivePhotosObject.isPlaying; + return photo.isLivePhotoInitialized() && photo.livePhotosObject.isPlaying; }; +/** + * @returns {void} + */ photo.cycle_display_overlay = function () { - let oldtype = build.check_overlay_type(photo.json, lychee.image_overlay_type); - let newtype = build.check_overlay_type(photo.json, oldtype, true); - if (oldtype !== newtype) { - lychee.image_overlay_type = newtype; + const oldType = build.check_overlay_type(photo.json, lychee.image_overlay_type); + const newType = build.check_overlay_type(photo.json, oldType, true); + if (oldType !== newType) { + lychee.image_overlay_type = newType; $("#image_overlay").remove(); - let newoverlay = build.overlay_image(photo.json); - if (newoverlay !== "") lychee.imageview.append(newoverlay); + const newOverlay = build.overlay_image(photo.json); + if (newOverlay !== "") lychee.imageview.append(newOverlay); } }; -// Preload the next and previous photos for better response time +/** + * Preloads the next and previous photos for better response time + * + * @param {string} photoID + * @returns {void} + */ photo.preloadNextPrev = function (photoID) { - if (album.json && album.json.photos && album.getByID(photoID)) { - let previousPhotoID = album.getByID(photoID).previous_photo_id; - let nextPhotoID = album.getByID(photoID).next_photo_id; - let imgs = $("img#image"); - let isUsing2xCurrently = imgs.length > 0 && imgs[0].currentSrc !== null && imgs[0].currentSrc.includes("@2x."); - - $("head [data-prefetch]").remove(); - - let preload = function (preloadID) { - let preloadPhoto = album.getByID(preloadID); - let href = ""; - - if (preloadPhoto.size_variants.medium != null) { - href = preloadPhoto.size_variants.medium.url; - if (preloadPhoto.size_variants.medium2x != null && isUsing2xCurrently) { - // If the currently displayed image uses the 2x variant, - // chances are that so will the next one. - href = preloadPhoto.size_variants.medium2x.url; - } - } else if (preloadPhoto.type && preloadPhoto.type.indexOf("video") === -1) { - // Preload the original size, but only if it's not a video - href = preloadPhoto.url; + if (!album.json || !album.json.photos) return; + + const photo = album.getByID(photoID); + if (!photo) return; + + const imgs = $("img#image"); + // TODO: consider replacing the test for "@2x." by a simple comparison to photo.size_variants.medium2x.url. + const isUsing2xCurrently = imgs.length > 0 && imgs[0].currentSrc !== null && imgs[0].currentSrc.includes("@2x."); + + $("head [data-prefetch]").remove(); + + /** + * @param {string} preloadID + * @returns {void} + */ + const preload = function (preloadID) { + const preloadPhoto = album.getByID(preloadID); + let href = ""; + + if (preloadPhoto.size_variants.medium != null) { + href = preloadPhoto.size_variants.medium.url; + if (preloadPhoto.size_variants.medium2x != null && isUsing2xCurrently) { + // If the currently displayed image uses the 2x variant, + // chances are that so will the next one. + href = preloadPhoto.size_variants.medium2x.url; } + } else if (preloadPhoto.type && preloadPhoto.type.indexOf("video") === -1) { + // Preload the original size, but only if it's not a video + href = preloadPhoto.size_variants.original.url; + } - if (href !== "") { - if (photo.supportsPrefetch === null) { - // Copied from https://www.smashingmagazine.com/2016/02/preload-what-is-it-good-for/ - let DOMTokenListSupports = function (tokenList, token) { + if (href !== "") { + if (photo.supportsPrefetch === null) { + /** + * Copied from https://www.smashingmagazine.com/2016/02/preload-what-is-it-good-for/ + * + * TODO: This method should not be defined dynamically, but defined and executed upon initialization once + * + * @param {DOMTokenList} tokenList + * @param {string} token + * @returns {boolean} + */ + const DOMTokenListSupports = function (tokenList, token) { + try { if (!tokenList || !tokenList.supports) { - return null; + return false; } - try { - return tokenList.supports(token); - } catch (e) { - if (e instanceof TypeError) { - console.log("The DOMTokenList doesn't have a supported tokens list"); - } else { - console.error("That shouldn't have happened"); - } + return tokenList.supports(token); + } catch (e) { + if (e instanceof TypeError) { + console.log("The DOMTokenList doesn't have a supported tokens list"); + } else { + console.error("That shouldn't have happened"); } - }; - photo.supportsPrefetch = DOMTokenListSupports(document.createElement("link").relList, "prefetch"); - } - - if (photo.supportsPrefetch) { - $("head").append(lychee.html``); - } else { - // According to https://caniuse.com/#feat=link-rel-prefetch, - // as of mid-2019 it's mainly Safari (both on desktop and mobile) - new Image().src = href; - } + return false; + } + }; + photo.supportsPrefetch = DOMTokenListSupports(document.createElement("link").relList, "prefetch"); } - }; - if (nextPhotoID) { - preload(nextPhotoID); - } - if (previousPhotoID) { - preload(previousPhotoID); + if (photo.supportsPrefetch) { + $("head").append(lychee.html``); + } else { + // According to https://caniuse.com/#feat=link-rel-prefetch, + // as of mid-2019 it's mainly Safari (both on desktop and mobile) + new Image().src = href; + } } - } -}; + }; -photo.parse = function () { - if (!photo.json.title) photo.json.title = lychee.locale["UNTITLED"]; + if (photo.next_photo_id) { + preload(photo.next_photo_id); + } + if (photo.previous_photo_id) { + preload(photo.previous_photo_id); + } }; -photo.updateSizeLivePhotoDuringAnimation = function (animationDuraction = 300, pauseBetweenUpdated = 10) { +/** + * @param {number} [animationDuration=300] + * @param {number} [pauseBetweenUpdated=10] + * @returns {void} + */ +photo.updateSizeLivePhotoDuringAnimation = function (animationDuration = 300, pauseBetweenUpdated = 10) { // For the LivePhotoKit, we need to call the updateSize manually // during CSS animations // - var interval = setInterval(function () { - if (photo.isLivePhotoInitizalized()) { - photo.LivePhotosObject.updateSize(); + const interval = setInterval(function () { + if (photo.isLivePhotoInitialized()) { + photo.livePhotosObject.updateSize(); } }, pauseBetweenUpdated); setTimeout(function () { clearInterval(interval); - }, animationDuraction); + }, animationDuration); }; +/** + * @param {boolean} animate + * @returns {void} + */ photo.previous = function (animate) { - if (photo.getID() !== null && album.json && album.getByID(photo.getID()) && album.getByID(photo.getID()).previous_photo_id !== null) { - let delay = 0; + const curPhoto = photo.getID() !== null && album.json ? album.getByID(photo.getID()) : null; + if (!curPhoto || !curPhoto.previous_photo_id) return; - if (animate === true) { - delay = 200; + const delay = animate ? 200 : 0; - $("#imageview #image").css({ - WebkitTransform: "translateX(100%)", - MozTransform: "translateX(100%)", - transform: "translateX(100%)", - opacity: 0, - }); - } - - setTimeout(() => { - if (photo.getID() === null) return false; - photo.LivePhotosObject = null; - lychee.goto(album.getID() + "/" + album.getByID(photo.getID()).previous_photo_id, false); - }, delay); + if (animate) { + $("#imageview #image").css({ + WebkitTransform: "translateX(100%)", + MozTransform: "translateX(100%)", + transform: "translateX(100%)", + opacity: 0, + }); } + + setTimeout(() => { + photo.livePhotosObject = null; + lychee.goto(album.getID() + "/" + curPhoto.previous_photo_id, false); + }, delay); }; +/** + * @param {boolean} animate + * @returns {void} + */ photo.next = function (animate) { - if (photo.getID() !== null && album.json && album.getByID(photo.getID()) && album.getByID(photo.getID()).next_photo_id !== null) { - let delay = 0; + const curPhoto = photo.getID() !== null && album.json ? album.getByID(photo.getID()) : null; + if (!curPhoto || !curPhoto.next_photo_id) return; - if (animate === true) { - delay = 200; + const delay = animate ? 200 : 0; - $("#imageview #image").css({ - WebkitTransform: "translateX(-100%)", - MozTransform: "translateX(-100%)", - transform: "translateX(-100%)", - opacity: 0, - }); - } - - setTimeout(() => { - if (photo.getID() === null) return false; - photo.LivePhotosObject = null; - lychee.goto(album.getID() + "/" + album.getByID(photo.getID()).next_photo_id, false); - }, delay); + if (animate === true) { + $("#imageview #image").css({ + WebkitTransform: "translateX(-100%)", + MozTransform: "translateX(-100%)", + transform: "translateX(-100%)", + opacity: 0, + }); } + + setTimeout(() => { + photo.livePhotosObject = null; + lychee.goto(album.getID() + "/" + curPhoto.next_photo_id, false); + }, delay); }; +/** + * @param {string[]} photoIDs + * @returns {boolean} + */ photo.delete = function (photoIDs) { let action = {}; let cancel = {}; let msg = ""; let photoTitle = ""; - if (!photoIDs) return false; - if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; - 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"]; + if (!photoTitle) photoTitle = lychee.locale["UNTITLED"]; } action.fn = function () { @@ -288,11 +338,7 @@ photo.delete = function (photoIDs) { lychee.goto(album.getID()); } - let params = { - photoIDs: photoIDs.join(), - }; - - api.post("Photo::delete", params, null); + api.post("Photo::delete", { photoIDs: photoIDs }); }; if (photoIDs.length === 1) { @@ -323,19 +369,25 @@ photo.delete = function (photoIDs) { }); }; +/** + * + * @param {string[]} photoIDs + * @returns {void} + */ photo.setTitle = function (photoIDs) { let oldTitle = ""; let msg = ""; - if (!photoIDs) return false; - if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; - 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"); @@ -344,31 +396,26 @@ photo.setTitle = function (photoIDs) { basicModal.close(); - let newTitle = data.title; + const newTitle = data.title ? data.title : null; if (visible.photo()) { - photo.json.title = newTitle === "" ? "Untitled" : newTitle; + photo.json.title = newTitle; view.photo.title(); } photoIDs.forEach(function (id) { + // TODO: The line below looks suspicious: It is inconsistent to the code some lines above. album.getByID(id).title = newTitle; view.album.content.title(id); }); - let params = { - photoIDs: photoIDs.join(), + api.post("Photo::setTitle", { + photoIDs: photoIDs, title: newTitle, - }; - - api.post("Photo::setTitle", params, function (_data) { - if (_data !== true) { - lychee.error(null, params, _data); - } }); }; - let input = lychee.html``; + const input = lychee.html``; if (photoIDs.length === 1) msg = lychee.html`

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

`; else msg = lychee.html`

${lychee.locale["PHOTOS_NEW_TITLE_1"]} ${photoIDs.length} ${lychee.locale["PHOTOS_NEW_TITLE_2"]} ${input}

`; @@ -392,33 +439,28 @@ photo.setTitle = function (photoIDs) { * * @param {string[]} photoIDs IDs of photos to be copied * @param {?string} albumID ID of destination album; `null` means root album - * @return {void} + * @returns {void} */ photo.copyTo = function (photoIDs, albumID) { - if (!photoIDs) return; - if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; - - let params = { - photoIDs: photoIDs.join(), - albumID, - }; - - api.post("Photo::duplicate", params, function (data) { - if (data instanceof Object) { - album.reload(); - } else { - lychee.error(null, params, data); - } - }); + api.post( + "Photo::duplicate", + { + photoIDs: photoIDs, + albumID: albumID, + }, + () => album.reload() + ); }; +/** + * @param {string[]} photoIDs + * @param {string} albumID + * @returns {void} + */ photo.setAlbum = function (photoIDs, albumID) { let nextPhotoID = null; let previousPhotoID = null; - if (!photoIDs) return false; - if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; - photoIDs.forEach(function (id, index) { // Change reference for the next and previous photo let curPhoto = album.getByID(id); @@ -453,15 +495,13 @@ photo.setAlbum = function (photoIDs, albumID) { } } - let params = { - photoIDs: photoIDs.join(), - albumID, - }; - - api.post("Photo::setAlbum", params, function (data) { - if (data !== true) { - lychee.error(null, params, data); - } else { + api.post( + "Photo::setAlbum", + { + photoIDs: photoIDs, + albumID: albumID, + }, + function () { // We only really need to do anything here if the destination // is a (possibly nested) subalbum of the current album; but // since we have no way of figuring it out (albums.json is @@ -470,35 +510,57 @@ photo.setAlbum = function (photoIDs, albumID) { album.reload(); } } - }); + ); }; -photo.setStar = function (photoIDs) { - if (!photoIDs) return false; +/** + * Toggles the star-property of the currently visible photo. + * + * @returns {void} + */ +photo.toggleStar = function () { + photo.json.is_starred = !photo.json.is_starred; + view.photo.star(); + albums.refresh(); - if (visible.photo()) { - photo.json.is_starred = !photo.json.is_starred; - view.photo.star(); - } + api.post("Photo::setStar", { + photoIDs: [photo.json.id], + is_starred: photo.json.is_starred, + }); +}; +/** + * Sets the star-property of the given photos. + * + * @param {string[]} photoIDs + * @param {boolean} isStarred + * @returns {void} + */ +photo.setStar = function (photoIDs, isStarred) { photoIDs.forEach(function (id) { - album.getByID(id).is_starred = !album.getByID(id).is_starred; + album.getByID(id).is_starred = isStarred; view.album.content.star(id); }); albums.refresh(); - let params = { - photoIDs: photoIDs.join(), - }; - - api.post("Photo::setStar", params, function (data) { - if (data !== true) lychee.error(null, params, data); + api.post("Photo::setStar", { + photoIDs: photoIDs, + is_starred: isStarred, }); }; -photo.setPublic = function (photoID, e) { - let msg_switch = lychee.html` +/** + * Edits the protection policy of a photo. + * + * This method is a misnomer, it does not only set the policy, it also creates + * and handles the edit dialog + * + * @param {string} photoID + * @returns {void} + */ +photo.setProtectionPolicy = function (photoID) { + const msg_switch = lychee.html`
`; - let msg_choices = lychee.html` + const msg_choices = lychee.html`
`; - if (photo.json.is_public == 2) { - // Public album. We can't actually change anything but we will + if (photo.json.is_public === 2) { + // Public album. We can't actually change anything, but we will // display the current settings. - let msg = lychee.html` + const msg = lychee.html`

${lychee.locale["PHOTO_NO_EDIT_SHARING_TEXT"]}

${msg_switch} ${msg_choices} @@ -593,14 +655,19 @@ photo.setPublic = function (photoID, e) { } else { // Private album -- each photo can be shared individually. - let msg = lychee.html` + 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 () { - let newIsPublic = $('.basicModal .switch input[name="is_public"]:checked').length === 1; + /** + * 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()) { @@ -613,10 +680,9 @@ photo.setPublic = function (photoID, e) { albums.refresh(); - // Photo::setPublic simply flips the current state. - // Ugly API but effective... - api.post("Photo::setPublic", { photoID }, function (data) { - if (data !== true) lychee.error(null, params, data); + api.post("Photo::setPublic", { + photoID: photoID, + is_public: newIsPublic !== 0, }); } @@ -657,36 +723,39 @@ photo.setPublic = function (photoID, e) { } }); - if (photo.json.is_public == 1) { + if (photo.json.is_public === 1) { $('.basicModal .switch input[name="is_public"]').click(); } } - - return true; }; +/** + * Edits the description of a photo. + * + * This method is a misnomer, it does not only set the description, it also creates and handles the edit dialog + * + * @param {string} photoID + * @returns {void} + */ photo.setDescription = function (photoID) { - let oldDescription = photo.json.description ? photo.json.description : ""; + const oldDescription = photo.json.description ? photo.json.description : ""; + /** + * @param {{description: string}} data + */ const action = function (data) { basicModal.close(); - let description = data.description ? data.description : null; + const description = data.description ? data.description : null; if (visible.photo()) { photo.json.description = description; view.photo.description(); } - let params = { - photoID, - description, - }; - - api.post("Photo::setDescription", params, function (_data) { - if (_data !== true) { - lychee.error(null, params, _data); - } + api.post("Photo::setDescription", { + photoID: photoID, + description: description, }); }; @@ -705,41 +774,51 @@ photo.setDescription = function (photoID) { }); }; +/** + * @param {string[]} photoIDs + * @returns {void} + */ photo.editTags = function (photoIDs) { - let oldTags = ""; - let msg = ""; - - if (!photoIDs) return false; - if (photoIDs instanceof Array === false) photoIDs = [photoIDs]; + /** @type {string[]} */ + let oldTags = []; // Get tags - if (visible.photo()) oldTags = photo.json.tags; - else if (visible.album() && photoIDs.length === 1) oldTags = album.getByID(photoIDs[0]).tags; - else if (visible.search() && photoIDs.length === 1) oldTags = album.getByID(photoIDs[0]).tags; + if (visible.photo()) oldTags = photo.json.tags.sort(); + else if (visible.album() && photoIDs.length === 1) oldTags = album.getByID(photoIDs[0]).tags.sort(); + else if (visible.search() && photoIDs.length === 1) oldTags = album.getByID(photoIDs[0]).tags.sort(); else if (visible.album() && photoIDs.length > 1) { - let same = true; - photoIDs.forEach(function (id) { - same = album.getByID(id).tags === album.getByID(photoIDs[0]).tags && same === true; + oldTags = album.getByID(photoIDs[0]).tags.sort(); + const areIdentical = photoIDs.every(function (id) { + const oldTags2 = album.getByID(id).tags.sort(); + if (oldTags.length !== oldTags2.length) return false; + for (let tagIdx = 0; tagIdx !== oldTags.length; tagIdx++) { + if (oldTags[tagIdx] !== oldTags2[tagIdx]) return false; + } + return true; }); - if (same === true) oldTags = album.getByID(photoIDs[0]).tags; - } - - // Improve tags - if (typeof oldTags === "string" && oldTags !== "") { - oldTags = oldTags.replace(/,/g, ", "); - } else { - oldTags = ""; + if (!areIdentical) oldTags = []; } + /** + * @param {{tags: string}} data + * @returns {void} + */ const action = function (data) { basicModal.close(); - photo.setTags(photoIDs, data.tags); + const newTags = data.tags + .split(",") + .map((tag) => tag.trim()) + .filter((tag) => tag !== "" && tag.indexOf(",") === -1) + .sort(); + photo.setTags(photoIDs, newTags); }; - let input = lychee.html``; + const input = lychee.html``; - if (photoIDs.length === 1) msg = lychee.html`

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

`; - else msg = lychee.html`

${lychee.locale["PHOTO_NEW_TAGS_1"]} ${photoIDs.length} ${lychee.locale["PHOTO_NEW_TAGS_2"]} ${input}

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

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

` + : lychee.html`

${lychee.locale["PHOTO_NEW_TAGS_1"]} ${photoIDs.length} ${lychee.locale["PHOTO_NEW_TAGS_2"]} ${input}

`; basicModal.show({ body: msg, @@ -756,61 +835,58 @@ photo.editTags = function (photoIDs) { }); }; +/** + * @param {string[]} photoIDs + * @param {string[]} tags + * @returns {void} + */ photo.setTags = function (photoIDs, tags) { - if (!photoIDs) return false; - if (!(photoIDs instanceof Array)) photoIDs = [photoIDs]; - - // Parse tags - tags = tags.replace(/( , )|( ,)|(, )|(,+ *)|(,$|^,)/g, ","); - tags = tags.replace(/,$|^,|( )*$/g, ""); - if (visible.photo()) { photo.json.tags = tags; view.photo.tags(); } - photoIDs.forEach(function (id, index, array) { + photoIDs.forEach(function (id) { album.getByID(id).tags = tags; }); - let params = { - photoIDs: photoIDs.join(), - tags, - }; - - api.post("Photo::setTags", params, function (data) { - if (data !== true) { - lychee.error(null, params, data); - } else if (albums.json && albums.json.smart_albums) { - $.each(Object.entries(albums.json.smart_albums), function () { - if (this.length === 2 && this[1]["is_tag_album"] === true) { - // If we have any tag albums, force a refresh. - albums.refresh(); - return false; - } - }); + api.post( + "Photo::setTags", + { + photoIDs: photoIDs, + tags: tags, + }, + function () { + // If we have any tag albums, force a refresh. + if (albums.json && albums.json.tag_albums.length !== 0) { + albums.refresh(); + } } - }); + ); }; +/** + * Deletes the tag at the given index from the photo. + * + * @param {string} photoID + * @param {number} index + */ photo.deleteTag = function (photoID, index) { - let tags; - - // Remove - tags = photo.json.tags.split(","); - tags.splice(index, 1); - - // Save - photo.json.tags = tags.toString(); + photo.json.tags.splice(index, 1); photo.setTags([photoID], photo.json.tags); }; +/** + * @param {string} photoID + * @param {string} service - one out of `"twitter"`, `"facebook"`, `"mail"` or `"dropbox"` + * @returns {void} + */ photo.share = function (photoID, service) { - if (photo.json.hasOwnProperty("is_share_button_visible") && !photo.json.is_share_button_visible) { + if (!photo.json.is_share_button_visible) { return; } - let url = photo.getViewLink(photoID); + const url = photo.getViewLink(photoID); switch (service) { case "twitter": @@ -831,12 +907,14 @@ photo.share = function (photoID, service) { } }; +/** + * @param {string} photoID + * @returns {void} + */ photo.setLicense = function (photoID) { - const callback = function () { - $("select#license").val(photo.json.license === "" ? "none" : photo.json.license); - return false; - }; - + /** + * @param {{license: string}} data + */ const action = function (data) { basicModal.close(); let license = data.license; @@ -846,18 +924,14 @@ photo.setLicense = function (photoID) { license, }; - api.post("Photo::setLicense", params, function (_data) { - if (_data) { - lychee.error(null, params, _data); - } else { - // update the photo JSON and reload the license in the sidebar - photo.json.license = params.license; - view.photo.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(); }); }; - let msg = lychee.html` + const msg = lychee.html`

${lychee.locale["PHOTO_LICENSE"]} @@ -904,7 +978,9 @@ photo.setLicense = function (photoID) { basicModal.show({ body: msg, - callback: callback, + callback: function () { + $("select#license").val(photo.json.license === "" ? "none" : photo.json.license); + }, buttons: { action: { title: lychee.locale["PHOTO_SET_LICENSE"], @@ -918,6 +994,14 @@ photo.setLicense = function (photoID) { }); }; +/** + * @param {string[]} photoIDs + * @param {?string} [kind=null] - the type of size variant; one out of + * `"FULL"`, `"MEDIUM2X"`, `"MEDIUM"`, + * `"SMALL2X"`, `"SMALL"`, `"THUMB2X"` or + * `"THUMB"`, + * @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. @@ -930,6 +1014,11 @@ photo.getArchive = function (photoIDs, kind = null) { myPhoto = 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` @@ -1010,43 +1099,46 @@ photo.getArchive = function (photoIDs, kind = null) { }); $(".downloads .basicModal__button").on(lychee.getEventName(), function () { - kind = this.id; + const kind = this.id; basicModal.close(); photo.getArchive(photoIDs, kind); }); - - return true; + } else { + location.href = "api/Photo::getArchive?photoIDs=" + photoIDs.join() + "&kind=" + kind; } - - location.href = "api/Photo::getArchive" + lychee.html`?photoIDs=${photoIDs.join()}&kind=${kind}`; }; +/** + * @returns {string} + */ photo.getDirectLink = function () { - let url = ""; - - if ( - photo.json && - photo.json.size_variants && - photo.json.size_variants.original && - photo.json.size_variants.original.url && - photo.json.size_variants.original.url !== "" - ) - url = photo.json.size_variants.original.url; - - return url; + return photo.json && photo.json.size_variants && photo.json.size_variants.original && photo.json.size_variants.original.url + ? photo.json.size_variants.original.url + : ""; }; +/** + * @param {string} photoID + * @returns {string} + */ photo.getViewLink = function (photoID) { - let url = "view?p=" + photoID; - - return lychee.getBaseUrl() + url; + return lychee.getBaseUrl() + "view?p=" + photoID; }; +/** + * @param photoID + * @returns {void} + */ photo.showDirectLinks = function (photoID) { - if (!photo.json || photo.json.id != photoID) { + if (!photo.json || photo.json.id !== photoID) { return; } + /** + * @param {string} label + * @param {string} url + * @returns {string} - HTML + */ const buildLine = function (label, url) { return lychee.html`

@@ -1111,7 +1203,7 @@ photo.showDirectLinks = function (photoID) { lychee.getBaseUrl() + photo.json.size_variants.thumb.url ); } - if (photo.json.live_photo_url !== "") { + if (photo.json.live_photo_url) { msg += buildLine(` ${lychee.locale["PHOTO_LIVE_VIDEO"]} `, lychee.getBaseUrl() + photo.json.live_photo_url); } @@ -1134,8 +1226,6 @@ photo.showDirectLinks = function (photoID) { $(".basicModal input:focus").blur(); $(".directLinks .basicModal__button").on(lychee.getEventName(), function () { - if (lychee.clipboardCopy($(this).prev().val())) { - loadingBar.show("success", lychee.locale["URL_COPIED_TO_CLIPBOARD"]); - } + navigator.clipboard.writeText($(this).prev().val()).then(() => loadingBar.show("success", lychee.locale["URL_COPIED_TO_CLIPBOARD"])); }); }; diff --git a/scripts/main/photoeditor.js b/scripts/main/photoeditor.js index 0f975dbe..351ff8c6 100644 --- a/scripts/main/photoeditor.js +++ b/scripts/main/photoeditor.js @@ -4,26 +4,29 @@ photoeditor = {}; +/** + * @param {string} photoID + * @param {number} direction - either `1` or `-1` + * @returns {void} + */ photoeditor.rotate = function (photoID, direction) { - if (!photoID) return false; - if (!direction) return false; - - let params = { - photoID: photoID, - direction: direction, - }; - - api.post("PhotoEditor::rotate", params, function (data) { - if (data === false) { - lychee.error(null, params, data); - } else { + api.post( + "PhotoEditor::rotate", + { + photoID: photoID, + direction: direction, + }, + /** @param {Photo} data */ + function (data) { photo.json = data; + // TODO: `photo.json.original_album_id` is set only, but never read; do we need it? photo.json.original_album_id = photo.json.album_id; if (album.json) { + // TODO: Why do we overwrite the true album ID of a photo, by the externally provided one? I guess we need it, because the album which the user came from might also be a smart album or a tag album. However, in this case I would prefer to leave the `album_id untouched (don't rename it to `original_album_id`) and call this one `effective_album_id` instead. photo.json.album_id = album.json.id; } - let image = $("img#image"); + const image = $("img#image"); if (photo.json.size_variants.medium2x !== null) { image.prop( "srcset", @@ -35,8 +38,7 @@ photoeditor.rotate = function (photoID, direction) { image.prop("src", photo.json.size_variants.medium !== null ? photo.json.size_variants.medium.url : photo.json.size_variants.original.url); view.photo.onresize(); view.photo.sidebar(); - album.updatePhoto(data); } - }); + ); }; diff --git a/scripts/main/search.js b/scripts/main/search.js index 43fd86b8..e07426bc 100644 --- a/scripts/main/search.js +++ b/scripts/main/search.js @@ -2,109 +2,168 @@ * @description Searches through your photos and albums. */ -let search = { - hash: null, +/** + * The ID of the search album + * + * Constant `'search'`. + * + * @type {string} + */ +const SearchAlbumID = "search"; + +/** + * @typedef SearchAlbum + * + * A "virtual" album which holds the search results in a form which is + * mostly compatible with the other album types, i.e. + * {@link Album}, {@link TagAlbum} and {@link SmartAlbum}. + * + * @property {string} id - always equals `SearchAlbumID` + * @property {string} title - always equals `lychee.locale["SEARCH_RESULTS"]` + * @property {Photo[]} photos - the found photos + * @property {Album[]} albums - the found albums + * @property {TagAlbum[]} tag_albums - the found tag albums + * @property {?Thumb} thumb - always `null`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it + * @property {boolean} is_public - always `false`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it + * @property {boolean} is_downloadable - always `false`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it + * @property {boolean} is_share_button_visible - always `false`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it + */ + +/** + * The search object + */ +const search = { + /** @type {?SearchResult} */ + json: null, }; +/** + * @param {string} term + * @returns {void} + */ search.find = function (term) { - if (term.trim() === "") return false; + if (term.trim() === "") return; + + /** @param {SearchResult} data */ + const successHandler = function (data) { + // Do nothing, if search result is identical to previous result + if (search.json && search.json.checksum === data.checksum) { + return; + } + + search.json = data; + + // Create and assign a `SearchAlbum` + album.json = { + id: SearchAlbumID, + title: lychee.locale["SEARCH_RESULTS"], + photos: search.json.photos, + albums: search.json.albums, + tag_albums: search.json.tag_albums, + thumb: null, + is_public: false, + is_downloadable: false, + is_share_button_visible: false, + }; + + let albumsData = ""; + let photosData = ""; + + // Build HTML for album + search.json.tag_albums.forEach(function (album) { + albums.parse(album); + albumsData += build.album(album); + }); + search.json.albums.forEach(function (album) { + albums.parse(album); + albumsData += build.album(album); + }); + + // Build HTML for photo + search.json.photos.forEach(function (photo) { + photosData += build.photo(photo); + }); + + let albums_divider = lychee.locale["ALBUMS"]; + let photos_divider = lychee.locale["PHOTOS"]; + + if (albumsData !== "") albums_divider += " (" + (search.json.tag_albums.length + search.json.albums.length) + ")"; + if (photosData !== "") { + photos_divider += " (" + search.json.photos.length + ")"; + if (lychee.layout === 1) { + photosData = '

"; + } else if (lychee.layout === 2) { + photosData = '
' + photosData + "
"; + } + } + + // 1. No albums and photos + // 2. Only photos + // 3. Only albums + // 4. Albums and photos + const html = + albumsData === "" && photosData === "" + ? "" + : albumsData === "" + ? build.divider(photos_divider) + photosData + : photosData === "" + ? build.divider(albums_divider) + albumsData + : build.divider(albums_divider) + albumsData + build.divider(photos_divider) + photosData; + + $(".no_content").remove(); + lychee.animate($(".content"), "contentZoomOut"); + + setTimeout(() => { + if (visible.photo()) view.photo.hide(); + if (visible.sidebar()) sidebar.toggle(false); + if (visible.mapview()) mapview.close(); + + header.setMode("albums"); + + if (html === "") { + lychee.content.html(""); + $("body").append(build.no_content("magnifying-glass")); + } else { + lychee.content.html(html); + // Here we exploit the layout method of an album although + // the search result is not a proper album. + // It would be much better to have a component like + // `view.photos` (note the plural form) which takes care of + // all photo listings independent of the surrounding "thing" + // (i.e. regular album, tag album, search result) + view.album.content.justify(search.json.photos); + lychee.animate(lychee.content, "contentZoomIn"); + } + lychee.setTitle(lychee.locale["SEARCH_RESULTS"], false); + + $(window).scrollTop(0); + }, 300); + }; + + /** @returns {void} */ + const timeoutHandler = function () { + if (header.dom(".header__search").val().length !== 0) { + api.post("Search::run", { term }, successHandler); + } else { + search.reset(); + } + }; clearTimeout($(window).data("timeout")); - - $(window).data( - "timeout", - setTimeout(function () { - if (header.dom(".header__search").val().length !== 0) { - api.post("search", { term }, function (data) { - let html = ""; - let albumsData = ""; - let photosData = ""; - - // Build albums - if (data && data.albums) { - albums.json = { albums: data.albums }; - $.each(albums.json.albums, function () { - albums.parse(this); - albumsData += build.album(this); - }); - } - - // Build photos - if (data && data.photos) { - album.json = { photos: data.photos }; - $.each(album.json.photos, function () { - photosData += build.photo(this); - }); - } - - let albums_divider = lychee.locale["ALBUMS"]; - let photos_divider = lychee.locale["PHOTOS"]; - - if (albumsData !== "") albums_divider += " (" + data.albums.length + ")"; - if (photosData !== "") { - photos_divider += " (" + data.photos.length + ")"; - if (lychee.layout === "1") { - photosData = '
' + photosData + "
"; - } else if (lychee.layout === "2") { - photosData = '
' + photosData + "
"; - } - } - - // 1. No albums and photos - // 2. Only photos - // 3. Only albums - // 4. Albums and photos - if (albumsData === "" && photosData === "") html = "error"; - else if (albumsData === "") html = build.divider(photos_divider) + photosData; - else if (photosData === "") html = build.divider(albums_divider) + albumsData; - else html = build.divider(albums_divider) + albumsData + build.divider(photos_divider) + photosData; - - // Only refresh view when search results are different - if (search.hash !== data.hash) { - $(".no_content").remove(); - - lychee.animate(".content", "contentZoomOut"); - - search.hash = data.hash; - - setTimeout(() => { - if (visible.photo()) view.photo.hide(); - if (visible.sidebar()) sidebar.toggle(false); - if (visible.mapview()) mapview.close(); - - header.setMode("albums"); - - if (html === "error") { - lychee.content.html(""); - $("body").append(build.no_content("magnifying-glass")); - } else { - lychee.content.html(html); - view.album.content.justify(); - lychee.animate(lychee.content, "contentZoomIn"); - } - lychee.setTitle(lychee.locale["SEARCH_RESULTS"], false); - - $(window).scrollTop(0); - }, 300); - } - }); - } else search.reset(); - }, 250) - ); + $(window).data("timeout", setTimeout(timeoutHandler, 250)); }; search.reset = function () { header.dom(".header__search").val(""); $(".no_content").remove(); - if (search.hash != null) { + if (search.json !== null) { // Trash data - albums.json = null; album.json = null; photo.json = null; - search.hash = null; + search.json = null; - lychee.animate(".divider", "fadeOut"); + lychee.animate($(".divider"), "fadeOut"); lychee.goto(); } }; diff --git a/scripts/main/settings.js b/scripts/main/settings.js index dfaafa78..30ae8489 100644 --- a/scripts/main/settings.js +++ b/scripts/main/settings.js @@ -4,161 +4,65 @@ let settings = {}; +/** + * @returns {void} + */ settings.open = function () { view.settings.init(); }; -settings.createConfig = function () { - const action = function (data) { - let dbName = data.dbName || ""; - let dbUser = data.dbUser || ""; - let dbPassword = data.dbPassword || ""; - let dbHost = data.dbHost || ""; - let dbTablePrefix = data.dbTablePrefix || ""; - - if (dbUser.length < 1) { - basicModal.error("dbUser"); - return false; - } - - if (dbHost.length < 1) dbHost = "localhost"; - if (dbName.length < 1) dbName = "lychee"; - - let params = { - dbName, - dbUser, - dbPassword, - dbHost, - dbTablePrefix, - }; - - api.post("Config::create", params, function (_data) { - if (_data !== true) { - // Connection failed - if (_data === "Warning: Connection failed!") { - basicModal.show({ - body: "

" + lychee.locale["ERROR_DB_1"] + "

", - buttons: { - action: { - title: lychee.locale["RETRY"], - fn: settings.createConfig, - }, - }, - }); - - return false; - } - - // Creation failed - if (_data === "Warning: Creation failed!") { - basicModal.show({ - body: "

" + lychee.locale["ERROR_DB_2"] + "

", - buttons: { - action: { - title: lychee.locale["RETRY"], - fn: settings.createConfig, - }, - }, - }); - - return false; - } - - // Could not create file - if (_data === "Warning: Could not create file!") { - basicModal.show({ - body: "

" + lychee.locale["ERROR_CONFIG_FILE"] + "

", - buttons: { - action: { - title: lychee.locale["RETRY"], - fn: settings.createConfig, - }, - }, - }); - - return false; - } - - // Something went wrong - basicModal.show({ - body: "

" + lychee.locale["ERROR_UNKNOWN"] + "

", - buttons: { - action: { - title: lychee.locale["RETRY"], - fn: settings.createConfig, - }, - }, - }); - - return false; - } else { - // Configuration successful - window.location.reload(); - - return false; - } +settings.createLogin = function () { + /** + * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. + * @param {Object} params the original JSON parameters of the request + * @param {?LycheeException} lycheeException the Lychee exception + * @returns {boolean} + */ + const errorHandler = function (jqXHR, params, lycheeException) { + let htmlBody = "

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

"; + htmlBody += lycheeException ? "

" + lycheeException.message + "

" : ""; + basicModal.show({ + body: htmlBody, + buttons: { + action: { + title: lychee.locale["RETRY"], + fn: () => settings.createLogin(), + }, + }, }); + return true; }; - let msg = - ` -

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

-

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

- `; - - basicModal.show({ - body: msg, - buttons: { - action: { - title: lychee.locale["DB_CONNECT"], - fn: action, - }, - }, - }); -}; - -settings.createLogin = function () { + /** + * @typedef SetLoginDialogResult + * + * @property {string} username + * @property {string} password + * @property {string} confirm + */ + + /** + * @param {SetLoginDialogResult} data + * @returns {void} + */ const action = function (data) { - let username = data.username; - let password = data.password; - let confirm = data.confirm; + const username = data.username; + const password = data.password; + const confirm = data.confirm; if (!username.trim()) { basicModal.error("username"); - return false; + return; } if (!password.trim()) { basicModal.error("password"); - return false; + return; } if (password !== confirm) { basicModal.error("confirm"); - return false; + return; } basicModal.close(); @@ -168,42 +72,16 @@ settings.createLogin = function () { password, }; - api.post("Settings::setLogin", params, function (_data) { - if (_data !== true) { - basicModal.show({ - body: "

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

", - buttons: { - action: { - title: lychee.locale["RETRY"], - fn: settings.createLogin, - }, - }, - }); - } - // else - // { - // window.location.reload() - // } - }); + api.post("Settings::setLogin", params, null, null, errorHandler); }; - let msg = - ` -

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

- `; + const msg = ` +

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

`; basicModal.show({ body: msg, @@ -216,41 +94,94 @@ settings.createLogin = function () { }); }; -// from https://github.com/electerious/basicModal/blob/master/src/scripts/main.js -settings.getValues = function (form_name) { - let values = {}; - let inputs_select = $(form_name + " input[name], " + form_name + " select[name]"); +/** + * A dictionary of (name,value)-pairs of the form. + * + * @typedef SettingsFormData + * + * @type {Object.} + */ + +/** + * From https://github.com/electerious/basicModal/blob/master/src/scripts/main.js + * + * @param {string} formSelector + * @returns {SettingsFormData} + */ +settings.getValues = function (formSelector) { + const values = {}; + + /** @type {?NodeListOf} */ + const inputElements = document.querySelectorAll(formSelector + " input[name]"); // Get value from all inputs - $(inputs_select).each(function () { - let name = $(this).attr("name"); - // Store name and value of input - values[name] = $(this).val(); + inputElements.forEach(function (inputElement) { + switch (inputElement.type) { + case "checkbox": + case "radio": + values[inputElement.name] = inputElement.checked; + break; + case "number": + case "range": + values[inputElement.name] = parseInt(inputElement.value, 10); + break; + case "file": + values[inputElement.name] = inputElement.files; + break; + default: + switch (inputElement.getAttribute("inputmode")) { + case "numeric": + values[inputElement.name] = parseInt(inputElement.value, 10); + break; + case "decimal": + values[inputElement.name] = parseFloat(inputElement.value); + break; + default: + values[inputElement.name] = inputElement.value; + } + } }); - return Object.keys(values).length === 0 ? null : values; + + /** @type {?NodeListOf} */ + const selectElements = document.querySelectorAll(formSelector + " select[name]"); + + // Get name of selected option from all selects + selectElements.forEach(function (selectElement) { + values[selectElement.name] = selectElement.selectedIndex !== -1 ? selectElement.options[selectElement.selectedIndex].value : null; + }); + + return values; }; -// from https://github.com/electerious/basicModal/blob/master/src/scripts/main.js -settings.bind = function (item, name, fn) { - // if ($(item).length) - // { - // console.log('found'); - // } - // else - // { - // console.log('not found: ' + item); - // } - // Action-button - $(item).on("click", function () { - fn(settings.getValues(name)); +/** + * @callback SettingClickCB + * + * @param {SettingsFormData} formData + * @returns {void} + */ + +/** + * From https://github.com/electerious/basicModal/blob/master/src/scripts/main.js. + * + * @param {string} inputSelector + * @param {string} formSelector + * @param {SettingClickCB} settingClickCB + */ +settings.bind = function (inputSelector, formSelector, settingClickCB) { + $(inputSelector).on("click", function () { + settingClickCB(settings.getValues(formSelector)); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.changeLogin = function (params) { if (params.username.length < 1) { loadingBar.show("error", "new username cannot be empty."); $("input[name=username]").addClass("error"); - return false; + return; } else { $("input[name=username]").removeClass("error"); } @@ -258,7 +189,7 @@ settings.changeLogin = function (params) { if (params.password.length < 1) { loadingBar.show("error", "new password cannot be empty."); $("input[name=password]").addClass("error"); - return false; + return; } else { $("input[name=password]").removeClass("error"); } @@ -266,213 +197,176 @@ settings.changeLogin = function (params) { if (params.password !== params.confirm) { loadingBar.show("error", "new password does not match."); $("input[name=confirm]").addClass("error"); - return false; + return; } else { $("input[name=confirm]").removeClass("error"); } - api.post("Settings::setLogin", params, function (data) { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, datas, data); - } else { - $("input[name]").removeClass("error"); - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LOGIN"]); - view.settings.content.clearLogin(); - } + api.post("Settings::setLogin", params, function () { + $("input[name]").removeClass("error"); + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LOGIN"]); + view.settings.content.clearLogin(); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.changeSorting = function (params) { - api.post("Settings::setSorting", params, function (data) { - if (data === true) { - lychee.sortingAlbums = "ORDER BY " + params["typeAlbums"] + " " + params["orderAlbums"]; - lychee.sortingPhotos = "ORDER BY " + params["typePhotos"] + " " + params["orderPhotos"]; - albums.refresh(); - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_SORT"]); - } else lychee.error(null, params, data); + api.post("Settings::setSorting", params, function () { + lychee.sorting_albums.column = params["sorting_albums_column"]; + lychee.sorting_albums.order = params["sorting_albums_order"]; + lychee.sorting_photos.column = params["sorting_photos_column"]; + lychee.sorting_photos.order = params["sorting_photos_order"]; + albums.refresh(); + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_SORT"]); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.changeDropboxKey = function (params) { // if params.key == "" key is cleared - api.post("Settings::setDropboxKey", params, function (data) { - if (data === true) { - lychee.dropboxKey = params.key; - // if (callback) lychee.loadDropbox(callback) - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_DROPBOX"]); - } else lychee.error(null, params, data); + api.post("Settings::setDropboxKey", params, function () { + lychee.dropboxKey = params.key; + // if (callback) lychee.loadDropbox(callback) + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_DROPBOX"]); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.changeLang = function (params) { - api.post("Settings::setLang", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LANG"]); - lychee.init(); - } else lychee.error(null, params, data); + api.post("Settings::setLang", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LANG"]); + lychee.init(); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.setDefaultLicense = function (params) { - api.post("Settings::setDefaultLicense", params, function (data) { - if (data === true) { - lychee.default_license = params.license; - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LICENSE"]); - } else lychee.error(null, params, data); + api.post("Settings::setDefaultLicense", params, function () { + lychee.default_license = params.license; + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LICENSE"]); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.setLayout = function (params) { - api.post("Settings::setLayout", params, function (data) { - if (data === true) { - lychee.layout = params.layout; - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LAYOUT"]); - } else lychee.error(null, params, data); + api.post("Settings::setLayout", params, function () { + lychee.layout = params.layout; + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_LAYOUT"]); }); }; -settings.changePublicSearch = function () { - var params = {}; - if ($("#PublicSearch:checked").length === 1) { - params.public_search = "1"; - } else { - params.public_search = "0"; - } - api.post("Settings::setPublicSearch", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_PUBLIC_SEARCH"]); - lychee.public_search = params.public_search === "1"; - } else lychee.error(null, params, data); +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changePublicSearch = function (params) { + api.post("Settings::setPublicSearch", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_PUBLIC_SEARCH"]); + lychee.public_search = params.public_search; }); }; -settings.setOverlayType = function () { - // validate the input - let params = {}; - let check = $("#ImageOverlay:checked") ? true : false; - let type = $("#ImgOverlayType").val(); - if (check && type === "exif") { - params.image_overlay_type = "exif"; - } else if (check && type === "desc") { - params.image_overlay_type = "desc"; - } else if (check && type === "date") { - params.image_overlay_type = "date"; - } else if (check && type === "none") { - params.image_overlay_type = "none"; - } else { - params.image_overlay_type = "exif"; - console.log("Error - default used"); - } - - api.post("Settings::setOverlayType", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_IMAGE_OVERLAY"]); - lychee.image_overlay_type = params.image_overlay_type; - lychee.image_overlay_type_default = params.image_overlay_type; - } else lychee.error(null, params, data); +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.setOverlayType = function (params) { + api.post("Settings::setOverlayType", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_IMAGE_OVERLAY"]); + lychee.image_overlay_type = params.image_overlay_type; + lychee.image_overlay_type_default = params.image_overlay_type; }); }; -settings.changeMapDisplay = function () { - var params = {}; - if ($("#MapDisplay:checked").length === 1) { - params.map_display = "1"; - } else { - params.map_display = "0"; - } - api.post("Settings::setMapDisplay", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); - lychee.map_display = params.map_display === "1"; - } else lychee.error(null, params, data); +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeMapDisplay = function (params) { + api.post("Settings::setMapDisplay", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); + lychee.map_display = params.map_display; + // Map functionality is disabled + // -> map for public albums also needs to be disabled + if (!lychee.map_display && lychee.map_display_public) { + $("#MapDisplayPublic").click(); + } }); - // Map functionality is disabled - // -> map for public albums also needs to be disabled - if (lychee.map_display_public === true) { - $("#MapDisplayPublic").click(); - } }; -settings.changeMapDisplayPublic = function () { - var params = {}; - if ($("#MapDisplayPublic:checked").length === 1) { - params.map_display_public = "1"; - +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeMapDisplayPublic = function (params) { + api.post("Settings::setMapDisplayPublic", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY_PUBLIC"]); + lychee.map_display_public = params.map_display_public; // If public map functionality is enabled, but map in general is disabled // General map functionality needs to be enabled - if (lychee.map_display === false) { + if (lychee.map_display_public && !lychee.map_display) { $("#MapDisplay").click(); } - } else { - params.map_display_public = "0"; - } - api.post("Settings::setMapDisplayPublic", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY_PUBLIC"]); - lychee.map_display_public = params.map_display_public === "1"; - } else lychee.error(null, params, data); }); }; -settings.setMapProvider = function () { - // validate the input - let params = {}; - params.map_provider = $("#MapProvider").val(); - - api.post("Settings::setMapProvider", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_PROVIDER"]); - lychee.map_provider = params.map_provider; - } else lychee.error(null, params, data); +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.setMapProvider = function (params) { + api.post("Settings::setMapProvider", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_PROVIDER"]); + lychee.map_provider = params.map_provider; }); }; -settings.changeMapIncludeSubalbums = function () { - var params = {}; - if ($("#MapIncludeSubalbums:checked").length === 1) { - params.map_include_subalbums = "1"; - } else { - params.map_include_subalbums = "0"; - } - api.post("Settings::setMapIncludeSubalbums", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); - lychee.map_include_subalbums = params.map_include_subalbums === "1"; - } else lychee.error(null, params, data); +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeMapIncludeSubAlbums = function (params) { + api.post("Settings::setMapIncludeSubAlbums", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); + lychee.map_include_subalbums = params.map_include_subalbums; }); }; -settings.changeLocationDecoding = function () { - var params = {}; - if ($("#LocationDecoding:checked").length === 1) { - params.location_decoding = "1"; - } else { - params.location_decoding = "0"; - } - api.post("Settings::setLocationDecoding", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); - lychee.location_decoding = params.location_decoding === "1"; - } else lychee.error(null, params, data); +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeLocationDecoding = function (params) { + api.post("Settings::setLocationDecoding", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); + lychee.location_decoding = params.location_decoding; }); }; -settings.changeNSFWVisible = function () { - var params = {}; - if ($("#NSFWVisible:checked").length === 1) { - params.nsfw_visible = "1"; - } else { - params.nsfw_visible = "0"; - } - api.post("Settings::setNSFWVisible", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_NSFW_VISIBLE"]); - lychee.nsfw_visible = params.nsfw_visible === "1"; - lychee.nsfw_visible_saved = lychee.nsfw_visible; - } else { - lychee.error(null, params, data); - } +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeNSFWVisible = function (params) { + api.post("Settings::setNSFWVisible", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_NSFW_VISIBLE"]); + lychee.nsfw_visible = params.nsfw_visible; + lychee.nsfw_visible_saved = lychee.nsfw_visible; }); }; @@ -481,117 +375,111 @@ settings.changeNSFWVisible = function () { // lychee.nsfw_warning = (data.config.nsfw_warning && data.config.nsfw_warning === '1') || false; // lychee.nsfw_warning_text = data.config.nsfw_warning_text || 'Sensitive content

This album contains sensitive content which some people may find offensive or disturbing.

'; -settings.changeLocationShow = function () { - var params = {}; - if ($("#LocationShow:checked").length === 1) { - params.location_show = "1"; - } else { - params.location_show = "0"; +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeLocationShow = function (params) { + api.post("Settings::setLocationShow", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); + lychee.location_show = params.location_show; // Don't show location // -> location for public albums also needs to be disabled - if (lychee.location_show_public === true) { + if (!lychee.location_show && lychee.location_show_public) { $("#LocationShowPublic").click(); } - } - api.post("Settings::setLocationShow", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); - lychee.location_show = params.location_show === "1"; - } else lychee.error(null, params, data); }); }; -settings.changeLocationShowPublic = function () { - var params = {}; - if ($("#LocationShowPublic:checked").length === 1) { - params.location_show_public = "1"; +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeLocationShowPublic = function (params) { + api.post("Settings::setLocationShowPublic", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); + lychee.location_show_public = params.location_show_public; // If public map functionality is enabled, but map in general is disabled // General map functionality needs to be enabled - if (lychee.location_show === false) { + if (lychee.location_show_public && !lychee.location_show) { $("#LocationShow").click(); } - } else { - params.location_show_public = "0"; - } - api.post("Settings::setLocationShowPublic", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_MAP_DISPLAY"]); - lychee.location_show_public = params.location_show_public === "1"; - } else lychee.error(null, params, data); }); }; -settings.changeNewPhotosNotification = function () { - var params = {}; - if ($("#NewPhotosNotification:checked").length === 1) { - params.new_photos_notification = "1"; - } else { - params.new_photos_notification = "0"; - } - api.post("Settings::setNewPhotosNotification", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_NEW_PHOTOS_NOTIFICATION"]); - lychee.new_photos_notification = params.new_photos_notification === "1"; - } else { - lychee.error(null, params, data); - } +/** + * @param {SettingsFormData} params + * @returns {void} + */ +settings.changeNewPhotosNotification = function (params) { + api.post("Settings::setNewPhotosNotification", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_NEW_PHOTOS_NOTIFICATION"]); + lychee.new_photos_notification = params.new_photos_notification; }); }; +/** + * @returns {void} + */ settings.changeCSS = function () { - let params = {}; - params.css = $("#css").val(); - - api.post("Settings::setCSS", params, function (data) { - if (data === true) { - lychee.css = params.css; - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_CSS"]); - } else lychee.error(null, params, data); + const params = { + css: $("#css").val(), + }; + api.post("Settings::setCSS", params, function () { + lychee.css = params.css; + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_CSS"]); }); }; +/** + * @param {SettingsFormData} params + * @returns {void} + */ settings.save = function (params) { - api.post("Settings::saveAll", params, function (data) { - if (data === true) { - loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_UPDATE"]); - view.full_settings.init(); - // re-read settings - lychee.init(false); - } else lychee.error("Check the Logs", params, data); + api.post("Settings::saveAll", params, function () { + loadingBar.show("success", lychee.locale["SETTINGS_SUCCESS_UPDATE"]); + view.full_settings.init(); + // re-read settings + lychee.init(false); }); }; +/** + * @param {jQuery.Event} e + * @returns {void} + */ settings.save_enter = function (e) { - if (e.which === 13) { - // show confirmation box - $(":focus").blur(); + // We only handle "enter" + if (e.which !== 13) return; - let action = {}; - let cancel = {}; + // show confirmation box + $(":focus").blur(); - action.title = lychee.locale["ENTER"]; - action.msg = lychee.html`

${lychee.locale["SAVE_RISK"]}

`; + let action = {}; + let cancel = {}; - cancel.title = lychee.locale["CANCEL"]; + action.title = lychee.locale["ENTER"]; + action.msg = lychee.html`

${lychee.locale["SAVE_RISK"]}

`; - action.fn = function () { - settings.save(settings.getValues("#fullSettings")); - basicModal.close(); - }; + cancel.title = lychee.locale["CANCEL"]; - basicModal.show({ - body: action.msg, - buttons: { - action: { - title: action.title, - fn: action.fn, - class: "red", - }, - cancel: { - title: cancel.title, - fn: basicModal.close, - }, + action.fn = function () { + settings.save(settings.getValues("#fullSettings")); + basicModal.close(); + }; + + basicModal.show({ + body: action.msg, + buttons: { + action: { + title: action.title, + fn: action.fn, + class: "red", }, - }); - } + cancel: { + title: cancel.title, + fn: basicModal.close, + }, + }, + }); }; diff --git a/scripts/main/sharing.js b/scripts/main/sharing.js index 9868a625..4309438e 100644 --- a/scripts/main/sharing.js +++ b/scripts/main/sharing.js @@ -1,71 +1,76 @@ let sharing = { + /** @type {?SharingInfo} */ json: null, }; +/** + * @returns {void} + */ sharing.add = function () { - let params = { - albumIDs: "", - UserIDs: "", + const params = { + /** @type {string[]} */ + albumIDs: [], + /** @type {number[]} */ + userIDs: [], }; $("#albums_list_to option").each(function () { - if (params.albumIDs !== "") params.albumIDs += ","; - params.albumIDs += this.value; + params.albumIDs.push(this.value); }); $("#user_list_to option").each(function () { - if (params.UserIDs !== "") params.UserIDs += ","; - params.UserIDs += this.value; + params.userIDs.push(Number.parseInt(this.value, 10)); }); - if (params.albumIDs === "") { + if (params.albumIDs.length === 0) { loadingBar.show("error", "Select an album to share!"); - return false; + return; } - if (params.UserIDs === "") { + if (params.userIDs.length === 0) { loadingBar.show("error", "Select a user to share with!"); - return false; + return; } - api.post("Sharing::Add", params, function (data) { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", "Sharing updated!"); - sharing.list(); // reload user list - } + api.post("Sharing::add", params, function () { + loadingBar.show("success", "Sharing updated!"); + sharing.list(); // reload user list }); }; +/** + * @returns {void} + */ sharing.delete = function () { - let params = { - ShareIDs: "", + const params = { + /** @type {number[]} */ + shareIDs: [], }; $('input[name="remove_id"]:checked').each(function () { - if (params.ShareIDs !== "") params.ShareIDs += ","; - params.ShareIDs += this.value; + params.shareIDs.push(Number.parseInt(this.value, 10)); }); - if (params.ShareIDs === "") { + if (params.shareIDs.length === 0) { loadingBar.show("error", "Select a sharing to remove!"); - return false; + return; } - api.post("Sharing::Delete", params, function (data) { - if (data !== true) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", "Sharing removed!"); - sharing.list(); // reload user list - } + api.post("Sharing::delete", params, function () { + loadingBar.show("success", "Sharing removed!"); + sharing.list(); // reload user list }); }; +/** + * @returns {void} + */ sharing.list = function () { - api.post("Sharing::List", {}, function (data) { - sharing.json = data; - view.sharing.init(); - }); + api.post( + "Sharing::list", + {}, + /** @param {SharingInfo} data */ + function (data) { + sharing.json = data; + view.sharing.init(); + } + ); }; diff --git a/scripts/main/sidebar.js b/scripts/main/sidebar.js index 60579b3b..65ce4bff 100644 --- a/scripts/main/sidebar.js +++ b/scripts/main/sidebar.js @@ -2,7 +2,11 @@ * @description This module takes care of the sidebar. */ +/** + * @namespace + */ let sidebar = { + /** @type {jQuery} */ _dom: $(".sidebar"), types: { DEFAULT: 0, @@ -11,20 +15,25 @@ let sidebar = { createStructure: {}, }; +/** + * @param {?string} [selector=null] + * @returns {jQuery} + */ sidebar.dom = function (selector) { if (selector == null || selector === "") return sidebar._dom; - return sidebar._dom.find(selector); }; +/** + * This function should be called after building and appending + * the sidebars content to the DOM. + * This function can be called multiple times, therefore + * event handlers should be removed before binding a new one. + * + * @returns {void} + */ sidebar.bind = function () { - // This function should be called after building and appending - // the sidebars content to the DOM. - // This function can be called multiple times, therefore - // event handlers should be removed before binding a new one. - - // Event Name - let eventName = lychee.getEventName(); + const eventName = lychee.getEventName(); sidebar .dom("#edit_title") @@ -91,68 +100,86 @@ sidebar.bind = function () { .on(eventName, function () { sidebar.triggerSearch($(this).text()); }); - - return true; }; +/** + * @param {string} search_string + * @returns {void} + */ sidebar.triggerSearch = function (search_string) { - // If public search is diabled -> do nothing - if (lychee.publicMode === true && !lychee.public_search) { + // If public search is disabled -> do nothing + if (lychee.publicMode && !lychee.public_search) { // Do not display an error -> just do nothing to not confuse the user return; } - search.hash = null; + search.json = null; // We're either logged in or public search is allowed lychee.goto("search/" + encodeURIComponent(search_string)); }; +/** + * @returns {boolean} + */ sidebar.keepSidebarVisible = function () { - let v = sessionStorage.getItem("keepSidebarVisible"); + const v = sessionStorage.getItem("keepSidebarVisible"); return v !== null ? v === "true" : false; }; +/** + * @param {boolean} is_user_initiated - indicates if the user requested to + * toggle and hence the new state shall + * be saved in session storage + * @returns {void} + */ sidebar.toggle = function (is_user_initiated) { if (visible.sidebar() || visible.sidebarbutton()) { header.dom(".button--info").toggleClass("active"); lychee.content.toggleClass("content--sidebar"); lychee.imageview.toggleClass("image--sidebar"); - if (typeof view !== "undefined") view.album.content.justify(); + if (typeof view !== "undefined") view.album.content.justify(album.json ? album.json.photos : []); sidebar.dom().toggleClass("active"); photo.updateSizeLivePhotoDuringAnimation(); - if (is_user_initiated) sessionStorage.setItem("keepSidebarVisible", visible.sidebar()); - - return true; + if (is_user_initiated) sessionStorage.setItem("keepSidebarVisible", visible.sidebar() ? "true" : "false"); } - - return false; }; +/** + * Attributes/Values inside the sidebar are selectable by default. + * Selection needs to be deactivated to prevent an unwanted selection + * while using multiselect. + * + * @param {boolean} [selectable=true] + * @returns {void} + */ sidebar.setSelectable = function (selectable = true) { - // Attributes/Values inside the sidebar are selectable by default. - // Selection needs to be deactivated to prevent an unwanted selection - // while using multiselect. - - if (selectable === true) sidebar.dom().removeClass("notSelectable"); + if (selectable) sidebar.dom().removeClass("notSelectable"); else sidebar.dom().addClass("notSelectable"); }; +/** + * @param {string} attr - selector of attribute without the `attr_` prefix + * @param {?string} value - a `null` value is replaced by the empty string + * @param {boolean} [dangerouslySetInnerHTML=false] + * + * @returns {void} + */ sidebar.changeAttr = function (attr, value = "", dangerouslySetInnerHTML = false) { - if (attr == null || attr === "") return false; - - // Set a default for the value - if (value === null) value = ""; + if (!attr) return; + if (!value) value = ""; + // TODO: Don't use our home-brewed `escapeHTML` method; use `jQuery#text` instead // Escape value - if (dangerouslySetInnerHTML === false) value = lychee.escapeHTML(value); + if (!dangerouslySetInnerHTML) value = lychee.escapeHTML(value); - // Set new value sidebar.dom(".attr_" + attr).html(value); - - return true; }; +/** + * @param {string} attr - selector of attribute without the `attr_` prefix + * @returns {void} + */ sidebar.hideAttr = function (attr) { sidebar .dom(".attr_" + attr) @@ -160,17 +187,46 @@ sidebar.hideAttr = function (attr) { .hide(); }; +/** + * Converts integer seconds into "hours:minutes:seconds". + * + * TODO: Consider to make this method part of `lychee.locale`. + * + * @param {(number|string)} d + * @returns {string} + */ sidebar.secondsToHMS = function (d) { d = Number(d); - var h = Math.floor(d / 3600); - var m = Math.floor((d % 3600) / 60); - var s = Math.floor(d % 60); + const h = Math.floor(d / 3600); + const m = Math.floor((d % 3600) / 60); + const s = Math.floor(d % 60); - return (h > 0 ? h.toString() + "h" : "") + (m > 0 ? m.toString() + "m" : "") + (s > 0 || (h == 0 && m == 0) ? s.toString() + "s" : ""); + return (h > 0 ? h.toString() + "h" : "") + (m > 0 ? m.toString() + "m" : "") + (s > 0 || (h === 0 && m === 0) ? s.toString() + "s" : ""); }; +/** + * @typedef Section + * + * @property {string} title + * @property {number} type + * @property {SectionRow[]} rows + */ + +/** + * @typedef SectionRow + * + * @property {string} title + * @property {string} kind + * @property {(string|string[])} value + * @property {boolean} [editable] + */ + +/** + * @param {?Photo} data + * @returns {Section[]} + */ sidebar.createStructure.photo = function (data) { - if (data == null || data === "") return false; + if (!data) return []; let editable = typeof album !== "undefined" ? album.isUploadable() : false; let hasExif = !!data.taken_at || !!data.make || !!data.model || !!data.shutter || !!data.aperture || !!data.focal || !!data.iso; @@ -249,17 +305,19 @@ sidebar.createStructure.photo = function (data) { // We overload the database, storing duration (in full seconds) in // "aperture" and frame rate (floating point with three digits after // the decimal point) in "focal". - if (data.aperture != "") { + if (data.aperture) { structure.image.rows.push({ title: lychee.locale["PHOTO_DURATION"], kind: "duration", value: sidebar.secondsToHMS(data.aperture) }); } - if (data.focal != "") { + if (data.focal) { structure.image.rows.push({ title: lychee.locale["PHOTO_FPS"], kind: "fps", value: data.focal + " fps" }); } } // Always create tags section - behaviour for editing - //tags handled when contructing the html code for tags + // tags handled when constructing the html code for tags + // TODO: IDE warns, that `value` is not property and `rows` is missing; the tags should actually be stored in a row for consistency + // TODO: Consider to NOT call `build.tags` here, but simply pass the plain JSON. `build.tags` should be called in `sidebar.render` below structure.tags = { title: lychee.locale["PHOTO_TAGS"], type: sidebar.types.TAGS, @@ -324,9 +382,15 @@ sidebar.createStructure.photo = function (data) { { title: lychee.locale["PHOTO_ALTITUDE"], kind: "altitude", - value: data.altitude ? (Math.round(parseFloat(data.altitude) * 10) / 10).toString() + "m" : "", + value: data.altitude ? (Math.round(data.altitude * 10) / 10).toString() + "m" : "", + }, + { + title: lychee.locale["PHOTO_LOCATION"], + kind: "location", + // Explode location string into an array to keep street, city etc. separate + // TODO: We should consider to keep the components apart on the server-side and send an structured object to the front-end. + value: data.location ? data.location.split(",").map((item) => item.trim()) : "", }, - { title: lychee.locale["PHOTO_LOCATION"], kind: "location", value: data.location ? data.location : "" }, ], }; if (data.img_direction !== null) { @@ -342,7 +406,7 @@ sidebar.createStructure.photo = function (data) { } // Construct all parts of the structure - let structure_ret = [structure.basics, structure.image, structure.tags, structure.exif, structure.location, structure.license]; + const structure_ret = [structure.basics, structure.image, structure.tags, structure.exif, structure.location, structure.license]; if (!lychee.publicMode) { structure_ret.push(structure.sharing); @@ -351,10 +415,12 @@ sidebar.createStructure.photo = function (data) { return structure_ret; }; -sidebar.createStructure.album = function (album) { - let data = album.json; - - if (data == null || data === "") return false; +/** + * @param {(Album|TagAlbum|SmartAlbum)} data + * @returns {Section[]} + */ +sidebar.createStructure.album = function (data) { + if (!data) return []; let editable = album.isUploadable(); let structure = {}; @@ -380,10 +446,10 @@ sidebar.createStructure.album = function (album) { } if (!lychee.publicMode) { - if (data.sorting_col === null) { + if (!data.sorting) { sorting = lychee.locale["DEFAULT"]; } else { - sorting = data.sorting_col + " " + data.sorting_order; + sorting = data.sorting.column + " " + data.sorting.order; } } @@ -400,12 +466,8 @@ sidebar.createStructure.album = function (album) { structure.basics.rows.push({ title: lychee.locale["ALBUM_SHOW_TAGS"], kind: "showtags", value: data.show_tags, editable }); } - let videoCount = 0; - $.each(data.photos, function () { - if (this.type && this.type.indexOf("video") > -1) { - videoCount++; - } - }); + const videoCount = data.photos.reduce((count, photo) => count + (photo.type.indexOf("video") > -1 ? 1 : 0), 0); + structure.album = { title: lychee.locale["ALBUM_ALBUM"], type: sidebar.types.DEFAULT, @@ -439,7 +501,7 @@ sidebar.createStructure.album = function (album) { ], }; - if (data.owner_name != null) { + if (data.owner_name) { structure.share.rows.push({ title: lychee.locale["ALBUM_OWNER"], kind: "owner", value: data.owner_name }); } @@ -458,13 +520,15 @@ sidebar.createStructure.album = function (album) { return structure_ret; }; +/** + * @param {Section[]} structure + * @returns {boolean} - true if the passed structure contains a "location" section + */ sidebar.has_location = function (structure) { - if (structure == null || structure === "" || structure === false) return false; - let _has_location = false; structure.forEach(function (section) { - if (section.title == lychee.locale["PHOTO_LOCATION"]) { + if (section.title === lychee.locale["PHOTO_LOCATION"]) { _has_location = true; } }); @@ -472,47 +536,32 @@ sidebar.has_location = function (structure) { return _has_location; }; +/** + * @param {Section[]} structure + * @returns {string} - HTML + */ sidebar.render = function (structure) { - if (structure == null || structure === "" || structure === false) return false; - - let html = ""; - - let renderDefault = function (section) { - let _html = ""; - - _html += ` + /** + * @param {Section} section + * @returns {string} + */ + const renderDefault = function (section) { + let _html = ` `; - if (section.title == lychee.locale["PHOTO_LOCATION"]) { - let _has_latitude = false; - let _has_longitude = false; - - section.rows.forEach(function (row, index, object) { - if (row.kind == "latitude" && row.value !== "") { - _has_latitude = true; - } - - if (row.kind == "longitude" && row.value !== "") { - _has_longitude = true; - } - - // Do not show location is not enabled - if (row.kind == "location" && ((lychee.publicMode === true && !lychee.location_show_public) || !lychee.location_show)) { - object.splice(index, 1); - } else { - // Explode location string into an array to keep street, city etc separate - if (!(row.value === "" || row.value == null)) { - section.rows[index].value = row.value.split(",").map(function (item) { - return item.trim(); - }); - } - } - }); - + if (section.title === lychee.locale["PHOTO_LOCATION"]) { + const _has_latitude = section.rows.findIndex((row) => row.kind === "latitude" && row.value) !== -1; + const _has_longitude = section.rows.findIndex((row) => row.kind === "longitude" && row.value) !== -1; + const idxLocation = section.rows.findIndex((row) => row.kind === "location"); + // Do not show location if not enabled + if (idxLocation !== -1 && ((lychee.publicMode === true && !lychee.location_show_public) || !lychee.location_show)) { + section.rows.splice(idxLocation, 1); + } + // Show map if we have coordinates if (_has_latitude && _has_longitude && lychee.map_display) { _html += `
@@ -523,21 +572,24 @@ sidebar.render = function (structure) { section.rows.forEach(function (row) { let value = row.value; - // show only Exif rows which have a value or if its editable + // show only rows which have a value or are editable if (!(value === "" || value == null) || row.editable === true) { // Wrap span-element around value for easier selecting on change if (Array.isArray(row.value)) { - value = ""; - row.value.forEach(function (v) { - if (v === "" || v == null) { - return; - } - // Add separator if needed - if (value !== "") { - value += lychee.html`, `; - } - value += lychee.html`$${v}`; - }); + value = row.value.reduce( + /** + * @param {string} prev + * @param {string} cur + */ + function (prev, cur) { + // Add separator if needed + if (prev !== "") { + prev += lychee.html`, `; + } + return prev + lychee.html`$${cur}`; + }, + "" + ); } else { value = lychee.html`$${value}`; } @@ -561,10 +613,15 @@ sidebar.render = function (structure) { return _html; }; - let renderTags = function (section) { + /** + * @param {Section} section + * @returns {string} + */ + const renderTags = function (section) { let _html = ""; let editable = ""; + // TODO: IDE warns me that the `Section` has no properties `editable` nor `value`; cause of the problem is that the section `tags` is built differently, see above // Add edit-icon to the value when editable if (section.editable === true) editable = build.editIcon("edit_tags"); @@ -581,6 +638,8 @@ sidebar.render = function (structure) { return _html; }; + let html = ""; + structure.forEach(function (section) { if (section.type === sidebar.types.DEFAULT) html += renderDefault(section); else if (section.type === sidebar.types.TAGS) html += renderTags(section); @@ -589,22 +648,29 @@ sidebar.render = function (structure) { return html; }; +/** + * Converts a decimal degree into integer degree, minutes and seconds. + * + * TODO: Consider to make this method part of `lychee.locale`. + * + * @param {number} decimal + * @param {boolean} type - indicates if the passed decimal indicates a + * latitude (`true`) or a longitude (`false`) + * @returns {string} + */ function DecimalToDegreeMinutesSeconds(decimal, type) { + const d = Math.abs(decimal); let degrees = 0; let minutes = 0; let seconds = 0; let direction; - //decimal must be integer or float no larger than 180; - //type must be Boolean - if (Math.abs(decimal) > 180 || typeof type !== "boolean") { - return false; + // absolute value of decimal must be smaller than 180; + if (d > 180) { + return ""; } - //inputs OK, proceed - //type is latitude when true, longitude when false - - //set direction; north assumed + // set direction; north assumed if (type && decimal < 0) { direction = "S"; } else if (!type && decimal < 0) { @@ -615,9 +681,6 @@ function DecimalToDegreeMinutesSeconds(decimal, type) { direction = "N"; } - //get absolute value of decimal - let d = Math.abs(decimal); - //get degrees degrees = Math.floor(d); diff --git a/scripts/main/swipe.js b/scripts/main/swipe.js index bb757fdb..c495a698 100644 --- a/scripts/main/swipe.js +++ b/scripts/main/swipe.js @@ -2,21 +2,32 @@ * @description Swipes and moves an object. */ -let swipe = { +const swipe = { + /** @type {?jQuery} */ obj: null, + /** @type {number} */ offsetX: 0, + /** @type {number} */ offsetY: 0, + /** @type {boolean} */ preventNextHeaderToggle: false, }; +/** + * @param {jQuery} obj + * @returns {void} + */ swipe.start = function (obj) { - if (obj) swipe.obj = obj; - return true; + swipe.obj = obj; }; +/** + * @param {jQuery.Event} e + * @returns {void} + */ swipe.move = function (e) { if (swipe.obj === null) { - return false; + return; } if (Math.abs(e.x) > Math.abs(e.y)) { @@ -33,13 +44,28 @@ swipe.move = function (e) { MozTransform: value, transform: value, }); - return; }; +/** + * @callback SwipeStoppedCB + * + * Find a better name for that, but I have no idea what this callback is + * supposed to do. + * + * @param {boolean} animate + * @returns {void} + */ + +/** + * @param {{x: number, y: number, direction: number, distance: number, angle: number, speed: number, }} e + * @param {SwipeStoppedCB} left + * @param {SwipeStoppedCB} right + * @returns {void} + */ swipe.stop = function (e, left, right) { // Only execute once - if (swipe.obj == null) { - return false; + if (swipe.obj === null) { + return; } if (e.y <= -lychee.swipe_tolerance_y) { @@ -72,6 +98,4 @@ swipe.stop = function (e, left, right) { swipe.obj = null; swipe.offsetX = 0; swipe.offsetY = 0; - - return; }; diff --git a/scripts/main/tabindex.js b/scripts/main/tabindex.js index cf02cac4..b1a9346e 100644 --- a/scripts/main/tabindex.js +++ b/scripts/main/tabindex.js @@ -2,92 +2,142 @@ * @description Helper class to manage tabindex */ -let tabindex = { +const tabindex = { offset_for_header: 100, next_tab_index: 100, }; +/** + * @param {jQuery} elem + * @returns {void} + */ tabindex.saveSettings = function (elem) { if (!lychee.enable_tabindex) return; // Todo: Make shorter notation // Get all elements which have a tabindex - let tmp = $(elem).find("[tabindex]"); - - // iterate over all elements and set tabindex to stored value (i.e. make is not focussable) - tmp.each(function (i, e) { - // TODO: shorter notation - a = $(e).attr("tabindex"); - $(this).data("tabindex-saved", a); - }); + // TODO @Hallenser: What did you intended by the TODO above? It seems as if the jQuery selector is already as short as possible? + const tmp = elem.find("[tabindex]"); + + // iterate over all elements and set tabindex to stored value (i.e. make is not focusable) + tmp.each( + /** + * @param {number} i - the index + * @param {Element} e - the HTML element + * @this {Element} - identical to `e` + */ + function (i, e) { + // TODO: shorter notation + // TODO @Hallenser: What do you intended by the TODO `short notation`? Moreover: Why do we use `this` and `e`? They refer to the identical instance of a HTML element. + const a = $(e).attr("tabindex"); + $(this).data("tabindex-saved", a); + } + ); }; tabindex.restoreSettings = function (elem) { if (!lychee.enable_tabindex) return; - // Todo: Make shorter noation + // Todo: Make shorter notation // Get all elements which have a tabindex - let tmp = $(elem).find("[tabindex]"); + // TODO @Hallenser: What did you intended by the TODO above? It seems as if the jQuery selector is already as short as possible? + const tmp = $(elem).find("[tabindex]"); // iterate over all elements and set tabindex to stored value (i.e. make is not focussable) - tmp.each(function (i, e) { - // TODO: shorter notation - a = $(e).data("tabindex-saved"); - $(e).attr("tabindex", a); - }); + tmp.each( + /** + * @param {number} i - the index + * @param {Element} e - the HTML element + * @this {Element} - identical to `e` + */ + function (i, e) { + // TODO: shorter notation + // TODO @Hallenser: What do you intended by the TODO `short notation`? Moreover: Why do we use `this` and `e`? They refer to the identical instance of a HTML element. + const a = $(e).data("tabindex-saved"); + $(e).attr("tabindex", a); + } + ); }; +/** + * @param {jQuery} elem + * @param {boolean} [saveFocusElement=false] + * @returns {void} + */ tabindex.makeUnfocusable = function (elem, saveFocusElement = false) { if (!lychee.enable_tabindex) return; - // Todo: Make shorter noation + // Todo: Make shorter notation // Get all elements which have a tabindex - let tmp = $(elem).find("[tabindex]"); + const tmp = elem.find("[tabindex]"); // iterate over all elements and set tabindex to -1 (i.e. make is not focussable) - tmp.each(function (i, e) { - $(e).attr("tabindex", "-1"); - // Save which element had focus before we make it unfocusable - if (saveFocusElement && $(e).is(":focus")) { - $(e).data("tabindex-focus", true); - // Remove focus - $(e).blur(); + tmp.each( + /** + * @param {number} i - the index + * @param {Element} e - the HTML element + */ + function (i, e) { + $(e).attr("tabindex", "-1"); + // Save which element had focus before we make it unfocusable + if (saveFocusElement && $(e).is(":focus")) { + $(e).data("tabindex-focus", true); + // Remove focus + $(e).blur(); + } } - }); + ); // Disable input fields - $(elem).find("input").attr("disabled", "disabled"); + elem.find("input").attr("disabled", "disabled"); }; +/** + * @param {jQuery} elem + * @param {boolean} [restoreFocusElement=false] + * @returns {void} + */ tabindex.makeFocusable = function (elem, restoreFocusElement = false) { if (!lychee.enable_tabindex) return; - // Todo: Make shorter noation + // Todo: Make shorter notation // Get all elements which have a tabindex - let tmp = $(elem).find("[data-tabindex]"); - - // iterate over all elements and set tabindex to stored value (i.e. make is not focussable) - tmp.each(function (i, e) { - $(e).attr("tabindex", $(e).data("tabindex")); - // restore focus elemente if wanted - if (restoreFocusElement) { - if ($(e).data("tabindex-focus") && lychee.active_focus_on_page_load) { - $(e).focus(); - $(e).removeData("tabindex-focus"); + const tmp = elem.find("[data-tabindex]"); + + // iterate over all elements and set tabindex to stored value + tmp.each( + /** + * @param {number} i + * @param {Element} e + */ + function (i, e) { + $(e).attr("tabindex", $(e).data("tabindex")); + // restore focus element if wanted + if (restoreFocusElement) { + if ($(e).data("tabindex-focus") && lychee.active_focus_on_page_load) { + $(e).focus(); + $(e).removeData("tabindex-focus"); + } } } - }); + ); // Enable input fields - $(elem).find("input").removeAttr("disabled"); + elem.find("input").removeAttr("disabled"); }; +/** + * @returns {number} + */ tabindex.get_next_tab_index = function () { tabindex.next_tab_index = tabindex.next_tab_index + 1; return tabindex.next_tab_index - 1; }; +/** + * @returns {void} + */ tabindex.reset = function () { tabindex.next_tab_index = tabindex.offset_for_header; }; diff --git a/scripts/main/u2f.js b/scripts/main/u2f.js index c265fabf..f626f091 100644 --- a/scripts/main/u2f.js +++ b/scripts/main/u2f.js @@ -1,10 +1,14 @@ -let u2f = { +const u2f = { + /** @type {?WebAuthnCredential[]} */ json: null, }; +/** + * @returns {boolean} + */ u2f.is_available = function () { if (!window.isSecureContext && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1") { - let msg = lychee.html`

${lychee.locale["U2F_NOT_SECURE"]}

`; + const msg = lychee.html`

${lychee.locale["U2F_NOT_SECURE"]}

`; basicModal.show({ body: msg, @@ -21,63 +25,71 @@ u2f.is_available = function () { return true; }; +/** + * @returns {void} + */ u2f.login = function () { if (!u2f.is_available()) { return; } new Larapass({ - login: "/api/webauthn::login", - loginOptions: "/api/webauthn::login/gen", + login: "/api/WebAuthn::login", + loginOptions: "/api/WebAuthn::login/gen", }) .login({ user_id: 0, // for now it is only available to Admin user via a secret key shortcut. }) - .then(function (data) { + .then(function () { loadingBar.show("success", lychee.locale["U2F_AUTHENTIFICATION_SUCCESS"]); window.location.reload(); }) - .catch((error) => loadingBar.show("error", "Something went wrong!")); + .catch(() => loadingBar.show("error", "Something went wrong!")); }; +/** + * @returns {void} + */ u2f.register = function () { if (!u2f.is_available()) { return; } - let larapass = new Larapass({ - register: "/api/webauthn::register", - registerOptions: "/api/webauthn::register/gen", + const larapass = new Larapass({ + register: "/api/WebAuthn::register", + registerOptions: "/api/WebAuthn::register/gen", }); if (Larapass.supportsWebAuthn()) { larapass .register() - .then(function (response) { + .then(function () { loadingBar.show("success", lychee.locale["U2F_REGISTRATION_SUCCESS"]); u2f.list(); // reload credential list }) - .catch((response) => loadingBar.show("error", "Something went wrong!")); + .catch(() => loadingBar.show("error", "Something went wrong!")); } else { loadingBar.show("error", lychee.locale["U2F_NOT_SUPPORTED"]); } }; +/** + * @param {{id: string}} params - ID of WebAuthn credential + */ u2f.delete = function (params) { - api.post("webauthn::delete", params, function (data) { - console.log(data); - if (!data) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", lychee.locale["U2F_CREDENTIALS_DELETED"]); - u2f.list(); // reload credential list - } + api.post("WebAuthn::delete", params, function () { + loadingBar.show("success", lychee.locale["U2F_CREDENTIALS_DELETED"]); + u2f.list(); // reload credential list }); }; u2f.list = function () { - api.post("webauthn::list", {}, function (data) { - u2f.json = data; - view.u2f.init(); - }); + api.post( + "WebAuthn::list", + {}, + /** @param {WebAuthnCredential[]} data*/ + function (data) { + u2f.json = data; + view.u2f.init(); + } + ); }; diff --git a/scripts/main/upload.js b/scripts/main/upload.js index f4aa9bea..1a069923 100644 --- a/scripts/main/upload.js +++ b/scripts/main/upload.js @@ -4,14 +4,12 @@ let upload = {}; -const choiceDeleteSelector = '.basicModal .choice input[name="delete"]'; -const choiceSymlinkSelector = '.basicModal .choice input[name="symlinks"]'; -const choiceDuplicateSelector = '.basicModal .choice input[name="skipduplicates"]'; -const choiceResyncSelector = '.basicModal .choice input[name="resyncmetadata"]'; +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 lastRowSelector = ".basicModal .rows .row:last-child"; -const prelastRowSelector = ".basicModal .rows .row:nth-last-child(2)"; let nRowStatusSelector = function (row) { return ".basicModal .rows .row:nth-child(" + row + ") .status"; @@ -23,6 +21,12 @@ let showCloseButton = function () { $(cancelSelector).removeClass("basicModal__button--active").hide(); }; +/** + * @param {string} title + * @param {(FileList|File[]|DropboxFile[]|{name: string}[])} files + * @param {ModalDialogReadyCB} run_callback + * @param {?ModalDialogButtonCB} cancel_callback + */ upload.show = function (title, files, run_callback, cancel_callback = null) { basicModal.show({ body: build.uploadModal(title, files), @@ -65,200 +69,289 @@ upload.notify = function (title, text) { }; upload.start = { + /** + * @param {(FileList|File[])} files + */ local: function (files) { - let albumID = album.getID(); - let error = false; - let warning = false; - let processing_count = 0; - let next_upload = 0; - let currently_uploading = false; - let cancelUpload = false; - - const process = function (file_num) { - let formData = new FormData(); - let xhr = new XMLHttpRequest(); - let pre_progress = 0; - let progress = 0; - - if (file_num === 0) { - $(cancelSelector).show(); + if (files.length <= 0) return; + + const albumID = album.getID(); + let hasErrorOccurred = false; + let hasWarningOccurred = false; + /** + * The number of requests which are "on the fly", i.e. for which a + * response has not yet completely been received. + * + * Note, that Lychee supports a restricted kind of "parallelism" + * which is limited by the configuration option + * `lychee.upload_processing_limit`: + * While always only a single file is uploaded at once, upload of the + * next file already starts after transmission of the previous file + * has been finished, the response to the previous file might still be + * outstanding as the uploaded file is processed at the server-side. + * + * @type {number} + */ + let outstandingResponsesCount = 0; + /** + * The latest (aka highest) index of a file which is being or has + * been uploaded to the server. + * + * @type {number} + */ + let latestFileIdx = 0; + /** + * Semaphore whether a file is currently being uploaded. + * + * This is used as a semaphore to serialize the upload transmissions + * between several instances of the method {@link process}. + * + * @type {boolean} + */ + let isUploadRunning = false; + /** + * Semaphore whether a further upload shall be cancelled on the next + * occasion. + * + * @type {boolean} + */ + let shallCancelUpload = false; + + /** + * This callback is invoked when the last file has been processed. + * + * It closes the modal dialog or shows the close button and + * reloads the album. + */ + const finish = function () { + window.onbeforeunload = null; + + $("#upload_files").val(""); + + if (!hasErrorOccurred && !hasWarningOccurred) { + // Success + basicModal.close(); + upload.notify(lychee.locale["UPLOAD_COMPLETE"]); + } else if (!hasErrorOccurred && hasWarningOccurred) { + // Warning + showCloseButton(); + upload.notify(lychee.locale["UPLOAD_COMPLETE"]); + } else { + // Error + showCloseButton(); + if (shallCancelUpload) { + $(".basicModal .rows .row:nth-child(n+" + (latestFileIdx + 2).toString() + ") .status") + .html(lychee.locale["UPLOAD_CANCELLED"]) + .addClass("warning"); + } + upload.notify(lychee.locale["UPLOAD_COMPLETE"], lychee.locale["UPLOAD_COMPLETE_FAILED"]); } - const finish = function () { - window.onbeforeunload = null; - - $("#upload_files").val(""); + album.reload(); + }; - if (error === false && warning === false) { - // Success - basicModal.close(); - upload.notify(lychee.locale["UPLOAD_COMPLETE"]); - } else if (error === false && warning === true) { - // Warning - showCloseButton(); - upload.notify(lychee.locale["UPLOAD_COMPLETE"]); - } else { - // Error - showCloseButton(); - upload.notify(lychee.locale["UPLOAD_COMPLETE"], lychee.locale["UPLOAD_COMPLETE_FAILED"]); - } + /** + * Processes the upload and response for a single file. + * + * Note that up to `lychee.upload_processing_limit` "instances" of + * this method can be "alive" simultaneously. + * The parameter `fileIdx` is limited by `latestFileIdx`. + * + * @param {number} fileIdx the index of the file being processed + */ + const process = function (fileIdx) { + /** + * The upload progress of the file with index `fileIdx` so far. + * + * @type {number} + */ + let uploadProgress = 0; + + /** + * A function to be called when the upload has transmitted more data. + * + * This method updates the upload percentage counter in the dialog. + * + * If the progress equals 100%, i.e. if the upload has been + * completed, this method + * + * - unsets the semaphore for a running upload, + * - scrolls the dialog such that the file with index `fileIdx` + * becomes visible, and + * - changes the status text to "Upload processing". + * + * After the current upload has reached 100%, this method starts a + * new upload, if + * + * - there are more files to be uploaded, + * - no other upload is currently running, and + * - the number of outstanding responses does not exceed the + * processing limit of Lychee. + * + * @param {ProgressEvent} e + * @this XMLHttpRequest + */ + const onUploadProgress = function (e) { + if (e.lengthComputable !== true) return; - albums.refresh(); + // Calculate progress + const progress = ((e.loaded / e.total) * 100) | 0; - if (albumID === null) lychee.goto(); - else album.load(albumID); + // Set progress when progress has changed + if (progress > uploadProgress) { + uploadProgress = progress; + /** @type {?jQuery} */ + const jqStatusMsg = $(nRowStatusSelector(fileIdx + 1)); + jqStatusMsg.html(uploadProgress + "%"); + + if (progress >= 100) { + jqStatusMsg.html(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 + if ( + !isUploadRunning && + !shallCancelUpload && + (outstandingResponsesCount < lychee.upload_processing_limit || lychee.upload_processing_limit === 0) && + latestFileIdx + 1 < files.length + ) { + latestFileIdx++; + process(latestFileIdx); + } + } + } }; - formData.append("function", "Photo::add"); - // For form data, a `null` value is indicated by the empty - // string `""`. Form data falsely converts the value `null` to the - // literal string `"null"`. - formData.append("albumID", albumID ? albumID : ""); - formData.append(0, files[file_num]); - - var api_url = "api/" + "Photo::add"; - - xhr.open("POST", api_url); - - xhr.onload = function () { - let data = null; + /** + * A function to be called when a response has been received. + * + * This method updates the status of the affected file. + * + * @this XMLHttpRequest + */ + const onLoaded = function () { + /** @type {?LycheeException} */ + const lycheeException = this.status >= 400 ? this.response : null; let errorText = ""; - - const isModelID = (photoID) => typeof photoID === "string" && photoID.length === 24; - - data = xhr.responseText; - - if (typeof data === "string" && data.search("phpdebugbar") !== -1) { - // get rid of phpdebugbar thingy - var debug_bar_n = data.search(" 0) { - data = data.slice(0, debug_bar_n); - } + let statusText; + let statusClass; + + switch (this.status) { + case 200: + case 201: + case 204: + statusText = lychee.locale["UPLOAD_FINISHED"]; + statusClass = "success"; + break; + case 409: + statusText = lychee.locale["UPLOAD_SKIPPED"]; + errorText = 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"]; + hasErrorOccurred = true; + statusClass = "error"; + break; + default: + statusText = lychee.locale["UPLOAD_FAILED"]; + errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_ERROR_UNKNOWN"]; + hasErrorOccurred = true; + statusClass = "error"; + break; } - try { - data = JSON.parse(data); - } catch (e) { - data = ""; - } + $(nRowStatusSelector(fileIdx + 1)) + .html(statusText) + .addClass(statusClass); - // Set status - if ((xhr.status === 200 || xhr.status === 201) && isModelID(data.id)) { - // Success - $(nRowStatusSelector(file_num + 1)) - .html(lychee.locale["UPLOAD_FINISHED"]) - .addClass("success"); - } else { - if (xhr.status === 413 || data.substr(0, 6) === "Error:") { - if (xhr.status === 413) { - errorText = lychee.locale["UPLOAD_ERROR_POSTSIZE"]; - } else { - errorText = data.substr(6); - if (errorText === " validation failed") { - errorText = lychee.locale["UPLOAD_ERROR_FILESIZE"]; - } else { - errorText += " " + lychee.locale["UPLOAD_ERROR_CONSOLE"]; - } - } - error = true; - - // Error Status - $(nRowStatusSelector(file_num + 1)) - .html(lychee.locale["UPLOAD_FAILED"]) - .addClass("error"); - - // Throw error - lychee.error(lychee.locale["UPLOAD_FAILED_ERROR"], xhr, data); - } else if (data.substr(0, 8) === "Warning:") { - errorText = data.substr(8); - warning = true; - - // Warning Status - $(nRowStatusSelector(file_num + 1)) - .html(lychee.locale["UPLOAD_SKIPPED"]) - .addClass("warning"); - - // Throw error - lychee.error(lychee.locale["UPLOAD_FAILED_WARNING"], xhr, data); - } else { - errorText = lychee.locale["UPLOAD_UNKNOWN"]; - error = true; - - // Error Status - $(nRowStatusSelector(file_num + 1)) - .html(lychee.locale["UPLOAD_FAILED"]) - .addClass("error"); - - // Throw error - lychee.error(lychee.locale["UPLOAD_ERROR_UNKNOWN"], xhr, data); - } + if (statusClass === "error") { + api.onError(this, { albumID: albumID }, lycheeException); + } - $(".basicModal .rows .row:nth-child(" + (file_num + 1) + ") p.notice") + if (errorText !== "") { + $(".basicModal .rows .row:nth-child(" + (fileIdx + 1) + ") p.notice") .html(errorText) .show(); } + }; - processing_count--; + /** + * A function to be called when any response has been received + * (after specific success and error callbacks have been executed) + * + * This method starts a new upload, if + * + * - there are more files to be uploaded, + * - no other upload is currently running, and + * - the number of outstanding responses does not exceed the + * processing limit of Lychee. + * + * This method calls {@link finish}, if + * + * - the process shall be cancelled or no more files are left for processing, + * - no upload is running anymore, and + * - no response is outstanding + * + * @this XMLHttpRequest + */ + const onComplete = function () { + outstandingResponsesCount--; - // Upload next file if ( - !currently_uploading && - !cancelUpload && - (processing_count < lychee.upload_processing_limit || lychee.upload_processing_limit === 0) && - next_upload < files.length + !isUploadRunning && + !shallCancelUpload && + (outstandingResponsesCount < lychee.upload_processing_limit || lychee.upload_processing_limit === 0) && + latestFileIdx + 1 < files.length ) { - process(next_upload); + latestFileIdx++; + process(latestFileIdx); } - // Finish upload when all files are finished - if (!currently_uploading && processing_count === 0) { + if ((shallCancelUpload || latestFileIdx + 1 === files.length) && !isUploadRunning && outstandingResponsesCount === 0) { finish(); } }; - xhr.upload.onprogress = function (e) { - if (e.lengthComputable !== true) return false; - - // Calculate progress - progress = ((e.loaded / e.total) * 100) | 0; - - // Set progress when progress has changed - if (progress > pre_progress) { - $(nRowStatusSelector(file_num + 1)).html(progress + "%"); - pre_progress = progress; - } - - if (progress >= 100) { - // Scroll to the uploading file - let scrollPos = 0; - if (file_num + 1 > 4) scrollPos = (file_num + 1 - 4) * 40; - $(".basicModal .rows").scrollTop(scrollPos); - - // Set status to processing - $(nRowStatusSelector(file_num + 1)).html(lychee.locale["UPLOAD_PROCESSING"]); - processing_count++; - currently_uploading = false; - - // Upload next file - if ( - !cancelUpload && - (processing_count < lychee.upload_processing_limit || lychee.upload_processing_limit === 0) && - next_upload < files.length - ) { - process(next_upload); - } - } - }; - - currently_uploading = true; - next_upload++; + const formData = new FormData(); + const xhr = new XMLHttpRequest(); - xhr.setRequestHeader("X-XSRF-TOKEN", csrf.getCookie("XSRF-TOKEN")); + // For form data, a `null` value is indicated by the empty + // string `""`. Form data falsely converts the value `null` to the + // literal string `"null"`. + formData.append("albumID", albumID ? albumID : ""); + formData.append("file", files[fileIdx]); + + // We must not use the `onload` event of the `XMLHttpRequestUpload` + // object. + // Instead, we only use the `onprogress` event and check within + // the event handler if the progress counter reached 100%. + // The reason is that `upload.onload` is not immediately called + // after the browser has completed the upload (as the name + // suggests), but only after the browser has already received the + // response header. + // For our purposes this is too late, as this way we would never + // show the "processing" status, during which the backend has + // received the upload, but has not yet started to send a response. + xhr.upload.onprogress = onUploadProgress; + xhr.onload = onLoaded; + xhr.onloadend = onComplete; + xhr.responseType = "json"; + xhr.open("POST", "api/Photo::add"); + xhr.setRequestHeader("X-XSRF-TOKEN", csrf.getCSRFCookieValue()); + xhr.setRequestHeader("Accept", "application/json"); + + outstandingResponsesCount++; + isUploadRunning = true; xhr.send(formData); }; - if (files.length <= 0) return false; - window.onbeforeunload = function () { return lychee.locale["UPLOAD_IN_PROGRESS"]; }; @@ -268,63 +361,105 @@ upload.start = { files, function () { // Upload first file - process(next_upload); + $(cancelSelector).show(); + process(0); }, function () { - cancelUpload = true; - error = true; + shallCancelUpload = true; + hasErrorOccurred = true; } ); }, - url: function (url = "") { - let albumID = album.getID(); + /** + * @param {string} preselectedUrl + */ + url: function (preselectedUrl = "") { + const albumID = album.getID(); - url = typeof url === "string" ? url : ""; + /** + * @typedef UrlDialogResult + * @property {string} url + */ + /** @param {UrlDialogResult} data */ const action = function (data) { - let files = []; - - if (data.link && data.link.trim().length > 3) { - basicModal.close(); + const runImport = function () { + $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_IMPORTING"]); - files[0] = { - name: data.link, + const successHandler = function () { + // Same code as in import.dropbox() + basicModal.close(); + upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"]); + album.reload(); }; - upload.show(lychee.locale["UPLOAD_IMPORTING_URL"], files, function () { - $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_IMPORTING"]); - - let params = { - url: data.link, - albumID: albumID, - }; - - api.post("Import::url", params, function (_data) { - // Same code as in import.dropbox() - - if (_data !== true) { - $(".basicModal .rows .row p.notice").html(lychee.locale["UPLOAD_IMPORT_WARN_ERR"]).show(); - - $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_FINISHED"]).addClass("warning"); - - // Show close button - $(".basicModal #basicModal__action.hidden").show(); - - // Log error - lychee.error(null, params, _data); - } else { - basicModal.close(); - } + /** + * @param {XMLHttpRequest} jqXHR + * @param {Object} params + * @param {?LycheeException} lycheeException + * @returns {boolean} + */ + const errorHandler = function (jqXHR, params, lycheeException) { + // Same code as in import.dropbox() + let errorText; + let statusText; + let statusClass; + + switch (jqXHR.status) { + case 409: + statusText = lychee.locale["UPLOAD_SKIPPED"]; + errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; + statusClass = "warning"; + break; + default: + statusText = lychee.locale["UPLOAD_FAILED"]; + errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; + statusClass = "error"; + break; + } - upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"]); + $(".basicModal .rows .row p.notice").html(errorText).show(); + $(".basicModal .rows .row .status").html(statusText).addClass(statusClass); + // Show close button + $(".basicModal #basicModal__action.hidden").show(); + upload.notify(lychee.locale["UPLOAD_IMPORT_WARN_ERR"]); + album.reload(); + return true; + }; - albums.refresh(); + // 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 + // list of URLs. + // Another problem which already exists even for a single + // URL concerns timeouts. + // Below, we transmit a single HTTP request which must respond + // within about 500ms either with a success or error response. + // Otherwise, JS assumes that the AJAX request just timed out. + // But the server, first need to download the image from the + // specified URL, process it and then generate a HTTP response. + // Probably, it would be much better to use a streamed + // response here like we already have for imports from the + // local server. + // This way, the server could also report its own progress of + // downloading the images. + // TODO: Use a streamed response (see description above). + api.post( + "Import::url", + { + urls: [data.url], + albumID: albumID, + }, + successHandler, + null, + errorHandler + ); + }; - if (albumID === null) lychee.goto(); - else album.load(albumID); - }); - }); + 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"); }; @@ -332,7 +467,7 @@ upload.start = { body: lychee.html`

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

`, + `

`, buttons: { action: { title: lychee.locale["UPLOAD_IMPORT"], @@ -347,245 +482,277 @@ upload.start = { }, server: function () { - let albumID = album.getID(); + const albumID = album.getID(); + + const importDialogSetupCB = function () { + const $delete = $(choiceDeleteSelector); + const $symlinks = $(choiceSymlinkSelector); + const $duplicates = $(choiceDuplicateSelector); + const $resync = $(choiceResyncSelector); + + if (lychee.delete_imported) { + $delete.prop("checked", true); + $symlinks.prop("checked", false).prop("disabled", true); + } else { + if (lychee.import_via_symlink) { + $symlinks.prop("checked", true); + $delete.prop("checked", false).prop("disabled", true); + } + } + if (lychee.skip_duplicates) { + $duplicates.prop("checked", true); + if (lychee.resync_metadata) $resync.prop("checked", true); + } else { + $resync.prop("disabled", true); + } + }; + + /** + * @typedef ServerImportDialogResult + * @property {string} path + * @property {boolean} delete_imported + * @property {boolean} import_via_symlink + * @property {boolean} skip_duplicates + * @property {boolean} resync_metadata + */ + /** @param {ServerImportDialogResult} data */ const action = function (data) { if (!data.path.trim()) { basicModal.error("path"); 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 + 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(); } - let files = []; + let isUploadCancelled = false; - files[0] = { - name: data.path, + const cancelUpload = function () { + if (!isUploadCancelled) { + api.post("Import::serverCancel", {}, function () { + isUploadCancelled = true; + }); + } }; - let delete_imported = $(choiceDeleteSelector).prop("checked") ? "1" : "0"; - let import_via_symlink = $(choiceSymlinkSelector).prop("checked") ? "1" : "0"; - let skip_duplicates = $(choiceDuplicateSelector).prop("checked") ? "1" : "0"; - let resync_metadata = $(choiceResyncSelector).prop("checked") ? "1" : "0"; - let cancelUpload = false; + const runUpload = function () { + $(cancelSelector).show(); - upload.show( - lychee.locale["UPLOAD_IMPORT_SERVER"], - files, - function () { - $(cancelSelector).show(); - $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_IMPORTING"]); + // 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 + * callback and the completion callback. + * + * @param {(ImportProgressReport|ImportEventReport)[]} reports + */ + 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(); + } - let params = { - albumID: albumID, - path: data.path, - delete_imported: delete_imported, - import_via_symlink: import_via_symlink, - skip_duplicates: skip_duplicates, - resync_metadata: resync_metadata, - }; - - // Variables holding state across the invocations of - // processIncremental(). - let lastReadIdx = 0; - let currentDir = data.path; - let encounteredProblems = false; - let topSkip = 0; - - // Worker function invoked from both the response progress - // callback and the completion callback. - const processIncremental = function (jsonResponse) { - // Skip the part that we've already processed during - // the previous invocation(s). - let newResponse = jsonResponse.substring(lastReadIdx); - // Because of all the potential buffering along the way, - // we can't be sure if the last line is complete. For - // that reason, our custom protocol terminates every - // line with the newline character, including the last - // line. - let lastNewline = newResponse.lastIndexOf("\n"); - if (lastNewline === -1) { - // No valid input data to process. - return; - } - if (lastNewline !== newResponse.length - 1) { - // Last line is not newline-terminated, so it - // must be incomplete. Strip it; it will be - // handled during the next invocation. - newResponse = newResponse.substring(0, lastNewline + 1); - } - // Advance the counter past the last valid character. - lastReadIdx += newResponse.length; - newResponse.split("\n").forEach(function (resp) { - let matches = resp.match(/^Status: (.*): (\d+)$/); - if (matches !== null) { - if (matches[2] !== "100") { - if (currentDir !== matches[1]) { - // New directory. Add a new line to - // the dialog box. - currentDir = matches[1]; - $(".basicModal .rows").append(build.uploadNewFile(currentDir)); - topSkip += $(lastRowSelector).outerHeight(); - } - $(lastRowSelector + " .status").html(matches[2] + "%"); - } else { - // Final status report for this directory. - $(lastRowSelector + " .status") - .html(lychee.locale["UPLOAD_FINISHED"]) - .addClass("success"); - } - } else if ((matches = resp.match(/^Problem: (.*): ([^:]*)$/)) !== null) { - let rowSelector; - if (currentDir !== matches[1]) { - $(lastRowSelector).before(build.uploadNewFile(matches[1])); - rowSelector = prelastRowSelector; + if (report.progress !== 100) { + $(".status", jqCurrentRow).text("" + report.progress + "%"); + } else { + // Final status report for this directory. + $(".status", jqCurrentRow).text(lychee.locale["UPLOAD_FINISHED"]).addClass("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. - rowSelector = lastRowSelector; - topSkip -= $(rowSelector).outerHeight(); - } - if (matches[2] === "Given path is not a directory" || matches[2] === "Given path is reserved") { - $(rowSelector + " .status") - .html(lychee.locale["UPLOAD_FAILED"]) - .addClass("error"); - } else if (matches[2] === "Skipped duplicate (resynced metadata)") { - $(rowSelector + " .status") - .html(lychee.locale["UPLOAD_UPDATED"]) - .addClass("warning"); - } else if (matches[2] === "Import cancelled") { - $(rowSelector + " .status") - .html(lychee.locale["UPLOAD_CANCELLED"]) - .addClass("error"); - } else { - $(rowSelector + " .status") - .html(lychee.locale["UPLOAD_SKIPPED"]) - .addClass("warning"); + jqEventRow = jqCurrentRow; } - const translations = { - "Given path is not a directory": "UPLOAD_IMPORT_NOT_A_DIRECTORY", - "Given path is reserved": "UPLOAD_IMPORT_PATH_RESERVED", - "Could not read file": "UPLOAD_IMPORT_UNREADABLE", - "Could not import file": "UPLOAD_IMPORT_FAILED", - "Unsupported file type": "UPLOAD_IMPORT_UNSUPPORTED", - "Could not create album": "UPLOAD_IMPORT_ALBUM_FAILED", - "Skipped duplicate": "UPLOAD_IMPORT_SKIPPED_DUPLICATE", - "Skipped duplicate (resynced metadata)": "UPLOAD_IMPORT_RESYNCED_DUPLICATE", - "Import cancelled": "UPLOAD_IMPORT_CANCELLED", - }; - $(rowSelector + " .notice") - .html(matches[2] in translations ? lychee.locale[translations[matches[2]]] : matches[2]) - .show(); - topSkip += $(rowSelector).outerHeight(); - encounteredProblems = true; - } else if (resp === "Warning: Approaching memory limit") { - $(lastRowSelector).before(build.uploadNewFile(lychee.locale["UPLOAD_IMPORT_LOW_MEMORY"])); - topSkip += $(prelastRowSelector).outerHeight(); - $(prelastRowSelector + " .status") - .html(lychee.locale["UPLOAD_WARNING"]) - .addClass("warning"); - $(prelastRowSelector + " .notice") - .html(lychee.locale["UPLOAD_IMPORT_LOW_MEMORY_EXPL"]) - .show(); + } 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(); } - $(".basicModal .rows").scrollTop(topSkip); - }); // forEach (resp) - }; // processIncremental - - api.post( - "Import::server", - params, - function (_data) { - // _data is already JSON-parsed. - processIncremental(_data); - - albums.refresh(); - - upload.notify( - lychee.locale["UPLOAD_IMPORT_COMPLETE"], - encounteredProblems ? lychee.locale["UPLOAD_COMPLETE_FAILED"] : null - ); - - if (albumID === null) lychee.goto(); - else album.load(albumID); - - if (encounteredProblems) showCloseButton(); - else basicModal.close(); - }, - function (event) { - // We received a possibly partial response. - // We need to begin by terminating the data with a - // '"' so that it can be JSON-parsed. - let response = this.response; - if (response.length > 0) { - if (response.substring(this.response.length - 1) === '"') { - // This might be either a terminating '"' - // or it may come from, say, a filename, in - // which case it would be escaped. - if (response.length > 1) { - if (response.substring(this.response.length - 2) === '"') { - response += '"'; - } - // else it's a complete response, - // requiring no termination from us. - } else { - // The response is just '"'. - response += '"'; - } - } else { - // This should be the most common case for - // partial responses. - response += '"'; - } + + let severityClass = ""; + let statusText = ""; + let noteText = ""; + + switch (report.severity) { + case "debug": + case "info": + break; + case "notice": + case "warning": + severityClass = "warning"; + break; + case "error": + case "critical": + case "emergency": + severityClass = "error"; + break; } - // Parse the response as JSON. This will remove - // the surrounding '"' characters, unescape any '"' - // from the middle, and translate '\n' sequences into - // newlines. - let jsonResponse; - try { - jsonResponse = JSON.parse(response); - } catch (e) { - // Most likely a SyntaxError due to something - // that went wrong on the server side. - $(lastRowSelector + " .status") - .html(lychee.locale["UPLOAD_FAILED"]) - .addClass("error"); - - albums.refresh(); + + switch (report.subtype) { + case "mem_limit": + statusText = lychee.locale["UPLOAD_WARNING"]; + noteText = lychee.locale["UPLOAD_IMPORT_LOW_MEMORY_EXPL"]; + break; + case "FileOperationException": + case "MediaFileOperationException": + statusText = lychee.locale["UPLOAD_SKIPPED"]; + noteText = lychee.locale["UPLOAD_IMPORT_FAILED"]; + break; + case "MediaFileUnsupportedException": + statusText = lychee.locale["UPLOAD_SKIPPED"]; + noteText = lychee.locale["UPLOAD_IMPORT_UNSUPPORTED"]; + break; + case "InvalidDirectoryException": + statusText = lychee.locale["UPLOAD_FAILED"]; + noteText = lychee.locale["UPLOAD_IMPORT_NOT_A_DIRECTORY"]; + break; + case "ReservedDirectoryException": + statusText = lychee.locale["UPLOAD_FAILED"]; + noteText = lychee.locale["UPLOAD_IMPORT_PATH_RESERVED"]; + break; + case "PhotoSkippedException": + statusText = lychee.locale["UPLOAD_SKIPPED"]; + noteText = lychee.locale["UPLOAD_IMPORT_SKIPPED_DUPLICATE"]; + break; + case "PhotoResyncedException": + statusText = lychee.locale["UPLOAD_UPDATED"]; + noteText = lychee.locale["UPLOAD_IMPORT_RESYNCED_DUPLICATE"]; + break; + case "ImportCancelledException": + statusText = lychee.locale["UPLOAD_CANCELLED"]; + noteText = lychee.locale["UPLOAD_IMPORT_CANCELLED"]; + break; + default: + statusText = lychee.locale["UPLOAD_SKIPPED"]; + noteText = report.message; + break; + } + + $(".status", jqEventRow).text(statusText).addClass(severityClass); + $(".notice", jqEventRow).text(noteText).show(); + + encounteredProblems = true; + } + }); // forEach (resp) + lastReadIdx = reports.length; + $(jqRows).scrollTop(topSkip); + }; // processIncremental + + /** + * @param {ImportReport[]} reports + */ + const successHandler = function (reports) { + // reports is already JSON-parsed. + processIncremental(reports); + + upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"], encounteredProblems ? lychee.locale["UPLOAD_COMPLETE_FAILED"] : null); + + album.reload(); + + if (encounteredProblems) showCloseButton(); + else basicModal.close(); + }; + + /** + * @this {XMLHttpRequest} + */ + const progressHandler = function () { + /** @type {string} */ + let response = this.response; + /** @type {ImportReport[]} */ + let reports = []; + // We received a possibly partial response. + // We must ensure that the last object in the + // array is complete and terminate the array. + while (response.length > 2 && reports.length === 0) { + // Search the last '}', assume that this terminates + // the last JSON object, cut the string and terminate + // the array with `]`. + const fixedResponse = response.substring(0, response.lastIndexOf("}") + 1) + "]"; + try { + // If the assumption is wrong and the last found + // '}' does not terminate the last object, then + // `JSON.parse` will fail and tell us where the + // problem occurred. + reports = JSON.parse(fixedResponse); + } catch (e) { + if (e instanceof SyntaxError) { + const errorPos = e.columnNumber; + const lastBrace = response.lastIndexOf("}"); + const cutResponse = errorPos < lastBrace ? errorPos : lastBrace; + response = response.substring(0, cutResponse); + } else { + // Something else went wrong upload.notify(lychee.locale["UPLOAD_COMPLETE"], lychee.locale["UPLOAD_COMPLETE_FAILED"]); - if (albumID === null) lychee.goto(); - else album.load(albumID); + album.reload(); showCloseButton(); return; } - // The rest of the work is the same as for the full - // response. - processIncremental(jsonResponse); } - ); // api.post - }, - function () { - if (!cancelUpload) { - api.post("Import::serverCancel", {}, function (resp) { - if (resp === "true") cancelUpload = true; - }); } - } - ); // upload.show + // The rest of the work is the same as for the full + // response. + processIncremental(reports); + }; + + const params = { + albumID: albumID, + path: data.path, + delete_imported: data.delete_imported, + import_via_symlink: data.import_via_symlink, + skip_duplicates: data.skip_duplicates, + resync_metadata: data.resync_metadata, + }; + + api.post("Import::server", params, successHandler, progressHandler); + }; + + upload.show(lychee.locale["UPLOAD_IMPORT_SERVER"], [], runUpload, cancelUpload); }; // action - let msg = lychee.html` + const msg = lychee.html`

${lychee.locale["UPLOAD_IMPORT_SERVER_INSTR"]}

- `; - msg += lychee.html`
@@ -595,7 +762,7 @@ upload.start = {
@@ -605,7 +772,7 @@ upload.start = {
@@ -615,7 +782,7 @@ upload.start = {
@@ -627,6 +794,7 @@ upload.start = { basicModal.show({ body: msg, + callback: importDialogSetupCB, buttons: { action: { title: lychee.locale["UPLOAD_IMPORT"], @@ -638,86 +806,81 @@ upload.start = { }, }, }); - - let $delete = $(choiceDeleteSelector); - let $symlinks = $(choiceSymlinkSelector); - let $duplicates = $(choiceDuplicateSelector); - let $resync = $(choiceResyncSelector); - - if (lychee.delete_imported) { - $delete.prop("checked", true); - $symlinks.prop("checked", false).prop("disabled", true); - } else { - if (lychee.import_via_symlink) { - $symlinks.prop("checked", true); - $delete.prop("checked", false).prop("disabled", true); - } - } - if (lychee.skip_duplicates) { - $duplicates.prop("checked", true); - if (lychee.resync_metadata) $resync.prop("checked", true); - } else { - $resync.prop("disabled", true); - } }, dropbox: function () { - let albumID = album.getID(); - - const success = function (files) { - let links = ""; - - for (let i = 0; i < files.length; i++) { - links += files[i].link + ","; - - files[i] = { - name: files[i].link, - }; - } - - // Remove last comma - links = links.substr(0, links.length - 1); - - upload.show("Importing from Dropbox", files, function () { - $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_IMPORTING"]); - - let params = { - url: links, - albumID: albumID, + const albumID = album.getID(); + + /** + * @param {DropboxFile[]} files + */ + const action = function (files) { + const runImport = function () { + const successHandler = function () { + // Same code as in import.url() + basicModal.close(); + upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"]); + album.reload(); }; - api.post("Import::url", params, function (data) { + /** + * @param {XMLHttpRequest} jqXHR + * @param {Object} params + * @param {?LycheeException} lycheeException + * @returns {boolean} + */ + const errorHandler = function (jqXHR, params, lycheeException) { // Same code as in import.url() - - if (data !== true) { - $(".basicModal .rows .row p.notice").html(lychee.locale["UPLOAD_IMPORT_WARN_ERR"]).show(); - - $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_FINISHED"]).addClass("warning"); - - // Show close button - $(".basicModal #basicModal__action.hidden").show(); - - // Log error - lychee.error(null, params, data); - } else { - basicModal.close(); + let errorText; + let statusText; + let statusClass; + + switch (jqXHR.status) { + case 409: + statusText = lychee.locale["UPLOAD_SKIPPED"]; + errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; + statusClass = "warning"; + break; + default: + statusText = lychee.locale["UPLOAD_FAILED"]; + errorText = lycheeException ? lycheeException.message : lychee.locale["UPLOAD_IMPORT_WARN_ERR"]; + statusClass = "error"; + break; } - upload.notify(lychee.locale["UPLOAD_IMPORT_COMPLETE"]); + $(".basicModal .rows .row p.notice").html(errorText).show(); + $(".basicModal .rows .row .status").html(statusText).addClass(statusClass); + // Show close button + $(".basicModal #basicModal__action.hidden").show(); + upload.notify(lychee.locale["UPLOAD_IMPORT_WARN_ERR"]); + album.reload(); + return true; + }; + + $(".basicModal .rows .row .status").html(lychee.locale["UPLOAD_IMPORTING"]); - albums.refresh(); + // TODO: Use a streamed response; see long comment in `import.url()` for the reasons + api.post( + "Import::url", + { + urls: files.map((file) => file.link), + albumID: albumID, + }, + successHandler, + null, + errorHandler + ); + }; - if (albumID === null) lychee.goto(); - else album.load(albumID); - }); - }); + files.forEach((file) => (file.name = file.link)); + upload.show("Importing from Dropbox", files, runImport); }; lychee.loadDropbox(function () { Dropbox.choose({ linkType: "direct", multiselect: true, - success, + success: action, }); }); }, diff --git a/scripts/main/users.js b/scripts/main/users.js index 593b92bf..12fba66a 100644 --- a/scripts/main/users.js +++ b/scripts/main/users.js @@ -1,72 +1,89 @@ -let users = { +const users = { + /** @type {?User[]} */ json: null, }; +/** + * Updates a user account. + * + * The object `params` must be kept in sync with the HTML form constructed + * by {@link build.user}. + * + * @param {{id: number, username: string, password: string, may_upload: boolean, is_locked: boolean}} params + * @returns {void} + */ users.update = function (params) { if (params.username.length < 1) { loadingBar.show("error", "new username cannot be empty."); - return false; + return; } - if ($("#UserData" + params.id + ' .choice input[name="upload"]:checked').length === 1) { - params.may_upload = true; - } else { - params.may_upload = false; - } - if ($("#UserData" + params.id + ' .choice input[name="lock"]:checked').length === 1) { - params.is_locked = true; - } else { - params.is_locked = false; + // If the password is empty, then the password shall not be changed. + // In this case, the password must not be an attribute of the object at + // all. + // An existing, but empty password, would indicate to clear the password. + if (params.password.length === 0) { + delete params.password; } - api.post("User::Save", params, function (data) { - if (data) { - loadingBar.show("error", data.description); - lychee.error(null, params, data); - } else { - loadingBar.show("success", "User updated!"); - users.list(); // reload user list - } + api.post("User::save", params, function () { + loadingBar.show("success", "User updated!"); + users.list(); // reload user list }); }; +/** + * Creates a new user account. + * + * The object `params` must be kept in sync with the HTML form constructed + * by {@link view.users.content}. + * + * @param {{id: string, username: string, password: string, may_upload: boolean, is_locked: boolean}} params + * @returns {void} + */ users.create = function (params) { if (params.username.length < 1) { loadingBar.show("error", "new username cannot be empty."); - return false; + return; } if (params.password.length < 1) { loadingBar.show("error", "new password cannot be empty."); - return false; - } - - if ($('#UserCreate .choice input[name="upload"]:checked').length === 1) { - params.may_upload = true; - } else { - params.may_upload = false; - } - if ($('#UserCreate .choice input[name="lock"]:checked').length === 1) { - params.is_locked = true; - } else { - params.is_locked = false; + return; } - api.post("User::Create", params, function () { + api.post("User::create", params, function () { loadingBar.show("success", "User created!"); users.list(); // reload user list }); }; +/** + * Deletes a user account. + * + * The object `params` must be kept in sync with the HTML form constructed + * by {@link build.user}. + * + * @param {{id: number}} params + * @returns {boolean} + */ users.delete = function (params) { - api.post("User::Delete", params, function () { + api.post("User::delete", params, function () { loadingBar.show("success", "User deleted!"); users.list(); // reload user list }); }; +/** + * @returns {void} + */ users.list = function () { - api.post("User::List", {}, function (data) { - users.json = data; - view.users.init(); - }); + api.post( + "User::list", + {}, + /** @param {User[]} data */ + function (data) { + users.json = data; + view.users.init(); + } + ); }; diff --git a/scripts/main/view.js b/scripts/main/view.js index c9dcb776..1f8f1306 100644 --- a/scripts/main/view.js +++ b/scripts/main/view.js @@ -2,9 +2,10 @@ * @description Responsible to reflect data changes to the UI. */ -let view = {}; +const view = {}; view.albums = { + /** @returns {void} */ init: function () { multiselect.clearSelection(); @@ -12,109 +13,107 @@ view.albums = { view.albums.content.init(); }, + /** @returns {void} */ title: function () { if (lychee.landing_page_enable) { - if (lychee.title !== "Lychee v4") { - lychee.setTitle(lychee.title, false); - } else { - lychee.setTitle(lychee.locale["ALBUMS"], false); - } + lychee.setTitle("", false); } else { lychee.setTitle(lychee.locale["ALBUMS"], false); } }, content: { + /** @returns {void} */ init: function () { let smartData = ""; + let tagAlbumsData = ""; let albumsData = ""; let sharedData = ""; // Smart Albums - if (albums.json.smart_albums != null) { - if (lychee.publicMode === false) { - smartData = build.divider(lychee.locale["SMART_ALBUMS"]); - } - if (albums.json.smart_albums.unsorted) { - albums.parse(albums.json.smart_albums.unsorted); - smartData += build.album(albums.json.smart_albums.unsorted); - } - if (albums.json.smart_albums.public) { - albums.parse(albums.json.smart_albums.public); - smartData += build.album(albums.json.smart_albums.public); - } - if (albums.json.smart_albums.starred) { - albums.parse(albums.json.smart_albums.starred); - smartData += build.album(albums.json.smart_albums.starred); - } - if (albums.json.smart_albums.recent) { - albums.parse(albums.json.smart_albums.recent); - smartData += build.album(albums.json.smart_albums.recent); - } - - Object.entries(albums.json.smart_albums).forEach(([, albumData]) => { - if (albumData["is_tag_album"]) { - albums.parse(albumData); - smartData += build.album(albumData); - } - }); + if ( + lychee.publicMode === false && + (albums.json.smart_albums.public || + albums.json.smart_albums.recent || + albums.json.smart_albums.starred || + albums.json.smart_albums.unsorted || + albums.json.tag_albums.length > 0) + ) { + smartData = build.divider(lychee.locale["SMART_ALBUMS"]); + } + if (albums.json.smart_albums.unsorted) { + albums.parse(albums.json.smart_albums.unsorted); + smartData += build.album(albums.json.smart_albums.unsorted); + } + if (albums.json.smart_albums.public) { + albums.parse(albums.json.smart_albums.public); + smartData += build.album(albums.json.smart_albums.public); + } + if (albums.json.smart_albums.starred) { + albums.parse(albums.json.smart_albums.starred); + smartData += build.album(albums.json.smart_albums.starred); + } + if (albums.json.smart_albums.recent) { + albums.parse(albums.json.smart_albums.recent); + smartData += build.album(albums.json.smart_albums.recent); } - // Albums - if (albums.json.albums && albums.json.albums.length !== 0) { - $.each(albums.json.albums, function () { - if (!this.parent_id) { - albums.parse(this); - albumsData += build.album(this); - } - }); + // Tag albums + tagAlbumsData += albums.json.tag_albums.reduce(function (html, tagAlbum) { + albums.parse(tagAlbum); + return html + build.album(tagAlbum); + }, ""); - // Add divider - if (lychee.publicMode === false) albumsData = build.divider(lychee.locale["ALBUMS"]) + albumsData; - } + // Albums + if (lychee.publicMode === false && albums.json.albums.length > 0) albumsData = build.divider(lychee.locale["ALBUMS"]); + albumsData += albums.json.albums.reduce(function (html, album) { + albums.parse(album); + return html + build.album(album); + }, ""); let current_owner = ""; - let i; // Shared - if (albums.json.shared_albums && albums.json.shared_albums.length !== 0) { - for (i = 0; i < albums.json.shared_albums.length; ++i) { - let alb = albums.json.shared_albums[i]; - if (!alb.parent_id) { - albums.parse(alb); - if (current_owner !== alb.owner_name && lychee.publicMode === false) { - sharedData += build.divider(alb.owner_name); - current_owner = alb.owner_name; - } - sharedData += build.album(alb, !lychee.admin); - } + sharedData += albums.json.shared_albums.reduce(function (html, album) { + albums.parse(album); + if (current_owner !== album.owner_name && lychee.publicMode === false) { + html += build.divider(album.owner_name); + current_owner = album.owner_name; } - } + return html + build.album(album, !lychee.admin); + }, ""); - if (smartData === "" && albumsData === "" && sharedData === "") { + if (smartData === "" && tagAlbumsData === "" && albumsData === "" && sharedData === "") { lychee.content.html(""); $("body").append(build.no_content("eye")); } else { - lychee.content.html(smartData + albumsData + sharedData); + lychee.content.html(smartData + tagAlbumsData + albumsData + sharedData); } album.apply_nsfw_filter(); // Restore scroll position - let urls = JSON.parse(localStorage.getItem("scroll")); - let urlWindow = window.location.href; + const urls = JSON.parse(localStorage.getItem("scroll")); + const urlWindow = window.location.href; $(window).scrollTop(urls != null && urls[urlWindow] ? urls[urlWindow] : 0); }, + /** + * @param {string} albumID + * @returns {void} + */ title: function (albumID) { - let title = albums.getByID(albumID).title; - - title = lychee.escapeHTML(title); + const album = albums.getByID(albumID); + const title = album.title ? album.title : lychee.locale["UNTITLED"]; $('.album[data-id="' + albumID + '"] .overlay h1') - .html(title) + .text(title) .attr("title", title); }, + /** + * @param {string} albumID + * @returns {void} + */ delete: function (albumID) { $('.album[data-id="' + albumID + '"]') .css("opacity", 0) @@ -134,11 +133,10 @@ view.albums = { }; view.album = { + /** @returns {void} */ init: function () { multiselect.clearSelection(); - album.parse(); - view.album.sidebar(); view.album.title(); view.album.public(); @@ -146,22 +144,24 @@ view.album = { view.album.nsfw_warning.init(); view.album.content.init(); - album.json.init = 1; + // TODO: `init` is not a property of the Album JSON; this is a property of the view. Consider to move it to `view.album.isInitialized` + album.json.init = true; }, + /** @returns {void} */ title: function () { if ((visible.album() || !album.json.init) && !visible.photo()) { switch (album.getID()) { - case "starred": + case SmartAlbumID.STARRED: lychee.setTitle(lychee.locale["STARRED"], true); break; - case "public": + case SmartAlbumID.PUBLIC: lychee.setTitle(lychee.locale["PUBLIC"], true); break; - case "recent": + case SmartAlbumID.RECENT: lychee.setTitle(lychee.locale["RECENT"], true); break; - case "unsorted": + case SmartAlbumID.UNSORTED: lychee.setTitle(lychee.locale["UNSORTED"], true); break; default: @@ -173,6 +173,7 @@ view.album = { }, nsfw_warning: { + /** @returns {void} */ init: function () { if (!lychee.nsfw_warning) { $("#sensitive_warning").hide(); @@ -186,6 +187,7 @@ view.album = { } }, + /** @returns {void} */ next: function () { lychee.nsfw_unlocked_albums.push(album.json.id); $("#sensitive_warning").hide(); @@ -193,28 +195,29 @@ view.album = { }, content: { + /** @returns {void} */ init: function () { let photosData = ""; let albumsData = ""; let html = ""; - if (album.json.albums && album.json.albums !== false) { - $.each(album.json.albums, function () { - albums.parse(this); - albumsData += build.album(this, !album.isUploadable()); + if (album.json.albums) { + album.json.albums.forEach(function (_album) { + albums.parse(_album); + albumsData += build.album(_album, !album.isUploadable()); }); } - if (album.json.photos && album.json.photos !== false) { + if (album.json.photos) { // Build photos - $.each(album.json.photos, function () { - photosData += build.photo(this, !album.isUploadable()); + album.json.photos.forEach(function (_photo) { + photosData += build.photo(_photo, !album.isUploadable()); }); } if (photosData !== "") { - if (lychee.layout === "1") { + if (lychee.layout === 1) { photosData = '
' + photosData + "
"; - } else if (lychee.layout === "2") { + } else if (lychee.layout === 2) { photosData = '
' + photosData + "
"; } } @@ -232,48 +235,71 @@ view.album = { lychee.content.html(html); album.apply_nsfw_filter(); - view.album.content.justify(); + view.album.content.justify(album.json ? album.json.photos : []); + view.album.content.restoreScroll(); + }, + + /** @returns {void} */ + restoreScroll: function () { // Restore scroll position - let urls = JSON.parse(localStorage.getItem("scroll")); - let urlWindow = window.location.href; + const urls = JSON.parse(localStorage.getItem("scroll")); + const urlWindow = window.location.href; $(window).scrollTop(urls != null && urls[urlWindow] ? urls[urlWindow] : 0); }, + /** + * @param {string} photoID + * @returns {void} + */ title: function (photoID) { - let title = album.getByID(photoID).title; - - title = lychee.escapeHTML(title); + const photo = album.getByID(photoID); + const title = photo.title ? photo.title : lychee.locale["UNTITLED"]; $('.photo[data-id="' + photoID + '"] .overlay h1') - .html(title) + .text(title) .attr("title", title); }, + /** + * @param {string} albumID + * @returns {void} + */ titleSub: function (albumID) { - let title = album.getSubByID(albumID).title; - - title = lychee.escapeHTML(title); + const album = album.getSubByID(albumID); + const title = album.title ? album.title : lychee.locale["UNTITLED"]; $('.album[data-id="' + albumID + '"] .overlay h1') - .html(title) + .text(title) .attr("title", title); }, + /** + * @param {string} photoID + * @returns {void} + */ star: function (photoID) { - let $badge = $('.photo[data-id="' + photoID + '"] .icn-star'); + const $badge = $('.photo[data-id="' + photoID + '"] .icn-star'); if (album.getByID(photoID).is_starred) $badge.addClass("badge--star"); else $badge.removeClass("badge--star"); }, + /** + * @param {string} photoID + * @returns {void} + */ public: function (photoID) { - let $badge = $('.photo[data-id="' + photoID + '"] .icn-share'); + const $badge = $('.photo[data-id="' + photoID + '"] .icn-share'); - if (album.getByID(photoID).is_public == 1) $badge.addClass("badge--visible badge--hidden"); + if (album.getByID(photoID).is_public === 1) $badge.addClass("badge--visible badge--hidden"); else $badge.removeClass("badge--visible badge--hidden"); }, + /** + * @param {string} photoID + * @returns {void} + */ cover: function (photoID) { $(".album .icn-cover").removeClass("badge--cover"); $(".photo .icn-cover").removeClass("badge--cover"); @@ -293,12 +319,16 @@ view.album = { } }, + /** + * @param {Photo} data + * @returns {void} + */ updatePhoto: function (data) { let src, srcset = ""; // This mimicks the structure of build.photo - if (lychee.layout === "0") { + if (lychee.layout === 0) { src = data.size_variants.thumb.url; if (data.size_variants.thumb2x !== null) { srcset = `${data.size_variants.thumb2x.url} 2x`; @@ -329,9 +359,14 @@ view.album = { .attr("data-srcset", srcset) .addClass("lazyload"); - view.album.content.justify(); + view.album.content.justify(album.json ? album.json.photos : []); }, + /** + * @param {string} photoID + * @param {boolean} [justify=false] + * @returns {void} + */ delete: function (photoID, justify = false) { $('.photo[data-id="' + photoID + '"]') .css("opacity", 0) @@ -353,12 +388,12 @@ view.album = { } }); if (album.json.photos.length - videoCount > 0) { - sidebar.changeAttr("images", album.json.photos.length - videoCount); + sidebar.changeAttr("images", (album.json.photos.length - videoCount).toString()); } else { sidebar.hideAttr("images"); } if (videoCount > 0) { - sidebar.changeAttr("videos", videoCount); + sidebar.changeAttr("videos", videoCount.toString()); } else { sidebar.hideAttr("videos"); } @@ -367,13 +402,17 @@ view.album = { lychee.content.find(".divider").remove(); } if (justify) { - view.album.content.justify(); + view.album.content.justify(album.json ? album.json.photos : []); } } } ); }, + /** + * @param {string} albumID + * @returns {void} + */ deleteSub: function (albumID) { $('.album[data-id="' + albumID + '"]') .css("opacity", 0) @@ -391,7 +430,7 @@ view.album = { } if (visible.sidebar()) { if (album.json.albums.length > 0) { - sidebar.changeAttr("subalbums", album.json.albums.length); + sidebar.changeAttr("subalbums", album.json.albums.length.toString()); } else { sidebar.hideAttr("subalbums"); } @@ -401,47 +440,63 @@ view.album = { ); }, - justify: function () { - if (!album.json || !album.json.photos || album.json.photos === false) return; - if (lychee.layout === "1") { - let containerWidth = parseFloat($(".justified-layout").width(), 10); - if (containerWidth == 0) { + /** + * Lays out the photos inside an album or a search result. + * + * This method is a misnomer, because it does not necessarily + * create a justified layout, but the configured layout as specified + * by `lychee.layout` which can also be a non-justified layout. + * + * Also note that this method is bastardized by `search.find`. + * Hence, this method would better not be part of `view.album.content`, + * because it is not exclusively used for an album. + * + * @param {Photo[]} photos - the photos to be laid out + * + * @returns {void} + */ + justify: function (photos) { + if (photos.length === 0) return; + if (lychee.layout === 1) { + let containerWidth = parseFloat($(".justified-layout").width()); + if (containerWidth === 0) { // Triggered on Reload in photo view. containerWidth = $(window).width() - - parseFloat($(".justified-layout").css("margin-left"), 10) - - parseFloat($(".justified-layout").css("margin-right"), 10) - - parseFloat($(".content").css("padding-right"), 10); + parseFloat($(".justified-layout").css("margin-left")) - + parseFloat($(".justified-layout").css("margin-right")) - + parseFloat($(".content").css("padding-right")); } - let ratio = []; - $.each(album.json.photos, function (i) { - let height = this.size_variants.original.height; - let width = this.size_variants.original.width; - ratio[i] = height > 0 ? width / height : 1; - - if (this.type && this.type.indexOf("video") > -1) { - // Video. If there's no small and medium, we have - // to fall back to the square thumb. - if (this.size_variants.small === null && this.size_variants.medium === null) { - ratio[i] = 1; - } - } + /** @type {number[]} */ + const ratio = photos.map(function (_photo) { + const height = _photo.size_variants.original.height; + const width = _photo.size_variants.original.width; + const ratio = height > 0 ? width / height : 1; + // If there is no small and medium size variants for videos, + // we have to fall back to square thumbs + return _photo.type && + _photo.type.indexOf("video") !== -1 && + _photo.size_variants.small === null && + _photo.size_variants.medium === null + ? 1 + : ratio; }); - let layoutGeometry = require("justified-layout")(ratio, { + + const layoutGeometry = require("justified-layout")(ratio, { containerWidth: containerWidth, containerPadding: 0, // boxSpacing: { // horizontal: 42, // vertical: 150 // }, - targetRowHeight: parseFloat($(".photo").css("--lychee-default-height"), 10), + targetRowHeight: parseFloat($(".photo").css("--lychee-default-height")), }); // if (lychee.admin) console.log(layoutGeometry); $(".justified-layout").css("height", layoutGeometry.containerHeight + "px"); $(".justified-layout > div").each(function (i) { if (!layoutGeometry.boxes[i]) { // Race condition in search.find -- window content - // and album.json can get out of sync as search + // and `photos` can get out of sync as search // query is being modified. return false; } @@ -455,37 +510,37 @@ view.album = { imgs[0].setAttribute("sizes", layoutGeometry.boxes[i].width + "px"); } }); - } else if (lychee.layout === "2") { - let containerWidth = parseFloat($(".unjustified-layout").width(), 10); - if (containerWidth == 0) { + } else if (lychee.layout === 2) { + let containerWidth = parseFloat($(".unjustified-layout").width()); + if (containerWidth === 0) { // Triggered on Reload in photo view. containerWidth = $(window).width() - - parseFloat($(".unjustified-layout").css("margin-left"), 10) - - parseFloat($(".unjustified-layout").css("margin-right"), 10) - - parseFloat($(".content").css("padding-right"), 10); + parseFloat($(".unjustified-layout").css("margin-left")) - + parseFloat($(".unjustified-layout").css("margin-right")) - + parseFloat($(".content").css("padding-right")); } // For whatever reason, the calculation of margin is // super-slow in Firefox (tested with 68), so we make sure to // do it just once, outside the loop. Height doesn't seem to // be affected, but we do it the same way for consistency. - let margin = parseFloat($(".photo").css("margin-right"), 10); - let origHeight = parseFloat($(".photo").css("max-height"), 10); + let margin = parseFloat($(".photo").css("margin-right")); + let origHeight = parseFloat($(".photo").css("max-height")); $(".unjustified-layout > div").each(function (i) { - if (!album.json.photos[i]) { + if (!photos[i]) { // Race condition in search.find -- window content - // and album.json can get out of sync as search + // and `photos` can get out of sync as search // query is being modified. return false; } let ratio = - album.json.photos[i].size_variants.original.height > 0 - ? album.json.photos[i].size_variants.original.width / album.json.photos[i].size_variants.original.height + photos[i].size_variants.original.height > 0 + ? photos[i].size_variants.original.width / photos[i].size_variants.original.height : 1; - if (album.json.photos[i].type && album.json.photos[i].type.indexOf("video") > -1) { + if (photos[i].type && photos[i].type.indexOf("video") > -1) { // Video. If there's no small and medium, we have // to fall back to the square thumb. - if (album.json.photos[i].size_variants.small === null && album.json.photos[i].size_variants.medium === null) { + if (photos[i].size_variants.small === null && photos[i].size_variants.medium === null) { ratio = 1; } } @@ -509,18 +564,28 @@ view.album = { }, }, + /** + * @returns {void} + */ description: function () { sidebar.changeAttr("description", album.json.description ? album.json.description : ""); }, + /** + * @returns {void} + */ show_tags: function () { - sidebar.changeAttr("show_tags", album.json.show_tags); + sidebar.changeAttr("show_tags", album.json.show_tags.join(", ")); }, + /** + * @returns {void} + */ license: function () { let license; switch (album.json.license) { case "none": + // TODO: If we do not use `"none"` as a literal string, we should convert `license` to a nullable DB attribute and use `null` for none to be consistent which everything else license = ""; // none is displayed as - thus is empty. break; case "reserved": @@ -535,6 +600,9 @@ view.album = { sidebar.changeAttr("license", license); }, + /** + * @returns {void} + */ public: function () { $("#button_visibility_album, #button_sharing_album_users").removeClass("active--not-hidden active--hidden"); @@ -553,11 +621,17 @@ view.album = { } }, + /** + * @returns {void} + */ requiresLink: function () { if (album.json.requires_link) sidebar.changeAttr("hidden", lychee.locale["ALBUM_SHR_YES"]); else sidebar.changeAttr("hidden", lychee.locale["ALBUM_SHR_NO"]); }, + /** + * @returns {void} + */ nsfw: function () { if (album.json.is_nsfw) { // Sensitive @@ -568,25 +642,37 @@ view.album = { } }, + /** + * @returns {void} + */ downloadable: function () { if (album.json.is_downloadable) sidebar.changeAttr("downloadable", lychee.locale["ALBUM_SHR_YES"]); else sidebar.changeAttr("downloadable", lychee.locale["ALBUM_SHR_NO"]); }, + /** + * @returns {void} + */ shareButtonVisible: () => { if (album.json.is_share_button_visible) sidebar.changeAttr("share_button_visible", lychee.locale["ALBUM_SHR_YES"]); else sidebar.changeAttr("share_button_visible", lychee.locale["ALBUM_SHR_NO"]); }, + /** + * @returns {void} + */ password: function () { if (album.json.has_password) sidebar.changeAttr("password", lychee.locale["ALBUM_SHR_YES"]); else sidebar.changeAttr("password", lychee.locale["ALBUM_SHR_NO"]); }, + /** + * @returns {void} + */ sidebar: function () { if ((visible.album() || (album.json && !album.json.init)) && !visible.photo()) { - let structure = sidebar.createStructure.album(album); - let html = sidebar.render(structure); + const structure = sidebar.createStructure.album(album.json); + const html = sidebar.render(structure); sidebar.dom(".sidebar__wrapper").html(html); sidebar.bind(); @@ -595,11 +681,13 @@ view.album = { }; view.photo = { + /** + * @param {boolean} autoplay + * @returns {void} + */ init: function (autoplay) { multiselect.clearSelection(); - photo.parse(); - view.photo.sidebar(); view.photo.title(); view.photo.star(); @@ -607,9 +695,13 @@ view.photo = { view.photo.header(); view.photo.photo(autoplay); - photo.json.init = 1; + // TODO: `init` is not a property of the Photo JSON; this is a property of the view. Consider to move it to `view.photo.isInitialized` + photo.json.init = true; }, + /** + * @returns {void} + */ show: function () { // Change header lychee.content.addClass("view"); @@ -627,7 +719,7 @@ view.photo = { let timeout = null; $(document).bind("mousemove", function () { clearTimeout(timeout); - // For live Photos: header animtion only if LivePhoto is not playing + // For live Photos: header animation only if LivePhoto is not playing if (!photo.isLivePhotoPlaying() && lychee.header_auto_hide) { header.show(); timeout = setTimeout(header.hideIfLivePhotoNotPlaying, 2500); @@ -642,6 +734,9 @@ view.photo = { lychee.animate(lychee.imageview, "fadeIn"); }, + /** + * @returns {void} + */ hide: function () { header.show(); @@ -665,21 +760,31 @@ view.photo = { }, 300); }, + /** + * @returns {void} + */ title: function () { - if (photo.json.init) sidebar.changeAttr("title", photo.json.title); - lychee.setTitle(photo.json.title, true); + if (photo.json.init) sidebar.changeAttr("title", photo.json.title ? photo.json.title : ""); + lychee.setTitle(photo.json.title ? photo.json.title : lychee.locale["UNTITLED"], true); }, + /** + * @returns {void} + */ description: function () { if (photo.json.init) sidebar.changeAttr("description", photo.json.description ? photo.json.description : ""); }, + /** + * @returns {void} + */ license: function () { let license; // Process key to display correct string switch (photo.json.license) { case "none": + // TODO: If we do not use `"none"` as a literal string, we should convert `license` to a nullable DB attribute and use `null` for none to be consistent which everything else license = ""; // none is displayed as - thus is empty (uniformity of the display). break; case "reserved": @@ -694,6 +799,9 @@ view.photo = { if (photo.json.init) sidebar.changeAttr("license", license); }, + /** + * @returns {void} + */ star: function () { if (photo.json.is_starred) { // Starred @@ -704,12 +812,15 @@ view.photo = { } }, + /** + * @returns {void} + */ public: function () { $("#button_visibility").removeClass("active--hidden active--not-hidden"); - if (photo.json.is_public == 1 || photo.json.is_public == 2) { + if (photo.json.is_public === 1 || photo.json.is_public === 2) { // Photo public - if (photo.json.is_public == 1) { + if (photo.json.is_public === 1) { $("#button_visibility").addClass("active--hidden"); } else { $("#button_visibility").addClass("active--not-hidden"); @@ -718,15 +829,22 @@ view.photo = { if (photo.json.init) sidebar.changeAttr("public", lychee.locale["PHOTO_SHR_YES"]); } else { // Photo private - if (photo.json.init) sidebar.changeAttr("public", "No"); + if (photo.json.init) sidebar.changeAttr("public", lychee.locale["PHOTO_SHR_NO"]); } }, + /** + * @returns {void} + */ tags: function () { sidebar.changeAttr("tags", build.tags(photo.json.tags), true); sidebar.bind(); }, + /** + * @param {boolean} autoplay + * @returns {void} + */ photo: function (autoplay) { let ret = build.imageview(photo.json, visible.header(), autoplay); lychee.imageview.html(ret.html); @@ -737,19 +855,22 @@ view.photo = { // Package gives warning that function will be remove and // shoud be replaced by LivePhotosKit.augementElementAsPlayer // But, LivePhotosKit.augementElementAsPlayer is not yet available - photo.LivePhotosObject = LivePhotosKit.Player(document.getElementById("livephoto")); + photo.livePhotosObject = LivePhotosKit.Player(document.getElementById("livephoto")); } view.photo.onresize(); - let $nextArrow = lychee.imageview.find("a#next"); - let $previousArrow = lychee.imageview.find("a#previous"); - let photoID = photo.getID(); - let photoInAlbum = album.json && album.json.photos ? album.getByID(photoID) : null; - let hasNext = photoInAlbum !== null && photoInAlbum.hasOwnProperty("next_photo_id") && photoInAlbum.next_photo_id !== null; - let hasPrevious = photoInAlbum !== null && photoInAlbum.hasOwnProperty("previous_photo_id") && photoInAlbum.previous_photo_id !== null; - - let img = $("img#image"); + const $nextArrow = lychee.imageview.find("a#next"); + const $previousArrow = lychee.imageview.find("a#previous"); + const photoID = photo.getID(); + /** @type {?Photo} */ + const photoInAlbum = album.json && album.json.photos ? album.getByID(photoID) : null; + /** @type {?Photo} */ + const nextPhotoInAlbum = photoInAlbum && photoInAlbum.next_photo_id ? album.getByID(photoInAlbum.next_photo_id) : null; + /** @type {?Photo} */ + const prevPhotoInAlbum = photoInAlbum && photoInAlbum.previous_photo_id ? album.getByID(photoInAlbum.previous_photo_id) : null; + + const img = $("img#image"); if (img.length > 0) { if (!img[0].complete || (img[0].currentSrc !== null && img[0].currentSrc === "")) { // Image is still loading. Display the thumb version in the @@ -768,49 +889,46 @@ view.photo = { } } - if (hasNext === false || lychee.viewMode === true) { + if (nextPhotoInAlbum === null || lychee.viewMode === true) { $nextArrow.hide(); } else { - let nextPhotoID = photoInAlbum.next_photo_id; - let nextPhoto = album.getByID(nextPhotoID); - // Check if thumbUrl exists (for videos w/o ffmpeg, we add a play-icon) let thumbUrl = "img/placeholder.png"; - if (nextPhoto.size_variants.thumb !== null) { - thumbUrl = nextPhoto.size_variants.thumb.url; - } else if (nextPhoto.type.indexOf("video") > -1) { + if (nextPhotoInAlbum.size_variants.thumb !== null) { + thumbUrl = nextPhotoInAlbum.size_variants.thumb.url; + } else if (nextPhotoInAlbum.type.indexOf("video") > -1) { thumbUrl = "img/play-icon.png"; } $nextArrow.css("background-image", lychee.html`linear-gradient(to bottom, rgba(0, 0, 0, .4), rgba(0, 0, 0, .4)), url("${thumbUrl}")`); } - if (hasPrevious === false || lychee.viewMode === true) { + if (prevPhotoInAlbum === null || lychee.viewMode === true) { $previousArrow.hide(); } else { - let previousPhotoID = photoInAlbum.previous_photo_id; - let previousPhoto = album.getByID(previousPhotoID); - // Check if thumbUrl exists (for videos w/o ffmpeg, we add a play-icon) let thumbUrl = "img/placeholder.png"; - if (previousPhoto.size_variants.thumb !== null) { - thumbUrl = previousPhoto.size_variants.thumb.url; - } else if (previousPhoto.type.indexOf("video") > -1) { + if (prevPhotoInAlbum.size_variants.thumb !== null) { + thumbUrl = prevPhotoInAlbum.size_variants.thumb.url; + } else if (prevPhotoInAlbum.type.indexOf("video") > -1) { thumbUrl = "img/play-icon.png"; } $previousArrow.css("background-image", lychee.html`linear-gradient(to bottom, rgba(0, 0, 0, .4), rgba(0, 0, 0, .4)), url("${thumbUrl}")`); } }, + /** + * @returns {void} + */ sidebar: function () { - let structure = sidebar.createStructure.photo(photo.json); - let html = sidebar.render(structure); - let has_location = photo.json.latitude && photo.json.longitude ? true : false; + const structure = sidebar.createStructure.photo(photo.json); + const html = sidebar.render(structure); + const has_location = !!(photo.json.latitude && photo.json.longitude); sidebar.dom(".sidebar__wrapper").html(html); sidebar.bind(); if (has_location && lychee.map_display) { - // Leaflet seaches for icon in same directoy as js file -> paths needs + // Leaflet searches for icon in same directory as js file -> paths needs // to be overwritten delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ @@ -819,29 +937,32 @@ view.photo = { shadowUrl: "img/marker-shadow.png", }); - var mymap = L.map("leaflet_map_single_photo").setView([photo.json.latitude, photo.json.longitude], 13); + const myMap = L.map("leaflet_map_single_photo").setView([photo.json.latitude, photo.json.longitude], 13); L.tileLayer(map_provider_layer_attribution[lychee.map_provider].layer, { attribution: map_provider_layer_attribution[lychee.map_provider].attribution, - }).addTo(mymap); + }).addTo(myMap); if (!lychee.map_display_direction || !photo.json.img_direction) { // Add Marker to map, direction is not set - L.marker([photo.json.latitude, photo.json.longitude]).addTo(mymap); + L.marker([photo.json.latitude, photo.json.longitude]).addTo(myMap); } else { // Add Marker, direction has been set - let viewDirectionIcon = L.icon({ + const viewDirectionIcon = L.icon({ iconUrl: "img/view-angle-icon.png", iconRetinaUrl: "img/view-angle-icon-2x.png", iconSize: [100, 58], // size of the icon iconAnchor: [50, 49], // point of the icon which will correspond to marker's location }); - let marker = L.marker([photo.json.latitude, photo.json.longitude], { icon: viewDirectionIcon }).addTo(mymap); + const marker = L.marker([photo.json.latitude, photo.json.longitude], { icon: viewDirectionIcon }).addTo(myMap); marker.setRotationAngle(photo.json.img_direction); } } }, + /** + * @returns {void} + */ header: function () { /* Note: the condition below is duplicated in contextMenu.photoMore() */ if ( @@ -854,15 +975,18 @@ view.photo = { } }, + /** + * @returns {void} + */ onresize: function () { if (!photo.json || photo.json.size_variants.medium === null || photo.json.size_variants.medium2x === null) return; // Calculate the width of the image in the current window without // borders and set 'sizes' to it. - let imgWidth = photo.json.size_variants.medium.width; - let imgHeight = photo.json.size_variants.medium.height; - let containerWidth = $(window).outerWidth(); - let containerHeight = $(window).outerHeight(); + const imgWidth = photo.json.size_variants.medium.width; + const imgHeight = photo.json.size_variants.medium.height; + const containerWidth = $(window).outerWidth(); + const containerHeight = $(window).outerHeight(); // Image can be no larger than its natural size, but it can be // smaller depending on the size of the window. @@ -877,6 +1001,9 @@ view.photo = { }; view.settings = { + /** + * @returns {void} + */ init: function () { multiselect.clearSelection(); @@ -886,15 +1013,24 @@ view.settings = { view.settings.content.init(); }, + /** + * @returns {void} + */ title: function () { lychee.setTitle(lychee.locale["SETTINGS"], false); }, + /** + * @returns {void} + */ clearContent: function () { lychee.content.html('
'); }, content: { + /** + * @returns {void} + */ init: function () { view.settings.clearContent(); view.settings.content.setLogin(); @@ -914,8 +1050,11 @@ view.settings = { } }, + /** + * @returns {void} + */ setLogin: function () { - let msg = lychee.html` + const msg = lychee.html`

$${lychee.locale["PASSWORD_TITLE"]} @@ -937,20 +1076,30 @@ view.settings = { settings.bind("#basicModal__action_password_change", ".setLogin", settings.changeLogin); }, + /** + * @returns {void} + */ clearLogin: function () { $("input[name=oldUsername], input[name=oldPassword], input[name=username], input[name=password], input[name=confirm]").val(""); }, + /** + * Renders the area of the settings related to sorting + * + * TODO: Note, the method is a misnomer. + * It does not **set** any sorting, see {@link settings.changeSorting} + * for that. + * This method only creates the HTML GUI. + * + * @returns {void} + */ setSorting: function () { - let sortingPhotos = []; - let sortingAlbums = []; - - let msg = lychee.html` + const msg = lychee.html`

$${lychee.locale["SORT_ALBUM_BY_1"]} - @@ -961,7 +1110,7 @@ view.settings = { $${lychee.locale["SORT_ALBUM_BY_2"]} - @@ -971,7 +1120,7 @@ view.settings = {

$${lychee.locale["SORT_PHOTO_BY_1"]} - @@ -983,7 +1132,7 @@ view.settings = { $${lychee.locale["SORT_PHOTO_BY_2"]} - @@ -999,23 +1148,22 @@ view.settings = { $(".settings_view").append(msg); - if (lychee.sortingAlbums !== "") { - sortingAlbums = lychee.sortingAlbums.replace("ORDER BY ", "").split(" "); - - $(".setSorting select#settings_albums_type").val(sortingAlbums[0]); - $(".setSorting select#settings_albums_order").val(sortingAlbums[1]); + if (lychee.sorting_albums) { + $(".setSorting select#settings_albums_sorting_column").val(lychee.sorting_albums.column); + $(".setSorting select#settings_albums_sorting_order").val(lychee.sorting_albums.order); } - if (lychee.sortingPhotos !== "") { - sortingPhotos = lychee.sortingPhotos.replace("ORDER BY ", "").split(" "); - - $(".setSorting select#settings_photos_type").val(sortingPhotos[0]); - $(".setSorting select#settings_photos_order").val(sortingPhotos[1]); + if (lychee.sorting_photos) { + $(".setSorting select#settings_photos_sorting_column").val(lychee.sorting_photos.column); + $(".setSorting select#settings_photos_sorting_order").val(lychee.sorting_photos.order); } settings.bind("#basicModal__action_sorting_change", ".setSorting", settings.changeSorting); }, + /** + * @returns {void} + */ setDropboxKey: function () { let msg = `

@@ -1032,33 +1180,36 @@ view.settings = { settings.bind("#basicModal__action_dropbox_change", ".setDropBox", settings.changeDropboxKey); }, + /** + * @returns {void} + */ setLang: function () { let msg = ` -
-

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

- -
`; +
+

+ ${lychee.locale["LANG_TEXT"]} + + + +

+ +
`; $(".settings_view").append(msg); settings.bind("#basicModal__action_set_lang", ".setLang", settings.changeLang); }, + /** + * @returns {void} + */ setDefaultLicense: function () { - let msg = ` + const msg = `

${lychee.locale["DEFAULT_LICENSE"]} @@ -1111,8 +1262,11 @@ view.settings = { settings.bind("#basicModal__action_set_license", ".setDefaultLicense", settings.setDefaultLicense); }, + /** + * @returns {void} + */ setLayout: function () { - let msg = ` + const msg = `

${lychee.locale["LAYOUT_TYPE"]} @@ -1133,12 +1287,15 @@ view.settings = { settings.bind("#basicModal__action_set_layout", ".setLayout", settings.setLayout); }, + /** + * @returns {void} + */ setPublicSearch: function () { - let msg = ` + const msg = `

${lychee.locale["PUBLIC_SEARCH_TEXT"]}

@@ -1151,12 +1308,15 @@ view.settings = { settings.bind("#PublicSearch", ".setPublicSearch", settings.changePublicSearch); }, + /** + * @returns {void} + */ setNSFWVisible: function () { let msg = `

${lychee.locale["NSFW_VISIBLE_TEXT_1"]}

${lychee.locale["NSFW_VISIBLE_TEXT_2"]} @@ -1173,12 +1333,15 @@ view.settings = { }, // TODO: extend to the other settings. + /** + * @returns {void} + */ setOverlayType: function () { let msg = `

${lychee.locale["OVERLAY_TYPE"]} - @@ -1197,12 +1360,15 @@ view.settings = { settings.bind("#basicModal__action_set_overlay_type", ".setOverlayType", settings.setOverlayType); }, + /** + * @returns {void} + */ setMapDisplay: function () { let msg = `

${lychee.locale["MAP_DISPLAY_TEXT"]}

@@ -1218,7 +1384,7 @@ view.settings = {

${lychee.locale["MAP_DISPLAY_PUBLIC_TEXT"]}

@@ -1234,7 +1400,7 @@ view.settings = {

${lychee.locale["MAP_PROVIDER"]} - @@ -1254,10 +1420,10 @@ view.settings = { settings.bind("#basicModal__action_set_map_provider", ".setMapProvider", settings.setMapProvider); msg = ` -

+

${lychee.locale["MAP_INCLUDE_SUBALBUMS_TEXT"]}

@@ -1265,15 +1431,15 @@ view.settings = { `; $(".settings_view").append(msg); - if (lychee.map_include_subalbums) $("#MapIncludeSubalbums").click(); + if (lychee.map_include_subalbums) $("#MapIncludeSubAlbums").click(); - settings.bind("#MapIncludeSubalbums", ".setMapIncludeSubalbums", settings.changeMapIncludeSubalbums); + settings.bind("#MapIncludeSubAlbums", ".setMapIncludeSubAlbums", settings.changeMapIncludeSubAlbums); msg = `

${lychee.locale["LOCATION_DECODING"]}

@@ -1289,7 +1455,7 @@ view.settings = {

${lychee.locale["LOCATION_SHOW"]}

@@ -1305,7 +1471,7 @@ view.settings = {

${lychee.locale["LOCATION_SHOW_PUBLIC"]}

@@ -1318,12 +1484,15 @@ view.settings = { settings.bind("#LocationShowPublic", ".setLocationShowPublic", settings.changeLocationShowPublic); }, + /** + * @returns {void} + */ setNotification: function () { - msg = ` + const msg = `

${lychee.locale["NEW_PHOTOS_NOTIFICATION"]}

@@ -1336,8 +1505,11 @@ view.settings = { settings.bind("#NewPhotosNotification", ".setNewPhotosNotification", settings.changeNewPhotosNotification); }, + /** + * @returns {void} + */ setCSS: function () { - let msg = ` + const msg = `

${lychee.locale["CSS_TEXT"]}

@@ -1350,15 +1522,18 @@ view.settings = { let css_addr = $($("link")[1]).attr("href"); - api.get(css_addr, function (data) { + api.getCSS(css_addr, function (data) { $("#css").html(data); }); settings.bind("#basicModal__action_set_css", ".setCSS", settings.changeCSS); }, + /** + * @returns {void} + */ moreButton: function () { - let msg = lychee.html` + const msg = lychee.html` @@ -1372,6 +1547,9 @@ view.settings = { }; view.full_settings = { + /** + * @returns {void} + */ init: function () { multiselect.clearSelection(); @@ -1379,10 +1557,16 @@ view.full_settings = { view.full_settings.content.init(); }, + /** + * @returns {void} + */ title: function () { lychee.setTitle("Full Settings", false); }, + /** + * @returns {void} + */ clearContent: function () { lychee.content.html('
'); }, @@ -1391,56 +1575,59 @@ view.full_settings = { init: function () { view.full_settings.clearContent(); - api.post("Settings::getAll", {}, function (data) { - let msg = lychee.html` -
-
-

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

-
- `; - - let prev = ""; - $.each(data, function () { - if (this.cat && prev !== this.cat) { - msg += lychee.html` -
-

- $${this.cat} + api.post( + "Settings::getAll", + {}, + /** @param {ConfigSetting[]} data */ + function (data) { + let msg = lychee.html` +

+
+

+ ${lychee.locale["SETTINGS_WARNING"]}

-
`; - prev = this.cat; - } - // prevent 'null' string for empty values - let val = this.value ? this.value : ""; +
+ `; + + let prev = ""; + data.forEach(function (_config) { + if (_config.cat && prev !== _config.cat) { + msg += lychee.html` +
+

$${_config.cat}

+
`; + prev = _config.cat; + } + // prevent 'null' string for empty values + const val = _config.value ? _config.value : ""; + msg += lychee.html` +
+

+ $${_config.key} + +

+
`; + }); + msg += lychee.html` -
-

- $${this.key} - -

-
- `; - }); + ${lychee.locale["SAVE_RISK"]} +
`; - msg += lychee.html` - ${lychee.locale["SAVE_RISK"]} -
- `; - $(".settings_view").append(msg); + $(".settings_view").append(msg); - settings.bind("#FullSettingsSave_button", "#fullSettings", settings.save); + settings.bind("#FullSettingsSave_button", "#fullSettings", settings.save); - $("#fullSettings").on("keypress", function (e) { - settings.save_enter(e); - }); - }); + $("#fullSettings").on("keypress", function (e) { + settings.save_enter(e); + }); + } + ); }, }, }; view.notifications = { + /** @returns {void} */ init: function () { multiselect.clearSelection(); @@ -1450,31 +1637,37 @@ view.notifications = { view.notifications.content.init(); }, + /** @returns {void} */ title: function () { lychee.setTitle("Notifications", false); }, + /** @returns {void} */ clearContent: function () { lychee.content.html('
'); }, content: { + /** @returns {void} */ init: function () { view.notifications.clearContent(); - $(".settings_view").append('

' + `${lychee.locale["USER_EMAIL_INSTRUCTION"]}` + "

"); - - let html = ""; - - html += - '

' + - "Enter your email address:" + - '' + - '

' + - 'Save' + - "
"; + const html = ` +
+

${lychee.locale["USER_EMAIL_INSTRUCTION"]}

+
+

+ Enter your email address: + +

+
+ Save +
+
`; $(".settings_view").append(html); settings.bind("#UserUpdate_button", "#UserUpdate", notifications.update); @@ -1483,6 +1676,7 @@ view.notifications = { }; view.users = { + /** @returns {void} */ init: function () { multiselect.clearSelection(); @@ -1492,15 +1686,18 @@ view.users = { view.users.content.init(); }, + /** @returns {void} */ title: function () { lychee.setTitle("Users", false); }, + /** @returns {void} */ clearContent: function () { lychee.content.html('
'); }, content: { + /** @returns {void} */ init: function () { view.users.clearContent(); @@ -1510,61 +1707,53 @@ view.users = { ); } - let html = ""; - - html += - '
' + - "

" + - 'username' + - 'new password' + - '' + - build.iconic("data-transfer-upload") + - "" + - '' + - build.iconic("lock-locked") + - "" + - "

" + - "
"; + let html = ` +

+ username + new password + + ${build.iconic("data-transfer-upload")} + + + ${build.iconic("lock-locked")} + +

`; $(".users_view").append(html); - $.each(users.json, function () { - $(".users_view").append(build.user(this)); - settings.bind("#UserUpdate" + this.id, "#UserData" + this.id, users.update); - settings.bind("#UserDelete" + this.id, "#UserData" + this.id, users.delete); - if (this.may_upload) { - $("#UserData" + this.id + ' .choice input[name="upload"]').click(); + users.json.forEach(function (_user) { + $(".users_view").append(build.user(_user)); + // TODO: Instead of binding an event handler to each input element it would be much more efficient, to bind a single event handler to the common parent view, let the event bubble up the DOM tree and use the `originalElement` property of the event to get the input element which caused the event. + settings.bind("#UserUpdate" + _user.id, "#UserData" + _user.id, users.update); + settings.bind("#UserDelete" + _user.id, "#UserData" + _user.id, users.delete); + if (_user.may_upload) { + $("#UserData" + _user.id + ' .choice input[name="may_upload"]').click(); } - if (this.is_locked) { - $("#UserData" + this.id + ' .choice input[name="lock"]').click(); + if (_user.is_locked) { + $("#UserData" + _user.id + ' .choice input[name="is_locked"]').click(); } }); - html = '
" + - '

' + - ' ' + - ' ' + - '' + - "" + - " " + - '' + - "" + - "" + - "

" + - 'Create' + - "
"; + html = ` +
+

+ + + + + + + + +

+ Create +
`; $(".users_view").append(html); settings.bind("#UserCreate_button", "#UserCreate", users.create); }, @@ -1572,6 +1761,7 @@ view.users = { }; view.sharing = { + /** @returns {void} */ init: function () { multiselect.clearSelection(); @@ -1581,15 +1771,18 @@ view.sharing = { view.sharing.content.init(); }, + /** @returns {void} */ title: function () { lychee.setTitle("Sharing", false); }, + /** @returns {void} */ clearContent: function () { lychee.content.html(''); }, content: { + /** @returns {void} */ init: function () { view.sharing.clearContent(); @@ -1599,102 +1792,95 @@ view.sharing = { ); } - let html = ""; - - html += ` - - `; - - html += ` - -