├── .prettierrc.json ├── images ├── play-icon.png ├── placeholder.png ├── live-photo-icon.png ├── apple-touch-icon-ipad.png ├── apple-touch-icon-iphone.png ├── apple-touch-icon-iphone-plus.png ├── no_cover.svg ├── no_images.svg ├── password.svg └── ionicons.svg ├── .gitignore ├── .github ├── ISSUE_TEMPLATE.md ├── workflows │ └── NodeJS.yml └── CONTRIBUTING.md ├── styles ├── main │ ├── _multiselect.scss │ ├── _view_container.scss │ ├── _dialog_about.scss │ ├── _dialog_photo_links.scss │ ├── _dialog_downloads.scss │ ├── _warning.scss │ ├── _application_container.scss │ ├── _workbench_container.scss │ ├── _dialog_token.scss │ ├── _tv.scss │ ├── _footer.scss │ ├── _mapview.scss │ ├── _dialog_login.scss │ ├── _social-footer.scss │ ├── _frame.scss │ ├── _animations.scss │ ├── _loading.scss │ ├── _justified_layout.scss │ ├── _basicContext.extended.scss │ ├── _basicContext.custom.scss │ ├── _dialog_import.scss │ ├── _logs_diagnostics.scss │ ├── _leftMenu.scss │ ├── _basicModal.custom.scss │ ├── main.scss │ ├── _imageview.scss │ ├── _container.scss │ ├── _u2f.scss │ ├── _users.scss │ ├── _header.scss │ ├── _sharing.scss │ ├── _sidebar.scss │ └── _settings.scss ├── landing │ ├── _slide.scss │ ├── _intro.scss │ ├── _logo.scss │ ├── _footer.scss │ ├── _menu.scss │ ├── _social.scss │ ├── _animate.scss │ └── landing.scss └── _reset.scss ├── .editorconfig ├── pre-commit ├── scripts ├── main │ ├── notifications.js │ ├── photoeditor.js │ ├── visible.js │ ├── password.js │ ├── sharing.js │ ├── users.js │ ├── swipe.js │ ├── u2f.js │ ├── loadingBar.js │ ├── _swipe.jquery.js │ ├── tabindex.js │ ├── search.js │ ├── leftMenu.js │ ├── frame.js │ └── albums.js ├── csrf_protection.js ├── 3rd-party │ ├── dropbox.js │ └── basicModal.js ├── landing │ └── landing.js └── api.js ├── API.md ├── LICENSE ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md ├── gulpfile.js └── html └── frontend.html /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150 3 | } 4 | -------------------------------------------------------------------------------- /images/play-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LycheeOrg/Lychee-front/HEAD/images/play-icon.png -------------------------------------------------------------------------------- /images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LycheeOrg/Lychee-front/HEAD/images/placeholder.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm 2 | node_modules/ 3 | bower_components/ 4 | 5 | .eslintrc.json 6 | .DS_Store 7 | .idea 8 | -------------------------------------------------------------------------------- /images/live-photo-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LycheeOrg/Lychee-front/HEAD/images/live-photo-icon.png -------------------------------------------------------------------------------- /images/apple-touch-icon-ipad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LycheeOrg/Lychee-front/HEAD/images/apple-touch-icon-ipad.png -------------------------------------------------------------------------------- /images/apple-touch-icon-iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LycheeOrg/Lychee-front/HEAD/images/apple-touch-icon-iphone.png -------------------------------------------------------------------------------- /images/apple-touch-icon-iphone-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LycheeOrg/Lychee-front/HEAD/images/apple-touch-icon-iphone-plus.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Detailed description of the problem 2 | 3 | ### Steps to reproduce the issue 4 | 5 | ### Output of the diagnostics (Settings => Diagnostics) 6 | 7 | ### Browser and system 8 | -------------------------------------------------------------------------------- /styles/main/_multiselect.scss: -------------------------------------------------------------------------------- 1 | #multiselect { 2 | position: absolute; 3 | background-color: rgba(0, 94, 204, 0.3); 4 | border: 1px solid rgba(0, 94, 204, 1); 5 | border-radius: 3px; 6 | z-index: 5; 7 | } 8 | -------------------------------------------------------------------------------- /images/no_cover.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = false 7 | indent_style = tab 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.js] 15 | indent_style = tab 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /images/no_images.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /styles/landing/_slide.scss: -------------------------------------------------------------------------------- 1 | #slides { 2 | position: absolute; 3 | left: 0; 4 | top: 0; 5 | width: 100vw; 6 | height: 98vh; 7 | 8 | .slides-container, 9 | li, 10 | img { 11 | height: 100%; 12 | width: 100%; 13 | } 14 | 15 | img { 16 | top: 0; 17 | left: 0; 18 | position: absolute; 19 | object-fit: cover; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /images/password.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /styles/main/_view_container.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * The view container is a child of the workbench and shares the vertical 3 | * space of workbench with the footer. 4 | * The workbench uses `dispay: flex` thus making the view container a flex item. 5 | * The view container holds the actual views. 6 | */ 7 | #lychee_view_container { 8 | position: absolute; 9 | top: 0; 10 | left: 0; 11 | height: 100%; 12 | width: 100%; 13 | overflow: clip auto; 14 | } 15 | -------------------------------------------------------------------------------- /styles/main/_dialog_about.scss: -------------------------------------------------------------------------------- 1 | div.basicModal.about-dialog div.basicModal__content { 2 | h1 { 3 | font-size: 120%; 4 | font-weight: bold; 5 | text-align: center; 6 | color: $colorDialogEmphasizedFg; 7 | } 8 | h2 { 9 | font-weight: bold; 10 | color: $colorDialogEmphasizedFg; 11 | } 12 | p.update-status { 13 | &.up-to-date-release, 14 | &.up-to-date-git { 15 | display: none; 16 | } 17 | } 18 | p.about-desc { 19 | line-height: 1.4em; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /styles/main/_dialog_photo_links.scss: -------------------------------------------------------------------------------- 1 | form.photo-links div.input-group { 2 | padding-right: 30px; 3 | 4 | a.button { 5 | display: block; 6 | position: absolute; 7 | margin: 0; 8 | padding: 4px; 9 | right: 0; 10 | bottom: 0; 11 | width: 26px; 12 | height: 26px; 13 | cursor: pointer; 14 | box-shadow: inset 1px 1px 0 white(0.02); 15 | border: solid 1px black(0.2); 16 | 17 | .iconic { 18 | width: 100%; 19 | height: 100%; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /styles/landing/_intro.scss: -------------------------------------------------------------------------------- 1 | #intro { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | bottom: 0; 6 | right: 0; 7 | z-index: 1000; 8 | background: #000000; 9 | 10 | h1 { 11 | text-align: center; 12 | font-size: 1.5em; 13 | color: #ffffff; 14 | text-transform: uppercase; 15 | font-weight: 200; 16 | } 17 | 18 | h2 { 19 | text-align: center; 20 | font-size: 1em; 21 | color: #ececec; 22 | text-transform: uppercase; 23 | font-weight: 200; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /styles/main/_dialog_downloads.scss: -------------------------------------------------------------------------------- 1 | div.basicModal.downloads div.basicModal__content a.button { 2 | display: block; 3 | margin: 12px 0; 4 | padding: 12px; 5 | font-weight: bold; 6 | box-shadow: inset 0 1px 0 white(0.02); 7 | border: solid 1px black(0.2); 8 | 9 | .iconic { 10 | width: 12px; 11 | height: 12px; 12 | margin-right: 12px; 13 | } 14 | } 15 | 16 | div.basicModal.qr-code { 17 | width: 300px; 18 | 19 | div.basicModal__content { 20 | padding: 12px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /styles/main/_warning.scss: -------------------------------------------------------------------------------- 1 | #sensitive_warning { 2 | background: rgba(100, 0, 0, 0.95); 3 | text-align: center; 4 | flex-direction: column; 5 | justify-content: center; 6 | align-items: center; 7 | color: white; 8 | &.active { 9 | display: flex; 10 | } 11 | h1 { 12 | font-size: 36px; 13 | font-weight: bold; 14 | border-bottom: 2px solid white; 15 | margin-bottom: 15px; 16 | } 17 | p { 18 | font-size: 20px; 19 | max-width: 40%; 20 | margin-top: 15px; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /styles/landing/_logo.scss: -------------------------------------------------------------------------------- 1 | #header { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | right: 0; 6 | z-index: 98; 7 | } 8 | 9 | #logo { 10 | float: left; 11 | padding: 15px; 12 | 13 | h1 { 14 | color: #ffffff; 15 | font-size: 1em; 16 | text-transform: uppercase; 17 | font-weight: 700; 18 | text-align: center; 19 | 20 | span { 21 | font-family: "Roboto", sans-serif; 22 | font-size: 0.6em; 23 | display: block; 24 | font-weight: 300; 25 | letter-spacing: 1px; 26 | padding: 0 0 0 0; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /styles/landing/_footer.scss: -------------------------------------------------------------------------------- 1 | #footer { 2 | z-index: 3; 3 | left: 0; 4 | right: 0; 5 | bottom: 0; 6 | text-align: center; 7 | padding: 5px 0 5px 0; 8 | transition: color 0.3s, opacity 0.3s ease-out, transform 0.3s ease-out, box-shadow 0.3s, margin-left 0.5s; 9 | 10 | p { 11 | color: #cccccc; 12 | font-size: 0.5em; 13 | font-weight: 400; 14 | 15 | a { 16 | color: #ccc; 17 | } 18 | a:visited { 19 | color: #ccc; 20 | } 21 | } 22 | 23 | p.hosted_by, 24 | p.home_copyright { 25 | text-transform: uppercase; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | NO_COLOR="\033[0m" 3 | GREEN="\033[38;5;010m" 4 | YELLOW="\033[38;5;011m" 5 | 6 | printf "\n${GREEN}pre commit hook start${NO_COLOR}\n" 7 | 8 | PRETTIER="./node_modules/prettier/bin-prettier.js" 9 | 10 | if [ -x "$PRETTIER" ]; then 11 | git status --porcelain | grep -e '^[AM]\(.*\).[js|yml|md|css|scss|json]$' | cut -c 3- | while read line; do 12 | ${PRETTIER} --write ${line}; 13 | git add "$line"; 14 | done 15 | else 16 | echo "" 17 | printf "${YELLOW}Please install prettier, e.g.:${NO_COLOR}" 18 | echo "" 19 | echo " npm install" 20 | echo "" 21 | fi 22 | 23 | printf "\n${GREEN}pre commit hook finish${NO_COLOR}\n" -------------------------------------------------------------------------------- /styles/main/_application_container.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * The application container is a child of the body and shares the vertical 3 | * space of the body with the loading indicator. 4 | * The body uses `dispay: flex` thus making the application container a flex 5 | * item. 6 | * The application container holds the left menu and the workbench container 7 | * and aligns them horizontally in a row. 8 | * The left menu has always its natural width (0 for a closed menu, 9 | * specific width for an open menu), the width of the workbench 10 | * container is flexible and uses the remaining width. 11 | */ 12 | body.mode-frame, 13 | body.mode-none { 14 | #lychee_application_container { 15 | display: none; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /styles/main/_workbench_container.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * The workbench shares horizontal space with the right sidebar. 3 | * The anonymous outer container uses `dispay: flex` thus making the 4 | * workbench a flex item. 5 | * The workbench holds the the view container and the footer 6 | * and aligns them vertically in a column. 7 | * The footer has its natural height, the remaining vertical space is filled 8 | * by the view container. 9 | * However, view container may grow vertically as much its content requires. 10 | * Hence, the workbench provides a scrollbar in vertical direction. 11 | */ 12 | #lychee_workbench_container { 13 | // The workbench scrolls the view container together with the footer if 14 | // required. 15 | } 16 | -------------------------------------------------------------------------------- /styles/landing/_menu.scss: -------------------------------------------------------------------------------- 1 | #menu { 2 | width: 100%; 3 | 4 | li { 5 | position: relative; 6 | display: block; 7 | float: right; 8 | padding: 22px 1.5% 20px 1.5%; 9 | } 10 | 11 | a { 12 | display: block; 13 | font-size: 0.8em; 14 | color: #ffffff; 15 | text-transform: uppercase; 16 | font-weight: 400; 17 | transition: all 0.3s; 18 | -webkit-transition: all 0.3s; 19 | -moz-transition: all 0.3s; 20 | -o-transition: all 0.3s; 21 | } 22 | 23 | .current-menu-item a { 24 | color: #b5b5b5 !important; 25 | } 26 | } 27 | 28 | #menu_wrap { 29 | position: fixed; 30 | right: 0; 31 | top: 0; 32 | z-index: 98; 33 | width: 80%; 34 | } 35 | 36 | // restrict hover features to devices that support it 37 | @media (hover: hover) { 38 | #menu a:hover { 39 | color: #b5b5b5 !important; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /styles/main/_dialog_token.scss: -------------------------------------------------------------------------------- 1 | form.token div.input-group { 2 | padding-right: 82px; 3 | 4 | input[disabled], 5 | input.disabled { 6 | color: $colorDialogDisabledFg; 7 | } 8 | 9 | div.button-group { 10 | display: block; 11 | position: absolute; 12 | margin: 0; 13 | padding: 0; 14 | right: 0; 15 | bottom: 0; 16 | width: 78px; // 3 buttons à 26px 17 | 18 | a.button { 19 | display: block; 20 | float: right; 21 | margin: 0; 22 | padding: 4px; 23 | bottom: 4px; // compensation for bottom padding (3px) and bottom border (1px) of input element 24 | width: 26px; 25 | height: 26px; 26 | cursor: pointer; 27 | box-shadow: inset 1px 1px 0 white(0.02); 28 | border: solid 1px black(0.2); 29 | 30 | .iconic { 31 | width: 100%; 32 | height: 100%; 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /styles/main/_tv.scss: -------------------------------------------------------------------------------- 1 | @media tv { 2 | .basicModal__button:focus { 3 | background: #2293ec; 4 | color: #ffffff; 5 | cursor: pointer; 6 | outline-style: none; 7 | } 8 | 9 | .basicModal__button#basicModal__action:focus { 10 | color: #ffffff; 11 | } 12 | 13 | .photo:focus { 14 | outline-style: solid; 15 | outline-color: #ffffff; 16 | outline-width: 10px; 17 | } 18 | 19 | .album:focus { 20 | outline-width: 0; 21 | } 22 | 23 | .toolbar .button:focus { 24 | outline-width: 0; 25 | background-color: #ffffff; 26 | } 27 | 28 | .header__title:focus { 29 | outline-width: 0; 30 | background-color: #ffffff; 31 | color: #000000; 32 | } 33 | 34 | .toolbar .button:focus .iconic { 35 | fill: #000000; 36 | } 37 | 38 | #imageview { 39 | background-color: #000000; 40 | } 41 | #imageview #image, 42 | #imageview #livephoto { 43 | outline-width: 0; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/NodeJS.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "**/*.md" 7 | pull_request: 8 | paths-ignore: 9 | - "**/*.md" 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | if: (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository) 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14, 16, 18, 20] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Install 29 | run: npm install 30 | 31 | - name: Check Style 32 | run: npm run check-formatting 33 | 34 | - name: Compile Front-end 35 | run: npm run compile 36 | -------------------------------------------------------------------------------- /scripts/main/notifications.js: -------------------------------------------------------------------------------- 1 | const notifications = { 2 | /** @type {?EMailData} */ 3 | json: null, 4 | }; 5 | 6 | /** 7 | * @param {EMailData} params 8 | * @returns {void} 9 | */ 10 | notifications.update = function (params) { 11 | if (params.email && params.email.length > 1) { 12 | const regexp = 13 | /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; 14 | 15 | if (!regexp.test(String(params.email).toLowerCase())) { 16 | loadingBar.show("error", lychee.locale["ERROR_INVALID_EMAIL"]); 17 | return; 18 | } 19 | } 20 | 21 | api.post("User::setEmail", params, function () { 22 | loadingBar.show("success", lychee.locale["EMAIL_SUCCESS"]); 23 | }); 24 | }; 25 | 26 | notifications.load = function () { 27 | api.post( 28 | "User::getAuthenticatedUser", 29 | {}, 30 | /** @param {EMailData} data */ function (data) { 31 | notifications.json = data.email; 32 | view.notifications.init(); 33 | } 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /styles/main/_footer.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The footer. 3 | * The footer is a child of the workbench and shares the vertical space of the 4 | * workbench with the view container. 5 | * The workbench uses `dispay: flex` thus making the footer a flex item. 6 | * The footer always has its natural height. 7 | */ 8 | #lychee_footer { 9 | text-align: center; 10 | padding: 5px 0 5px 0; 11 | background: $colorAppBg; 12 | transition: color 0.3s, opacity 0.3s ease-out, transform 0.3s ease-out, box-shadow 0.3s, margin-left 0.5s; 13 | 14 | p { 15 | color: #cccccc; 16 | font-size: 0.75em; 17 | font-weight: 400; 18 | line-height: 26px; 19 | 20 | a { 21 | color: #ccc; 22 | } 23 | a:visited { 24 | color: #ccc; 25 | } 26 | } 27 | 28 | p.hosted_by, 29 | p.home_copyright { 30 | text-transform: uppercase; 31 | } 32 | 33 | p:empty, 34 | #home_socials a[href=""] { 35 | display: none; 36 | } 37 | } 38 | 39 | .hide_footer { 40 | display: none; 41 | } 42 | 43 | body.mode-frame, 44 | body.mode-none { 45 | div#footer { 46 | display: none; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | # API Functions 2 | 3 | The current API is described in details [here](https://lycheeorg.github.io/docs/api.html) 4 | 5 | # Module 6 | 7 | [api.js »](scripts/api.js) 8 | 9 | ```js 10 | /** 11 | * @description This module communicates with Lychee's API 12 | */ 13 | 14 | api = { 15 | onError: null, 16 | }; 17 | 18 | api.post = function (fn, params, callback) { 19 | loadingBar.show(); 20 | 21 | params = $.extend({ function: fn }, params); 22 | 23 | let api_url = "api/" + fn; 24 | 25 | const success = (data) => { 26 | setTimeout(loadingBar.hide, 100); 27 | 28 | // Catch errors 29 | if (typeof data === "string" && data.substring(0, 7) === "Error: ") { 30 | api.onError(data.substring(7, data.length), params, data); 31 | return false; 32 | } 33 | 34 | callback(data); 35 | }; 36 | 37 | const error = (jqXHR, textStatus, errorThrown) => { 38 | api.onError("Server error or API not found.", params, errorThrown); 39 | }; 40 | 41 | $.ajax({ 42 | type: "POST", 43 | url: api_url, 44 | data: params, 45 | dataType: "json", 46 | success, 47 | error, 48 | }); 49 | }; 50 | ``` 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2018 Tobias Reich 4 | Copyright (c) 2018-2020 LycheeOrg 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /scripts/csrf_protection.js: -------------------------------------------------------------------------------- 1 | const csrf = {}; 2 | 3 | /** 4 | * Returns the value of the CSRF token. 5 | * 6 | * Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2 7 | * 8 | * @returns {?string} 9 | */ 10 | csrf.getCSRFCookieValue = function () { 11 | const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row)); 12 | // We must remove all '%3D' from the end of the string. 13 | // Background: 14 | // The actual binary value of the CSFR value is encoded in Base64. 15 | // If the length of original, binary value is not a multiple of 3 bytes, 16 | // the encoding gets padded with `=` on the right; i.e. there might be 17 | // zero, one or two `=` at the end of the encoded value. 18 | // If the value is sent from the server to the client as part of a cookie, 19 | // the `=` character is URL-encoded as `%3D`, because `=` is already used 20 | // to separate a cookie key from its value. 21 | // When we send back the value to the server as part of an AJAX request, 22 | // Laravel expects an unpadded value. 23 | // Hence, we must remove the `%3D`. 24 | return cookie ? cookie.split("=")[1].trim().replace(/%3D/g, "") : null; 25 | }; 26 | -------------------------------------------------------------------------------- /styles/main/_mapview.scss: -------------------------------------------------------------------------------- 1 | .leaflet-marker-photo img { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | .leaflet-popup-content-wrapper { 7 | background-color: rgb(0, 0, 0); 8 | padding: 3px; 9 | } 10 | 11 | .image-leaflet-popup { 12 | width: 100%; 13 | } 14 | 15 | .leaflet-popup-content div { 16 | pointer-events: none; 17 | position: absolute; 18 | bottom: 19px; 19 | left: 22px; 20 | right: 22px; 21 | padding-bottom: 10px; 22 | background: linear-gradient(to bottom, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.6)); 23 | } 24 | 25 | .leaflet-popup-content h1 { 26 | top: 0; 27 | position: relative; 28 | margin: 12px 0 5px 15px; 29 | font-size: 16px; 30 | font-weight: 700; 31 | text-shadow: 0 1px 3px rgba(255, 255, 255, 0.4); 32 | color: rgb(255, 255, 255); 33 | white-space: nowrap; 34 | text-overflow: ellipsis; 35 | } 36 | 37 | .leaflet-popup-content span { 38 | margin-left: 12px; 39 | } 40 | 41 | .leaflet-popup-content svg { 42 | fill: rgb(255, 255, 255); 43 | vertical-align: middle; 44 | } 45 | 46 | .leaflet-popup-content p { 47 | display: inline; 48 | font-size: 11px; 49 | margin: 18px 5px; 50 | color: rgb(255, 255, 255); 51 | } 52 | 53 | .leaflet-popup-content .iconic { 54 | width: 20px; 55 | height: 15px; 56 | } 57 | -------------------------------------------------------------------------------- /styles/main/_dialog_login.scss: -------------------------------------------------------------------------------- 1 | div.basicModal.login div.basicModal__content { 2 | a.button#signInKeyLess { 3 | position: absolute; 4 | display: block; 5 | color: $colorDialogMainButtonFont; 6 | top: 8px; 7 | left: 8px; 8 | width: 30px; 9 | height: 30px; 10 | margin: 0; 11 | padding: 5px; 12 | cursor: pointer; 13 | box-shadow: inset 1px 1px 0 white(0.02); 14 | border: solid 1px black(0.2); 15 | 16 | .iconic { 17 | width: 100%; 18 | height: 100%; 19 | fill: $colorDialogMainButtonFont; 20 | } 21 | } 22 | 23 | p.version { 24 | font-size: 12px; 25 | text-align: right; 26 | 27 | span.update-status { 28 | &.up-to-date-release, 29 | &.up-to-date-git { 30 | display: none; 31 | } 32 | } 33 | } 34 | } 35 | 36 | // restrict hover features to devices that support it 37 | // for some unknown reason the button for keyless sign-in of the login dialog 38 | // uses another color-scheme then all other buttons 39 | // in particular, it does not use the blue accent color 40 | @media (hover: hover) { 41 | div.basicModal.login div.basicModal__content a.button#signInKeyLess:hover { 42 | color: $colorDialogEmphasizedFg; 43 | background: inherit; 44 | 45 | .iconic { 46 | fill: $colorDialogEmphasizedFg; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /styles/main/_social-footer.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "socials"; 3 | src: url("fonts/socials.eot?egvu10"); 4 | src: url("fonts/socials.eot?egvu10#iefix") format("embedded-opentype"), url("fonts/socials.ttf?egvu10") format("truetype"), 5 | url("fonts/socials.woff?egvu10") format("woff"), url("fonts/socials.svg?egvu10#socials") format("svg"); 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | #socials_footer { 11 | padding: 0; 12 | text-align: center; 13 | left: 0; 14 | right: 0; 15 | } 16 | 17 | .socialicons { 18 | display: inline-block; 19 | font-size: 18px; 20 | font-family: "socials" !important; 21 | speak: none; 22 | color: #cccccc; 23 | text-decoration: none; 24 | margin: 15px 15px 5px 15px; 25 | transition: all 0.3s; 26 | -webkit-transition: all 0.3s; 27 | -moz-transition: all 0.3s; 28 | -o-transition: all 0.3s; 29 | } 30 | 31 | #twitter:before { 32 | content: "\ea96"; 33 | } 34 | 35 | #instagram:before { 36 | content: "\ea92"; 37 | } 38 | 39 | #youtube:before { 40 | content: "\ea9d"; 41 | } 42 | 43 | #flickr:before { 44 | content: "\eaa4"; 45 | } 46 | 47 | #facebook:before { 48 | content: "\ea91"; 49 | } 50 | 51 | // restrict hover features to devices that support it 52 | @media (hover: hover) { 53 | .socialicons:hover { 54 | color: #b5b5b5; 55 | -ms-transform: scale(1.3); 56 | transform: scale(1.3); 57 | -webkit-transform: scale(1.3); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /styles/landing/_social.scss: -------------------------------------------------------------------------------- 1 | #home_socials { 2 | position: fixed; 3 | bottom: 30px; 4 | left: 0; 5 | right: 0; 6 | text-align: center; 7 | z-index: 2; 8 | 9 | .socialicons { 10 | display: inline-block; 11 | font-size: 1.4em; 12 | margin: 15px 20px 15px 20px; 13 | } 14 | } 15 | 16 | #socials { 17 | position: fixed; 18 | left: 0; 19 | top: 37%; 20 | background: rgba(0, 0, 0, 0.8); 21 | z-index: 2; 22 | } 23 | 24 | .socialicons { 25 | display: block; 26 | font-size: 23px; 27 | font-family: "socials" !important; 28 | speak: none; 29 | font-style: normal; 30 | font-weight: normal; 31 | font-variant: normal; 32 | text-transform: none; 33 | line-height: 1; 34 | color: #ffffff; 35 | //font-size: 1.1em; 36 | text-decoration: none; 37 | margin: 15px; 38 | transition: all 0.3s; 39 | -webkit-transition: all 0.3s; 40 | -moz-transition: all 0.3s; 41 | -o-transition: all 0.3s; 42 | } 43 | 44 | #twitter:before { 45 | content: "\ea96"; 46 | } 47 | 48 | #instagram:before { 49 | content: "\ea92"; 50 | } 51 | 52 | #youtube:before { 53 | content: "\ea9d"; 54 | } 55 | 56 | #flickr:before { 57 | content: "\eaa4"; 58 | } 59 | 60 | #facebook:before { 61 | content: "\ea91"; 62 | } 63 | 64 | // restrict hover features to devices that support it 65 | @media (hover: hover) { 66 | .socialicons:hover { 67 | color: #b5b5b5; 68 | transform: scale(1.3); 69 | -webkit-transform: scale(1.3); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /styles/main/_frame.scss: -------------------------------------------------------------------------------- 1 | body.mode-gallery, 2 | body.mode-view, 3 | body.mode-none { 4 | #lychee_frame_container { 5 | display: none; 6 | } 7 | } 8 | 9 | #lychee_frame_bg_canvas { 10 | width: 100%; 11 | height: 100%; 12 | position: absolute; 13 | } 14 | 15 | #lychee_frame_bg_image { 16 | position: absolute; 17 | display: none; 18 | } 19 | 20 | #lychee_frame_noise_layer { 21 | position: absolute; 22 | top: 0; 23 | left: 0; 24 | width: 100%; 25 | height: 100%; 26 | background-image: url("../img/noise.png"); 27 | background-repeat: repeat; 28 | background-position: 44px 44px; 29 | } 30 | 31 | #lychee_frame_image_container { 32 | width: 100%; 33 | height: 100%; 34 | display: flex; 35 | // Although the container only contains a single element (the ) 36 | // and hence no wrapping will ever occur, `flex-wrap` must be set as 37 | // otherwise align-content has no effect. 38 | flex-wrap: wrap; 39 | justify-content: center; 40 | align-content: center; 41 | 42 | img { 43 | height: 95%; 44 | width: 95%; 45 | object-fit: contain; 46 | filter: drop-shadow(0px 0px 1px rgba(0, 0, 0, 0.3)) drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.3)); 47 | } 48 | } 49 | 50 | #lychee_frame_shutter { 51 | position: absolute; 52 | width: 100%; 53 | height: 100%; 54 | top: 0; 55 | left: 0; 56 | padding: 0; 57 | margin: 0; 58 | background-color: $colorAppBg; 59 | opacity: 1; 60 | transition: opacity 1s ease-in-out; 61 | 62 | &.opened { 63 | opacity: 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /styles/landing/_animate.scss: -------------------------------------------------------------------------------- 1 | .pop-in.toggled, 2 | .pop-out.toggled, 3 | .pop-in-last.toggled { 4 | opacity: 1; 5 | transform: scale(1); 6 | -webkit-transform: scale(1); 7 | -moz-transform: scale(1); 8 | -o-transform: scale(1); 9 | } 10 | .pop-in, 11 | .pop-in-last { 12 | opacity: 0; 13 | transform: scale(1.1); 14 | -webkit-transform: scale(1.1); 15 | -moz-transform: scale(1.1); 16 | -o-transform: scale(1.1); 17 | } 18 | .animate_slower { 19 | transition: all 2s ease-in-out !important; 20 | -webkit-transition: all 2s ease-in-out !important; 21 | -moz-transition: all 2s ease-in-out !important; 22 | -o-transition: all 2s ease-in-out !important; 23 | } 24 | 25 | .animate-up.toggled, 26 | .animate-down.toggled { 27 | opacity: 1; 28 | transform: translateY(0px); 29 | -webkit-transform: translateY(0px); 30 | -moz-transform: translateY(0px); 31 | -o-transform: translateY(0px); 32 | } 33 | 34 | .animate-down { 35 | opacity: 0; 36 | transform: translateY(-300px); 37 | -webkit-transform: translateY(-300px); 38 | -moz-transform: translateY(-300px); 39 | -o-transform: translateY(-300px); 40 | } 41 | 42 | .animate-up { 43 | opacity: 0; 44 | transform: translateY(300px); 45 | -webkit-transform: translateY(300px); 46 | -moz-transform: translateY(300px); 47 | -o-transform: translateY(300px); 48 | } 49 | 50 | .animate { 51 | transition: all 1s ease-in-out; 52 | -webkit-transition: all 1s ease-in-out; 53 | -moz-transition: all 1s ease-in-out; 54 | -o-transition: all 1s ease-in-out; 55 | } 56 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to report a bug 2 | 3 | Read the following before reporting a bug on GitHub: 4 | 5 | 1. Update Lychee to the newest version 6 | 2. Update your browser to the newest version 7 | 3. Take a look in the [FAQ](https://github.com/electerious/Lychee/blob/master/docs/FAQ.md) 8 | 4. Check if someone has [already reported](https://github.com/electerious/Lychee/issues) the same bug 9 | 10 | When reporting a bug on GitHub, make sure you include the following information: 11 | 12 | - Detailed description of the problem 13 | - How to reproduce the issue (step-by-step) 14 | - What you have already tried 15 | - Output of the diagnostics (`plugins/Diagnostics/index.php`) 16 | - Browser and system version 17 | - Attach files when you have problems which specific photos 18 | 19 | ## Coding Guidelines 20 | 21 | Check if there are branches newer than `master`. Always fork the newest available branch. 22 | 23 | Please follow the conventions already established in the code. 24 | 25 | - **Spacing**:
26 | Use tabs for indentation. Spaces for alignment. 27 | 28 | - **Naming**:
29 | Keep variable and method names concise and descriptive. 30 | 31 | - **Quotes**:
32 | Single-quoted strings are preferred to double-quoted strings 33 | 34 | - **Comments**:
35 | Please use single-line comments to annotate significant additions. Use `//` for comments in PHP, JS and CSS. 36 | 37 | Merge your changes when the forked branch has been updated in the meanwhile. Make sure your code is 100% working before creating a Pull-Request on GitHub. 38 | -------------------------------------------------------------------------------- /styles/main/_animations.scss: -------------------------------------------------------------------------------- 1 | // Animation Setter ---------------------------------------------------- // 2 | .fadeIn { 3 | animation-name: fadeIn; 4 | animation-duration: 0.3s; 5 | animation-fill-mode: forwards; 6 | animation-timing-function: $timing; 7 | } 8 | 9 | .fadeOut { 10 | animation-name: fadeOut; 11 | animation-duration: 0.3s; 12 | animation-fill-mode: forwards; 13 | animation-timing-function: $timing; 14 | } 15 | 16 | // Fade -------------------------------------------------------------- // 17 | @keyframes fadeIn { 18 | 0% { 19 | opacity: 0; 20 | } 21 | 100% { 22 | opacity: 1; 23 | } 24 | } 25 | 26 | @keyframes fadeOut { 27 | 0% { 28 | opacity: 1; 29 | } 30 | 100% { 31 | opacity: 0; 32 | } 33 | } 34 | 35 | // Move -------------------------------------------------------------- // 36 | @keyframes moveBackground { 37 | 0% { 38 | background-position-x: 0px; 39 | } 40 | 100% { 41 | background-position-x: -100px; 42 | } 43 | } 44 | 45 | // Zoom -------------------------------------------------------------- // 46 | @keyframes zoomIn { 47 | 0% { 48 | opacity: 0; 49 | transform: scale(0.8); 50 | } 51 | 100% { 52 | opacity: 1; 53 | transform: scale(1); 54 | } 55 | } 56 | 57 | @keyframes zoomOut { 58 | 0% { 59 | opacity: 1; 60 | transform: scale(1); 61 | } 62 | 100% { 63 | opacity: 0; 64 | transform: scale(0.8); 65 | } 66 | } 67 | 68 | // Pulse -------------------------------------------------------------- // 69 | @keyframes pulse { 70 | 0% { 71 | opacity: 1; 72 | } 73 | 50% { 74 | opacity: 0.3; 75 | } 76 | 100% { 77 | opacity: 1; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /styles/main/_loading.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The loading indicator and error bag. 3 | * The loading indicator is a child of the body and shares the vertical 4 | * space of the body with the application container and the frame container. 5 | * The body uses `dispay: flex` thus making the loading indicator a flex item. 6 | */ 7 | #lychee_loading { 8 | height: 0; /* per default, the loading indicator is "closed" */ 9 | transition: height 0.3s ease; 10 | 11 | background-size: 100px 3px; 12 | background-repeat: repeat-x; 13 | 14 | animation-name: moveBackground; 15 | animation-duration: 0.3s; 16 | animation-iteration-count: infinite; 17 | animation-timing-function: linear; 18 | 19 | // Modes -------------------------------------------------------------- // 20 | &.loading { 21 | height: 3px; 22 | background-image: linear-gradient(to right, #153674 0%, #153674 47%, #2651ae 53%, #2651ae 100%); 23 | } 24 | 25 | &.error { 26 | height: 40px; 27 | background-color: #2f0d0e; 28 | background-image: linear-gradient(to right, #451317 0%, #451317 47%, #aa3039 53%, #aa3039 100%); 29 | } 30 | 31 | &.success { 32 | height: 40px; 33 | background-color: #007700; 34 | background-image: linear-gradient(to right, #007700 0%, #009900 47%, #00aa00 53%, #00cc00 100%); 35 | } 36 | 37 | // Content -------------------------------------------------------------- // 38 | h1 { 39 | margin: 13px 13px 0 13px; 40 | color: #ddd; 41 | font-size: 14px; 42 | font-weight: bold; 43 | text-shadow: 0 1px 0 black(1); 44 | text-transform: capitalize; 45 | 46 | span { 47 | margin-left: 10px; 48 | font-weight: normal; 49 | text-transform: none; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /styles/main/_justified_layout.scss: -------------------------------------------------------------------------------- 1 | .unjustified-layout, 2 | .justified-layout { 3 | margin: 30px; 4 | width: 100%; 5 | position: relative; 6 | &.laying-out { 7 | display: none; 8 | } 9 | } 10 | 11 | .justified-layout > .photo { 12 | position: absolute; 13 | --lychee-default-height: 320px; 14 | margin: 0; 15 | } 16 | 17 | .unjustified-layout > .photo { 18 | float: left; 19 | max-height: 240px; 20 | margin: 5px; 21 | } 22 | 23 | .justified-layout > .photo > .thumbimg > img, 24 | .justified-layout > .photo > .thumbimg, 25 | .unjustified-layout > .photo > .thumbimg > img, 26 | .unjustified-layout > .photo > .thumbimg { 27 | width: 100%; 28 | height: 100%; 29 | border: none; 30 | object-fit: cover; 31 | } 32 | 33 | .justified-layout > .photo > .overlay, 34 | .unjustified-layout > .photo > .overlay { 35 | width: 100%; 36 | bottom: 0; 37 | margin: 0 0 0 0; 38 | } 39 | 40 | .justified-layout > .photo > .overlay > h1, 41 | .unjustified-layout > .photo > .overlay > h1 { 42 | width: auto; 43 | margin-right: 15px; 44 | } 45 | 46 | // responsive web design for smaller screens 47 | @media only screen and (min-width: 320px) and (max-width: 567px) { 48 | .justified-layout { 49 | margin: 8px; 50 | .photo { 51 | --lychee-default-height: 160px; 52 | } 53 | } 54 | } 55 | 56 | @media only screen and (min-width: 568px) and (max-width: 639px) { 57 | .justified-layout { 58 | margin: 9px; 59 | .photo { 60 | --lychee-default-height: 200px; 61 | } 62 | } 63 | } 64 | 65 | @media only screen and (min-width: 640px) and (max-width: 768px) { 66 | .justified-layout { 67 | margin: 10px; 68 | .photo { 69 | --lychee-default-height: 240px; 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /styles/landing/landing.scss: -------------------------------------------------------------------------------- 1 | // Rest -------------------------------------------------------------------- // 2 | /* roboto-300 - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ 3 | @font-face { 4 | font-family: "Roboto"; 5 | font-style: normal; 6 | font-weight: 300; 7 | src: local(""), url("../fonts/roboto-v29-300.woff2") format("woff2"); 8 | } 9 | 10 | /* roboto-regular - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ 11 | @font-face { 12 | font-family: "Roboto"; 13 | font-style: normal; 14 | font-weight: 400; 15 | src: local(""), url("../fonts/roboto-v29-400.woff2") format("woff2"); 16 | } 17 | 18 | /* roboto-700 - vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic */ 19 | @font-face { 20 | font-family: "Roboto"; 21 | font-style: normal; 22 | font-weight: 700; 23 | src: local(""), url("../fonts/roboto-v29-700.woff2") format("woff2"); 24 | } 25 | 26 | @import "../reset"; 27 | 28 | * { 29 | -webkit-user-select: none; 30 | -moz-user-select: none; 31 | user-select: none; 32 | transition: color 0.3s, opacity 0.3s ease-out, transform 0.3s ease-out, box-shadow 0.3s; 33 | } 34 | 35 | html, 36 | body { 37 | font-family: "Roboto", sans-serif; 38 | background: #000000; 39 | overflow: hidden; 40 | } 41 | 42 | ol, 43 | ul { 44 | list-style: none; 45 | } 46 | 47 | a { 48 | text-decoration: none; 49 | } 50 | 51 | @import "fonts"; 52 | @import "animate"; 53 | @import "social"; 54 | @import "footer"; 55 | @import "menu"; 56 | @import "logo"; 57 | @import "intro"; 58 | @import "slide"; 59 | 60 | #footer { 61 | position: absolute; 62 | background: #000000; 63 | 64 | p.home_copyright { 65 | color: #ffffff; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /styles/main/_basicContext.extended.scss: -------------------------------------------------------------------------------- 1 | // Extended Theme -------------------------------------------------------------- // 2 | #addMenu { 3 | // positioning of add menu in upper right corner 4 | top: 48px !important; 5 | left: unset !important; 6 | right: 4px; 7 | } 8 | 9 | .basicContext { 10 | &__data { 11 | // make room for icon 12 | padding-left: 40px; 13 | } 14 | 15 | &__data .cover { 16 | position: absolute; 17 | background-color: #222; 18 | border-radius: 2px; 19 | box-shadow: 0 0 0 1px black(0.5); 20 | } 21 | 22 | &__data .title { 23 | display: inline-block; 24 | margin: 0 0 3px 26px; 25 | } 26 | 27 | &__data .iconic { 28 | display: inline-block; 29 | // shift icon into the margin 30 | margin: 0 10px 0 -22px; 31 | width: 11px; 32 | height: 10px; 33 | fill: white(1); 34 | } 35 | 36 | &__data .iconic.active { 37 | fill: $colorOrange; 38 | } 39 | 40 | &__data .iconic.ionicons { 41 | margin: 0 8px -2px 0; 42 | width: 14px; 43 | height: 14px; 44 | } 45 | 46 | // Link -------------------------------------------------------------- // 47 | &__data input#link { 48 | margin: -2px 0; 49 | padding: 5px 7px 6px; 50 | width: 100%; 51 | background: #333; 52 | color: #fff; 53 | box-shadow: 0px 1px 0px white(0.05); 54 | border: 1px solid black(0.4); 55 | border-radius: 3px; 56 | outline: none; 57 | } 58 | 59 | // No hover -------------------------------------------------------------- // 60 | &__item--noHover &__data { 61 | padding-right: 12px; 62 | } 63 | } 64 | 65 | // restrict hover features to devices that support it 66 | @media (hover: hover) { 67 | .basicContext__item--noHover:hover .basicContext__data { 68 | background: none !important; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /scripts/main/photoeditor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Takes care of every action a photoeditor can handle and execute. 3 | */ 4 | 5 | photoeditor = {}; 6 | 7 | /** 8 | * @param {string} photoID 9 | * @param {number} direction - either `1` or `-1` 10 | * @returns {void} 11 | */ 12 | photoeditor.rotate = function (photoID, direction) { 13 | api.post( 14 | "PhotoEditor::rotate", 15 | { 16 | photoID: photoID, 17 | direction: direction, 18 | }, 19 | /** @param {Photo} data */ 20 | function (data) { 21 | photo.json = data; 22 | // TODO: `photo.json.original_album_id` is set only, but never read; do we need it? 23 | photo.json.original_album_id = photo.json.album_id; 24 | if (album.json) { 25 | // 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. 26 | photo.json.album_id = album.json.id; 27 | } 28 | 29 | const image = $("img#image"); 30 | if (photo.json.size_variants.medium2x !== null) { 31 | image.prop( 32 | "srcset", 33 | `${photo.json.size_variants.medium.url} ${photo.json.size_variants.medium.width}w, ${photo.json.size_variants.medium2x.url} ${photo.json.size_variants.medium2x.width}w` 34 | ); 35 | } else { 36 | image.prop("srcset", ""); 37 | } 38 | image.prop("src", photo.json.size_variants.medium !== null ? photo.json.size_variants.medium.url : photo.json.size_variants.original.url); 39 | view.photo.onresize(); 40 | view.photo.sidebar(); 41 | album.updatePhoto(data); 42 | } 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lychee", 3 | "version": "4.0.10", 4 | "description": "Self-hosted photo-management done right.", 5 | "authors": [ 6 | "Tobias Reich ", 7 | "The Lychee Organization" 8 | ], 9 | "license": "MIT", 10 | "private": true, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/LycheeOrg/Lychee-front.git" 14 | }, 15 | "scripts": { 16 | "start": "gulp watch", 17 | "compile": "gulp", 18 | "check-formatting": "prettier --check scripts/ styles/", 19 | "format": "prettier --write scripts/ styles/" 20 | }, 21 | "engines": { 22 | "node": ">= 10" 23 | }, 24 | "dependencies": { 25 | "@lychee-org/basiccontext": "^4.0.0", 26 | "@lychee-org/basicmodal": "^4.0.1", 27 | "@lychee-org/leaflet.photo": "^1.0.0", 28 | "jquery": "^3.4.0", 29 | "justified-layout": "^4.1.0", 30 | "lazysizes": "^5.3.0", 31 | "leaflet": "^1.7.1", 32 | "leaflet-gpx": "^1.7.0", 33 | "leaflet-rotatedmarker": "^0.2.0", 34 | "leaflet.markercluster": "^1.4.1", 35 | "livephotoskit": "^1.5.6", 36 | "mousetrap": "^1.6.0", 37 | "multiselect-two-sides": "^2.5.5", 38 | "qr-creator": "^1.0.0", 39 | "resize-observer-polyfill": "^1.5.1", 40 | "sprintf-js": "^1.1.2", 41 | "stackblur-canvas": "^2.5.0" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.20.7", 45 | "@babel/preset-env": "^7.20.2", 46 | "del": "^5.1.0", 47 | "gulp": "^4.0.0", 48 | "gulp-autoprefixer": "^8.0.0", 49 | "gulp-babel": "^8.0.0", 50 | "gulp-chmod": "^3.0.0", 51 | "gulp-clean-css": "^3.10.0", 52 | "gulp-concat": "^2.6.1", 53 | "gulp-inject": "^4.2.0", 54 | "gulp-load-plugins": "^1.5.0", 55 | "gulp-sass": "^5.1.0", 56 | "gulp-uglify": "^3.0.2", 57 | "prettier": "^2.8.3", 58 | "sass": "^1.51.0", 59 | "uglify-js": "^3.12.4" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /styles/_reset.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | a, 18 | abbr, 19 | acronym, 20 | address, 21 | big, 22 | cite, 23 | code, 24 | del, 25 | dfn, 26 | em, 27 | img, 28 | ins, 29 | kbd, 30 | q, 31 | s, 32 | samp, 33 | small, 34 | strike, 35 | strong, 36 | sub, 37 | sup, 38 | tt, 39 | var, 40 | b, 41 | u, 42 | i, 43 | center, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | embed, 67 | figure, 68 | figcaption, 69 | footer, 70 | header, 71 | hgroup, 72 | menu, 73 | nav, 74 | output, 75 | ruby, 76 | section, 77 | summary, 78 | time, 79 | mark, 80 | audio, 81 | video { 82 | margin: 0; 83 | padding: 0; 84 | border: 0; 85 | font: inherit; 86 | font-size: 100%; 87 | vertical-align: baseline; 88 | } 89 | 90 | article, 91 | aside, 92 | details, 93 | figcaption, 94 | figure, 95 | footer, 96 | header, 97 | hgroup, 98 | menu, 99 | nav, 100 | section { 101 | display: block; 102 | } 103 | 104 | body { 105 | line-height: 1; 106 | } 107 | 108 | ol, 109 | ul { 110 | list-style: none; 111 | } 112 | 113 | blockquote, 114 | q { 115 | quotes: none; 116 | } 117 | 118 | blockquote:before, 119 | blockquote:after, 120 | q:before, 121 | q:after { 122 | content: ""; 123 | content: none; 124 | } 125 | 126 | table { 127 | border-collapse: collapse; 128 | border-spacing: 0; 129 | } 130 | 131 | em, 132 | i { 133 | font-style: italic; 134 | } 135 | 136 | strong, 137 | b { 138 | font-weight: bold; 139 | } 140 | -------------------------------------------------------------------------------- /styles/main/_basicContext.custom.scss: -------------------------------------------------------------------------------- 1 | // Default Theme ----------------------------------------------------- // 2 | .basicContext { 3 | padding: 5px 0 6px; 4 | background: linear-gradient(to bottom, #333, #252525); 5 | box-shadow: 0 1px 4px black(0.2), inset 0 1px 0 white(0.05); 6 | border-radius: 5px; 7 | border: 1px solid black(0.7); 8 | border-bottom: 1px solid black(0.8); 9 | transition: none; 10 | max-width: 240px; 11 | 12 | &__item { 13 | margin-bottom: 2px; 14 | font-size: 14px; 15 | color: #ccc; 16 | 17 | &--separator { 18 | margin: 4px 0; 19 | height: 2px; 20 | background: black(0.2); 21 | border-bottom: 1px solid white(0.06); 22 | } 23 | 24 | &--disabled { 25 | opacity: 0.5; 26 | } 27 | 28 | &:last-child { 29 | margin-bottom: 0; 30 | } 31 | } 32 | 33 | &__data { 34 | min-width: auto; 35 | padding: 6px 25px 7px 12px; 36 | white-space: normal; 37 | overflow-wrap: normal; 38 | 39 | @media (hover: none) and (pointer: coarse) { 40 | // increase size of menu entries for touch devices 41 | padding: 12px 25px 12px 12px; 42 | } 43 | 44 | transition: none; 45 | cursor: default; 46 | } 47 | 48 | &__item:not(.basicContext__item--disabled):active &__data { 49 | background: linear-gradient(to bottom, darken($colorBlue, 10%), darken($colorBlue, 15%)); 50 | } 51 | 52 | &__icon { 53 | margin-right: 10px; 54 | width: 12px; 55 | text-align: center; 56 | } 57 | } 58 | 59 | // restrict hover features to devices that support it 60 | @media (hover: hover) { 61 | .basicContext__item:not(.basicContext__item--disabled):hover .basicContext__data { 62 | background: linear-gradient(to bottom, $colorBlue, darken($colorBlue, 5%)); 63 | } 64 | 65 | .basicContext { 66 | /* When you mouse over the navigation links, change their color (use same color for close and menu entries) */ 67 | &__item:hover { 68 | color: lighten(white, 20%); 69 | transition: 0.3s; 70 | transform: scale(1.05); 71 | 72 | .iconic { 73 | fill: lighten(white, 20%); 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /styles/main/_dialog_import.scss: -------------------------------------------------------------------------------- 1 | .basicModal.import div.basicModal__content { 2 | padding: 12px 8px; 3 | 4 | // Title -------------------------------------------------------------- // 5 | h1 { 6 | margin-bottom: 12px; 7 | color: $colorDialogEmphasizedFg; 8 | font-size: 16px; 9 | line-height: 19px; 10 | font-weight: bold; 11 | text-align: center; 12 | } 13 | 14 | // Rows -------------------------------------------------------------- // 15 | ol { 16 | margin-top: 12px; 17 | height: 300px; 18 | background-color: $colorFormElementBg; 19 | overflow: hidden; 20 | overflow-y: auto; 21 | border-radius: 3px; 22 | box-shadow: inset 0 0 3px black(0.4); 23 | } 24 | 25 | // Row -------------------------------------------------------------- // 26 | ol li { 27 | float: left; 28 | padding: 8px 0; 29 | width: 100%; 30 | background-color: white(0.02); 31 | 32 | &:nth-child(2n) { 33 | background-color: white(0); 34 | } 35 | 36 | h2 { 37 | float: left; 38 | padding: 5px 10px; 39 | width: 70%; 40 | color: $colorDialogEmphasizedFg; 41 | font-size: 14px; 42 | white-space: nowrap; 43 | overflow: hidden; 44 | } 45 | 46 | p.status { 47 | float: left; 48 | padding: 5px 10px; 49 | width: 30%; 50 | color: $colorDialogMainButtonFont; 51 | font-size: 14px; 52 | text-align: right; 53 | 54 | animation-name: pulse; 55 | animation-duration: 2s; 56 | animation-timing-function: ease-in-out; 57 | animation-iteration-count: infinite; 58 | 59 | &.error, 60 | &.warning, 61 | &.success { 62 | animation: none; 63 | } 64 | 65 | &.error { 66 | color: $colorImportError; 67 | } 68 | 69 | &.warning { 70 | color: $colorImportWarning; 71 | } 72 | 73 | &.success { 74 | color: $colorImportSuccess; 75 | } 76 | } 77 | 78 | p.notice { 79 | float: left; 80 | padding: 2px 10px 5px; 81 | width: 100%; 82 | color: $colorDialogMainButtonFont; 83 | font-size: 12px; 84 | overflow: hidden; 85 | line-height: 16px; 86 | } 87 | 88 | p.notice:empty { 89 | display: none; 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /scripts/main/visible.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description This module is used to check if elements are visible or not. 3 | */ 4 | 5 | const visible = {}; 6 | 7 | /** 8 | * TODO: Whether the albums view is visible or not should not be determined based on the visibility of a toolbar, especially as this does not work for the photo view in full screen mode which makes this approach inconsistent. 9 | * @returns {boolean} 10 | */ 11 | visible.albums = function () { 12 | return !!header.dom("#lychee_toolbar_public").hasClass("visible") || !!header.dom("#lychee_toolbar_albums").hasClass("visible"); 13 | }; 14 | 15 | /** @returns {boolean} */ 16 | visible.album = function () { 17 | return !!header.dom("#lychee_toolbar_album").hasClass("visible"); 18 | }; 19 | 20 | /** @returns {boolean} */ 21 | visible.photo = function () { 22 | return $("#imageview.fadeIn").length > 0; 23 | }; 24 | 25 | /** @returns {boolean} */ 26 | visible.mapview = function () { 27 | return $("#lychee_map_container.fadeIn").length > 0; 28 | }; 29 | 30 | /** @returns {boolean} */ 31 | visible.config = function () { 32 | return !!header.dom("#lychee_toolbar_config").hasClass("visible"); 33 | }; 34 | 35 | /** @returns {boolean} */ 36 | visible.search = function () { 37 | return visible.albums() && album.json !== null && album.isSearchID(album.json.id); 38 | }; 39 | 40 | /** @returns {boolean} */ 41 | visible.sidebar = function () { 42 | return !!sidebar.dom().hasClass("active"); 43 | }; 44 | 45 | /** @returns {boolean} */ 46 | visible.sidebarbutton = function () { 47 | return visible.photo() || (visible.album() && $("#button_info_album:visible").length > 0); 48 | }; 49 | 50 | /** @returns {boolean} */ 51 | visible.header = function () { 52 | return !header.dom().hasClass("hidden"); 53 | }; 54 | 55 | /** @returns {boolean} */ 56 | visible.contextMenu = function () { 57 | return basicContext.visible(); 58 | }; 59 | 60 | /** @returns {boolean} */ 61 | visible.multiselect = function () { 62 | return $("#multiselect").length > 0; 63 | }; 64 | 65 | /** @returns {boolean} */ 66 | visible.leftMenu = function () { 67 | return !!leftMenu.dom().hasClass("visible"); 68 | }; 69 | -------------------------------------------------------------------------------- /styles/main/_logs_diagnostics.scss: -------------------------------------------------------------------------------- 1 | .logs_diagnostics_view { 2 | width: 90%; 3 | margin-left: auto; 4 | margin-right: auto; 5 | color: #ccc; 6 | font-size: 12px; 7 | line-height: 14px; 8 | 9 | pre { 10 | font-family: monospace; 11 | -webkit-user-select: text; 12 | -moz-user-select: text; 13 | -ms-user-select: text; 14 | user-select: text; 15 | width: fit-content; 16 | padding-right: 30px; 17 | } 18 | } 19 | 20 | .clear_logs_update { 21 | margin-left: auto; 22 | margin-right: auto; 23 | margin-bottom: 20px; 24 | margin-top: 20px; 25 | padding-left: 30px; 26 | } 27 | 28 | .clear_logs_update, 29 | .logs_diagnostics_view { 30 | .basicModal__button { 31 | //margin-top: 10px; 32 | color: #2293ec; 33 | display: inline-block; 34 | -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 35 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 36 | border-radius: 5px; 37 | } 38 | 39 | .iconic { 40 | display: inline-block; 41 | margin: 0 10px 0 1px; 42 | width: 13px; 43 | height: 12px; 44 | fill: #2293ec; 45 | } 46 | 47 | .button_left { 48 | margin-left: 24px; 49 | width: 400px; 50 | } 51 | } 52 | 53 | // on touch devices draw buttons in color 54 | @media (hover: none) { 55 | .clear_logs_update, 56 | .logs_diagnostics_view { 57 | .basicModal__button { 58 | background: #2293ec; 59 | color: #fff; 60 | max-width: 320px; 61 | margin-top: 20px; 62 | } 63 | 64 | .iconic { 65 | fill: #fff; 66 | } 67 | } 68 | } 69 | 70 | // responsive web design for smaller screens 71 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 72 | .logs_diagnostics_view, 73 | .clear_logs_update { 74 | width: 100%; 75 | max-width: 100%; 76 | font-size: 11px; 77 | line-height: 12px; 78 | 79 | .basicModal__button, 80 | .button_left { 81 | width: 80%; 82 | margin: 0 10%; 83 | } 84 | } 85 | 86 | .logs_diagnostics_view { 87 | padding: 10px 10px 0 0; 88 | } 89 | 90 | .clear_logs_update { 91 | padding: 10px 10px 0 10px; 92 | margin: 0; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /scripts/main/password.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Controls the access to password-protected albums and photos. 3 | */ 4 | 5 | const password = {}; 6 | 7 | /** 8 | * @callback UnlockSuccessCB 9 | * @returns {void} 10 | */ 11 | 12 | /** 13 | * Shows the "album unlock"-dialog, tries to unlock the album and calls 14 | * the provided callback in case of success. 15 | * 16 | * @param {string} albumID - the ID of the album which shall be unlocked 17 | * @param {UnlockSuccessCB} callback - called in case of success 18 | */ 19 | password.getDialog = function (albumID, callback) { 20 | /** @param {{password: string}} data */ 21 | const action = (data) => { 22 | const params = { 23 | albumID: albumID, 24 | password: data.password, 25 | }; 26 | 27 | api.post( 28 | "Album::unlock", 29 | params, 30 | function () { 31 | basicModal.close(false, callback); 32 | }, 33 | null, 34 | function (jqXHR, params2, lycheeException) { 35 | if ((jqXHR.status === 401 || jqXHR.status === 403) && lycheeException.message.includes("Password is invalid")) { 36 | basicModal.focusError("password"); 37 | return true; 38 | } 39 | basicModal.close(); 40 | return false; 41 | } 42 | ); 43 | }; 44 | 45 | const cancel = function () { 46 | basicModal.close(false, function () { 47 | if (!visible.albums() && !visible.album()) lychee.goto(); 48 | }); 49 | }; 50 | 51 | const enterPasswordDialogBody = ` 52 |

53 |
54 |
55 |
`; 56 | 57 | /** 58 | * @param {ModalDialogFormElements} formElements 59 | * @param {HTMLDivElement} dialog 60 | * @returns {void} 61 | */ 62 | const initEnterPasswordDialog = function (formElements, dialog) { 63 | dialog.querySelector("p").textContent = lychee.locale["ALBUM_PASSWORD_REQUIRED"]; 64 | formElements.password.placeholder = lychee.locale["PASSWORD"]; 65 | }; 66 | 67 | basicModal.show({ 68 | body: enterPasswordDialogBody, 69 | readyCB: initEnterPasswordDialog, 70 | buttons: { 71 | action: { 72 | title: lychee.locale["ENTER"], 73 | fn: action, 74 | }, 75 | cancel: { 76 | title: lychee.locale["CANCEL"], 77 | fn: cancel, 78 | }, 79 | }, 80 | }); 81 | }; 82 | -------------------------------------------------------------------------------- /scripts/main/sharing.js: -------------------------------------------------------------------------------- 1 | let sharing = { 2 | /** @type {?SharingInfo} */ 3 | json: null, 4 | }; 5 | 6 | /** 7 | * @returns {void} 8 | */ 9 | sharing.add = function () { 10 | const params = { 11 | /** @type {string[]} */ 12 | albumIDs: [], 13 | /** @type {number[]} */ 14 | userIDs: [], 15 | }; 16 | 17 | $("#albums_list_to option").each(function () { 18 | params.albumIDs.push(this.value); 19 | }); 20 | 21 | $("#user_list_to option").each(function () { 22 | params.userIDs.push(Number.parseInt(this.value, 10)); 23 | }); 24 | 25 | if (params.albumIDs.length === 0) { 26 | loadingBar.show("error", lychee.locale["ERROR_SELECT_ALBUM"]); 27 | return; 28 | } 29 | if (params.userIDs.length === 0) { 30 | loadingBar.show("error", lychee.locale["ERROR_SELECT_USER"]); 31 | return; 32 | } 33 | 34 | api.post("Sharing::add", params, function () { 35 | loadingBar.show("success", lychee.locale["SHARING_SUCCESS"]); 36 | sharing.list(); // reload user list 37 | }); 38 | }; 39 | 40 | /** 41 | * @returns {void} 42 | */ 43 | sharing.delete = function () { 44 | const params = { 45 | /** @type {number[]} */ 46 | shareIDs: [], 47 | }; 48 | 49 | $('input[name="remove_id"]:checked').each(function () { 50 | params.shareIDs.push(Number.parseInt(this.value, 10)); 51 | }); 52 | 53 | if (params.shareIDs.length === 0) { 54 | loadingBar.show("error", lychee.locale["ERROR_SELECT_SHARING"]); 55 | return; 56 | } 57 | api.post("Sharing::delete", params, function () { 58 | loadingBar.show("success", lychee.locale["SHARING_REMOVED"]); 59 | sharing.list(); // reload user list 60 | }); 61 | }; 62 | 63 | /** 64 | * Queries the backend for a list of active shares, sharable albums and users 65 | * with whom albums can be shared. 66 | * 67 | * For admin user, the query is unrestricted, for non-admin user the 68 | * query is restricted to albums which are owned by the currently 69 | * authenticated user. 70 | * The latter is required as the backend forbids unrestricted queries for 71 | * non-admin users. 72 | * 73 | * @returns {void} 74 | */ 75 | sharing.list = function () { 76 | api.post( 77 | "Sharing::list", 78 | lychee.rights.is_admin ? {} : { ownerID: lychee.user.id }, 79 | /** @param {SharingInfo} data */ 80 | function (data) { 81 | sharing.json = data; 82 | view.sharing.init(); 83 | } 84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /styles/main/_leftMenu.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The side navigation menu 3 | * The left menu is a child of the application container and shares the 4 | * horizontal space of the application container with the workbench container. 5 | * The application container uses `dispay: flex` thus making the left menu a 6 | * flex item. 7 | */ 8 | #lychee_left_menu_container { 9 | width: 0; /* per default, the left menu is closed */ 10 | background-color: #111; 11 | padding-top: 16px; /* Place content 16px from the top (same as menu bar height) */ 12 | transition: width ease 0.5s 0s; /* 0.5 second transition effect to slide in the sidenav */ 13 | height: 100%; 14 | z-index: 998; 15 | } 16 | 17 | /** 18 | * The width of actual menu must be the same as the width of the menu 19 | * container if visible. 20 | * The width of the actual menu must be constant all the time to avoid 21 | * relayouting and re-wrapping of the child elements of the menu even 22 | * if the container shrinks and grows. 23 | */ 24 | #lychee_left_menu_container.visible, 25 | #lychee_left_menu { 26 | width: 250px; 27 | } 28 | 29 | /* The navigation menu links */ 30 | #lychee_left_menu a { 31 | padding: 8px 8px 8px 32px; 32 | text-decoration: none; 33 | font-size: 18px; 34 | color: #818181; 35 | display: block; 36 | cursor: pointer; 37 | 38 | &.linkMenu { 39 | white-space: nowrap; 40 | } 41 | } 42 | 43 | #lychee_left_menu .iconic { 44 | display: inline-block; 45 | margin: 0 10px 0 1px; 46 | width: 15px; 47 | height: 14px; 48 | fill: #818181; 49 | } 50 | 51 | #lychee_left_menu .iconic.ionicons { 52 | margin: 0 8px -2px 0; 53 | width: 18px; 54 | height: 18px; 55 | } 56 | 57 | // responsive web design for smaller screens 58 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 59 | // disable left menu on small devices and use context menu instead 60 | #lychee_left_menu_container, 61 | #lychee_left_menu { 62 | position: absolute; 63 | left: 0; 64 | } 65 | 66 | #lychee_left_menu_container.visible { 67 | width: 100%; 68 | } 69 | } 70 | 71 | // restrict hover features to devices that support it 72 | @media (hover: hover) { 73 | #lychee_left_menu { 74 | /* When you mouse over the navigation links, change their color */ 75 | a:hover { 76 | color: #f1f1f1; 77 | } 78 | } 79 | } 80 | 81 | // on touch devices increase space between entries 82 | @media (hover: none) { 83 | #lychee_left_menu a { 84 | padding: 14px 8px 14px 32px; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /scripts/main/users.js: -------------------------------------------------------------------------------- 1 | const users = { 2 | /** @type {?UserDTO[]} */ 3 | json: null, 4 | }; 5 | 6 | /** 7 | * Updates a user account. 8 | * 9 | * The object `params` must be kept in sync with the HTML form constructed 10 | * by {@link build.user}. 11 | * 12 | * @param {{id: number, username: string, password: string, may_upload: boolean, may_edit_own_settings: boolean}} params 13 | * @returns {void} 14 | */ 15 | users.update = function (params) { 16 | if (params.username.length < 1) { 17 | loadingBar.show("error", lychee.locale["ERROR_EMPTY_USERNAME"]); 18 | return; 19 | } 20 | 21 | // If the password is empty, then the password shall not be changed. 22 | // In this case, the password must not be an attribute of the object at 23 | // all. 24 | // An existing, but empty password, would indicate to clear the password. 25 | if (params.password.length === 0) { 26 | delete params.password; 27 | } 28 | 29 | api.post("Users::save", params, function () { 30 | loadingBar.show("success", lychee.locale["USER_UPDATED"]); 31 | users.list(); // reload user list 32 | }); 33 | }; 34 | 35 | /** 36 | * Creates a new user account. 37 | * 38 | * The object `params` must be kept in sync with the HTML form constructed 39 | * by {@link view.users.content}. 40 | * 41 | * @param {{id: string, username: string, password: string, may_upload: boolean, may_edit_own_settings: boolean}} params 42 | * @returns {void} 43 | */ 44 | users.create = function (params) { 45 | if (params.username.length < 1) { 46 | loadingBar.show("error", lychee.locale["ERROR_EMPTY_USERNAME"]); 47 | return; 48 | } 49 | if (params.password.length < 1) { 50 | loadingBar.show("error", lychee.locale["ERROR_EMPTY_PASSWORD"]); 51 | return; 52 | } 53 | 54 | api.post("Users::create", params, function () { 55 | loadingBar.show("success", lychee.locale["USER_CREATED"]); 56 | users.list(); // reload user list 57 | }); 58 | }; 59 | 60 | /** 61 | * Deletes a user account. 62 | * 63 | * The object `params` must be kept in sync with the HTML form constructed 64 | * by {@link build.user}. 65 | * 66 | * @param {{id: number}} params 67 | * @returns {boolean} 68 | */ 69 | users.delete = function (params) { 70 | api.post("Users::delete", params, function () { 71 | loadingBar.show("success", lychee.locale["USER_DELETED"]); 72 | users.list(); // reload user list 73 | }); 74 | }; 75 | 76 | /** 77 | * @returns {void} 78 | */ 79 | users.list = function () { 80 | api.post( 81 | "Users::list", 82 | {}, 83 | /** @param {UserDTO[]} data */ 84 | function (data) { 85 | users.json = data; 86 | view.users.init(); 87 | } 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /scripts/main/swipe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Swipes and moves an object. 3 | */ 4 | 5 | const swipe = { 6 | /** @type {?jQuery} */ 7 | obj: null, 8 | /** @type {number} */ 9 | offsetX: 0, 10 | /** @type {number} */ 11 | offsetY: 0, 12 | /** @type {boolean} */ 13 | preventNextHeaderToggle: false, 14 | }; 15 | 16 | /** 17 | * @param {jQuery} obj 18 | * @returns {void} 19 | */ 20 | swipe.start = function (obj) { 21 | swipe.obj = obj; 22 | }; 23 | 24 | /** 25 | * @param {jQuery.Event} e 26 | * @returns {void} 27 | */ 28 | swipe.move = function (e) { 29 | if (swipe.obj === null) { 30 | return; 31 | } 32 | 33 | if (Math.abs(e.x) > Math.abs(e.y)) { 34 | swipe.offsetX = -1 * e.x; 35 | swipe.offsetY = 0.0; 36 | } else { 37 | swipe.offsetX = 0.0; 38 | swipe.offsetY = +1 * e.y; 39 | } 40 | 41 | const value = "translate(" + swipe.offsetX + "px, " + swipe.offsetY + "px)"; 42 | swipe.obj.css({ 43 | WebkitTransform: value, 44 | MozTransform: value, 45 | transform: value, 46 | }); 47 | }; 48 | 49 | /** 50 | * @callback SwipeStoppedCB 51 | * 52 | * Find a better name for that, but I have no idea what this callback is 53 | * supposed to do. 54 | * 55 | * @param {boolean} animate 56 | * @returns {void} 57 | */ 58 | 59 | /** 60 | * @param {{x: number, y: number, direction: number, distance: number, angle: number, speed: number, }} e 61 | * @param {SwipeStoppedCB} left 62 | * @param {SwipeStoppedCB} right 63 | * @returns {void} 64 | */ 65 | swipe.stop = function (e, left, right) { 66 | // Only execute once 67 | if (swipe.obj === null) { 68 | return; 69 | } 70 | 71 | if (e.y <= -lychee.swipe_tolerance_y) { 72 | lychee.goto(album.getID()); 73 | } else if (e.y >= lychee.swipe_tolerance_y) { 74 | lychee.goto(album.getID()); 75 | } else if (e.x <= -lychee.swipe_tolerance_x) { 76 | left(true); 77 | 78 | // 'touchend' will be called after 'swipeEnd' 79 | // in case of moving to next image, we want to skip 80 | // the toggling of the header 81 | swipe.preventNextHeaderToggle = true; 82 | } else if (e.x >= lychee.swipe_tolerance_x) { 83 | right(true); 84 | 85 | // 'touchend' will be called after 'swipeEnd' 86 | // in case of moving to next image, we want to skip 87 | // the toggling of the header 88 | swipe.preventNextHeaderToggle = true; 89 | } else { 90 | const value = "translate(0px, 0px)"; 91 | swipe.obj.css({ 92 | WebkitTransform: value, 93 | MozTransform: value, 94 | transform: value, 95 | }); 96 | } 97 | 98 | swipe.obj = null; 99 | swipe.offsetX = 0; 100 | swipe.offsetY = 0; 101 | }; 102 | -------------------------------------------------------------------------------- /scripts/main/u2f.js: -------------------------------------------------------------------------------- 1 | const u2f = { 2 | /** @type {?WebAuthnCredential[]} */ 3 | json: null, 4 | }; 5 | 6 | /** 7 | * @returns {boolean} 8 | */ 9 | u2f.is_available = function () { 10 | if (!window.isSecureContext && window.location.hostname !== "localhost" && window.location.hostname !== "127.0.0.1") { 11 | basicModal.show({ 12 | body: "

", 13 | readyCB: function (formElements, dialog) { 14 | dialog.querySelector("p").textContent = lychee.locale["U2F_NOT_SECURE"]; 15 | }, 16 | buttons: { 17 | cancel: { 18 | title: lychee.locale["CLOSE"], 19 | fn: basicModal.close, 20 | }, 21 | }, 22 | }); 23 | 24 | return false; 25 | } 26 | return true; 27 | }; 28 | 29 | /** 30 | * @returns {void} 31 | */ 32 | u2f.login = function () { 33 | if (!u2f.is_available()) { 34 | return; 35 | } 36 | 37 | new WebAuthn( 38 | { 39 | login: "/api/WebAuthn::login", 40 | loginOptions: "/api/WebAuthn::login/options", 41 | }, 42 | {}, 43 | false 44 | ) 45 | .login({ 46 | user_id: 1, // for now it is only available to Admin user via a secret key shortcut. 47 | }) 48 | .then(function () { 49 | loadingBar.show("success", lychee.locale["U2F_AUTHENTIFICATION_SUCCESS"]); 50 | window.location.reload(); 51 | }) 52 | .catch(() => loadingBar.show("error", lychee.locale["ERROR_TEXT"])); 53 | }; 54 | 55 | /** 56 | * @returns {void} 57 | */ 58 | u2f.register = function () { 59 | if (!u2f.is_available()) { 60 | return; 61 | } 62 | 63 | const webauthn = new WebAuthn( 64 | { 65 | register: "/api/WebAuthn::register", 66 | registerOptions: "/api/WebAuthn::register/options", 67 | }, 68 | {}, 69 | false 70 | ); 71 | if (WebAuthn.supportsWebAuthn()) { 72 | webauthn 73 | .register() 74 | .then(function () { 75 | loadingBar.show("success", lychee.locale["U2F_REGISTRATION_SUCCESS"]); 76 | u2f.list(); // reload credential list 77 | }) 78 | .catch(() => loadingBar.show("error", lychee.locale["ERROR_TEXT"])); 79 | } else { 80 | loadingBar.show("error", lychee.locale["U2F_NOT_SUPPORTED"]); 81 | } 82 | }; 83 | 84 | /** 85 | * @param {{id: string}} params - ID of WebAuthn credential 86 | */ 87 | u2f.delete = function (params) { 88 | api.post("WebAuthn::delete", params, function () { 89 | loadingBar.show("success", lychee.locale["U2F_CREDENTIALS_DELETED"]); 90 | u2f.list(); // reload credential list 91 | }); 92 | }; 93 | 94 | u2f.list = function () { 95 | api.post( 96 | "WebAuthn::list", 97 | {}, 98 | /** @param {WebAuthnCredential[]} data*/ 99 | function (data) { 100 | u2f.json = data; 101 | view.u2f.init(); 102 | } 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /scripts/main/loadingBar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description This module is used to show and hide the loading bar. 3 | */ 4 | 5 | const loadingBar = { 6 | /** @type {?string} */ 7 | status: null, 8 | /** @type {jQuery} */ 9 | _dom: $("#lychee_loading"), 10 | }; 11 | 12 | /** 13 | * @param {string} [selector=""] 14 | * @returns {jQuery} 15 | */ 16 | loadingBar.dom = function (selector) { 17 | if (selector == null || selector === "") return loadingBar._dom; 18 | return loadingBar._dom.find(selector); 19 | }; 20 | 21 | /** 22 | * @param {?string} [status=null] the status, either `null`, `"error"` or `"success"` 23 | * @param {?string} [errorText=null] the error text to show 24 | * @returns {void} 25 | */ 26 | loadingBar.show = function (status = null, errorText = null) { 27 | if (status === "error") { 28 | // Set status 29 | loadingBar.status = "error"; 30 | 31 | // Parse text 32 | if (errorText) errorText = errorText.replace("
", ""); 33 | if (!errorText) errorText = lychee.locale["ERROR_TEXT"]; 34 | 35 | // Modify loading 36 | loadingBar 37 | .dom() 38 | .removeClass() 39 | .html(`

` + lychee.locale["ERROR"] + `: ${errorText}

`) 40 | .addClass(status); 41 | 42 | // Set timeout 43 | clearTimeout(loadingBar._timeout); 44 | loadingBar._timeout = setTimeout(() => loadingBar.hide(true), 3000); 45 | 46 | return; 47 | } 48 | 49 | if (status === "success") { 50 | // Set status 51 | loadingBar.status = "success"; 52 | 53 | // Parse text 54 | if (errorText) errorText = errorText.replace("
", ""); 55 | if (!errorText) errorText = lychee.locale["ERROR_TEXT"]; 56 | 57 | // Modify loading 58 | loadingBar 59 | .dom() 60 | .removeClass() 61 | .html(`

` + lychee.locale["SUCCESS"] + `: ${errorText}

`) 62 | .addClass(status); 63 | 64 | // Set timeout 65 | clearTimeout(loadingBar._timeout); 66 | loadingBar._timeout = setTimeout(() => loadingBar.hide(true), 2000); 67 | 68 | return; 69 | } 70 | 71 | if (loadingBar.status === null) { 72 | // Set status 73 | loadingBar.status = lychee.locale["LOADING"]; 74 | 75 | // Set timeout 76 | clearTimeout(loadingBar._timeout); 77 | loadingBar._timeout = setTimeout(() => { 78 | // Modify loading 79 | loadingBar.dom().removeClass().html("").addClass("loading"); 80 | }, 1000); 81 | } 82 | }; 83 | 84 | /** 85 | * @param {boolean} force 86 | * @returns {void} 87 | */ 88 | loadingBar.hide = function (force) { 89 | if ((loadingBar.status !== "error" && loadingBar.status !== "success" && loadingBar.status != null) || force) { 90 | // Remove status 91 | loadingBar.status = null; 92 | 93 | // Also move up the dark background 94 | $(".basicModalContainer").removeClass("basicModalContainer--error"); 95 | $(".basicModal").removeClass("basicModal--error"); 96 | 97 | // Set timeout 98 | clearTimeout(loadingBar._timeout); 99 | setTimeout(() => loadingBar.dom().removeClass(), 300); 100 | } 101 | }; 102 | -------------------------------------------------------------------------------- /images/ionicons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /scripts/3rd-party/dropbox.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The Dropbox JS component. 3 | * 4 | * It is "dynamically" loaded by {@link lychee.loadDropbox}. 5 | * See: 6 | * 7 | * - {@link https://www.dropbox.com/developers/documentation} 8 | * - {@link https://www.dropbox.com/developers/chooser} 9 | * 10 | * @namespace Dropbox 11 | */ 12 | 13 | /** 14 | * Shows the Dropbox Chooser component and allows the user to pick files. 15 | * 16 | * @function choose 17 | * @param {DropboxChooserOptions} options 18 | * @memberOf Dropbox 19 | */ 20 | 21 | /** 22 | * See {@link https://www.dropbox.com/developers/chooser}. 23 | * 24 | * @typedef DropboxChooserOptions 25 | * @property {DropboxChooserSuccessCB} success Called when a user has selected files. 26 | * @property {DropboxChooserCancelCB} [cancel] Called when the user cancels the 27 | * chooser without having selected 28 | * files. 29 | * @property {string} [linkType=preview] `"preview"` (default) is a preview link 30 | * to the document for sharing, `"direct"` 31 | * is an expiring link to download the 32 | * contents of the file. 33 | * @property {boolean} [multiselect=false] A value of `false` (default) limits 34 | * selection to a single file, while 35 | * `true` enables multiple file selection. 36 | * @property {string[]} [extensions] a list of file extensions which the 37 | * user is able to select 38 | * @property {boolean} [folderselect=false] determines whether the user is able to 39 | * select folders, too 40 | * @property {number} [sizeLimit] a limit on the size of each file which 41 | * may be selected 42 | */ 43 | 44 | /** 45 | * Callback if users have successfully selected files from their Dropbox. 46 | * 47 | * @callback DropboxChooserSuccessCB 48 | * @param {DropboxFile[]} files 49 | * @returns {void} 50 | */ 51 | 52 | /** 53 | * Callback if users cancelled selecting files from their Dropbox. 54 | * 55 | * @callback DropboxChooserCancelCB 56 | * @returns {void} 57 | */ 58 | 59 | /** 60 | * @typedef DropboxFile 61 | * @property {string} id unique ID 62 | * @property {string} name name of the file, e.g. `"filename.txt`" 63 | * @property {string} link URL to access the file 64 | * @property {number} bytes size of file in bytes 65 | * @property {string} icon URL to a 64x64px icon based on the file type 66 | * @property {string} [thumbnailLink] a thumbnail link for image and video files 67 | * @property {boolean} isDir indicates whether the file is actually a directory 68 | */ 69 | -------------------------------------------------------------------------------- /scripts/main/_swipe.jquery.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | const Swipe = function (el) { 3 | const self = this; 4 | 5 | this.el = $(el); 6 | this.pos = { start: { x: 0, y: 0 }, end: { x: 0, y: 0 } }; 7 | this.startTime = null; 8 | 9 | el.on("touchstart", function (e) { 10 | self.touchStart(e); 11 | }); 12 | el.on("touchmove", function (e) { 13 | self.touchMove(e); 14 | }); 15 | el.on("touchend", function () { 16 | self.swipeEnd(); 17 | }); 18 | el.on("mousedown", function (e) { 19 | self.mouseDown(e); 20 | }); 21 | }; 22 | 23 | Swipe.prototype = { 24 | touchStart: function (e) { 25 | const touch = e.originalEvent.touches[0]; 26 | 27 | this.swipeStart(e, touch.pageX, touch.pageY); 28 | }, 29 | 30 | touchMove: function (e) { 31 | const touch = e.originalEvent.touches[0]; 32 | 33 | this.swipeMove(e, touch.pageX, touch.pageY); 34 | }, 35 | 36 | mouseDown: function (e) { 37 | const self = this; 38 | 39 | this.swipeStart(e, e.pageX, e.pageY); 40 | 41 | this.el.on("mousemove", function (_e) { 42 | self.mouseMove(_e); 43 | }); 44 | this.el.on("mouseup", function () { 45 | self.mouseUp(); 46 | }); 47 | }, 48 | 49 | mouseMove: function (e) { 50 | this.swipeMove(e, e.pageX, e.pageY); 51 | }, 52 | 53 | mouseUp: function (e) { 54 | this.swipeEnd(e); 55 | 56 | this.el.off("mousemove"); 57 | this.el.off("mouseup"); 58 | }, 59 | 60 | swipeStart: function (e, x, y) { 61 | this.pos.start.x = x; 62 | this.pos.start.y = y; 63 | this.pos.end.x = x; 64 | this.pos.end.y = y; 65 | 66 | this.startTime = new Date().getTime(); 67 | 68 | this.trigger("swipeStart", e); 69 | }, 70 | 71 | swipeMove: function (e, x, y) { 72 | this.pos.end.x = x; 73 | this.pos.end.y = y; 74 | 75 | this.trigger("swipeMove", e); 76 | }, 77 | 78 | swipeEnd: function (e) { 79 | this.trigger("swipeEnd", e); 80 | }, 81 | 82 | trigger: function (e, originalEvent) { 83 | let self = this; 84 | 85 | let event = $.Event(e), 86 | x = self.pos.start.x - self.pos.end.x, 87 | y = self.pos.end.y - self.pos.start.y, 88 | radians = Math.atan2(y, x), 89 | direction = "up", 90 | distance = Math.round(Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2))), 91 | angle = Math.round((radians * 180) / Math.PI), 92 | speed = Math.round((distance / (new Date().getTime() - self.startTime)) * 1000); 93 | 94 | if (angle < 0) { 95 | angle = 360 - Math.abs(angle); 96 | } 97 | 98 | if ((angle <= 45 && angle >= 0) || (angle <= 360 && angle >= 315)) { 99 | direction = "left"; 100 | } else if (angle >= 135 && angle <= 225) { 101 | direction = "right"; 102 | } else if (angle > 45 && angle < 135) { 103 | direction = "down"; 104 | } 105 | 106 | event.originalEvent = originalEvent; 107 | 108 | event.swipe = { 109 | x: x, 110 | y: y, 111 | direction: direction, 112 | distance: distance, 113 | angle: angle, 114 | speed: speed, 115 | }; 116 | 117 | $(self.el).trigger(event); 118 | }, 119 | }; 120 | 121 | $.fn.swipe = function () { 122 | // let swipe = new Swipe(this); 123 | new Swipe(this); 124 | 125 | return this; 126 | }; 127 | })(jQuery); 128 | -------------------------------------------------------------------------------- /styles/main/_basicModal.custom.scss: -------------------------------------------------------------------------------- 1 | div.basicModalContainer { 2 | background-color: $colorDialogContainerBg; 3 | z-index: 999; 4 | 5 | &--error { 6 | transform: translateY(40px); 7 | } 8 | } 9 | 10 | div.basicModal { 11 | background: $colorDialogBg; 12 | box-shadow: 0 1px 4px black(0.2), inset 0 1px 0 white(0.05); 13 | 14 | font-size: 14px; 15 | // Most browser use a default line height roughly about 120%. 16 | // This yields 1.2 * 14px = 16.8px and makes it difficult to align 17 | // certain elements (e.g. checkboxes) "pixel-perfect" due to annoying 18 | // rounding issue. 19 | // So we enforce an integer line height here. 20 | line-height: 17px; 21 | 22 | &--error { 23 | transform: translateY(-40px); 24 | } 25 | } 26 | 27 | div.basicModal__buttons { 28 | box-shadow: none; 29 | } 30 | 31 | .basicModal__button { 32 | padding: 13px 0 15px; 33 | background: transparent; 34 | color: $colorDialogMainButtonFont; 35 | border-top: 1px solid black(0.2); 36 | box-shadow: inset 0 1px 0 white(0.02); 37 | cursor: default; 38 | 39 | &:active, 40 | &--busy { 41 | transition: none; 42 | background: black(0.1); 43 | cursor: wait; 44 | } 45 | 46 | &#basicModal__action { 47 | color: $colorDialogMainActionButtonFont; 48 | box-shadow: inset 0 1px 0 white(0.02), inset 1px 0 0 black(0.2); 49 | } 50 | 51 | &#basicModal__action.red, 52 | &#basicModal__cancel.red { 53 | color: $colorDialogMainButtonWarningFont; 54 | } 55 | 56 | &.hidden { 57 | display: none; 58 | } 59 | } 60 | 61 | // restrict hover features to devices that support it 62 | @media (hover: hover) { 63 | .basicModal__button:hover { 64 | background: white(0.02); 65 | } 66 | } 67 | 68 | div.basicModal__content { 69 | padding: 36px; 70 | color: $colorDialogDefaultFg; 71 | text-align: left; 72 | 73 | // the expected elements of a modal dialog are either: p, hr, form 74 | > * { 75 | display: block; 76 | width: 100%; 77 | margin: 24px 0; 78 | padding: 0; 79 | 80 | &:first-child, 81 | &.force-first-child { 82 | margin-top: 0; 83 | } 84 | 85 | &:last-child, 86 | &.force-last-child { 87 | margin-bottom: 0; 88 | } 89 | } 90 | 91 | .disabled { 92 | color: $colorDialogDisabledFg; 93 | } 94 | 95 | b { 96 | font-weight: bold; 97 | color: $colorDialogEmphasizedFg; 98 | } 99 | 100 | a { 101 | color: inherit; 102 | text-decoration: none; 103 | border-bottom: 1px dashed $colorDialogDefaultFg; 104 | } 105 | 106 | a.button { 107 | display: inline-block; 108 | margin: 0 6px; 109 | padding: 3px 12px; 110 | color: $colorFormElementAccent; 111 | text-align: center; 112 | border-radius: 5px; 113 | border: none; 114 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 115 | 116 | .iconic { 117 | fill: $colorFormElementAccent; 118 | } 119 | } 120 | 121 | > hr { 122 | border: none; 123 | border-top: 1px solid black(0.3); 124 | } 125 | } 126 | 127 | // restrict hover features to devices that support it 128 | @media (hover: hover) { 129 | div.basicModal__content a.button:hover { 130 | color: $colorDialogEmphasizedFg; 131 | background: $colorFormElementAccent; 132 | 133 | .iconic { 134 | fill: $colorDialogEmphasizedFg; 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at lychee@electerious.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /styles/main/main.scss: -------------------------------------------------------------------------------- 1 | // Functions --------------------------------------------------------------- // 2 | @function black($opacity) { 3 | @return rgba(0, 0, 0, $opacity); 4 | } 5 | 6 | @function white($opacity) { 7 | @return rgba(255, 255, 255, $opacity); 8 | } 9 | 10 | // Properties -------------------------------------------------------------- // 11 | $shadow: 0 -1px 0 black(0.2); 12 | // Colors ------------------------------------------------------------------ // 13 | $colorBlue: #2293ec; 14 | $colorRed: #d92c34; 15 | $colorPink: #ff82ee; 16 | $colorGreen: #00aa00; 17 | $colorYellow: #ffcc00; 18 | $colorOrange: #ff9900; 19 | $colorBlack: #000000; 20 | $colorDarkAnthracite: #1d1d1d; 21 | 22 | $colorDialogContainerBg: black(0.85); 23 | $colorAppBg: $colorDarkAnthracite; 24 | $colorAppBgFullMode: $colorBlack; 25 | $colorDialogBg: linear-gradient(to bottom, #444, #333); 26 | $colorDialogMainButtonFont: #999999; 27 | $colorDialogMainActionButtonFont: $colorBlue; 28 | $colorDialogMainButtonWarningFont: $colorRed; 29 | $colorDialogDisabledFg: #999999; 30 | $colorDialogDefaultFg: #ececec; 31 | $colorDialogEmphasizedFg: #ffffff; 32 | $colorFormElementFg: #ffffff; 33 | $colorFormElementBg: #2c2c2c; 34 | $colorFormElementAccent: $colorBlue; 35 | $colorFormElementError: $colorRed; 36 | 37 | $colorImportError: $colorRed; 38 | $colorImportWarning: $colorYellow; 39 | $colorImportSuccess: $colorGreen; 40 | 41 | // Animations -------------------------------------------------------------- // 42 | $timing: cubic-bezier(0.51, 0.92, 0.24, 1); 43 | $timingBounce: cubic-bezier(0.51, 0.92, 0.24, 1.15); 44 | // Rest -------------------------------------------------------------------- // 45 | @import "../reset"; 46 | * { 47 | -webkit-user-select: none; 48 | -moz-user-select: none; 49 | -ms-user-select: none; 50 | user-select: none; 51 | transition: color 0.3s, opacity 0.3s ease-out, transform 0.3s ease-out, box-shadow 0.3s; 52 | } 53 | 54 | html, 55 | body { 56 | width: 100%; 57 | height: 100%; 58 | position: relative; 59 | overflow: clip; 60 | } 61 | 62 | body { 63 | background-color: $colorAppBg; 64 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 65 | font-size: 12px; 66 | -webkit-font-smoothing: antialiased; 67 | -moz-font-smoothing: antialiased; 68 | -moz-osx-font-smoothing: grayscale; 69 | } 70 | 71 | body.mode-frame, 72 | body.mode-none { 73 | div#container { 74 | display: none; 75 | } 76 | } 77 | 78 | input, 79 | textarea { 80 | -webkit-user-select: text !important; 81 | -moz-user-select: text !important; 82 | -ms-user-select: text !important; 83 | user-select: text !important; 84 | } 85 | 86 | .svgsprite { 87 | display: none; 88 | } 89 | 90 | .iconic { 91 | width: 100%; 92 | height: 100%; 93 | } 94 | 95 | #upload { 96 | display: none; 97 | } 98 | 99 | // Files ------------------------------------------------------------------- // 100 | @import "animations"; 101 | @import "application_container"; 102 | @import "container"; 103 | @import "content"; 104 | @import "frame"; 105 | @import "leftMenu"; 106 | @import "basicContext.custom"; 107 | @import "basicContext.extended"; 108 | @import "basicModal.custom"; 109 | @import "header"; 110 | @import "imageview"; 111 | @import "mapview"; 112 | @import "sidebar"; 113 | @import "loading"; 114 | @import "form"; 115 | @import "dialog_about"; 116 | @import "dialog_downloads"; 117 | @import "dialog_import"; 118 | @import "dialog_login"; 119 | @import "dialog_photo_links"; 120 | @import "dialog_token"; 121 | @import "warning"; 122 | @import "settings"; 123 | @import "users"; 124 | @import "u2f"; 125 | @import "logs_diagnostics"; 126 | @import "sharing"; 127 | @import "multiselect"; 128 | @import "justified_layout"; 129 | @import "footer"; 130 | @import "social-footer"; 131 | @import "tv"; 132 | @import "view_container"; 133 | @import "workbench_container"; 134 | -------------------------------------------------------------------------------- /styles/main/_imageview.scss: -------------------------------------------------------------------------------- 1 | #imageview { 2 | // ImageView -------------------------------------------------------------- // 3 | #image, 4 | #livephoto { 5 | position: absolute; 6 | top: 30px; 7 | right: 30px; 8 | bottom: 30px; 9 | left: 30px; 10 | margin: auto; 11 | max-width: calc(100% - 60px); 12 | max-height: calc(100% - 60px); 13 | width: auto; 14 | height: auto; 15 | transition: top 0.3s, right 0.3s, bottom 0.3s, left 0.3s, max-width 0.3s, max-height 0.3s; 16 | 17 | animation-name: zoomIn; 18 | animation-duration: 0.3s; 19 | animation-timing-function: $timingBounce; 20 | background-size: contain; 21 | background-position: center; 22 | background-repeat: no-repeat; 23 | } 24 | 25 | &.full #image, 26 | &.full #livephoto { 27 | top: 0; 28 | right: 0; 29 | bottom: 0; 30 | left: 0; 31 | max-width: 100%; 32 | max-height: 100%; 33 | } 34 | 35 | #image_overlay { 36 | position: absolute; 37 | bottom: 30px; 38 | left: 30px; 39 | color: #ffffff; 40 | text-shadow: 1px 1px 2px #000000; 41 | z-index: 3; 42 | 43 | h1 { 44 | font-size: 28px; 45 | font-weight: 500; 46 | transition: visibility 0.3s linear, opacity 0.3s linear; 47 | } 48 | 49 | p { 50 | margin-top: 5px; 51 | font-size: 20px; 52 | line-height: 24px; 53 | } 54 | 55 | a .iconic { 56 | fill: #fff; 57 | margin: 0 5px 0 0; 58 | width: 14px; 59 | height: 14px; 60 | } 61 | } 62 | 63 | // Previous/Next Buttons -------------------------------------------------------------- // 64 | .arrow_wrapper { 65 | position: absolute; 66 | width: 15%; 67 | height: calc(100% - 60px); 68 | top: 60px; 69 | 70 | &--previous { 71 | left: 0; 72 | } 73 | 74 | &--next { 75 | right: 0; 76 | } 77 | 78 | a { 79 | position: absolute; 80 | top: 50%; 81 | margin: -19px 0 0; 82 | padding: 8px 12px; 83 | width: 16px; 84 | height: 22px; 85 | // The background-image will be styled dynamically via JS 86 | // background-image: linear-gradient(to bottom, rgba(0, 0, 0, .4), rgba(0, 0, 0, .4)), url(''); 87 | background-size: 100% 100%; 88 | border: 1px solid white(0.8); 89 | opacity: 0.6; 90 | z-index: 2; 91 | transition: transform 0.2s ease-out, opacity 0.2s ease-out; 92 | will-change: transform; 93 | 94 | &#previous { 95 | left: -1px; 96 | transform: translateX(-100%); 97 | } 98 | 99 | &#next { 100 | right: -1px; 101 | transform: translateX(100%); 102 | } 103 | } 104 | 105 | .iconic { 106 | fill: white(0.8); 107 | } 108 | } 109 | 110 | // We must not allow the wide next/prev arrow wrappers to cover the 111 | // on-screen buttons in videos. This is imperfect as now the video 112 | // covers part of the background image. 113 | video { 114 | z-index: 1; 115 | } 116 | } 117 | 118 | // restrict hover features to devices that support it 119 | @media (hover: hover) { 120 | #imageview .arrow_wrapper { 121 | &:hover a#previous, 122 | &:hover a#next { 123 | transform: translateX(0); 124 | } 125 | 126 | a:hover { 127 | opacity: 1; 128 | } 129 | } 130 | } 131 | 132 | // responsive web design for smaller screens 133 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 134 | // sidebar as overlay, small size 135 | #imageview { 136 | #image, 137 | #livephoto { 138 | top: 0; 139 | right: 0; 140 | bottom: 0; 141 | left: 0; 142 | max-width: 100%; 143 | max-height: 100%; 144 | } 145 | 146 | #image_overlay { 147 | h1 { 148 | font-size: 14px; 149 | } 150 | 151 | p { 152 | margin-top: 2px; 153 | font-size: 11px; 154 | line-height: 13px; 155 | } 156 | 157 | a .iconic { 158 | width: 9px; 159 | height: 9px; 160 | } 161 | } 162 | } 163 | } 164 | 165 | @media only screen and (min-width: 568px) and (max-width: 768px), 166 | only screen and (min-width: 568px) and (max-width: 640px) and (orientation: landscape) { 167 | // sidebar on side, medium size 168 | #imageview { 169 | #image, 170 | #livephoto { 171 | top: 0; 172 | right: 0; 173 | bottom: 0; 174 | left: 0; 175 | max-width: 100%; 176 | max-height: 100%; 177 | } 178 | 179 | #image_overlay { 180 | h1 { 181 | font-size: 18px; 182 | } 183 | 184 | p { 185 | margin-top: 4px; 186 | font-size: 14px; 187 | line-height: 16px; 188 | } 189 | 190 | a .iconic { 191 | width: 12px; 192 | height: 12px; 193 | } 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /scripts/main/tabindex.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Helper class to manage tabindex 3 | */ 4 | 5 | const tabindex = { 6 | offset_for_header: 100, 7 | next_tab_index: 100, 8 | }; 9 | 10 | /** 11 | * @param {jQuery} elem 12 | * @returns {void} 13 | */ 14 | tabindex.saveSettings = function (elem) { 15 | if (!lychee.enable_tabindex) return; 16 | 17 | // Todo: Make shorter notation 18 | // Get all elements which have a tabindex 19 | // TODO @Hallenser: What did you intended by the TODO above? It seems as if the jQuery selector is already as short as possible? 20 | const tmp = elem.find("[tabindex]"); 21 | 22 | // iterate over all elements and set tabindex to stored value (i.e. make is not focusable) 23 | tmp.each( 24 | /** 25 | * @param {number} i - the index 26 | * @param {Element} e - the HTML element 27 | * @this {Element} - identical to `e` 28 | */ 29 | function (i, e) { 30 | // TODO: shorter notation 31 | // 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. 32 | const a = $(e).attr("tabindex"); 33 | $(this).data("tabindex-saved", a); 34 | } 35 | ); 36 | }; 37 | 38 | tabindex.restoreSettings = function (elem) { 39 | if (!lychee.enable_tabindex) return; 40 | 41 | // Todo: Make shorter notation 42 | // Get all elements which have a tabindex 43 | // TODO @Hallenser: What did you intended by the TODO above? It seems as if the jQuery selector is already as short as possible? 44 | const tmp = $(elem).find("[tabindex]"); 45 | 46 | // iterate over all elements and set tabindex to stored value (i.e. make is not focussable) 47 | tmp.each( 48 | /** 49 | * @param {number} i - the index 50 | * @param {Element} e - the HTML element 51 | * @this {Element} - identical to `e` 52 | */ 53 | function (i, e) { 54 | // TODO: shorter notation 55 | // 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. 56 | const a = $(e).data("tabindex-saved"); 57 | $(e).attr("tabindex", a); 58 | } 59 | ); 60 | }; 61 | 62 | /** 63 | * @param {jQuery} elem 64 | * @param {boolean} [saveFocusElement=false] 65 | * @returns {void} 66 | */ 67 | tabindex.makeUnfocusable = function (elem, saveFocusElement = false) { 68 | if (!lychee.enable_tabindex) return; 69 | 70 | // Todo: Make shorter notation 71 | // Get all elements which have a tabindex 72 | const tmp = elem.find("[tabindex]"); 73 | 74 | // iterate over all elements and set tabindex to -1 (i.e. make is not focussable) 75 | tmp.each( 76 | /** 77 | * @param {number} i - the index 78 | * @param {Element} e - the HTML element 79 | */ 80 | function (i, e) { 81 | $(e).attr("tabindex", "-1"); 82 | // Save which element had focus before we make it unfocusable 83 | if (saveFocusElement && $(e).is(":focus")) { 84 | $(e).data("tabindex-focus", true); 85 | // Remove focus 86 | $(e).blur(); 87 | } 88 | } 89 | ); 90 | 91 | // Disable input fields 92 | elem.find("input").attr("disabled", "disabled"); 93 | }; 94 | 95 | /** 96 | * @param {jQuery} elem 97 | * @param {boolean} [restoreFocusElement=false] 98 | * @returns {void} 99 | */ 100 | tabindex.makeFocusable = function (elem, restoreFocusElement = false) { 101 | if (!lychee.enable_tabindex) return; 102 | 103 | // Todo: Make shorter notation 104 | // Get all elements which have a tabindex 105 | const tmp = elem.find("[data-tabindex]"); 106 | 107 | // iterate over all elements and set tabindex to stored value 108 | tmp.each( 109 | /** 110 | * @param {number} i 111 | * @param {Element} e 112 | */ 113 | function (i, e) { 114 | $(e).attr("tabindex", $(e).data("tabindex")); 115 | // restore focus element if wanted 116 | if (restoreFocusElement) { 117 | if ($(e).data("tabindex-focus") && lychee.active_focus_on_page_load) { 118 | $(e).focus(); 119 | $(e).removeData("tabindex-focus"); 120 | } 121 | } 122 | } 123 | ); 124 | 125 | // Enable input fields 126 | elem.find("input").removeAttr("disabled"); 127 | }; 128 | 129 | /** 130 | * @returns {number} 131 | */ 132 | tabindex.get_next_tab_index = function () { 133 | tabindex.next_tab_index = tabindex.next_tab_index + 1; 134 | 135 | return tabindex.next_tab_index - 1; 136 | }; 137 | 138 | /** 139 | * @returns {void} 140 | */ 141 | tabindex.reset = function () { 142 | tabindex.next_tab_index = tabindex.offset_for_header; 143 | }; 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lychee-front 2 | 3 | ## With the release of Lychee v5 we moved to a full TALL stack (Tailwind, Alpine, Livewire, Laravel). As a consequence this repository is now READ ONLY. 4 | 5 | **This repository contains the source of the JS frontend in order to allow its use with different backends.** 6 | 7 | [![Build Status](https://github.com/LycheeOrg/Lychee-front/workflows/Node.js%20CI/badge.svg?branch=master)](https://github.com/LycheeOrg/Lychee-front/actions?query=workflow%3A%22Node.js+CI%22) 8 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=LycheeOrg_Lychee-front&metric=alert_status)](https://sonarcloud.io/dashboard?id=LycheeOrg_Lychee-front) 9 | 10 | #### A great looking and easy-to-use photo-management-system. 11 | 12 | _Since the 1st of April 2018 this project has moved to it's own Organisation (https://github.com/LycheeOrg) where people are able to submit their fixes to it. We, the Organisation owners, want to thank electerious (Tobias Reich) for the opportunity to make this project live on._ 13 | 14 | ![Lychee](https://camo.githubusercontent.com/b9010f02c634219795950e034f511f4cf4af5c60/68747470733a2f2f732e656c6563746572696f75732e636f6d2f696d616765732f6c79636865652f312e6a706567) 15 | ![Lychee](https://camo.githubusercontent.com/5484591f0b15b6ba27d4845b292cc5d3a988b3b9/68747470733a2f2f732e656c6563746572696f75732e636f6d2f696d616765732f6c79636865652f322e6a706567) 16 | 17 | Lychee is a free photo-management tool, which runs on your server or web-space. Installing is a matter of seconds. Upload, manage and share photos like from a native application. Lychee comes with everything you need and all your photos are stored securely. Read more on our [Website](https://LycheeOrg.github.io). 18 | 19 | ## Installation 20 | 21 | To run Lychee, everything you need is a web-server with PHP 5.5 or later and a MySQL-Database. Follow the instructions to install Lychee on your server. [Installation »](https://github.com/LycheeOrg/Lychee/wiki/Installation) 22 | 23 | ## API 24 | 25 | The frontend send POST requests to the server through. Calls are described in [API »](API.md). 26 | 27 | ## How to build 28 | 29 | If you want to contribute and edit CSS or JS files, you need to rebuild Lychee. [Build »](https://github.com/LycheeOrg/Lychee/wiki/Build) 30 | 31 | ```sh 32 | # Clone Lychee 33 | git clone https://github.com/LycheeOrg/Lychee.git 34 | 35 | # Initialize the frontend submodule 36 | git submodule init 37 | 38 | # Get the frontend 39 | git submodule update 40 | 41 | # Go into the frontend 42 | cd Lychee-front 43 | ``` 44 | 45 | ### Dependencies 46 | 47 | First you have to install the following dependencies: 48 | 49 | - `node` [Node.js](http://nodejs.org) v10.0.0 or later 50 | - `npm` [Node Packaged Modules](https://www.npmjs.org) 51 | 52 | After [installing Node.js](http://nodejs.org) you can use the included `npm` package manager to download all dependencies: 53 | 54 | ```sh 55 | npm install 56 | ``` 57 | 58 | ### Build and Generated Files 59 | 60 | The Gulpfile is located in `/Lychee-front/` and can be executed using the `npm run compile` command. 61 | The generated files will placed into `../dist/` or `/dist/`. 62 | 63 | ### :warning: Style formatting 64 | 65 | Before submitting a pull request, please apply our formatting rules by executing: 66 | 67 | ```sh 68 | npm run format 69 | ``` 70 | 71 | You can also just incorporate a git hook: `.git/hooks/pre-commit` 72 | 73 | ```sh 74 | #!/bin/sh 75 | NO_COLOR="\033[0m" 76 | GREEN="\033[38;5;010m" 77 | YELLOW="\033[38;5;011m" 78 | 79 | printf "\n${GREEN}pre commit hook start${NO_COLOR}\n" 80 | 81 | PRETTIER="./node_modules/prettier/bin-prettier.js" 82 | 83 | if [ -x "$PRETTIER" ]; then 84 | git status --porcelain | grep -e '^[AM]\(.*\).php$' | cut -c 3- | while read line; do 85 | ${PRETTIER} --write ${line}; 86 | git add "$line"; 87 | done 88 | else 89 | echo "" 90 | printf "${YELLOW}Please install prettier, e.g.:${NO_COLOR}" 91 | echo "" 92 | echo " npm install" 93 | echo "" 94 | fi 95 | 96 | printf "\n${GREEN}pre commit hook finish${NO_COLOR}\n" 97 | ``` 98 | 99 | This can easily be installed by doing: 100 | 101 | ```sh 102 | cp pre-commit ../../.git/modules/public/Lychee-front/hooks 103 | chmod 755 ../../.git/modules/public/Lychee-front/hooks/pre-commit 104 | ``` 105 | 106 | ### Watch for changes 107 | 108 | While developing, you might want to use the following command to automatically build Lychee everytime you save a file: 109 | 110 | ```sh 111 | npm start 112 | ``` 113 | -------------------------------------------------------------------------------- /styles/main/_container.scss: -------------------------------------------------------------------------------- 1 | .vflex-container, 2 | .hflex-container, 3 | .vflex-item-stretch, 4 | .hflex-item-stretch, 5 | .vflex-item-rigid, 6 | .hflex-item-rigid { 7 | /* 8 | * Relative positioning is required in order to make the element a 9 | * "positioned" element such that coordinates of children are relative to 10 | * this. 11 | */ 12 | position: relative; 13 | /* 14 | * Content of all flex containers and items is clipped in both directions 15 | * by default. 16 | * (CSS default is "overflow"). 17 | * Overflowing content is likely to interfere badly with other parts of 18 | * the layout in an undesired way. 19 | * Even if overflowing content accidentally did not disturb the layout, 20 | * it would be considered a programming error and by clipping the content 21 | * we are able to spot these issues more easily. 22 | * If content is expected to be larger than its parent (e.g. the 23 | * main view area), then the respective element has to explicitly 24 | * provide scrollbars by a CSS rule with higher specificity. 25 | */ 26 | overflow: clip; 27 | } 28 | 29 | .vflex-container, 30 | .hflex-container { 31 | display: flex; 32 | align-content: stretch; 33 | gap: 0 0; 34 | } 35 | 36 | .vflex-container { 37 | flex-direction: column; 38 | } 39 | 40 | .hflex-container { 41 | flex-direction: row; 42 | } 43 | 44 | .vflex-item-stretch, 45 | .hflex-item-stretch { 46 | flex: auto; 47 | } 48 | 49 | .hflex-item-stretch { 50 | /** 51 | * Although the flex item is flexible in the horizontal direction, and 52 | * is allowed to grow **as well as shrink**, we must explicitly assign 53 | * a zero width to it and then let it grow. 54 | * 55 | * Firefox only considers the width of this item as a parent element, 56 | * if it has a "definite" size and Firefox uses an explicit defined 57 | * width to decide this. 58 | * If the size is not definite (i.e. keeping an implicit, `width: auto`), 59 | * then Firefox won't shrink the box below the natural size of its 60 | * children, even if the element as a parent box is scrollable and thus 61 | * _safely shrinkable_. 62 | * 63 | * However, with an explicitly set size (even if it is pointless), 64 | * the layout works as expected for Chromium-based browsers (Chrome, 65 | * modern Edge), Gecko-based browsers (Firefox) and Webkit-based browsers 66 | * (Safari, KDE Falkon, ...). 67 | * 68 | * It is unclear, whether Gecko or Chromium is wrong here or if it is 69 | * simply an under-specified oversight in the specs. 70 | * See: 71 | * 72 | * - [On Cross Size Determination](https://www.w3.org/TR/css-flexbox-1/#cross-sizing) 73 | * - [Definite and Indefinite Sizes](https://www.w3.org/TR/css-flexbox-1/#definite-sizes) 74 | * - [Automatic Minimum Size of Flex Items](https://www.w3.org/TR/css-flexbox-1/#min-size-auto) 75 | * - [Overflow Alignment: the safe and unsafe keywords and scroll safety limits](https://www.w3.org/TR/css-align-3/#overflow-values) 76 | * - https://stackoverflow.com/a/74075987/2690527 77 | */ 78 | width: 0; 79 | /* 80 | * We want all flex items to fill out their parents in the cross-direction. 81 | * Again, this is redundant to `align-content: stretch` of the parent 82 | * element, but we explicitly make this a _definite_ size. 83 | */ 84 | height: 100%; 85 | } 86 | 87 | .vflex-item-stretch { 88 | /** 89 | * See long comments on `.flex-item-hstretch` on `width` and `height` 90 | * but with swapped roles. 91 | */ 92 | width: 100%; 93 | height: 0; 94 | } 95 | 96 | .hflex-item-rigid, 97 | .vflex-item-rigid { 98 | flex: none; 99 | } 100 | 101 | .hflex-item-rigid { 102 | /* 103 | * For a rigid (horizontal) item the width shall be the natural width 104 | * of its child elements unless otherwise specified by a CSS rule with 105 | * higher specificity. 106 | */ 107 | width: auto; 108 | /* 109 | * We want all flex items to fill out their parents in the cross-direction. 110 | * Again, this is redundant to `align-content: stretch` of the parent 111 | * element, but we explicitly make this a _definite_ size. 112 | */ 113 | height: 100%; 114 | } 115 | 116 | .vflex-item-rigid { 117 | width: 100%; 118 | height: auto; 119 | } 120 | 121 | /** 122 | * An overlay container does not participate in the layout, but is absolutely 123 | * positioned and covers its entire parent. 124 | */ 125 | .overlay-container { 126 | position: absolute; 127 | display: none; 128 | top: 0; 129 | left: 0; 130 | width: 100%; 131 | height: 100%; 132 | background-color: $colorAppBgFullMode; 133 | transition: background-color 0.3s; 134 | 135 | &.full { 136 | cursor: none; 137 | } 138 | 139 | &.active { 140 | display: unset; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /styles/main/_u2f.scss: -------------------------------------------------------------------------------- 1 | .u2f_view { 2 | width: 90%; 3 | max-width: 700px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | } 7 | 8 | .u2f_view_line { 9 | font-size: 14px; 10 | width: 100%; 11 | &:last-child, 12 | &:first-child { 13 | padding-top: 50px; 14 | } 15 | p { 16 | width: 550px; 17 | margin: 0 0 5%; 18 | color: #ccc; 19 | display: inline-block; 20 | a { 21 | color: rgba(255, 255, 255, 0.9); 22 | text-decoration: none; 23 | border-bottom: 1px dashed #888; 24 | } 25 | &:last-of-type { 26 | margin: 0; 27 | } 28 | } 29 | p.line { 30 | margin: 0 0 0 0; 31 | } 32 | p.single { 33 | text-align: center; 34 | } 35 | span.text { 36 | display: inline-block; 37 | padding: 9px 4px; 38 | width: 80%; 39 | //margin: 0 2%; 40 | background-color: transparent; 41 | color: #fff; 42 | border: none; 43 | &_icon { 44 | width: 5%; 45 | .iconic { 46 | width: 15px; 47 | height: 14px; 48 | margin: 0 15px 0 1px; 49 | fill: #ffffff; 50 | } 51 | } 52 | } 53 | .choice label input:checked ~ .checkbox .iconic { 54 | opacity: 1; 55 | -ms-transform: scale(1); 56 | transform: scale(1); 57 | } 58 | .choice { 59 | display: inline-block; 60 | width: 5%; 61 | color: #fff; 62 | input { 63 | position: absolute; 64 | margin: 0; 65 | opacity: 0; 66 | } 67 | .checkbox { 68 | display: inline-block; 69 | width: 16px; 70 | height: 16px; 71 | margin-top: 10px; 72 | margin-left: 2px; 73 | background: rgba(0, 0, 0, 0.5); 74 | border-radius: 3px; 75 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7); 76 | .iconic { 77 | box-sizing: border-box; 78 | fill: #2293ec; 79 | padding: 2px; 80 | opacity: 0; 81 | -ms-transform: scale(0); 82 | transform: scale(0); 83 | transition: opacity 0.2s cubic-bezier(0.51, 0.92, 0.24, 1), transform 0.2s cubic-bezier(0.51, 0.92, 0.24, 1); 84 | } 85 | } 86 | } 87 | .basicModal__button { 88 | display: inline-block; 89 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 90 | width: 20%; 91 | min-width: 50px; 92 | border-radius: 0 0 0 0; 93 | } 94 | .basicModal__button_OK { 95 | color: #2293ec; 96 | border-radius: 5px 0 0 5px; 97 | } 98 | .basicModal__button_DEL { 99 | color: #b22027; 100 | border-radius: 0 5px 5px 0; 101 | } 102 | .basicModal__button_CREATE { 103 | width: 100%; 104 | color: #009900; 105 | border-radius: 5px; 106 | } 107 | .select { 108 | position: relative; 109 | margin: 1px 5px; 110 | padding: 0; 111 | width: 110px; 112 | color: #fff; 113 | border-radius: 3px; 114 | border: 1px solid rgba(0, 0, 0, 0.2); 115 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02); 116 | font-size: 11px; 117 | line-height: 16px; 118 | overflow: hidden; 119 | outline: 0; 120 | vertical-align: middle; 121 | background: rgba(0, 0, 0, 0.3); 122 | display: inline-block; 123 | select { 124 | margin: 0; 125 | padding: 4px 8px; 126 | width: 120%; 127 | color: #fff; 128 | font-size: 11px; 129 | line-height: 16px; 130 | border: 0; 131 | outline: 0; 132 | box-shadow: none; 133 | border-radius: 0; 134 | background: transparent none; 135 | -moz-appearance: none; 136 | -webkit-appearance: none; 137 | appearance: none; 138 | option { 139 | margin: 0; 140 | padding: 0; 141 | background: #fff; 142 | color: #333; 143 | transition: none; 144 | } 145 | } 146 | &::after { 147 | position: absolute; 148 | content: "≡"; 149 | right: 8px; 150 | top: 4px; 151 | color: #2293ec; 152 | font-size: 16px; 153 | line-height: 16px; 154 | font-weight: 700; 155 | pointer-events: none; 156 | } 157 | } 158 | } 159 | 160 | // restrict hover features to devices that support it 161 | @media (hover: hover) { 162 | .u2f_view_line { 163 | .basicModal__button:hover { 164 | cursor: pointer; 165 | } 166 | 167 | .basicModal__button_OK:hover { 168 | background: #2293ec; 169 | color: #ffffff; 170 | } 171 | 172 | .basicModal__button_DEL:hover { 173 | background: #b22027; 174 | color: #ffffff; 175 | } 176 | 177 | .basicModal__button_CREATE:hover { 178 | background: #009900; 179 | color: #ffffff; 180 | } 181 | 182 | input:hover { 183 | border-bottom: #2293ec solid 1px; 184 | } 185 | } 186 | } 187 | 188 | // on touch devices draw buttons in color 189 | @media (hover: none) { 190 | .u2f_view_line { 191 | .basicModal__button { 192 | color: #ffffff; 193 | 194 | &_OK { 195 | background: #2293ec; 196 | } 197 | 198 | &_DEL { 199 | background: #b22027; 200 | } 201 | 202 | &_CREATE { 203 | background: #009900; 204 | } 205 | } 206 | 207 | input { 208 | border-bottom: #2293ec solid 1px; 209 | } 210 | } 211 | } 212 | 213 | // responsive web design for smaller screens 214 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 215 | .u2f_view { 216 | width: 100%; 217 | max-width: 100%; 218 | padding: 20px; 219 | } 220 | 221 | .u2f_view_line { 222 | p { 223 | width: 100%; 224 | } 225 | 226 | .basicModal__button_CREATE { 227 | width: 80%; 228 | margin: 0 10%; 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /scripts/landing/landing.js: -------------------------------------------------------------------------------- 1 | const landing = {}; 2 | 3 | /** 4 | * @returns {void} 5 | */ 6 | landing.runInitAnimations = function () { 7 | $("#loader_wrap").fadeOut(1000); 8 | 9 | $(".animate-down").each(function (index) { 10 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 11 | }); 12 | 13 | $(".animate-up").each(function (index) { 14 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 15 | }); 16 | 17 | $(".pop-in").each(function (index) { 18 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 19 | }); 20 | 21 | $(".pop-out").each(function (index) { 22 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 23 | }); 24 | }; 25 | 26 | /** 27 | * @returns {void} 28 | */ 29 | landing.runInitAnimationsHome = function () { 30 | $(".pop-in").each(function (index) { 31 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 32 | }); 33 | 34 | const onFadedOut = function () { 35 | $(".pop-in-last").each(function (index) { 36 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 37 | }); 38 | 39 | $(".animate-down").each(function (index) { 40 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 41 | }); 42 | 43 | $(".animate-up").each(function (index) { 44 | setTimeout((elem) => elem.addClass("toggled"), 100 * index, $(this)); 45 | }); 46 | }; 47 | 48 | setTimeout(() => $("#intro").fadeOut(1000, onFadedOut), 2500); 49 | }; 50 | 51 | $(document).ready(function () { 52 | // Prevent users from saving images 53 | 54 | /* 55 | $("body").on("contextmenu",function(){ 56 | return false; 57 | }); 58 | */ 59 | 60 | // Toggle menu and menu setup 61 | 62 | $("#intro_content").css({ 63 | paddingTop: ($(window).height() - 50) / 2 + "px", 64 | }); 65 | 66 | $(".sub-menu").hide(); 67 | 68 | // $('#menu a').each(function() { 69 | 70 | // var $this = $(this); 71 | // var href = $(this).attr("href"); 72 | // var text = $(this).html(); 73 | 74 | // // if ( $this.html() == "Store" || $this.closest("ul").hasClass("sub-menu") ) { 75 | // // 76 | // // } else { 77 | // // $("#mobile_menu_wrap").prepend('' + text + ''); 78 | // // } 79 | 80 | // }); 81 | 82 | // $('.sub-menu a').each(function() { 83 | // 84 | // var $this = $(this); 85 | // var href = $(this).attr("href"); 86 | // var text = $(this).html(); 87 | // 88 | // $("#mobile_menu_wrap").append('' + text + ''); 89 | // 90 | // }); 91 | 92 | $("#menu li").hover( 93 | function () { 94 | if ($(this).find(".sub-menu").length > 0) { 95 | $(this).find(".sub-menu").show(); 96 | } 97 | }, 98 | function () { 99 | if ($(this).find(".sub-menu").length > 0) { 100 | $(this).find(".sub-menu").hide(); 101 | } 102 | } 103 | ); 104 | 105 | // $('.hamburger').on("click", function() { 106 | // 107 | // $(this).toggleClass("is-active"); 108 | // 109 | // if ( $(this).hasClass("is-active") == true ) { 110 | // $("#mobile_menu_wrap").fadeIn(800); 111 | // 112 | // $("#mobile_menu_wrap a").each(function(index) { 113 | // var $this = $(this); 114 | // setTimeout(function() { 115 | // $this.addClass("popped"); 116 | // }, 100 * index); 117 | // }); 118 | // 119 | // } else { 120 | // $("#mobile_menu_wrap").fadeOut(800); 121 | // $("#mobile_menu_wrap a").removeClass("popped"); 122 | // } 123 | // 124 | // return false; 125 | // }); 126 | 127 | // var homeslider = $('#slides'); 128 | // 129 | // if( homeslider ) { 130 | // 131 | // var homeslider_slides = homeslider.find("li").length; 132 | // var playSpeed = 0; 133 | // 134 | // if ( homeslider_slides > 1 ) { 135 | // playSpeed = 5000; 136 | // } 137 | // 138 | // homeslider.superslides({ 139 | // play : playSpeed, 140 | // pagination : true, 141 | // animation : "fade", 142 | // animation_speed : 1500 143 | // }); 144 | // } 145 | 146 | // Gallery page 147 | 148 | // $('#gallery_nav a').on("click", function() { 149 | // 150 | // var targets = $(this).data("category"); 151 | // 152 | // $(this).addClass("active").parent().siblings("li").find('a').removeClass("active"); 153 | // 154 | // if ( targets == "all" ) { 155 | // 156 | // $('.grid-item').show(); 157 | // 158 | // } else { 159 | // 160 | // $('.grid-item').each(function() { 161 | // 162 | // var $this = $(this); 163 | // var thisCat = $(this).data("category"); 164 | // 165 | // if ( thisCat.indexOf(targets) >= 0 ) { 166 | // $this.show(); 167 | // } else { 168 | // $this.hide(); 169 | // } 170 | // 171 | // }); 172 | // 173 | // } 174 | // 175 | // galleryGrid.masonry(); 176 | // 177 | // console.log(targets); 178 | // 179 | // return false; 180 | // }); 181 | 182 | if ($("#intro").length > 0) { 183 | landing.runInitAnimationsHome(); 184 | } else { 185 | landing.runInitAnimations(); 186 | } 187 | }); 188 | 189 | // $(window).load(function() { 190 | // 191 | // if ( $('#intro').length > 0 ) { 192 | // landing.runInitAnimationsHome(); 193 | // } else { 194 | // landing.runInitAnimations(); 195 | // } 196 | // 197 | // // if ( $('.gallery_grid').length > 0 ) { 198 | // // 199 | // // galleryGrid = $('.gallery_grid').masonry({ 200 | // // columnWidth: '.grid-sizer', 201 | // // itemSelector: '.grid-item', 202 | // // percentPosition: true 203 | // // }); 204 | // // 205 | // // } 206 | // 207 | // // $('#single_product_image').zoom({ 208 | // // url: $('#single_product_image').find('img').attr('src'), 209 | // // magnify : 0.7 210 | // // }); 211 | // 212 | // // Run animations 213 | // 214 | // }); 215 | -------------------------------------------------------------------------------- /scripts/main/search.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Searches through your photos and albums. 3 | */ 4 | 5 | /** 6 | * The ID of the search album 7 | * 8 | * Constant `'search'`. 9 | * 10 | * @type {string} 11 | */ 12 | const SearchAlbumIDPrefix = "search"; 13 | 14 | /** 15 | * @typedef SearchAlbum 16 | * 17 | * A "virtual" album which holds the search results in a form which is 18 | * mostly compatible with the other album types, i.e. 19 | * {@link Album}, {@link TagAlbum} and {@link SmartAlbum}. 20 | * 21 | * @property {string} id - always equals `SearchAlbumIDPrefix/search-term` 22 | * @property {string} title - always equals `lychee.locale["SEARCH_RESULTS"]` 23 | * @property {Photo[]} photos - the found photos 24 | * @property {Album[]} albums - the found albums 25 | * @property {TagAlbum[]} tag_albums - the found tag albums 26 | * @property {?Thumb} thumb - always `null`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it 27 | * @property {boolean} is_public - always `false`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it 28 | * @property {boolean} grant_download - always `false`; just a dummy entry, because all other albums {@link Album}, {@link TagAlbum}, {@link SmartAlbum} have it 29 | */ 30 | 31 | /** 32 | * The search object 33 | */ 34 | const search = { 35 | /** @type {?SearchResult} */ 36 | json: null, 37 | }; 38 | 39 | /** 40 | * @param {string} term 41 | * @returns {void} 42 | */ 43 | search.find = function (term) { 44 | if (term.trim() === "") return; 45 | 46 | /** @param {SearchResult} data */ 47 | const successHandler = function (data) { 48 | if (search.json && search.json.checksum === data.checksum) { 49 | // If search result is identical to previous result, just 50 | // update the album id with the new search term and bail out. 51 | album.json.id = SearchAlbumIDPrefix + "/" + term; 52 | return; 53 | } 54 | 55 | search.json = data; 56 | 57 | // Create and assign a `SearchAlbum` 58 | album.json = { 59 | id: SearchAlbumIDPrefix + "/" + term, 60 | title: lychee.locale["SEARCH_RESULTS"], 61 | photos: search.json.photos, 62 | albums: search.json.albums, 63 | tag_albums: search.json.tag_albums, 64 | thumb: null, 65 | rights: { can_download: false }, 66 | policy: { is_public: false }, 67 | }; 68 | 69 | let albumsData = ""; 70 | let photosData = ""; 71 | 72 | // Build HTML for album 73 | search.json.tag_albums.forEach(function (album) { 74 | albums.parse(album); 75 | albumsData += build.album(album); 76 | }); 77 | search.json.albums.forEach(function (album) { 78 | albums.parse(album); 79 | albumsData += build.album(album); 80 | }); 81 | 82 | // Build HTML for photo 83 | search.json.photos.forEach(function (photo) { 84 | photosData += build.photo(photo); 85 | }); 86 | 87 | let albums_divider = lychee.locale["ALBUMS"]; 88 | let photos_divider = lychee.locale["PHOTOS"]; 89 | 90 | if (albumsData !== "") albums_divider += " (" + (search.json.tag_albums.length + search.json.albums.length) + ")"; 91 | if (photosData !== "") { 92 | photos_divider += " (" + search.json.photos.length + ")"; 93 | if (lychee.layout === "justified") { 94 | photosData = '
' + photosData + "
"; 95 | } else if (lychee.layout === "unjustified") { 96 | photosData = '
' + photosData + "
"; 97 | } 98 | } 99 | 100 | // 1. No albums and photos 101 | // 2. Only photos 102 | // 3. Only albums 103 | // 4. Albums and photos 104 | const html = 105 | albumsData === "" && photosData === "" 106 | ? "" 107 | : albumsData === "" 108 | ? build.divider(photos_divider) + photosData 109 | : photosData === "" 110 | ? build.divider(albums_divider) + albumsData 111 | : build.divider(albums_divider) + albumsData + build.divider(photos_divider) + photosData; 112 | 113 | $(".no_content").remove(); 114 | lychee.animate(lychee.content, "contentZoomOut"); 115 | 116 | setTimeout(() => { 117 | if (visible.photo()) view.photo.hide(); 118 | if (visible.sidebar()) sidebar.toggle(false); 119 | if (visible.mapview()) mapview.close(); 120 | 121 | header.setMode("albums"); 122 | 123 | if (html === "") { 124 | lychee.content.html(""); 125 | lychee.content.append(build.no_content("magnifying-glass")); 126 | } else { 127 | lychee.content.html(html); 128 | // Here we exploit the layout method of an album although 129 | // the search result is not a proper album. 130 | // It would be much better to have a component like 131 | // `view.photos` (note the plural form) which takes care of 132 | // all photo listings independent of the surrounding "thing" 133 | // (i.e. regular album, tag album, search result) 134 | setTimeout(function () { 135 | view.album.content.justify(); 136 | lychee.animate(lychee.content, "contentZoomIn"); 137 | $("#lychee_view_container").scrollTop(0); 138 | }, 0); 139 | } 140 | lychee.setMetaData(lychee.locale["SEARCH_RESULTS"]); 141 | }, 300); 142 | }; 143 | 144 | /** @returns {void} */ 145 | const timeoutHandler = function () { 146 | if (header.dom(".header__search").val().length !== 0) { 147 | api.post("Search::run", { term }, successHandler); 148 | } else { 149 | search.reset(); 150 | } 151 | }; 152 | 153 | clearTimeout($(window).data("timeout")); 154 | $(window).data("timeout", setTimeout(timeoutHandler, 250)); 155 | }; 156 | 157 | search.reset = function () { 158 | header.dom(".header__search").val(""); 159 | $(".no_content").remove(); 160 | 161 | if (search.json !== null) { 162 | // Trash data 163 | album.json = null; 164 | photo.json = null; 165 | search.json = null; 166 | 167 | lychee.animate($(".divider"), "fadeOut"); 168 | lychee.goto(); 169 | } 170 | }; 171 | -------------------------------------------------------------------------------- /styles/main/_users.scss: -------------------------------------------------------------------------------- 1 | .users_view { 2 | width: 90%; 3 | max-width: 700px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | } 7 | 8 | .users_view_line { 9 | font-size: 14px; 10 | width: 100%; 11 | 12 | &:last-child, 13 | &:first-child { 14 | padding-top: 50px; 15 | } 16 | 17 | p { 18 | width: 550px; 19 | margin: 0 0 5%; 20 | color: #ccc; 21 | display: inline-block; 22 | 23 | a { 24 | color: rgba(255, 255, 255, 0.9); 25 | text-decoration: none; 26 | border-bottom: 1px dashed #888; 27 | } 28 | 29 | &:last-of-type { 30 | margin: 0; 31 | } 32 | } 33 | 34 | p.line { 35 | margin: 0 0 0 0; 36 | } 37 | 38 | span.text { 39 | display: inline-block; 40 | padding: 9px 6px 9px 0; 41 | width: 40%; 42 | //margin: 0 2%; 43 | background-color: transparent; 44 | color: #fff; 45 | border: none; 46 | 47 | &_icon { 48 | width: 5%; 49 | min-width: 32px; 50 | 51 | .iconic { 52 | width: 15px; 53 | height: 14px; 54 | margin: 0 8px; 55 | fill: #ffffff; 56 | } 57 | } 58 | } 59 | 60 | input.text { 61 | padding: 9px 6px 9px 0; 62 | width: 40%; 63 | //margin: 0 2%; 64 | background-color: transparent; 65 | color: #fff; 66 | border: none; 67 | border-bottom: 1px solid #222; 68 | border-radius: 0; 69 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05); 70 | outline: 0; 71 | margin: 0 0 10px; 72 | 73 | &:focus { 74 | border-bottom-color: #2293ec; 75 | } 76 | } 77 | 78 | input.text.error { 79 | border-bottom-color: #d92c34; 80 | } 81 | 82 | .choice label input:checked ~ .checkbox .iconic { 83 | opacity: 1; 84 | -ms-transform: scale(1); 85 | transform: scale(1); 86 | } 87 | 88 | .choice { 89 | display: inline-block; 90 | width: 5%; 91 | min-width: 32px; 92 | color: #fff; 93 | 94 | input { 95 | position: absolute; 96 | margin: 0; 97 | opacity: 0; 98 | } 99 | 100 | .checkbox { 101 | display: inline-block; 102 | width: 16px; 103 | height: 16px; 104 | margin: 10px 8px 0; 105 | background: rgba(0, 0, 0, 0.5); 106 | border-radius: 3px; 107 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7); 108 | 109 | .iconic { 110 | box-sizing: border-box; 111 | fill: #2293ec; 112 | padding: 2px; 113 | opacity: 0; 114 | -ms-transform: scale(0); 115 | transform: scale(0); 116 | transition: opacity 0.2s cubic-bezier(0.51, 0.92, 0.24, 1), transform 0.2s cubic-bezier(0.51, 0.92, 0.24, 1); 117 | } 118 | } 119 | } 120 | 121 | .basicModal__button { 122 | display: inline-block; 123 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 124 | width: 10%; 125 | min-width: 72px; 126 | border-radius: 0 0 0 0; 127 | } 128 | 129 | .basicModal__button_OK { 130 | color: #2293ec; 131 | border-radius: 5px 0 0 5px; 132 | margin-right: -4px; 133 | } 134 | 135 | .basicModal__button_OK_no_DEL { 136 | border-radius: 5px; 137 | min-width: 144px; 138 | width: 20%; 139 | } 140 | 141 | .basicModal__button_DEL { 142 | color: #b22027; 143 | border-radius: 0 5px 5px 0; 144 | } 145 | 146 | .basicModal__button_CREATE { 147 | width: 20%; 148 | color: #009900; 149 | border-radius: 5px; 150 | min-width: 144px; 151 | } 152 | 153 | .select { 154 | position: relative; 155 | margin: 1px 5px; 156 | padding: 0; 157 | width: 110px; 158 | color: #fff; 159 | border-radius: 3px; 160 | border: 1px solid rgba(0, 0, 0, 0.2); 161 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02); 162 | font-size: 11px; 163 | line-height: 16px; 164 | overflow: hidden; 165 | outline: 0; 166 | vertical-align: middle; 167 | background: rgba(0, 0, 0, 0.3); 168 | display: inline-block; 169 | 170 | select { 171 | margin: 0; 172 | padding: 4px 8px; 173 | width: 120%; 174 | color: #fff; 175 | font-size: 11px; 176 | line-height: 16px; 177 | border: 0; 178 | outline: 0; 179 | box-shadow: none; 180 | border-radius: 0; 181 | background: transparent none; 182 | -moz-appearance: none; 183 | -webkit-appearance: none; 184 | appearance: none; 185 | 186 | option { 187 | margin: 0; 188 | padding: 0; 189 | background: #fff; 190 | color: #333; 191 | transition: none; 192 | } 193 | } 194 | 195 | &::after { 196 | position: absolute; 197 | content: "≡"; 198 | right: 8px; 199 | top: 4px; 200 | color: #2293ec; 201 | font-size: 16px; 202 | line-height: 16px; 203 | font-weight: 700; 204 | pointer-events: none; 205 | } 206 | } 207 | } 208 | 209 | // restrict hover features to devices that support it 210 | @media (hover: hover) { 211 | .users_view_line { 212 | .basicModal__button { 213 | &:hover { 214 | cursor: pointer; 215 | color: #ffffff; 216 | } 217 | 218 | &_OK:hover { 219 | background: #2293ec; 220 | } 221 | 222 | &_DEL:hover { 223 | background: #b22027; 224 | } 225 | 226 | &_CREATE:hover { 227 | background: #009900; 228 | } 229 | } 230 | 231 | input:hover { 232 | border-bottom: #2293ec solid 1px; 233 | } 234 | } 235 | } 236 | 237 | // on touch devices draw buttons in color 238 | @media (hover: none) { 239 | .users_view_line { 240 | .basicModal__button { 241 | color: #ffffff; 242 | 243 | &_OK { 244 | background: #2293ec; 245 | } 246 | 247 | &_DEL { 248 | background: #b22027; 249 | } 250 | 251 | &_CREATE { 252 | background: #009900; 253 | } 254 | } 255 | 256 | input { 257 | border-bottom: #2293ec solid 1px; 258 | } 259 | } 260 | } 261 | 262 | // responsive web design for smaller screens 263 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 264 | .users_view { 265 | width: 100%; 266 | max-width: 100%; 267 | padding: 20px; 268 | } 269 | 270 | .users_view_line { 271 | p { 272 | width: 100%; 273 | 274 | .text, 275 | input.text { 276 | width: 36%; 277 | font-size: smaller; 278 | } 279 | } 280 | .choice { 281 | // aligning elements is painful - should use table... 282 | margin-left: -8px; 283 | margin-right: 3px; 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /styles/main/_header.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The toolbar container. 3 | * The toolbar container holds the actual toolbars and is reponsible 4 | * for showing/hiding them. 5 | */ 6 | #lychee_toolbar_container { 7 | transition: height 0.3s ease-out; 8 | &.hidden { 9 | height: 0; 10 | } 11 | } 12 | 13 | /** 14 | * The height of each actual toolbar must be the same as the height of the 15 | * toolbar container. 16 | * The width of the actual toolbars must be constant all the time to avoid 17 | * relayouting and re-wrapping of the child elements of the toolbars even 18 | * if the toolbar container shrinks and grows. 19 | */ 20 | #lychee_toolbar_container, 21 | .toolbar { 22 | height: 49px; 23 | } 24 | 25 | // Toolbars -------------------------------------------------------------- // 26 | .toolbar { 27 | background: linear-gradient(to bottom, #222222, #1a1a1a); 28 | border-bottom: 1px solid #0f0f0f; 29 | display: none; 30 | align-items: center; 31 | position: relative; 32 | box-sizing: border-box; 33 | width: 100%; 34 | 35 | &.visible { 36 | display: flex; 37 | } 38 | 39 | #lychee_toolbar_config & { 40 | .button .iconic { 41 | transform: rotate(45deg); 42 | } 43 | 44 | .header__title { 45 | padding-right: 80px; 46 | } 47 | } 48 | 49 | // Title -------------------------------------------------------------- // 50 | .header__title { 51 | width: 100%; 52 | padding: 16px 0; 53 | color: #fff; 54 | font-size: 16px; 55 | font-weight: bold; 56 | text-align: center; 57 | cursor: default; 58 | overflow: hidden; 59 | white-space: nowrap; 60 | text-overflow: ellipsis; 61 | transition: margin-left 0.5s; 62 | 63 | .iconic { 64 | display: none; 65 | margin: 0 0 0 5px; 66 | width: 10px; 67 | height: 10px; 68 | fill: white(0.5); 69 | transition: fill 0.2s ease-out; 70 | } 71 | 72 | &:active .iconic { 73 | transition: none; 74 | fill: white(0.8); 75 | } 76 | 77 | &--editable .iconic { 78 | display: inline-block; 79 | } 80 | } 81 | 82 | // Buttons -------------------------------------------------------------- // 83 | .button { 84 | flex-shrink: 0; 85 | padding: 16px 8px; 86 | height: 15px; 87 | 88 | .iconic { 89 | width: 15px; 90 | height: 15px; 91 | fill: white(0.5); 92 | transition: fill 0.2s ease-out; 93 | } 94 | 95 | &:active .iconic { 96 | transition: none; 97 | fill: white(0.8); 98 | } 99 | 100 | &--star.active .iconic { 101 | fill: #f0ef77; 102 | } 103 | 104 | &--eye.active .iconic { 105 | fill: $colorRed; 106 | } 107 | 108 | &--eye.active--not-hidden .iconic { 109 | fill: $colorGreen; 110 | } 111 | 112 | &--eye.active--hidden .iconic { 113 | fill: $colorOrange; 114 | } 115 | 116 | &--share .iconic.ionicons { 117 | margin: -2px 0 -2px; 118 | width: 18px; 119 | height: 18px; 120 | } 121 | 122 | &--nsfw.active .iconic { 123 | fill: $colorPink; 124 | } 125 | 126 | &--info.active .iconic { 127 | fill: $colorBlue; 128 | } 129 | } 130 | 131 | #button_back, 132 | #button_back_home, 133 | #button_settings, 134 | #button_close_config, 135 | #button_signin { 136 | // back button too small on small touch devices 137 | // remove left padding of menu bar and add here plus more padding on 138 | // the right as well 139 | padding: 16px 12px 16px 18px; 140 | } 141 | 142 | .button_add { 143 | padding: 16px 18px 16px 12px; 144 | } 145 | 146 | // Divider -------------------------------------------------------------- // 147 | .header__divider { 148 | flex-shrink: 0; 149 | width: 14px; 150 | } 151 | 152 | // Search -------------------------------------------------------------- // 153 | .header__search__field { 154 | position: relative; 155 | } 156 | 157 | input[type="text"].header__search { 158 | flex-shrink: 0; 159 | width: 80px; 160 | margin: 0; 161 | padding: 5px 12px 6px 12px; 162 | background-color: $colorAppBg; 163 | color: #fff; 164 | border: 1px solid black(0.9); 165 | box-shadow: 0 1px 0 white(0.04); 166 | outline: none; 167 | border-radius: 50px; 168 | opacity: 0.6; 169 | transition: opacity 0.3s ease-out, box-shadow 0.3s ease-out, width 0.2s ease-out; 170 | 171 | &:focus { 172 | width: 140px; 173 | border-color: $colorBlue; 174 | box-shadow: 0 1px 0 white(0); 175 | opacity: 1; 176 | } 177 | 178 | &:focus ~ .header__clear { 179 | opacity: 1; 180 | } 181 | 182 | &::-ms-clear { 183 | display: none; 184 | } 185 | } 186 | 187 | .header__clear { 188 | position: absolute; 189 | top: 50%; 190 | -ms-transform: translateY(-50%); 191 | transform: translateY(-50%); 192 | right: 8px; 193 | padding: 0; 194 | color: white(0.5); 195 | font-size: 24px; 196 | opacity: 0; 197 | transition: color 0.2s ease-out; 198 | cursor: default; 199 | } 200 | 201 | .header__clear_nomap { 202 | right: 60px; 203 | } 204 | 205 | // Hosted with -------------------------------------------------------------- // 206 | .header__hostedwith { 207 | flex-shrink: 0; 208 | padding: 5px 10px; 209 | margin: 11px 0; 210 | color: #888; 211 | font-size: 13px; 212 | border-radius: 100px; 213 | cursor: default; 214 | } 215 | } 216 | 217 | // restrict hover features to devices that support it 218 | @media (hover: hover) { 219 | .toolbar { 220 | .header__title, 221 | .button { 222 | &:hover .iconic { 223 | fill: white(1); 224 | } 225 | } 226 | 227 | .header__clear:hover { 228 | color: white(1); 229 | } 230 | 231 | .header__hostedwith:hover { 232 | background-color: black(0.3); 233 | } 234 | } 235 | } 236 | 237 | // responsive web design for smaller screens 238 | @media only screen and (max-width: 640px) { 239 | // reduce entries in menu bar on small screens 240 | // corresponding entries are added to the 'more' menu 241 | #button_move, 242 | #button_move_album, 243 | #button_trash, 244 | #button_trash_album, 245 | #button_visibility, 246 | #button_visibility_album, 247 | #button_nsfw_album { 248 | display: none !important; 249 | } 250 | 251 | @media (max-width: 567px) { 252 | // remove further buttons on tiny screens 253 | #button_rotate_ccwise, 254 | #button_rotate_cwise { 255 | display: none !important; 256 | } 257 | 258 | .header__divider { 259 | width: 0; 260 | } 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /styles/main/_sharing.scss: -------------------------------------------------------------------------------- 1 | .sharing_view { 2 | width: 90%; 3 | max-width: 700px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | margin-top: 20px; 7 | 8 | .sharing_view_line { 9 | width: 100%; 10 | display: block; 11 | clear: left; 12 | } 13 | 14 | .col-xs-1, 15 | .col-xs-10, 16 | .col-xs-11, 17 | .col-xs-12, 18 | .col-xs-2, 19 | .col-xs-3, 20 | .col-xs-4, 21 | .col-xs-5, 22 | .col-xs-6, 23 | .col-xs-7, 24 | .col-xs-8, 25 | .col-xs-9 { 26 | float: left; 27 | position: relative; 28 | min-height: 1px; 29 | } 30 | 31 | .col-xs-2 { 32 | width: 10%; 33 | padding-right: 3%; 34 | padding-left: 3%; 35 | } 36 | 37 | .col-xs-5 { 38 | width: 42%; 39 | } 40 | 41 | .btn-block + .btn-block { 42 | margin-top: 5px; 43 | } 44 | 45 | .btn-block { 46 | display: block; 47 | width: 100%; 48 | } 49 | 50 | .btn-default { 51 | color: $colorBlue; 52 | border-color: $colorBlue; 53 | background: rgba(0, 0, 0, 0.5); 54 | border-radius: 3px; 55 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7); 56 | } 57 | 58 | .btn { 59 | display: inline-block; 60 | padding: 6px 12px; 61 | margin-bottom: 0; 62 | font-size: 14px; 63 | font-weight: 400; 64 | line-height: 1.42857143; 65 | text-align: center; 66 | white-space: nowrap; 67 | vertical-align: middle; 68 | -ms-touch-action: manipulation; 69 | touch-action: manipulation; 70 | cursor: pointer; 71 | -webkit-user-select: none; 72 | -moz-user-select: none; 73 | -ms-user-select: none; 74 | user-select: none; 75 | background-image: none; 76 | border: 1px solid transparent; 77 | border-radius: 4px; 78 | } 79 | 80 | select[multiple], 81 | select[size] { 82 | height: 150px; 83 | } 84 | 85 | .form-control { 86 | display: block; 87 | width: 100%; 88 | height: 34px; 89 | padding: 6px 12px; 90 | font-size: 14px; 91 | line-height: 1.42857143; 92 | color: #555; 93 | background-color: #fff; 94 | background-image: none; 95 | border: 1px solid #ccc; 96 | border-radius: 4px; 97 | -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 98 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 99 | -webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s; 100 | -o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; 101 | transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; 102 | } 103 | 104 | .iconic { 105 | display: inline-block; 106 | width: 15px; 107 | height: 14px; 108 | fill: $colorBlue; 109 | 110 | .iconic.ionicons { 111 | margin: 0 8px -2px 0; 112 | width: 18px; 113 | height: 18px; 114 | } 115 | } 116 | 117 | .blue .iconic { 118 | fill: $colorBlue; 119 | } 120 | 121 | .grey .iconic { 122 | fill: #b4b4b4; 123 | } 124 | 125 | p { 126 | //margin: 0 0 5%; 127 | width: 100%; 128 | color: #ccc; 129 | text-align: center; 130 | font-size: 14px; 131 | display: block; 132 | } 133 | 134 | p.with { 135 | padding: 15px 0; 136 | } 137 | 138 | span.text { 139 | display: inline-block; 140 | padding: 0 2px; 141 | width: 40%; 142 | background-color: transparent; 143 | color: #fff; 144 | border: none; 145 | 146 | &:last-of-type { 147 | width: 5%; 148 | } 149 | 150 | .iconic { 151 | width: 15px; 152 | height: 14px; 153 | margin: 0 10px 0 1px; 154 | fill: #ffffff; 155 | } 156 | } 157 | 158 | .basicModal__button { 159 | margin-top: 10px; 160 | color: #2293ec; 161 | display: inline-block; 162 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 163 | border-radius: 5px; 164 | } 165 | 166 | .choice label input:checked ~ .checkbox .iconic { 167 | opacity: 1; 168 | -ms-transform: scale(1); 169 | transform: scale(1); 170 | } 171 | 172 | .choice { 173 | display: inline-block; 174 | //padding: 9px 2px; 175 | width: 5%; 176 | margin: 0 10px; 177 | color: #fff; 178 | 179 | input { 180 | position: absolute; 181 | margin: 0; 182 | opacity: 0; 183 | } 184 | 185 | .checkbox { 186 | display: inline-block; 187 | width: 16px; 188 | height: 16px; 189 | margin-top: 10px; 190 | margin-left: 2px; 191 | background: rgba(0, 0, 0, 0.5); 192 | border-radius: 3px; 193 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7); 194 | 195 | .iconic { 196 | box-sizing: border-box; 197 | fill: #2293ec; 198 | padding: 2px; 199 | opacity: 0; 200 | -ms-transform: scale(0); 201 | transform: scale(0); 202 | transition: opacity 0.2s cubic-bezier(0.51, 0.92, 0.24, 1), transform 0.2s cubic-bezier(0.51, 0.92, 0.24, 1); 203 | } 204 | } 205 | } 206 | 207 | .select { 208 | position: relative; 209 | //margin: 1px 5px; 210 | padding: 0; 211 | color: #fff; 212 | border-radius: 3px; 213 | border: 1px solid rgba(0, 0, 0, 0.2); 214 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02); 215 | font-size: 14px; 216 | line-height: 16px; 217 | //overflow: hidden; 218 | outline: 0; 219 | vertical-align: middle; 220 | background: rgba(0, 0, 0, 0.3); 221 | display: inline-block; 222 | } 223 | 224 | .borderBlue { 225 | border: 1px solid $colorBlue; 226 | } 227 | } 228 | 229 | // restrict hover features to devices that support it 230 | @media (hover: hover) { 231 | .sharing_view { 232 | .basicModal__button:hover { 233 | background: #2293ec; 234 | color: #ffffff; 235 | cursor: pointer; 236 | } 237 | 238 | input:hover { 239 | border-bottom: #2293ec solid 1px; 240 | } 241 | } 242 | } 243 | 244 | // on touch devices draw buttons in color 245 | @media (hover: none) { 246 | .sharing_view { 247 | .basicModal__button { 248 | background: #2293ec; 249 | color: #ffffff; 250 | } 251 | 252 | input { 253 | border-bottom: #2293ec solid 1px; 254 | } 255 | } 256 | } 257 | 258 | // responsive web design for smaller screens 259 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 260 | .sharing_view { 261 | width: 100%; 262 | max-width: 100%; 263 | padding: 10px; 264 | 265 | .select { 266 | font-size: 12px; 267 | } 268 | 269 | .iconic { 270 | margin-left: -4px; // help with centering 271 | } 272 | } 273 | 274 | .sharing_view_line { 275 | p { 276 | width: 100%; 277 | } 278 | 279 | .basicModal__button { 280 | width: 80%; 281 | margin: 0 10%; 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /scripts/main/leftMenu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description This module is used for the context menu. 3 | */ 4 | 5 | /** 6 | * @namespace 7 | * @property {jQuery} _dom 8 | */ 9 | const leftMenu = { 10 | _dom: $("#lychee_left_menu_container"), 11 | }; 12 | 13 | /** 14 | * @param {?string} [selector=null] 15 | * @returns {jQuery} 16 | */ 17 | leftMenu.dom = function (selector) { 18 | if (selector == null || selector === "") return leftMenu._dom; 19 | return leftMenu._dom.find(selector); 20 | }; 21 | 22 | /** 23 | * Build left menu 24 | * @returns {void} 25 | */ 26 | leftMenu.build = function () { 27 | let html = lychee.html` 28 | ${build.iconic("chevron-left")}${lychee.locale["CLOSE"]} 29 | `; 30 | 31 | if (lychee.rights.settings.can_edit || lychee.rights.user.can_edit) { 32 | html += lychee.html` 33 | ${lychee.locale["SETTINGS"]} 34 | `; 35 | } 36 | if (lychee.new_photos_notification) { 37 | html += lychee.html` 38 | ${build.iconic("bell")}${lychee.locale["NOTIFICATIONS"]} 39 | `; 40 | } 41 | if (lychee.rights.user_management.can_edit) { 42 | html += lychee.html` 43 | ${build.iconic("person")}${lychee.locale["USERS"]} 44 | `; 45 | } 46 | if (lychee.rights.user.can_use_2fa) { 47 | html += lychee.html` 48 | ${build.iconic("key")}${lychee.locale["U2F"]} 49 | `; 50 | } 51 | if (lychee.rights.root_album.can_upload) { 52 | html += lychee.html` 53 | ${build.iconic("cloud")}${lychee.locale["SHARING"]} 54 | `; 55 | } 56 | if (lychee.rights.settings.can_see_logs) { 57 | html += lychee.html` 58 | ${build.iconic("align-left")}${lychee.locale["LOGS"]} 59 | `; 60 | } 61 | if (lychee.rights.settings.can_see_diagnostics) { 62 | html += lychee.html` 63 | ${build.iconic("wrench")}${lychee.locale["DIAGNOSTICS"]} 64 | `; 65 | } 66 | html += lychee.html` 67 | ${build.iconic("info")}${lychee.locale["ABOUT_LYCHEE"]} 68 | ${build.iconic("account-logout")}${lychee.locale["SIGN_OUT"]}`; 69 | if (lychee.rights.settings.can_update && lychee.update_available) { 70 | html += lychee.html` 71 | ${build.iconic("timer")}${lychee.locale["UPDATE_AVAILABLE"]} 72 | `; 73 | } 74 | leftMenu.dom("#lychee_left_menu").html(html); 75 | }; 76 | 77 | /** Set the width of the side navigation to 250px and the left margin of the page content to 250px 78 | * 79 | * @returns {void} 80 | */ 81 | leftMenu.open = function () { 82 | leftMenu.dom().addClass("visible"); 83 | 84 | // Make background unfocusable 85 | tabindex.makeUnfocusable(header.dom()); 86 | tabindex.makeUnfocusable(lychee.content); 87 | tabindex.makeFocusable(leftMenu.dom()); 88 | $("#button_signout").focus(); 89 | 90 | multiselect.unbind(); 91 | }; 92 | 93 | /** 94 | * Set the width of the side navigation to 0 and the left margin of the page content to 0 95 | * 96 | * @returns {void} 97 | */ 98 | leftMenu.close = function () { 99 | leftMenu.dom().removeClass("visible"); 100 | 101 | tabindex.makeFocusable(header.dom()); 102 | tabindex.makeFocusable(lychee.content); 103 | tabindex.makeUnfocusable(leftMenu.dom()); 104 | 105 | multiselect.bind(); 106 | lychee.load(); 107 | }; 108 | 109 | /** 110 | * Close the menu if it's in responsive mode. 111 | * 112 | * @returns {void} 113 | */ 114 | leftMenu.closeIfResponsive = function () { 115 | if (window.matchMedia("only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait)").matches) { 116 | leftMenu.dom().removeClass("visible"); 117 | 118 | tabindex.makeFocusable(header.dom()); 119 | tabindex.makeFocusable(lychee.content); 120 | tabindex.makeUnfocusable(leftMenu.dom()); 121 | } 122 | }; 123 | 124 | /** 125 | * @returns {void} 126 | */ 127 | leftMenu.bind = function () { 128 | // Event Name 129 | const eventName = "click"; 130 | 131 | leftMenu.dom("#button_settings_close").on(eventName, leftMenu.close); 132 | leftMenu.dom("#button_settings_open").on(eventName, () => { 133 | leftMenu.closeIfResponsive(); 134 | settings.open(); 135 | }); 136 | leftMenu.dom("#button_signout").on(eventName, lychee.logout); 137 | leftMenu.dom("#button_logs").on(eventName, leftMenu.Logs); 138 | leftMenu.dom("#button_diagnostics").on(eventName, leftMenu.Diagnostics); 139 | leftMenu.dom("#button_about").on(eventName, lychee.aboutDialog); 140 | leftMenu.dom("#button_notifications").on(eventName, leftMenu.Notifications); 141 | leftMenu.dom("#button_users").on(eventName, leftMenu.Users); 142 | leftMenu.dom("#button_u2f").on(eventName, leftMenu.u2f); 143 | leftMenu.dom("#button_sharing").on(eventName, leftMenu.Sharing); 144 | leftMenu.dom("#button_update").on(eventName, leftMenu.Update); 145 | }; 146 | 147 | /** 148 | * @returns {void} 149 | */ 150 | leftMenu.Logs = function () { 151 | leftMenu.closeIfResponsive(); 152 | window.open("Logs"); 153 | }; 154 | 155 | /** 156 | * @returns {void} 157 | */ 158 | leftMenu.Diagnostics = function () { 159 | leftMenu.closeIfResponsive(); 160 | view.diagnostics.init(); 161 | }; 162 | 163 | /** 164 | * @returns {void} 165 | */ 166 | leftMenu.Update = function () { 167 | leftMenu.closeIfResponsive(); 168 | view.update.init(); 169 | }; 170 | 171 | /** 172 | * @returns {void} 173 | */ 174 | leftMenu.Notifications = function () { 175 | leftMenu.closeIfResponsive(); 176 | notifications.load(); 177 | }; 178 | 179 | /** 180 | * @returns {void} 181 | */ 182 | leftMenu.Users = function () { 183 | leftMenu.closeIfResponsive(); 184 | users.list(); 185 | }; 186 | 187 | /** 188 | * @returns {void} 189 | */ 190 | leftMenu.u2f = function () { 191 | leftMenu.closeIfResponsive(); 192 | u2f.list(); 193 | }; 194 | 195 | /** 196 | * @returns {void} 197 | */ 198 | leftMenu.Sharing = function () { 199 | leftMenu.closeIfResponsive(); 200 | sharing.list(); 201 | }; 202 | -------------------------------------------------------------------------------- /scripts/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description This module communicates with Lychee's API 3 | */ 4 | 5 | /** 6 | * @callback APISuccessCB 7 | * @param {Object} data the decoded JSON response 8 | * @returns {void} 9 | */ 10 | 11 | /** 12 | * @callback APIErrorCB 13 | * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. 14 | * @param {Object} params the original JSON parameters of the request 15 | * @param {?LycheeException} lycheeException the Lychee exception 16 | * @returns {boolean} `true`, if the callback has already handled the error 17 | * and does not want the API layer to handle the error any 18 | * further (i.e. show an error flash); 19 | * `false`, if the API layer should handle the error, too. 20 | */ 21 | 22 | /** 23 | * @callback APIProgressCB 24 | * @param {ProgressEvent} event the progress event 25 | * @returns {void} 26 | */ 27 | 28 | /** 29 | * The main API object 30 | */ 31 | let api = { 32 | /** 33 | * Global, default error handler 34 | * 35 | * @type {?APIErrorCB} 36 | */ 37 | onError: null, 38 | }; 39 | 40 | /** 41 | * Checks whether the returned error is probably due to an expired HTTP session. 42 | * 43 | * There seem to be two variants how an expired session may be reported: 44 | * 45 | * 1. The web-application has already been loaded, is fully initialized 46 | * and a user tries to navigate to another part of the gallery. 47 | * In this case, the AJAX request sends the previous, expired CSRF token 48 | * and the backend responds with a 419 status code. 49 | * 2. The user completely reloads the website (e.g. typically be hitting 50 | * F5 in most browsers). 51 | * In this case, the CSRF token is re-generated by the backend, so no 52 | * CSRF mismatch occurs, but the user is no longer authenticated. and the 53 | * backend responds with a 401 status code. 54 | * 55 | * Note, case 2 also happens if a user directly navigates to a link 56 | * of the form `#/album-id/` or `#/album-id/photo-id` unless the album is 57 | * public, but password protected. 58 | * In that case, the backend also sends a 401 status code, but with a 59 | * special "Password Required" exception which is handled specially in 60 | * `album.js`. 61 | * 62 | * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. 63 | * @param {?LycheeException} lycheeException the Lychee exception 64 | * 65 | * @returns {boolean} 66 | */ 67 | api.hasSessionExpired = function (jqXHR, lycheeException) { 68 | return ( 69 | (jqXHR.status === 419 && !!lycheeException && lycheeException.exception.endsWith("SessionExpiredException")) || 70 | (jqXHR.status === 401 && !!lycheeException && lycheeException.exception.endsWith("UnauthenticatedException")) 71 | ); 72 | }; 73 | 74 | /** 75 | * 76 | * @param {string} fn 77 | * @param {Object} params 78 | * @param {?APISuccessCB} successCallback 79 | * @param {?APIProgressCB} responseProgressCB 80 | * @param {?APIErrorCB} errorCallback called if the request fails; 81 | * the callback should return `true`, 82 | * if the callback has already 83 | * handled the error and does not 84 | * want the API layer to handle the 85 | * error any further 86 | * (i.e. show an error flash), 87 | * the callback should return `false`, 88 | * if the API layer should handle 89 | * the error, too. 90 | * @returns {void} 91 | */ 92 | api.post = function (fn, params, successCallback = null, responseProgressCB = null, errorCallback = null) { 93 | loadingBar.show(); 94 | 95 | /** 96 | * The success handler 97 | * @param {Object} data the decoded JSON object of the response 98 | */ 99 | const successHandler = (data) => { 100 | setTimeout(loadingBar.hide, 100); 101 | if (successCallback) successCallback(data); 102 | }; 103 | 104 | /** 105 | * The error handler 106 | * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. 107 | */ 108 | const errorHandler = (jqXHR) => { 109 | /** 110 | * @type {?LycheeException} 111 | */ 112 | const lycheeException = jqXHR.responseJSON; 113 | 114 | if (errorCallback) { 115 | let isHandled = errorCallback(jqXHR, params, lycheeException); 116 | if (isHandled) { 117 | setTimeout(loadingBar.hide, 100); 118 | return; 119 | } 120 | } 121 | // Call global error handler for unhandled errors 122 | api.onError(jqXHR, params, lycheeException); 123 | }; 124 | 125 | let ajaxParams = { 126 | type: "POST", 127 | url: "api/" + fn, 128 | contentType: "application/json", 129 | data: JSON.stringify(params), 130 | dataType: "json", 131 | headers: { 132 | "X-XSRF-TOKEN": csrf.getCSRFCookieValue(), 133 | }, 134 | success: successHandler, 135 | error: errorHandler, 136 | }; 137 | 138 | if (responseProgressCB !== null) { 139 | ajaxParams.xhrFields = { 140 | onprogress: responseProgressCB, 141 | }; 142 | } 143 | 144 | $.ajax(ajaxParams); 145 | }; 146 | 147 | /** 148 | * Given a URL return the text raw content of the file. 149 | * 150 | * @param {string} url 151 | * @param {APISuccessCB} callback 152 | * @returns {void} 153 | */ 154 | api.getRawContent = function (url, callback) { 155 | loadingBar.show(); 156 | 157 | /** 158 | * The success handler 159 | * @param {Object} data the decoded JSON object of the response 160 | */ 161 | const successHandler = (data) => { 162 | setTimeout(loadingBar.hide, 100); 163 | 164 | callback(data); 165 | }; 166 | 167 | /** 168 | * The error handler 169 | * @param {XMLHttpRequest} jqXHR the jQuery XMLHttpRequest object, see {@link https://api.jquery.com/jQuery.ajax/#jqXHR}. 170 | */ 171 | const errorHandler = (jqXHR) => { 172 | api.onError(jqXHR, {}, null); 173 | }; 174 | 175 | $.ajax({ 176 | type: "GET", 177 | url: url, 178 | data: {}, 179 | dataType: "text", 180 | headers: { 181 | "X-XSRF-TOKEN": csrf.getCSRFCookieValue(), 182 | }, 183 | success: successHandler, 184 | error: errorHandler, 185 | }); 186 | }; 187 | -------------------------------------------------------------------------------- /scripts/main/frame.js: -------------------------------------------------------------------------------- 1 | const frame = { 2 | /** @type {?Photo} */ 3 | photo: null, 4 | /** @type {Number} */ 5 | nextTimeOutId: 0, 6 | 7 | _dom: { 8 | /** 9 | * Hidden image element with thumb variant of current image used 10 | * as a source for blurring. 11 | * @type {?HTMLImageElement} 12 | */ 13 | bgImage: null, 14 | /** 15 | * Canvas element which shows the blurry variant of `bgImage`. 16 | * @type {?HTMLCanvasElement} 17 | */ 18 | canvas: null, 19 | /** 20 | * Image element which displays the full-size image 21 | * @type {?HTMLImageElement} 22 | */ 23 | image: null, 24 | /** 25 | * Div element which works as a shutter to blend over between 26 | * subsequent photos 27 | * @type {?HTMLDivElement} 28 | */ 29 | shutter: null, 30 | }, 31 | }; 32 | 33 | /** 34 | * Determines whether the photo loading loop of the frame mode is currently 35 | * running. 36 | * @returns {boolean} 37 | */ 38 | frame.isRunning = function () { 39 | return frame.nextTimeOutId !== 0; 40 | }; 41 | 42 | /** 43 | * Stops loading images for frame mode. 44 | * @returns {void} 45 | */ 46 | frame.stop = function () { 47 | if (frame.nextTimeOutId !== 0) { 48 | clearTimeout(frame.nextTimeOutId); 49 | } 50 | frame.photo = null; 51 | frame.nextTimeOutId = 0; 52 | }; 53 | 54 | /** 55 | * Initializes the DOM (if called for the very first time), sets the frontend 56 | * into "frame mode" and enters the photo loading loop. 57 | * 58 | * @returns {void} 59 | */ 60 | frame.initAndStart = function () { 61 | lychee.setMode("frame"); 62 | if (frame._dom.bgImage === null) { 63 | frame._dom.bgImage = document.getElementById("lychee_frame_bg_image"); 64 | frame._dom.bgImage.addEventListener("load", function () { 65 | // After a new background image has been loaded, draw a blurry 66 | // version on the canvas. 67 | StackBlur.image(frame._dom.bgImage, frame._dom.canvas, 20); 68 | // We must reset the canvas to its originally defined dimensions 69 | // as StackBlur resets it. 70 | frame._dom.canvas.style.width = null; 71 | frame._dom.canvas.style.height = null; 72 | }); 73 | } 74 | if (frame._dom.canvas === null) { 75 | frame._dom.canvas = document.getElementById("lychee_frame_bg_canvas"); 76 | } 77 | if (frame._dom.image === null) { 78 | frame._dom.image = document.getElementById("lychee_frame_image"); 79 | frame._dom.image.addEventListener("load", function () { 80 | // After a new image has been loaded, open the shutter 81 | frame._dom.shutter.classList.add("opened"); 82 | }); 83 | } 84 | if (frame._dom.shutter === null) { 85 | frame._dom.shutter = document.getElementById("lychee_frame_shutter"); 86 | } 87 | 88 | // We also must call the very first invocation of `runPhotoLoop` 89 | // asynchronously to ensure that `nextTimeOutId` is also set for the first 90 | // call, otherwise `frame.isRunning` and `frame.stop` will report false 91 | // results and not work during the first invocation. 92 | frame.nextTimeOutId = setTimeout(() => frame.runPhotoLoop(), 0); 93 | }; 94 | 95 | /** 96 | * Repeatedly loads random photos every {@link lychee.mod_frame_refresh} 97 | * interval. 98 | * 99 | * The method stops loading photos when {@link frame.stop} is called. 100 | * 101 | * @returns {void} 102 | */ 103 | frame.runPhotoLoop = function () { 104 | /** 105 | * Forwards loaded photo to handler and recalls this method after the 106 | * refresh timeout unless the loop hasn't been stopped in the meantime. 107 | * 108 | * @param {Photo} data 109 | * @returns {void} 110 | */ 111 | const onSuccess = function (data) { 112 | frame.onRandomPhotoLoaded(data); 113 | if (frame.nextTimeOutId !== 0) { 114 | frame.nextTimeOutId = setTimeout(() => frame.runPhotoLoop(), 1000 * lychee.mod_frame_refresh); 115 | } 116 | }; 117 | 118 | // Closes the shutter and loads a new, random photo after that. 119 | // The CSS defines that the shutter takes 1s to close; hence the 120 | // 1s of timeout here and the duration of the animation as defined in the 121 | // CSS must be aligned to for a pleasant visual experience. 122 | frame._dom.shutter.classList.remove("opened"); 123 | // Only set the timeout, if the loop hasn't been stopped in the 124 | // meantime 125 | if (frame.nextTimeOutId !== 0) { 126 | frame.nextTimeOutId = setTimeout(() => api.post("Photo::getRandom", {}, onSuccess), 1000); 127 | } 128 | }; 129 | 130 | /** 131 | * Attempts to load a random photo from the backend. 132 | * 133 | * Upon success, the method calls {@link frame.onRandomPhotoLoaded} followed 134 | * by `successCallback` in that order. 135 | * Upon error, the method calls `errorCallback`. 136 | * 137 | * @param {APISuccessCB} successCallback 138 | * @param {APIErrorCB} errorCallback 139 | * @returns {void} 140 | */ 141 | frame.loadRandomPhoto = function (successCallback, errorCallback) { 142 | api.post( 143 | "Photo::getRandom", 144 | {}, 145 | /** @param {Photo} data */ 146 | function (data) { 147 | frame.onRandomPhotoLoaded(data); 148 | successCallback(data); 149 | }, 150 | null, 151 | errorCallback 152 | ); 153 | }; 154 | 155 | /** 156 | * Displays the given photo in the central image area of the frame mode. 157 | * 158 | * This method is called by {@link frame.runPhotoLoop} for each successfully 159 | * loaded, random photo. 160 | * 161 | * @param {Photo} photo 162 | * 163 | * @returns {void} 164 | */ 165 | frame.onRandomPhotoLoaded = function (photo) { 166 | if (photo.size_variants.thumb) { 167 | frame._dom.bgImage.src = photo.size_variants.thumb.url; 168 | } else { 169 | frame._dom.bgImage.src = ""; 170 | console.log("Thumb not found"); 171 | } 172 | 173 | frame.photo = photo; 174 | frame._dom.image.src = photo.size_variants.medium !== null ? photo.size_variants.medium.url : photo.size_variants.original.url; 175 | frame._dom.image.srcset = 176 | photo.size_variants.medium !== null && photo.size_variants.medium2x !== null 177 | ? `${photo.size_variants.medium.url} ${photo.size_variants.medium.width}w, ${photo.size_variants.medium2x.url} ${photo.size_variants.medium2x.width}w` 178 | : ""; 179 | frame.resize(); 180 | }; 181 | 182 | /** 183 | * @returns {void} 184 | */ 185 | frame.resize = function () { 186 | if (frame.photo && frame._dom.image) { 187 | const ratio = 188 | frame.photo.size_variants.original.height > 0 ? frame.photo.size_variants.original.width / frame.photo.size_variants.original.height : 1; 189 | // Our math assumes that the image occupies the whole frame. That's 190 | // not quite the case (the default css sets it to 95%) but it's close 191 | // enough. 192 | const width = window.innerWidth / ratio > window.innerHeight ? window.innerHeight * ratio : window.innerWidth; 193 | 194 | frame._dom.image.sizes = "" + width + "px"; 195 | } 196 | }; 197 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | let gulp = require("gulp"), 2 | plugins = require("gulp-load-plugins")(), 3 | cleanCSS = require("gulp-clean-css"), 4 | chmod = require("gulp-chmod"), 5 | del = require("del"), 6 | sass = require("gulp-sass")(require("sass")), 7 | paths = {}; 8 | 9 | /* Error Handler -------------------------------- */ 10 | 11 | const catchError = function (err) { 12 | console.log(err.toString()); 13 | this.emit("end"); 14 | }; 15 | 16 | /* Frontend ----------------------------------------- */ 17 | 18 | paths.frontend = { 19 | js: [ 20 | "./scripts/*.js", 21 | "./scripts/main/*.js", 22 | "./scripts/3rd-party/backend.js", 23 | "./scripts/3rd-party/WebAuthn.js", 24 | ], 25 | scripts: [ 26 | "node_modules/jquery/dist/jquery.min.js", 27 | "node_modules/lazysizes/lazysizes.min.js", 28 | "node_modules/mousetrap/mousetrap.min.js", 29 | "node_modules/mousetrap/plugins/global-bind/mousetrap-global-bind.min.js", 30 | "node_modules/@lychee-org/basicmodal/dist/basicModal.min.js", 31 | "node_modules/multiselect-two-sides/dist/js/multiselect.min.js", 32 | "node_modules/justified-layout/dist/justified-layout.min.js", 33 | "node_modules/leaflet/dist/leaflet.js", 34 | "node_modules/leaflet-rotatedmarker/leaflet.rotatedMarker.js", 35 | "node_modules/leaflet-gpx/gpx.js", 36 | "node_modules/leaflet.markercluster/dist/leaflet.markercluster.js", 37 | "node_modules/livephotoskit/livephotoskit.js", 38 | "node_modules/qr-creator/dist/qr-creator.min.js", 39 | "node_modules/sprintf-js/dist/sprintf.min.js", 40 | "node_modules/stackblur-canvas/dist/stackblur.min.js", 41 | "node_modules/@lychee-org/leaflet.photo/Leaflet.Photo.js", 42 | "node_modules/@lychee-org/basiccontext/dist/basicContext.min.js", 43 | "node_modules/resize-observer-polyfill/dist/ResizeObserver.js", 44 | "../dist/_frontend--javascript.js", 45 | ], 46 | scss: ["./styles/main/*.scss"], 47 | styles: [ 48 | "node_modules/@lychee-org/basicmodal/src/styles/main.scss", 49 | "node_modules/@lychee-org/basiccontext/dist/basicContext.min.css", 50 | "node_modules/@lychee-org/basiccontext/dist/addons/popin.min.css", 51 | "./styles/main/main.scss", 52 | "node_modules/leaflet/dist/leaflet.css", 53 | "node_modules/leaflet.markercluster/dist/MarkerCluster.css", 54 | "node_modules/@lychee-org/leaflet.photo/Leaflet.Photo.css", 55 | ], 56 | html: "./html/frontend.html", 57 | svg: ["./images/iconic.svg", "./images/ionicons.svg"], 58 | }; 59 | 60 | gulp.task("frontend--js", function () { 61 | const babel = plugins.babel({ 62 | presets: ["@babel/preset-env"], 63 | }); 64 | 65 | return gulp 66 | .src(paths.frontend.js) 67 | .pipe(plugins.concat("_frontend--javascript.js", { newLine: "\n" })) 68 | .pipe(babel) 69 | .pipe(chmod({ execute: false })) 70 | .on("error", catchError) 71 | .pipe(gulp.dest("../dist/")); 72 | }); 73 | 74 | gulp.task( 75 | "frontend--scripts", 76 | gulp.series("frontend--js", function () { 77 | return gulp 78 | .src(paths.frontend.scripts) 79 | .pipe(plugins.concat("frontend.js", { newLine: "\n" })) 80 | .pipe(chmod({ execute: false })) 81 | .on("error", catchError) 82 | .pipe(gulp.dest("../dist/")); 83 | }) 84 | ); 85 | 86 | gulp.task("frontend--styles", function () { 87 | return gulp 88 | .src(paths.frontend.styles) 89 | .pipe(sass().on("error", catchError)) 90 | .pipe(plugins.concat("frontend.css", { newLine: "\n" })) 91 | .pipe(plugins.autoprefixer("last 4 versions", "> 5%")) 92 | .pipe(cleanCSS({ level: 2 })) 93 | .pipe(chmod({ execute: false })) 94 | .pipe(gulp.dest("../dist/")); 95 | }); 96 | 97 | gulp.task("frontend--html", function () { 98 | return gulp 99 | .src(paths.frontend.html) 100 | .pipe( 101 | plugins.inject(gulp.src(paths.frontend.svg), { 102 | starttag: "", 103 | transform: function (filePath, _file) { 104 | return _file.contents.toString("utf8"); 105 | }, 106 | }) 107 | ) 108 | .pipe(chmod({ execute: false })) 109 | .on("error", catchError) 110 | .pipe(gulp.dest("../dist/")); 111 | }); 112 | 113 | /* Landing ----------------------------------------- */ 114 | 115 | paths.landing = { 116 | js: ["./scripts/landing/*.js"], 117 | scripts: ["node_modules/jquery/dist/jquery.min.js", "node_modules/lazysizes/lazysizes.min.js", "../dist/_landing--javascript.js"], 118 | styles: ["./styles/landing/landing.scss"], 119 | }; 120 | 121 | gulp.task("landing--js", function () { 122 | const babel = plugins.babel({ 123 | presets: ["@babel/preset-env"], 124 | }); 125 | 126 | return gulp 127 | .src(paths.landing.js) 128 | .pipe(plugins.concat("_landing--javascript.js", { newLine: "\n" })) 129 | .pipe(babel) 130 | .pipe(chmod({ execute: false })) 131 | .on("error", catchError) 132 | .pipe(gulp.dest("../dist/")); 133 | }); 134 | 135 | gulp.task( 136 | "landing--scripts", 137 | gulp.series("landing--js", function () { 138 | return ( 139 | gulp 140 | .src(paths.landing.scripts) 141 | .pipe(plugins.concat("landing.js", { newLine: "\n" })) 142 | // .pipe(plugins.uglify()) 143 | .pipe(chmod({ execute: false })) 144 | .on("error", catchError) 145 | .pipe(gulp.dest("../dist/")) 146 | ); 147 | }) 148 | ); 149 | 150 | gulp.task("landing--styles", function () { 151 | return ( 152 | gulp 153 | .src(paths.landing.styles) 154 | .pipe(sass().on("error", catchError)) 155 | .pipe(plugins.concat("landing.css", { newLine: "\n" })) 156 | .pipe(plugins.autoprefixer("last 4 versions", "> 5%")) 157 | // .pipe(cleanCSS({level: 2})) 158 | .pipe(chmod({ execute: false })) 159 | .pipe(gulp.dest("../dist/")) 160 | ); 161 | }); 162 | 163 | /* Images ----------------------------------------- */ 164 | 165 | paths.images = { 166 | src: ["./images/password.svg", "./images/no_cover.svg", "./images/no_images.svg", "./node_modules/leaflet/dist/images/*png", "./images/*png"], 167 | }; 168 | 169 | gulp.task("images--copy", function () { 170 | return gulp.src(paths.images.src).on("error", catchError).pipe(gulp.dest("../img")); 171 | }); 172 | 173 | /* Clean ----------------------------------------- */ 174 | 175 | gulp.task("clean", function () { 176 | return del(["../dist/_*.*"], { force: true }).catch((error) => console.log(error)); 177 | }); 178 | 179 | /* Tasks ----------------------------------------- */ 180 | 181 | gulp.task( 182 | "default", 183 | gulp.series( 184 | gulp.parallel("frontend--scripts", "frontend--styles", "frontend--html", "landing--scripts", "landing--styles", "images--copy"), 185 | "clean" 186 | ) 187 | ); 188 | 189 | gulp.task( 190 | "watch", 191 | gulp.series("default", function () { 192 | gulp.watch(paths.frontend.js, gulp.series("frontend--scripts")); 193 | gulp.watch(paths.frontend.scss, gulp.series("frontend--styles")); 194 | gulp.watch(paths.frontend.html, gulp.series("frontend--html")); 195 | }) 196 | ); 197 | -------------------------------------------------------------------------------- /scripts/main/albums.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Takes care of every action albums can handle and execute. 3 | */ 4 | 5 | const albums = { 6 | /** @type {?Albums} */ 7 | json: null, 8 | }; 9 | 10 | /** 11 | * @returns {void} 12 | */ 13 | albums.load = function () { 14 | const showRootAlbum = function () { 15 | // DO NOT change the order of `header.setMode` and `view.albums.init`. 16 | // The latter relies on the header being set correctly. 17 | // 18 | // `view.albums.init` builds the HTML of the albums view (note the 19 | // plural-s). 20 | // Internally, this exploits code for regular albums which in 21 | // turn calls `album.isUploadabe` (note the missing plural-s) to 22 | // check whether the current album supports drag-&-drop. 23 | // In order to return the correct value `album.isUploadabe` resorts 24 | // to a hack: if no (regular) album is loaded `album.isUploadabe` 25 | // normally returns `false` except the root album is visible. 26 | // In that case `album.isUploadabe` returns a "fake" `true`. 27 | // However, in order to do so `album.isUploadabe` needs to check 28 | // whether the root album is visible which is determined by the 29 | // visibility of the corresponding header. 30 | // That is why the header needs to be set first. 31 | // 32 | // However, the actual bug is to call `album.isUploadable` for the 33 | // root view. 34 | // TODO: Fix the bug described above. 35 | header.setMode("albums"); 36 | view.albums.init(); 37 | lychee.animate(lychee.content, "contentZoomIn"); 38 | 39 | tabindex.makeFocusable(lychee.content); 40 | 41 | if (lychee.active_focus_on_page_load) { 42 | // Put focus on first element - either album or photo 43 | let first_album = $(".album:first"); 44 | if (first_album.length !== 0) { 45 | first_album.focus(); 46 | } else { 47 | let first_photo = $(".photo:first"); 48 | if (first_photo.length !== 0) { 49 | first_photo.focus(); 50 | } 51 | } 52 | } 53 | 54 | setTimeout(() => { 55 | lychee.footer_show(); 56 | }, 300); 57 | 58 | // If no user is authenticated and there is nothing to see in the 59 | // root album, we automatically show the login dialog 60 | if (lychee.publicMode === true && lychee.viewMode === false && albums.isEmpty()) { 61 | lychee.loginDialog(); 62 | } 63 | }; 64 | 65 | let startTime = new Date().getTime(); 66 | 67 | lychee.animate(lychee.content, "contentZoomOut"); 68 | 69 | /** 70 | * @param {Albums} data 71 | */ 72 | const successCallback = function (data) { 73 | albums.json = data; 74 | 75 | // Skip delay when opening a blank Lychee 76 | const skipDelay = (!visible.albums() && !visible.photo() && !visible.album()) || (visible.album() && lychee.content.html() === ""); 77 | // Calculate delay 78 | const durationTime = new Date().getTime() - startTime; 79 | const waitTime = durationTime > 300 || skipDelay ? 0 : 300 - durationTime; 80 | 81 | setTimeout(() => { 82 | showRootAlbum(); 83 | }, waitTime); 84 | }; 85 | 86 | if (albums.json === null) { 87 | api.post("Albums::get", {}, successCallback); 88 | } else { 89 | setTimeout(() => { 90 | showRootAlbum(); 91 | }, 300); 92 | } 93 | }; 94 | 95 | /** 96 | * @param {(Album|TagAlbum|SmartAlbum)} album 97 | * @returns {void} 98 | */ 99 | albums.parse = function (album) { 100 | if (!album.thumb) { 101 | album.thumb = { 102 | id: "", 103 | thumb: album.policy.is_password_required ? "img/password.svg" : "img/no_images.svg", 104 | type: "image/svg+xml", 105 | thumb2x: null, 106 | }; 107 | } 108 | }; 109 | 110 | /** 111 | * @param {?string} albumID 112 | * @returns {boolean} 113 | */ 114 | albums.isShared = function (albumID) { 115 | if (albumID == null) return false; 116 | if (!albums.json) return false; 117 | if (!albums.json.albums) return false; 118 | 119 | let found = false; 120 | 121 | /** 122 | * @this {Album} 123 | * @returns {boolean} 124 | */ 125 | const func = function () { 126 | if (this.id === albumID) { 127 | found = true; 128 | return false; // stop the loop 129 | } 130 | if (this.albums) { 131 | $.each(this.albums, func); 132 | } 133 | }; 134 | 135 | if (albums.json.shared_albums !== null) $.each(albums.json.shared_albums, func); 136 | 137 | return found; 138 | }; 139 | 140 | /** 141 | * @param {?string} albumID 142 | * @returns {(null|Album|TagAlbum|SmartAlbum)} 143 | */ 144 | albums.getByID = function (albumID) { 145 | if (albumID == null) return null; 146 | if (!albums.json) return null; 147 | if (!albums.json.albums) return null; 148 | 149 | if (albums.json.smart_albums.hasOwnProperty(albumID)) { 150 | return albums.json.smart_albums[albumID]; 151 | } 152 | 153 | let result = albums.json.tag_albums.find((tagAlbum) => tagAlbum.id === albumID); 154 | if (result) { 155 | return result; 156 | } 157 | 158 | result = albums.json.albums.find((album) => album.id === albumID); 159 | if (result) { 160 | return result; 161 | } 162 | 163 | result = albums.json.shared_albums.find((album) => album.id === albumID); 164 | if (result) { 165 | return result; 166 | } 167 | 168 | return null; 169 | }; 170 | 171 | /** 172 | * Deletes a top-level album by ID from the cached JSON for albums. 173 | * 174 | * The method is called by {@link album.delete} after a top-level album has 175 | * successfully been deleted at the server-side. 176 | * 177 | * @param {?string} albumID 178 | * @returns {void} 179 | */ 180 | albums.deleteByID = function (albumID) { 181 | if (albumID == null) return; 182 | if (!albums.json) return; 183 | if (!albums.json.albums) return; 184 | 185 | let idx = albums.json.albums.findIndex((a) => a.id === albumID); 186 | albums.json.albums.splice(idx, 1); 187 | 188 | if (idx !== -1) return; 189 | 190 | idx = albums.json.shared_albums.findIndex((a) => a.id === albumID); 191 | albums.json.shared_albums.splice(idx, 1); 192 | 193 | if (idx !== -1) return; 194 | 195 | idx = albums.json.tag_albums.findIndex((a) => a.id === albumID); 196 | albums.json.tag_albums.splice(idx, 1); 197 | }; 198 | 199 | /** 200 | * @returns {void} 201 | */ 202 | albums.refresh = function () { 203 | albums.json = null; 204 | }; 205 | 206 | /** 207 | * @param {?string} albumID 208 | * @returns {boolean} 209 | */ 210 | albums.isTagAlbum = function (albumID) { 211 | return albums.json && albums.json.tag_albums.find((tagAlbum) => tagAlbum.id === albumID); 212 | }; 213 | 214 | /** 215 | * Returns true if the root album is empty in the sense that there is no 216 | * visible user content. 217 | * 218 | * @returns {boolean} 219 | */ 220 | albums.isEmpty = function () { 221 | return ( 222 | albums.json === null || 223 | (albums.isSmartAlbumEmpty(albums.json.smart_albums.public) && 224 | albums.isSmartAlbumEmpty(albums.json.smart_albums.recent) && 225 | albums.isSmartAlbumEmpty(albums.json.smart_albums.starred) && 226 | albums.isSmartAlbumEmpty(albums.json.smart_albums.unsorted) && 227 | albums.isSmartAlbumEmpty(albums.json.smart_albums.on_this_day) && 228 | albums.json.albums.length === 0 && 229 | albums.json.shared_albums.length === 0 && 230 | albums.json.tag_albums.length === 0) 231 | ); 232 | }; 233 | 234 | /** 235 | * @param {?SmartAlbum} smartAlbum 236 | * @returns {boolean} 237 | */ 238 | albums.isSmartAlbumEmpty = function (smartAlbum) { 239 | return !smartAlbum || !smartAlbum.photos || smartAlbum.photos.length === 0; 240 | }; 241 | -------------------------------------------------------------------------------- /styles/main/_sidebar.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The sidebar container. 3 | * The right sidebar container shares the horizontal space with the workbench. 4 | * The outer container uses `dispay: flex` thus making the sidebar container 5 | * a flex item. 6 | * The sidebar container is responsible for showing/hiding the actual sidebar. 7 | */ 8 | #lychee_sidebar_container { 9 | width: 0; // per default the sidebar is closed and hence has a zero width 10 | transition: width 0.3s $timing; 11 | } 12 | 13 | /** 14 | * The width of actual sidebar must be the same as the width of the sidebar 15 | * container if activated. 16 | * The width of the actual sidebar must be constant all the time to avoid 17 | * relayouting and re-wrapping of the child elements of the sidebar even 18 | * if the container shrinks and grows. 19 | */ 20 | #lychee_sidebar_container.active, 21 | #lychee_sidebar { 22 | width: 350px; 23 | } 24 | 25 | /** 26 | * The sidebar is a flex container which layouts the sidebar header and 27 | * the sidebar content in a column. 28 | */ 29 | #lychee_sidebar { 30 | height: 100%; 31 | background-color: rgba(25, 25, 25, 0.98); 32 | border-left: 1px solid black(0.2); 33 | } 34 | 35 | // Header -------------------------------------------------------------- // 36 | #lychee_sidebar_header { 37 | height: 49px; 38 | background: linear-gradient(to bottom, white(0.02), black(0)); 39 | border-top: 1px solid $colorBlue; 40 | } 41 | 42 | #lychee_sidebar_header h1 { 43 | margin: 15px 0 15px 0; 44 | color: #fff; 45 | font-size: 16px; 46 | font-weight: bold; 47 | text-align: center; 48 | -webkit-user-select: text; 49 | -moz-user-select: text; 50 | -ms-user-select: text; 51 | user-select: text; 52 | } 53 | 54 | #lychee_sidebar_content { 55 | overflow: clip auto; 56 | -webkit-overflow-scrolling: touch; 57 | 58 | // Divider -------------------------------------------------------------- // 59 | .sidebar__divider { 60 | padding: 12px 0 8px; 61 | width: 100%; 62 | border-top: 1px solid white(0.02); 63 | box-shadow: $shadow; 64 | 65 | &:first-child { 66 | border-top: 0; 67 | box-shadow: none; 68 | } 69 | 70 | h1 { 71 | margin: 0 0 0 20px; 72 | color: white(0.6); 73 | font-size: 14px; 74 | font-weight: bold; 75 | -webkit-user-select: text; 76 | -moz-user-select: text; 77 | -ms-user-select: text; 78 | user-select: text; 79 | } 80 | } 81 | 82 | // Edit -------------------------------------------------------------- // 83 | .edit { 84 | display: inline-block; 85 | margin-left: 3px; 86 | width: 10px; 87 | 88 | .iconic { 89 | width: 10px; 90 | height: 10px; 91 | fill: white(0.5); 92 | transition: fill 0.2s ease-out; 93 | } 94 | 95 | &:active .iconic { 96 | transition: none; 97 | fill: white(0.8); 98 | } 99 | } 100 | 101 | // Table -------------------------------------------------------------- // 102 | table { 103 | margin: 10px 0 15px 20px; 104 | width: calc(100% - 20px); 105 | } 106 | 107 | table tr td { 108 | padding: 5px 0; 109 | color: #fff; 110 | font-size: 14px; 111 | line-height: 19px; 112 | -webkit-user-select: text; 113 | -moz-user-select: text; 114 | -ms-user-select: text; 115 | user-select: text; 116 | 117 | &:first-child { 118 | width: 110px; 119 | } 120 | 121 | &:last-child { 122 | padding-right: 10px; 123 | } 124 | 125 | span { 126 | -webkit-user-select: text; 127 | -moz-user-select: text; 128 | -ms-user-select: text; 129 | user-select: text; 130 | } 131 | } 132 | 133 | // Tags -------------------------------------------------------------- // 134 | #tags { 135 | width: calc(100% - 40px); 136 | margin: 16px 20px 12px 20px; 137 | color: #fff; 138 | display: inline-block; 139 | } 140 | 141 | #tags > div { 142 | display: inline-block; 143 | } 144 | 145 | #tags .empty { 146 | font-size: 14px; 147 | margin: 0 2px 8px 0; 148 | -webkit-user-select: text; 149 | -moz-user-select: text; 150 | -ms-user-select: text; 151 | user-select: text; 152 | } 153 | 154 | #tags .edit { 155 | margin-top: 6px; 156 | } 157 | 158 | #tags .empty .edit { 159 | margin-top: 0; 160 | } 161 | 162 | #tags .tag { 163 | cursor: default; 164 | display: inline-block; 165 | padding: 6px 10px; 166 | margin: 0 6px 8px 0; 167 | background-color: black(0.5); 168 | border-radius: 100px; 169 | font-size: 12px; 170 | transition: background-color 0.2s; 171 | -webkit-user-select: text; 172 | -moz-user-select: text; 173 | -ms-user-select: text; 174 | user-select: text; 175 | } 176 | 177 | #tags .tag span { 178 | display: inline-block; 179 | padding: 0; 180 | margin: 0 0 -2px 0; 181 | width: 0; 182 | overflow: hidden; 183 | transform: scale(0); 184 | transition: width 0.2s, margin 0.2s, transform 0.2s, fill 0.2s ease-out; 185 | 186 | .iconic { 187 | fill: $colorRed; 188 | width: 8px; 189 | height: 8px; 190 | } 191 | 192 | &:active .iconic { 193 | transition: none; 194 | fill: darken($colorRed, 10%); 195 | } 196 | } 197 | 198 | #leaflet_map_single_photo { 199 | margin: 10px 0 0 20px; 200 | height: 180px; 201 | width: calc(100% - 40px); 202 | } 203 | 204 | .attr_location { 205 | &.search { 206 | cursor: pointer; 207 | } 208 | } 209 | } 210 | 211 | // restrict hover features to devices that support it 212 | @media (hover: hover) { 213 | #lychee_sidebar { 214 | .edit:hover .iconic { 215 | fill: white(1); 216 | } 217 | 218 | #tags .tag { 219 | &:hover { 220 | background-color: black(0.3); 221 | 222 | &.search { 223 | cursor: pointer; 224 | } 225 | 226 | span { 227 | width: 9px; 228 | margin: 0 0 -2px 5px; 229 | transform: scale(1); 230 | } 231 | } 232 | 233 | span:hover .iconic { 234 | fill: lighten($colorRed, 10%); 235 | } 236 | } 237 | } 238 | } 239 | 240 | // responsive web design for smaller screens 241 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 242 | // sidebar as overlay, small size 243 | #lychee_sidebar_container { 244 | position: absolute; 245 | right: 0; 246 | } 247 | 248 | #lychee_sidebar { 249 | background-color: rgba(0, 0, 0, 0.6); 250 | } 251 | 252 | #lychee_sidebar_container.active, 253 | #lychee_sidebar { 254 | width: 240px; 255 | } 256 | 257 | #lychee_sidebar_header { 258 | height: 22px; 259 | } 260 | 261 | #lychee_sidebar_header h1 { 262 | margin: 6px 0; 263 | font-size: 13px; 264 | } 265 | 266 | #lychee_sidebar_content { 267 | padding-bottom: 10px; 268 | 269 | .sidebar__divider { 270 | padding: 6px 0 2px; 271 | 272 | h1 { 273 | margin: 0 0 0 10px; 274 | font-size: 12px; 275 | } 276 | } 277 | 278 | table { 279 | margin: 4px 0 6px 10px; 280 | width: calc(100% - 16px); 281 | 282 | tr td { 283 | padding: 2px 0; 284 | font-size: 11px; 285 | line-height: 12px; 286 | 287 | &:first-child { 288 | width: 80px; 289 | } 290 | } 291 | } 292 | 293 | #tags { 294 | margin: 4px 0 6px 10px; 295 | width: calc(100% - 16px); 296 | 297 | .empty { 298 | margin: 0; 299 | font-size: 11px; 300 | } 301 | } 302 | } 303 | } 304 | 305 | // sidebar on side, medium size 306 | @media only screen and (min-width: 568px) and (max-width: 768px), 307 | only screen and (min-width: 568px) and (max-width: 640px) and (orientation: landscape) { 308 | #lychee_sidebar_container.active, 309 | #lychee_sidebar { 310 | width: 280px; 311 | } 312 | 313 | #lychee_sidebar_header { 314 | height: 28px; 315 | } 316 | 317 | #lychee_sidebar_header h1 { 318 | margin: 8px 0; 319 | font-size: 15px; 320 | } 321 | 322 | #lychee_sidebar_content { 323 | padding-bottom: 10px; 324 | 325 | .sidebar__divider { 326 | padding: 8px 0 4px; 327 | 328 | h1 { 329 | margin: 0 0 0 10px; 330 | font-size: 13px; 331 | } 332 | } 333 | 334 | table { 335 | margin: 4px 0 6px 10px; 336 | width: calc(100% - 16px); 337 | 338 | tr td { 339 | padding: 2px 0; 340 | font-size: 12px; 341 | line-height: 13px; 342 | 343 | &:first-child { 344 | width: 90px; 345 | } 346 | } 347 | } 348 | 349 | #tags { 350 | margin: 4px 0 6px 10px; 351 | width: calc(100% - 16px); 352 | 353 | .empty { 354 | margin: 0; 355 | font-size: 12px; 356 | } 357 | } 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /styles/main/_settings.scss: -------------------------------------------------------------------------------- 1 | .settings_view { 2 | width: 90%; 3 | max-width: 700px; 4 | margin-left: auto; 5 | margin-right: auto; 6 | 7 | input.text { 8 | padding: 9px 2px; 9 | width: calc(50% - 4px); 10 | background-color: transparent; 11 | color: #fff; 12 | border: none; 13 | border-bottom: 1px solid #222; 14 | border-radius: 0; 15 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05); 16 | outline: 0; 17 | 18 | &:focus { 19 | border-bottom-color: #2293ec; 20 | } 21 | 22 | .error { 23 | border-bottom-color: #d92c34; 24 | } 25 | } 26 | 27 | .basicModal__button { 28 | color: #2293ec; 29 | display: inline-block; 30 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), inset 1px 0 0 rgba(0, 0, 0, 0.2); 31 | border-radius: 5px; 32 | } 33 | 34 | .basicModal__button_MORE, 35 | .basicModal__button_SAVE { 36 | color: #b22027; 37 | border-radius: 5px; 38 | } 39 | 40 | > div { 41 | font-size: 14px; 42 | width: 100%; 43 | padding: 12px 0; 44 | 45 | p { 46 | margin: 0 0 5%; 47 | width: 100%; 48 | color: #ccc; 49 | line-height: 16px; 50 | 51 | a { 52 | color: rgba(255, 255, 255, 0.9); 53 | text-decoration: none; 54 | border-bottom: 1px dashed #888; 55 | } 56 | } 57 | 58 | p:last-of-type { 59 | margin: 0; 60 | } 61 | 62 | input.text { 63 | width: 100%; 64 | } 65 | 66 | textarea { 67 | padding: 9px 9px; 68 | width: calc(100% - 18px); 69 | height: 100px; 70 | background-color: transparent; 71 | color: #fff; 72 | border: 1px solid #666666; 73 | border-radius: 0; 74 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05); 75 | outline: 0; 76 | resize: vertical; 77 | 78 | &:focus { 79 | border-color: #2293ec; 80 | } 81 | } 82 | 83 | .choice { 84 | padding: 0 30px 15px; 85 | width: 100%; 86 | color: #fff; 87 | } 88 | 89 | .choice:last-child { 90 | padding-bottom: 40px; 91 | } 92 | 93 | .choice label { 94 | float: left; 95 | color: #fff; 96 | font-size: 14px; 97 | font-weight: 700; 98 | } 99 | 100 | .choice label input { 101 | position: absolute; 102 | margin: 0; 103 | opacity: 0; 104 | } 105 | 106 | .choice label .checkbox { 107 | float: left; 108 | display: block; 109 | width: 16px; 110 | height: 16px; 111 | background: rgba(0, 0, 0, 0.5); 112 | border-radius: 3px; 113 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7); 114 | } 115 | 116 | .choice label .checkbox .iconic { 117 | box-sizing: border-box; 118 | fill: #2293ec; 119 | padding: 2px; 120 | opacity: 0; 121 | -ms-transform: scale(0); 122 | transform: scale(0); 123 | transition: opacity 0.2s cubic-bezier(0.51, 0.92, 0.24, 1), transform 0.2s cubic-bezier(0.51, 0.92, 0.24, 1); 124 | } 125 | 126 | .select { 127 | position: relative; 128 | margin: 1px 5px; 129 | padding: 0; 130 | width: 110px; 131 | color: #fff; 132 | border-radius: 3px; 133 | border: 1px solid rgba(0, 0, 0, 0.2); 134 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02); 135 | font-size: 11px; 136 | line-height: 16px; 137 | overflow: hidden; 138 | outline: 0; 139 | vertical-align: middle; 140 | background: rgba(0, 0, 0, 0.3); 141 | display: inline-block; 142 | 143 | select { 144 | margin: 0; 145 | padding: 4px 8px; 146 | width: 120%; 147 | color: #fff; 148 | font-size: 11px; 149 | line-height: 16px; 150 | border: 0; 151 | outline: 0; 152 | box-shadow: none; 153 | border-radius: 0; 154 | background-color: transparent; 155 | background-image: none; 156 | -moz-appearance: none; 157 | -webkit-appearance: none; 158 | appearance: none; 159 | 160 | option { 161 | margin: 0; 162 | padding: 0; 163 | background: #fff; 164 | color: #333; 165 | transition: none; 166 | } 167 | } 168 | 169 | select:disabled { 170 | color: #000; 171 | cursor: not-allowed; 172 | } 173 | } 174 | 175 | .select::after { 176 | position: absolute; 177 | content: "≡"; 178 | right: 8px; 179 | top: 4px; 180 | color: #2293ec; 181 | font-size: 16px; 182 | line-height: 16px; 183 | font-weight: 700; 184 | pointer-events: none; 185 | } 186 | 187 | /* The switch - the box around the slider */ 188 | .switch { 189 | position: relative; 190 | display: inline-block; 191 | width: 42px; 192 | height: 22px; 193 | bottom: -2px; 194 | line-height: 24px; 195 | } 196 | 197 | /* Hide default HTML checkbox */ 198 | .switch input { 199 | opacity: 0; 200 | width: 0; 201 | height: 0; 202 | } 203 | 204 | /* The slider */ 205 | .slider { 206 | position: absolute; 207 | cursor: pointer; 208 | top: 0; 209 | left: 0; 210 | right: 0; 211 | bottom: 0; 212 | border: 1px solid rgba(0, 0, 0, 0.2); 213 | box-shadow: 0 1px 0 rgba(255, 255, 255, 0.02); 214 | background: rgba(0, 0, 0, 0.3); 215 | -webkit-transition: 0.4s; 216 | transition: 0.4s; 217 | 218 | &:before { 219 | position: absolute; 220 | content: ""; 221 | height: 14px; 222 | width: 14px; 223 | left: 3px; 224 | bottom: 3px; 225 | background-color: $colorBlue; 226 | } 227 | } 228 | 229 | input:checked + .slider { 230 | background-color: $colorBlue; 231 | } 232 | 233 | input:checked + .slider:before { 234 | -ms-transform: translateX(20px); 235 | transform: translateX(20px); 236 | background-color: #ffffff; 237 | } 238 | 239 | /* Rounded sliders */ 240 | .slider.round { 241 | border-radius: 20px; 242 | } 243 | 244 | .slider.round:before { 245 | border-radius: 50%; 246 | } 247 | } 248 | 249 | .setting_category { 250 | font-size: 20px; 251 | width: 100%; 252 | padding-top: 10px; 253 | padding-left: 4px; 254 | border-bottom: dotted 1px #222222; 255 | margin-top: 20px; 256 | color: #ffffff; 257 | font-weight: bold; 258 | text-transform: capitalize; 259 | } 260 | 261 | .setting_line { 262 | font-size: 14px; 263 | width: 100%; 264 | 265 | &:last-child, 266 | &:first-child { 267 | padding-top: 50px; 268 | } 269 | 270 | p { 271 | min-width: 550px; 272 | margin: 0 0 0 0; 273 | color: #ccc; 274 | display: inline-block; 275 | width: 100%; 276 | overflow-wrap: break-word; 277 | 278 | a { 279 | color: rgba(255, 255, 255, 0.9); 280 | text-decoration: none; 281 | border-bottom: 1px dashed #888; 282 | } 283 | 284 | &:last-of-type { 285 | margin: 0; 286 | } 287 | 288 | .warning { 289 | margin-bottom: 30px; 290 | color: $colorRed; 291 | font-weight: bold; 292 | font-size: 18px; 293 | text-align: justify; 294 | line-height: 22px; 295 | } 296 | } 297 | 298 | span.text { 299 | display: inline-block; 300 | padding: 9px 4px; 301 | width: calc(50% - 12px); 302 | background-color: transparent; 303 | color: #fff; 304 | border: none; 305 | 306 | &_icon { 307 | width: 5%; 308 | 309 | .iconic { 310 | width: 15px; 311 | height: 14px; 312 | margin: 0 10px 0 1px; 313 | fill: #ffffff; 314 | } 315 | } 316 | } 317 | 318 | input.text { 319 | width: calc(50% - 4px); 320 | } 321 | } 322 | } 323 | 324 | // restrict hover features to devices that support it 325 | @media (hover: hover) { 326 | .settings_view { 327 | .basicModal__button:hover { 328 | background: #2293ec; 329 | color: #ffffff; 330 | cursor: pointer; 331 | } 332 | 333 | .basicModal__button_MORE:hover, 334 | .basicModal__button_SAVE:hover { 335 | background: #b22027; 336 | color: #ffffff; 337 | } 338 | 339 | input:hover { 340 | border-bottom: #2293ec solid 1px; 341 | } 342 | } 343 | } 344 | 345 | // on touch devices draw buttons in color 346 | @media (hover: none) { 347 | .settings_view { 348 | input.text { 349 | border-bottom: #2293ec solid 1px; 350 | margin: 6px 0; 351 | } 352 | 353 | > div { 354 | padding: 16px 0; 355 | } 356 | 357 | .basicModal__button { 358 | background: #2293ec; 359 | color: #ffffff; 360 | max-width: 320px; 361 | margin-top: 20px; 362 | 363 | &_MORE, 364 | &_SAVE { 365 | background: #b22027; 366 | } 367 | } 368 | } 369 | } 370 | 371 | // responsive web design for smaller screens 372 | @media only screen and (max-width: 567px), only screen and (max-width: 640px) and (orientation: portrait) { 373 | .settings_view { 374 | max-width: 100%; 375 | 376 | .setting_category { 377 | font-size: 14px; 378 | padding-left: 0; 379 | margin-bottom: 4px; 380 | } 381 | 382 | .setting_line { 383 | font-size: 12px; 384 | 385 | &:first-child { 386 | padding-top: 20px; 387 | } 388 | 389 | p { 390 | min-width: unset; 391 | line-height: 20px; 392 | 393 | &.warning { 394 | font-size: 14px; 395 | line-height: 16px; 396 | margin-bottom: 0; 397 | } 398 | 399 | span, 400 | input { 401 | padding: 0; 402 | } 403 | } 404 | } 405 | 406 | .basicModal__button_SAVE { 407 | margin-top: 20px; 408 | } 409 | } 410 | } 411 | -------------------------------------------------------------------------------- /scripts/3rd-party/basicModal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The Basic Model component. 3 | * 4 | * See: {@link https://github.com/LycheeOrg/basicModal} 5 | * 6 | * @namespace basicModal 7 | */ 8 | 9 | /** 10 | * @typedef ModalDialogConfiguration 11 | * @property {string} [body=''] HTML snippet to be inserted into the content 12 | * area of the dialog 13 | * @property {string[]} [classList=[]] CSS class to be applied to the content area 14 | * of the dialog 15 | * @property {boolean} [closable=true] indicates whether the dialog can be closed 16 | * via {@link close} 17 | * @property {ModalDialogButtonsData} buttons configuration data for the main action and 18 | * cancel button 19 | * @property {ModalDialogReadyCB} [readyCB=null] callback to be called after the dialog 20 | * has become visible and ready for user input 21 | */ 22 | 23 | /** 24 | * @callback ModalDialogReadyCB 25 | * @param {ModalDialogFormElements} htmlElements a dictionary that maps names to form elements 26 | * @param {HTMLDivElement} dialog the DIV element that represents the content area of the dialog 27 | * @returns {void} 28 | */ 29 | 30 | /** 31 | * @callback ModalDialogClosedCB 32 | * @returns {void} 33 | */ 34 | 35 | /** 36 | * @typedef ModalDialogFormElements 37 | * 38 | * A dictionary of names of form elements to those form elements. 39 | * 40 | * @type {Object.} 41 | */ 42 | 43 | /** 44 | * @typedef ModalDialogButtonsData 45 | * @property {ModalDialogButtonData} [action] configuration data for the main action button 46 | * @property {ModalDialogButtonData} [cancel] configuration data for the cancel button 47 | */ 48 | 49 | /** 50 | * @typedef ModalDialogButtonData 51 | * @property {string} [title] the caption of the button 52 | * @property {string[]} [classList=[]] CSS class to be applied to the button 53 | * @property {Object.} [attributes={}] a dictionary of arbitrary HTML attributes and their values 54 | * @property {ModalDialogButtonCB} fn callback to be called upon an "on-click" event 55 | */ 56 | 57 | /** 58 | * @callback ModalDialogButtonCB 59 | * @param {ModalDialogResult} values an associative object with the values of 60 | * all HTML input elements; see {@link getValues} 61 | * @returns {void} 62 | */ 63 | 64 | /** 65 | * A dictionary that maps names of form elements to their values. 66 | * 67 | * @typedef ModalDialogResult 68 | * @type {Object.} 69 | */ 70 | 71 | /** 72 | * @typedef ModalDialogException 73 | * @property {string} name 74 | * @property {string} message 75 | */ 76 | 77 | /** 78 | * Returns an associative object containing the values from all HTML form 79 | * elements. 80 | * 81 | * @function getValues 82 | * @memberOf basicModal 83 | * @returns {ModalDialogResult} 84 | */ 85 | 86 | /** 87 | * Constructs and shows a modal dialog. 88 | * 89 | * After the dialog has become ready, the callback `confData.readyCB` is 90 | * invoked. 91 | * 92 | * @function show 93 | * @memberOf basicModal 94 | * @param {ModalDialogConfiguration} confData configuration data for the dialog 95 | * @returns {void} 96 | * @throws {ModalDialogException} 97 | */ 98 | 99 | /** 100 | * Scans the dialog for any named form elements and caches them in an internal 101 | * dictionary to avoid repeated DOM queries with CSS selectors for efficiency 102 | * reasons. 103 | * 104 | * The found form elements are those which are included into the dialog result 105 | * set and are enabled/disabled automatically. 106 | * 107 | * Normally, it is not necessary to call this method manually from outside the 108 | * modal dialog as this method is automatically called as part of the dialog 109 | * building process inside `show`. 110 | * However, if the dialog is dynamically modified after `show` has been called 111 | * (e.g. if form elements are removed or added on the fly), then this method 112 | * must be called. 113 | * 114 | * @function cacheFormElements 115 | * @memberOf basicModal 116 | * @returns {void} 117 | */ 118 | 119 | /** 120 | * Removes (potentially) old error indicators and highlights the indicated 121 | * input element. 122 | * 123 | * @function focusError 124 | * @memberOf basicModal 125 | * @param {string} [name] the name of the HTML input element which 126 | * caused the error and shall be highlighted 127 | * @returns {void} 128 | */ 129 | 130 | /** 131 | * Determines whether a modal dialog is visible or not. 132 | * 133 | * @function isVisible 134 | * @memberOf basicModal 135 | * @returns {boolean} 136 | */ 137 | 138 | /** 139 | * Triggers a virtual "on-click" event on the main action button. 140 | * 141 | * The method closes the dialog and calls the registered callback for the main 142 | * action. 143 | * 144 | * @function action 145 | * @memberOf basicModal 146 | * @returns {boolean} `true`, if the main action button exists and a click 147 | * event has been triggered; `false` otherwise 148 | */ 149 | 150 | /** 151 | * Triggers a virtual "on-click" event on the cancel button. 152 | * 153 | * The method closes the dialog and calls the registered callback for the 154 | * cancel action. 155 | * 156 | * @function cancel 157 | * @memberOf basicModal 158 | * @returns {boolean} `true`, if the main action button exists and a click 159 | * event has been triggered; `false` otherwise 160 | */ 161 | 162 | /** 163 | * Reactivates buttons and removes any (potential) error indicator from the 164 | * input elements. 165 | * 166 | * @function reset 167 | * @memberOf basicModal 168 | * @returns {void} 169 | */ 170 | 171 | /** 172 | * Closes the dialog without triggering any action. 173 | * 174 | * @function close 175 | * @memberOf basicModal 176 | * @param {boolean} [force=false] 177 | * @param {ModalDialogClosedCB} [onClosedCB] 178 | * @returns {void} 179 | */ 180 | 181 | /** 182 | * @function isActionButtonBusy 183 | * @memberOf basicModal 184 | * @returns {boolean} 185 | */ 186 | 187 | /** 188 | * @function markActionButtonAsBusy 189 | * @memberOf basicModal 190 | * @returns {void} 191 | */ 192 | 193 | /** 194 | * @function markActionButtonAsIdle 195 | * @memberOf basicModal 196 | * @returns {void} 197 | */ 198 | 199 | /** 200 | * Returns `true`, if the Action button is visible. 201 | * 202 | * @function isActionButtonVisible 203 | * @memberOf basicModal 204 | * @returns {boolean} 205 | */ 206 | 207 | /** 208 | * Returns `true`, if the Action button is hidden. 209 | * 210 | * Note, this method is not exactly the opposite of 211 | * {@link basicModal#isActionButtonVisible}. 212 | * This method only returns `true` if the dialog owns an Action button which 213 | * can be hidden. 214 | * In other words, both {@link basicModal#isActionButtonVisible} and this method may 215 | * return `false` simultaneously, if there is no Action button at all. 216 | * 217 | * @function isActionButtonHidden 218 | * @memberOf basicModal 219 | * @returns {boolean} 220 | */ 221 | 222 | /** 223 | * Hides the Action button 224 | * 225 | * Note: This does not hide the button by setting the `display` property to 226 | * `none`, but completely removes the button from the DOM. 227 | * This is necessary, as an element which is not displayed is still considered 228 | * when it comes to calculating the first or last child and hence rounding 229 | * of the first/last button does not work as expected, if the button is still 230 | * part of the DOM. 231 | * 232 | * @function hideActionButton 233 | * @memberOf basicModal 234 | * @returns {void} 235 | */ 236 | 237 | /** 238 | * Shows the Action button, if one has been defined 239 | * 240 | * Note: This re-inserts the Action button into the DOM, but only if an Action 241 | * button has previously been defined during the dialog construction. 242 | * 243 | * @function showActionButton 244 | * @memberOf basicModal 245 | * @returns {void} 246 | */ 247 | 248 | /** 249 | * @function isCancelButtonBusy 250 | * @memberOf basicModal 251 | * @returns {boolean} 252 | */ 253 | 254 | /** 255 | * @function markCancelButtonAsBusy 256 | * @memberOf basicModal 257 | * @returns {void} 258 | */ 259 | 260 | /** 261 | * @function markCancelButtonAsIdle 262 | * @memberOf basicModal 263 | * @returns {void} 264 | */ 265 | 266 | /** 267 | * Returns `true`, if the Cancel button is visible. 268 | * 269 | * @function isCancelButtonVisible 270 | * @memberOf basicModal 271 | * @returns {boolean} 272 | */ 273 | 274 | /** 275 | * Returns `true`, if the Cancel button is hidden. 276 | * 277 | * Note, this method is not exactly the opposite of 278 | * {@link basicModal#isCancelButtonVisible}. 279 | * This method only returns `true` if the dialog owns a Cancel button which 280 | * can be hidden. 281 | * In other words, both {@link basicModal#isCancelButtonVisible} and this method may 282 | * return `false` simultaneously, if there is no Cancel button at all. 283 | * 284 | * @function isCancelButtonHidden 285 | * @memberOf basicModal 286 | * @returns {boolean} 287 | */ 288 | 289 | /** 290 | * Hides the Cancel button 291 | * 292 | * Note: This does not hide the button by setting the `display` property to 293 | * `none`, but completely removes the button from the DOM. 294 | * This is necessary, as an element which is not displayed is still considered 295 | * when it comes to calculating the first or last child and hence rounding 296 | * of the first/last button does not work as expected, if the button is still 297 | * part of the DOM. 298 | * 299 | * @function hideCancelButton 300 | * @memberOf basicModal 301 | * @returns {void} 302 | */ 303 | 304 | /** 305 | * Shows the Cancel button, if one has been defined 306 | * 307 | * Note: This re-inserts the Cancel button into the DOM, but only if a Cancel 308 | * button has previously been defined during the dialog construction. 309 | * 310 | * @function showCancelButton 311 | * @memberOf basicModal 312 | * @returns {void} 313 | */ 314 | -------------------------------------------------------------------------------- /html/frontend.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 16 |
17 | 18 |
19 |
20 |
21 | 25 |
26 | 27 |
28 |
29 | 30 | 31 |
32 | 33 | × 34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 | × 43 |
44 | 45 | 46 | 47 |
48 |
49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 |
66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
82 |
83 | 84 | 85 |
86 |
87 | 88 | 89 |
90 |
91 | 96 |
97 | 103 |
104 | 113 |
114 | 120 |
121 | 127 | 133 |
134 | 141 |
142 | 148 |
149 | 150 |

151 | 152 |
153 | 154 | 155 |
156 |
157 | 165 |
166 |
167 |

168 | 172 |
173 |
174 |
175 |
176 |
177 |
178 | 182 |
183 | image background 184 | 185 |
186 |
Random Image
187 |
188 |
189 | --------------------------------------------------------------------------------