├── .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 | `;
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 |
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 | [](https://github.com/LycheeOrg/Lychee-front/actions?query=workflow%3A%22Node.js+CI%22)
8 | [](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 | 
15 | 
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 |
29 | `;
30 |
31 | if (lychee.rights.settings.can_edit || lychee.rights.user.can_edit) {
32 | html += lychee.html`
33 |
34 | `;
35 | }
36 | if (lychee.new_photos_notification) {
37 | html += lychee.html`
38 |
39 | `;
40 | }
41 | if (lychee.rights.user_management.can_edit) {
42 | html += lychee.html`
43 |
44 | `;
45 | }
46 | if (lychee.rights.user.can_use_2fa) {
47 | html += lychee.html`
48 |
49 | `;
50 | }
51 | if (lychee.rights.root_album.can_upload) {
52 | html += lychee.html`
53 |
54 | `;
55 | }
56 | if (lychee.rights.settings.can_see_logs) {
57 | html += lychee.html`
58 |
59 | `;
60 | }
61 | if (lychee.rights.settings.can_see_diagnostics) {
62 | html += lychee.html`
63 |
64 | `;
65 | }
66 | html += lychee.html`
67 |
68 | `;
69 | if (lychee.rights.settings.can_update && lychee.update_available) {
70 | html += lychee.html`
71 |
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 |
21 |
25 |
26 |
27 |
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 |
175 |
176 |
177 |
178 |
182 |
183 |
![image background]()
184 |
185 |
186 |
187 |
188 |
189 |
--------------------------------------------------------------------------------