diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index 36ff3809..f02beff1 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -3,7 +3,7 @@ name: Build docker image on: push: branches: - - main + - develop tags: - 'v*' diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index fac8fded..3feee148 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -21,7 +21,6 @@ jobs: uses: docker://antonyurchenko/git-release:latest env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ALLOW_TAG_PREFIX: "true" ALLOW_EMPTY_CHANGELOG: "true" with: args: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e27a1e8..363b2b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.2.0] - 2021-07-12 +### Added +* [#433](https://github.com/shlinkio/shlink-web-client/pull/433) Added support to provide a default server to connect to via env vars: + + * `SHLINK_SERVER_URL`: The URL of the Shlink server to configure by default. + * `SHLINK_SERVER_API_KEY`: The API key of the Shlink server. + * `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided. + +* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder. +* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7. +* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7. +* [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7. +* [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design. +* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab. + +### Changed +* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer. +* [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns. +* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break. + + ## [3.1.2] - 2021-06-06 ### Added * *Nothing* diff --git a/Dockerfile b/Dockerfile index 3b704baf..d495a4eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,13 @@ -FROM node:14.15-alpine as node +FROM node:14.17-alpine as node COPY . /shlink-web-client ARG VERSION="latest" ENV VERSION ${VERSION} RUN cd /shlink-web-client && \ npm install && npm run build -- ${VERSION} --no-dist -FROM nginx:1.19.6-alpine +FROM nginx:1.21-alpine LABEL maintainer="Alejandro Celaya " RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY scripts/docker/servers_from_env.sh /docker-entrypoint.d/30-shlink-servers-json.sh COPY --from=node /shlink-web-client/build /usr/share/nginx/html diff --git a/README.md b/README.md index d58c59a8..b0e7a324 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,25 @@ Those servers can be exported and imported in other browsers, but if for some re If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container. docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client + +Alternatively, you can mount a `conf.d` directory, which in turn contains the `servers.json` file, in a volume inside `/usr/share/nginx/html`. *(since shlink-web-client 3.2.0)*. + + docker run --name shlink-web-client -p 8000:80 -v ${PWD}/my-config/:/usr/share/nginx/html/conf.d/ shlinkio/shlink-web-client + +If you want to pre-configure a single server, you can provide its config via env vars. When the container starts up, it will build the `servers.json` file dynamically based on them. *(since shlink-web-client 3.2.0)*. + + * `SHLINK_SERVER_URL`: The fully qualified URL for the Shlink server. + * `SHLINK_SERVER_API_KEY`: The API key. + * `SHLINK_SERVER_NAME`: The name to be displayed. Defaults to **Shlink** if not provided. + + ```shell + docker run \ + --name shlink-web-client \ + -p 8000:80 \ + -e SHLINK_SERVER_URL=https://doma.in \ + -e SHLINK_SERVER_API_KEY=6aeb82c6-e275-4538-a747-31f9abfba63c \ + shlinkio/shlink-web-client + ``` > **Be extremely careful when using this feature.** > diff --git a/config/docker/nginx.conf b/config/docker/nginx.conf index 1cb4901f..cee606bb 100644 --- a/config/docker/nginx.conf +++ b/config/docker/nginx.conf @@ -20,6 +20,11 @@ server { add_header Cache-Control "public"; } + # servers.json may be on the root, or in conf.d directory + location = /servers.json { + try_files /servers.json /conf.d/servers.json; + } + # When requesting static paths with extension, try them, and return a 404 if not found location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) { try_files $uri $uri/ =404; diff --git a/docker-compose.yml b/docker-compose.yml index 945494d6..e1e70d5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3' services: shlink_web_client_node: container_name: shlink_web_client_node - image: node:14.15-alpine + image: node:14.17-alpine command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start" volumes: - ./:/home/shlink/www diff --git a/package-lock.json b/package-lock.json index f0e9a83d..e3daeb0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6517,15 +6517,6 @@ "integrity": "sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==", "dev": true }, - "@types/moment": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/@types/moment/-/moment-2.13.0.tgz", - "integrity": "sha1-YE69GJvDvDShVIaJQE5hoqSqyJY=", - "dev": true, - "requires": { - "moment": "*" - } - }, "@types/node": { "version": "12.7.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz", @@ -6587,15 +6578,6 @@ "csstype": "^3.0.2" } }, - "@types/react-autosuggest": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.2.tgz", - "integrity": "sha512-K23lmXhC3Bbd8y/jm5+wYrw/NAeN4U/wlHTgAEBIwLOyQKFCFYA3ONKte9P21L+RGIXRP8UlzHOSRtmIZw5Nqw==", - "dev": true, - "requires": { - "@types/react": "*" - } - }, "@types/react-color": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.4.tgz", @@ -6678,10 +6660,10 @@ "@types/react-router": "*" } }, - "@types/react-tagsinput": { - "version": "3.19.7", - "resolved": "https://registry.npmjs.org/@types/react-tagsinput/-/react-tagsinput-3.19.7.tgz", - "integrity": "sha512-yj/3iFBLoan/0vzXMxC9zGhO1uJ89qjQldekf0o3fX4mYdaAPW/VbP921fsyYt6PdHmJ9UMo+kERSMzUAml1xQ==", + "@types/react-tag-autocomplete": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz", + "integrity": "sha512-6qJQS81ZMaqV/ZSADwiU91TXnR6ZJINPqoV3z2SMMSlUcO6CV8Vc5QnqcqcVTj2CHnU3UQ2Q5QfSj3NyXomcDg==", "dev": true, "requires": { "@types/react": "*" @@ -7627,9 +7609,9 @@ "dev": true }, "arch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz", - "integrity": "sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", "dev": true }, "arg": { @@ -10810,40 +10792,48 @@ "dev": true }, "clipboardy": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-1.2.3.tgz", - "integrity": "sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-2.3.0.tgz", + "integrity": "sha512-mKhiIL2DrQIsuXMgBgnfEHOZOryC7kY7YO//TN6c63wlEm3NG5tz+YgY5rVi29KCmq/QQjKYvM7a19+MDOTHOQ==", "dev": true, "requires": { - "arch": "^2.1.0", - "execa": "^0.8.0" + "arch": "^2.1.1", + "execa": "^1.0.0", + "is-wsl": "^2.1.1" }, "dependencies": { - "cross-spawn": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", - "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", - "dev": true, - "requires": { - "lru-cache": "^4.0.1", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - } - }, "execa": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-0.8.0.tgz", - "integrity": "sha1-2NdrvBtVIX7RkP1t1J08d07PyNo=", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", "dev": true, "requires": { - "cross-spawn": "^5.0.1", - "get-stream": "^3.0.0", + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", "is-stream": "^1.1.0", "npm-run-path": "^2.0.0", "p-finally": "^1.0.0", "signal-exit": "^3.0.0", "strip-eof": "^1.0.0" } + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } } } }, @@ -12082,9 +12072,9 @@ } }, "date-fns": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.16.1.tgz", - "integrity": "sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ==" + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.22.1.tgz", + "integrity": "sha512-yUFPQjrxEmIsMqlHhAhmxkuH769baF21Kk+nZwZGyrMoyLA+LugaQtC0+Tqf9CBUUULWwUJt6Q5ySI3LJDDCGg==" }, "date-format": { "version": "3.0.0", @@ -12928,7 +12918,8 @@ "es6-promise": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "dev": true }, "escalade": { "version": "3.1.1", @@ -14269,12 +14260,6 @@ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", "dev": true }, - "fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true - }, "fast-glob": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.4.tgz", @@ -24584,18 +24569,6 @@ } } }, - "react-autosuggest": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz", - "integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==", - "requires": { - "es6-promise": "^4.2.8", - "prop-types": "^15.7.2", - "react-themeable": "^1.1.0", - "section-iterator": "^2.0.0", - "shallow-equal": "^1.2.1" - } - }, "react-chartjs-2": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz", @@ -24903,11 +24876,6 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, - "react-moment": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.0.0.tgz", - "integrity": "sha512-J4iIiwUT4oZcL7cp2U7naQKbQtqvmzGXXBMg/DLj+Pi7n9EW0VhBRx/1aJ1Tp2poCqTCAPoadLEoUIkReGnNNg==" - }, "react-onclickoutside": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.10.0.tgz", @@ -25012,25 +24980,10 @@ "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-6.0.1.tgz", "integrity": "sha512-69nonicgjT4ofeHxZSpjuz37BoIiWMEbUYkX0mdTCY2mX1U53XDzDUIOVKRg6vVBNGL+pxYjbRzmylXWORh1xQ==" }, - "react-tagsinput": { - "version": "3.19.0", - "resolved": "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.19.0.tgz", - "integrity": "sha1-bjtFWV8tKV1GV78ZRJGYj5SMqr8=" - }, - "react-themeable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz", - "integrity": "sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4=", - "requires": { - "object-assign": "^3.0.0" - }, - "dependencies": { - "object-assign": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", - "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I=" - } - } + "react-tag-autocomplete": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-6.1.0.tgz", + "integrity": "sha512-AMhVqxEEIrOfzH0A9XrpsTaLZCVYgjjxp3DSTuSvx91LBSFI6uYcKe38ltR/H/TQw4aytofVghQ1hR9sKpXRQA==" }, "react-transition-group": { "version": "2.9.0", @@ -26135,11 +26088,6 @@ } } }, - "section-iterator": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz", - "integrity": "sha1-v0RNev7rlK1Dw5rS+yYVFifMuio=" - }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -26215,34 +26163,22 @@ } }, "serve": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/serve/-/serve-11.3.2.tgz", - "integrity": "sha512-yKWQfI3xbj/f7X1lTBg91fXBP0FqjJ4TEi+ilES5yzH0iKJpN5LjNb1YzIfQg9Rqn4ECUS2SOf2+Kmepogoa5w==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serve/-/serve-12.0.0.tgz", + "integrity": "sha512-BkTsETQYynAZ7rXX414kg4X6EvuZQS3UVs1NY0VQYdRHSTYWPYcH38nnDh48D0x6ONuislgjag8uKlU2gTBImA==", "dev": true, "requires": { "@zeit/schemas": "2.6.0", - "ajv": "6.5.3", + "ajv": "6.12.6", "arg": "2.0.0", "boxen": "1.3.0", "chalk": "2.4.1", - "clipboardy": "1.2.3", + "clipboardy": "2.3.0", "compression": "1.7.3", "serve-handler": "6.1.3", "update-check": "1.5.2" }, "dependencies": { - "ajv": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.3.tgz", - "integrity": "sha512-LqZ9wY+fx3UMiiPd741yB2pj3hhil+hQc8taf4o2QGRFpWgZ2V5C8HA165DY9sS3fJwsk7uT7ZlFEyC3Ig3lLg==", - "dev": true, - "requires": { - "fast-deep-equal": "^2.0.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, "chalk": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", @@ -26388,11 +26324,6 @@ "safe-buffer": "^5.0.1" } }, - "shallow-equal": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", - "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==" - }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", diff --git a/package.json b/package.json index b903f58c..efe43b57 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,13 @@ "classnames": "^2.2.6", "compare-versions": "^3.6.0", "csvjson": "^5.1.0", + "date-fns": "^2.22.1", "event-source-polyfill": "^1.0.22", "leaflet": "^1.7.1", - "moment": "^2.29.1", "promise": "^8.1.0", "qs": "^6.9.6", "ramda": "^0.27.1", "react": "^17.0.1", - "react-autosuggest": "^10.1.0", "react-chartjs-2": "^2.11.1", "react-color": "^2.19.3", "react-copy-to-clipboard": "^5.0.2", @@ -48,11 +47,10 @@ "react-dom": "^17.0.1", "react-external-link": "^1.2.0", "react-leaflet": "^3.1.0", - "react-moment": "^1.0.0", "react-redux": "^7.2.2", "react-router-dom": "^5.2.0", "react-swipeable": "^6.0.1", - "react-tagsinput": "^3.19.0", + "react-tag-autocomplete": "^6.1.0", "reactstrap": "^8.9.0", "redux": "^4.0.5", "redux-localstorage-simple": "^2.4.0", @@ -78,11 +76,9 @@ "@types/enzyme": "^3.10.8", "@types/jest": "^26.0.20", "@types/leaflet": "^1.5.23", - "@types/moment": "^2.13.0", "@types/qs": "^6.9.5", "@types/ramda": "^0.27.38", "@types/react": "^17.0.2", - "@types/react-autosuggest": "^10.1.2", "@types/react-color": "^3.0.4", "@types/react-copy-to-clipboard": "^5.0.0", "@types/react-datepicker": "^3.1.5", @@ -90,7 +86,7 @@ "@types/react-leaflet": "^2.5.2", "@types/react-redux": "^7.1.16", "@types/react-router-dom": "^5.1.7", - "@types/react-tagsinput": "^3.19.7", + "@types/react-tag-autocomplete": "^6.1.0", "@types/uuid": "^8.3.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.3.1", "adm-zip": "^0.4.16", @@ -134,7 +130,7 @@ "resolve": "^1.19.0", "sass": "^1.29.0", "sass-loader": "^10.1.0", - "serve": "^11.3.2", + "serve": "^12.0.0", "stryker-cli": "^1.0.0", "style-loader": "^2.0.0", "stylelint": "^13.7.2", diff --git a/scripts/docker/build b/scripts/docker/build index e7b0c8a4..1a39c9cb 100755 --- a/scripts/docker/build +++ b/scripts/docker/build @@ -5,12 +5,12 @@ set -ex PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64" DOCKER_IMAGE="shlinkio/shlink-web-client" -if [[ "$GITHUB_REF" == *"main"* ]]; then +if [[ "$GITHUB_REF" == *"develop"* ]]; then docker buildx build --push \ --platform ${PLATFORMS} \ -t ${DOCKER_IMAGE}:latest . -# If ref is not main, then this is a tag. Build that docker tag and also "stable" +# If ref is not develop, then this is a tag. Build that docker tag and also "stable" else VERSION=${GITHUB_REF#refs/tags/v} TAGS="-t ${DOCKER_IMAGE}:${VERSION}" diff --git a/scripts/docker/servers_from_env.sh b/scripts/docker/servers_from_env.sh new file mode 100755 index 00000000..4275f591 --- /dev/null +++ b/scripts/docker/servers_from_env.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +set -e + +ME=$(basename $0) + +setup_single_shlink_server() { + [ -n "$SHLINK_SERVER_URL" ] || return 0 + [ -n "$SHLINK_SERVER_API_KEY" ] || return 0 + local name="${SHLINK_SERVER_NAME:-Shlink}" + echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json +} + +setup_single_shlink_server + +exit 0 diff --git a/src/App.scss b/src/App.scss index 0096d6e4..a6566e27 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,5 +1,4 @@ @import './utils/base'; -@import './utils/mixins/horizontal-align'; .app-container { height: 100%; @@ -25,18 +24,3 @@ padding: 0 15px; } } - -.app__update-banner.app__update-banner { - @include horizontal-align(); - - position: fixed; - top: $headerHeight - 25px; - padding: 0 4rem 0 0; - z-index: 1040; - margin: 0; - color: var(--text-color); - text-align: center; - width: 700px; - max-width: calc(100% - 30px); - box-shadow: 0 0 1rem var(--brand-color); -} diff --git a/src/App.tsx b/src/App.tsx index f7343b53..d8ac9b2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,11 @@ import { useEffect, FC } from 'react'; import { Route, Switch } from 'react-router-dom'; -import { Alert } from 'reactstrap'; import NotFound from './common/NotFound'; import { ServersMap } from './servers/data'; import { Settings } from './settings/reducers/settings'; import { changeThemeInMarkup } from './utils/theme'; -import { SimpleCard } from './utils/SimpleCard'; +import { AppUpdateBanner } from './common/AppUpdateBanner'; +import { forceUpdate } from './utils/helpers/sw'; import './App.scss'; interface AppProps { @@ -55,16 +55,7 @@ const App = ( - -

This app has just been updated!

-

Restart it to enjoy the new features.

-
+ ); }; diff --git a/src/api/types/index.ts b/src/api/types/index.ts index b9da9fa6..acd0d4f7 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -55,6 +55,7 @@ export interface ShlinkVisitsParams { itemsPerPage?: number; startDate?: string; endDate?: string; + excludeBots?: boolean; } export interface ShlinkShortUrlData extends ShortUrlMeta { diff --git a/src/common/AppUpdateBanner.scss b/src/common/AppUpdateBanner.scss new file mode 100644 index 00000000..7f6f833a --- /dev/null +++ b/src/common/AppUpdateBanner.scss @@ -0,0 +1,17 @@ +@import '../utils/base'; +@import '../utils/mixins/horizontal-align'; + +.app-update-banner.app-update-banner { + @include horizontal-align(); + + position: fixed; + top: $headerHeight - 25px; + padding: 0 4rem 0 0; + z-index: 1040; + margin: 0; + color: var(--text-color); + text-align: center; + width: 700px; + max-width: calc(100% - 30px); + box-shadow: 0 0 1rem var(--brand-color); +} diff --git a/src/common/AppUpdateBanner.tsx b/src/common/AppUpdateBanner.tsx new file mode 100644 index 00000000..c114dd3d --- /dev/null +++ b/src/common/AppUpdateBanner.tsx @@ -0,0 +1,34 @@ +import { FC, MouseEventHandler } from 'react'; +import { Alert, Button } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons'; +import { SimpleCard } from '../utils/SimpleCard'; +import { useToggle } from '../utils/helpers/hooks'; +import './AppUpdateBanner.scss'; + +interface AppUpdateBannerProps { + isOpen: boolean; + toggle: MouseEventHandler; + forceUpdate: Function; +} + +export const AppUpdateBanner: FC = ({ isOpen, toggle, forceUpdate }) => { + const [ isUpdating,, setUpdating ] = useToggle(); + const update = () => { + setUpdating(); + forceUpdate(); + }; + + return ( + +

This app has just been updated!

+

+ Restart it to enjoy the new features. + +

+
+ ); +}; diff --git a/src/common/Home.tsx b/src/common/Home.tsx index 882aeb3a..76a65b6a 100644 --- a/src/common/Home.tsx +++ b/src/common/Home.tsx @@ -2,6 +2,8 @@ import { isEmpty, values } from 'ramda'; import { Link } from 'react-router-dom'; import { Card, Row } from 'reactstrap'; import { ExternalLink } from 'react-external-link'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons'; import ServersListGroup from '../servers/ServersListGroup'; import { ServersMap } from '../servers/data'; import { ShlinkLogo } from './img/ShlinkLogo'; @@ -30,12 +32,19 @@ const Home = ({ servers }: HomeProps) => { {!hasServers && ( -
-

This application will help you to manage your Shlink servers.

-

To start, please, add your first server.

-

- You still don‘t have a Shlink server? - Learn how to get started. +

+

This application will help you manage your Shlink servers.

+

+ + Add a server + +

+

+ + + Learn more about Shlink + +

)} diff --git a/src/common/react-tag-autocomplete.scss b/src/common/react-tag-autocomplete.scss new file mode 100644 index 00000000..b41d7b7c --- /dev/null +++ b/src/common/react-tag-autocomplete.scss @@ -0,0 +1,145 @@ +@import '../utils/base'; + +.react-tags { + position: relative; + padding: 5px 0 0 6px; + border-radius: .3rem; + background-color: var(--input-color); + border: 1px solid var(--input-border-color); + transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; + + /* shared font styles */ + font-size: 1em; + line-height: 1.2; + + /* clicking anywhere will focus the input */ + cursor: text; +} + +.react-tags.is-focused { + box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); +} + +.react-tags__tag { + font-size: 100%; +} + +.react-tags__selected { + display: inline; + vertical-align: 2px; +} + +.react-tags__selected-tag { + display: inline-block; + box-sizing: border-box; + margin: 0 6px 6px 0; + padding: 6px 8px; + border: 1px solid var(--input-border-color); + border-radius: .25rem; + background: #f1f1f1; + + /* match the font styles */ + font-size: inherit; + line-height: inherit; +} + +.react-tags__selected-tag:after { + content: '\2715'; + color: #aaaaaa; + margin-left: 8px; +} + +.react-tags__selected-tag:hover, +.react-tags__selected-tag:focus { + border-color: var(--input-border-color); +} + +.react-tags__search { + display: inline-block; + + /* match tag layout */ + padding: 6px 2px; + margin-bottom: 5px; + + /* prevent autoresize overflowing the container */ + max-width: 100%; +} + +@media screen and (min-width: $smMin) { + .react-tags__search { + /* this will become the offsetParent for suggestions */ + position: relative; + } +} + +.react-tags__search-input { + font-size: 1.25rem; + line-height: inherit; + color: var(--input-text-color); + background-color: var(--input-color); + + /* prevent autoresize overflowing the container */ + max-width: 100%; + + /* remove styles and layout from this element */ + margin: 0 0 0 7px; + padding: 0; + border: 0; + outline: none; +} + +.react-tags__search-input::-ms-clear { + display: none; +} + +.react-tags__suggestions { + position: absolute; + top: 100%; + left: 0; + width: 100%; + z-index: 10; +} + +@media screen and (min-width: $smMin) { + .react-tags__suggestions { + width: 240px; + } +} + +.react-tags__suggestions ul { + margin: 4px -1px; + padding: 0; + list-style: none; + background: var(--primary-color); + border: 1px solid var(--border-color); + border-radius: .25rem; + box-shadow: 0 2px 6px rgba(0, 0, 0, .2); +} + +.react-tags__suggestions li { + padding: 8px 10px; +} + +.react-tags__suggestions li:not(:last-child) { + border-bottom: 1px solid var(--border-color); +} + +.react-tags__suggestions li mark { + text-decoration: underline; + background: none; + font-weight: 600; +} + +.react-tags__suggestions li:hover { + cursor: pointer; + background-color: var(--active-color); +} + +.react-tags__suggestions li.is-active { + background-color: var(--active-color); +} + +.react-tags__suggestions li.is-disabled { + opacity: .5; + cursor: auto; +} diff --git a/src/common/react-tagsinput.scss b/src/common/react-tagsinput.scss deleted file mode 100644 index 6ecd1cd3..00000000 --- a/src/common/react-tagsinput.scss +++ /dev/null @@ -1,58 +0,0 @@ -@import '../utils/base'; - -.react-tagsinput { - background-color: var(--input-color); - border: 1px solid var(--input-border-color); - border-radius: .25rem; - overflow: hidden; - min-height: 2.6rem; - padding: .5rem 0 0 1rem; - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; -} - -.react-tagsinput--focused { - border-color: #80bdff; - box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); -} - -.react-tagsinput-tag { - font-size: 1rem; - background-color: #f1f1f1; - border-radius: 4px; - display: inline-block; - font-weight: 400; - margin: 0 5px 6px 0; - padding: 6px 8px; - line-height: 1; - color: #ffffff; -} - -.react-tagsinput-remove { - cursor: pointer; - font-weight: 700; - margin-left: 8px; -} - -.react-tagsinput-tag span:before { - content: '\2715'; - color: #ffffff; -} - -.react-tagsinput-input { - background: transparent; - border: 0; - outline: none; - padding: 1px 0; - width: 100%; - margin-bottom: 6px; - font-size: 1.25rem; - color: var(--input-text-color); -} - -.react-tagsinput-input::placeholder { - color: $textPlaceholder; -} - -.react-autosuggest__suggestion--highlighted { - background-color: var(--active-color); -} diff --git a/src/index.scss b/src/index.scss index 5bcc10ae..5a330475 100644 --- a/src/index.scss +++ b/src/index.scss @@ -2,7 +2,7 @@ @import './utils/base'; @import 'node_modules/bootstrap/scss/bootstrap.scss'; -@import './common/react-tagsinput.scss'; +@import './common/react-tag-autocomplete.scss'; @import './theme/theme'; * { diff --git a/src/servers/ServersDropdown.tsx b/src/servers/ServersDropdown.tsx index 81045e42..284643f6 100644 --- a/src/servers/ServersDropdown.tsx +++ b/src/servers/ServersDropdown.tsx @@ -15,7 +15,7 @@ const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, select const serversList = values(servers); const createServerItem = ( - Add server + Add a server ); diff --git a/src/short-urls/EditShortUrl.tsx b/src/short-urls/EditShortUrl.tsx index fdcec096..56151bfa 100644 --- a/src/short-urls/EditShortUrl.tsx +++ b/src/short-urls/EditShortUrl.tsx @@ -41,6 +41,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting validSince: shortUrl.meta.validSince ?? undefined, validUntil: shortUrl.meta.validUntil ?? undefined, maxVisits: shortUrl.meta.maxVisits ?? undefined, + crawlable: shortUrl.crawlable, validateUrl, }; }; diff --git a/src/short-urls/SearchBar.tsx b/src/short-urls/SearchBar.tsx index 9f78f352..de135dd4 100644 --- a/src/short-urls/SearchBar.tsx +++ b/src/short-urls/SearchBar.tsx @@ -1,7 +1,7 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEmpty, pipe } from 'ramda'; -import moment from 'moment'; +import { parseISO } from 'date-fns'; import SearchField from '../utils/SearchField'; import Tag from '../tags/helpers/Tag'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; @@ -16,7 +16,7 @@ interface SearchBarProps { shortUrlsListParams: ShortUrlsListParams; } -const dateOrNull = (date?: string) => date ? moment(date) : null; +const dateOrNull = (date?: string) => date ? parseISO(date) : null; const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => { const selectedTags = shortUrlsListParams.tags ?? []; diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 51714bbc..368da62e 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -2,10 +2,11 @@ import { FC, useEffect, useState } from 'react'; import { InputType } from 'reactstrap/lib/Input'; import { Button, FormGroup, Input, Row } from 'reactstrap'; import { isEmpty, pipe, replace, trim } from 'ramda'; -import m from 'moment'; import classNames from 'classnames'; +import { parseISO } from 'date-fns'; import DateInput, { DateInputProps } from '../utils/DateInput'; import { + supportsCrawlableVisits, supportsListingDomains, supportsSettingShortCodeLength, supportsShortUrlTitle, @@ -20,6 +21,7 @@ import { DomainSelectorProps } from '../domains/DomainSelector'; import { formatIsoDate } from '../utils/helpers/date'; import UseExistingIfFoundInfoIcon from './UseExistingIfFoundInfoIcon'; import { ShortUrlData } from './data'; +import { ShortUrlFormCheckboxGroup } from './helpers/ShortUrlFormCheckboxGroup'; import './ShortUrlForm.scss'; export type Mode = 'create' | 'create-basic' | 'edit'; @@ -36,6 +38,7 @@ export interface ShortUrlFormProps { } const normalizeTag = pipe(trim, replace(/ /g, '-')); +const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date; export const ShortUrlForm = ( TagsSelector: FC, @@ -72,7 +75,7 @@ export const ShortUrlForm = ( const renderDateInput = (id: DateFields, placeholder: string, props: Partial = {}) => (
setShortUrlData({ ...shortUrlData, [id]: date })} @@ -94,7 +97,7 @@ export const ShortUrlForm = ( - + ); @@ -108,7 +111,8 @@ export const ShortUrlForm = ( 'col-sm-12': !showCustomizeCard, }); const showValidateUrl = supportsValidateUrl(selectedServer); - const showExtraValidationsCard = showValidateUrl || !isEdit; + const showCrawlableControl = supportsCrawlableVisits(selectedServer); + const showExtraValidationsCard = showValidateUrl || showCrawlableControl || !isEdit; return (
@@ -160,30 +164,31 @@ export const ShortUrlForm = (
{renderOptionalInput('maxVisits', 'Maximum number of visits allowed', 'number', { min: 1 })} - {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? m(shortUrlData.validUntil) : undefined })} - {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? m(shortUrlData.validSince) : undefined })} + {renderDateInput('validSince', 'Enabled since...', { maxDate: shortUrlData.validUntil ? toDate(shortUrlData.validUntil) : undefined })} + {renderDateInput('validUntil', 'Enabled until...', { minDate: shortUrlData.validSince ? toDate(shortUrlData.validSince) : undefined })}
{showExtraValidationsCard && ( - - {!isEdit && ( -

- Make sure the long URL is valid, or ensure an existing short URL is returned if it matches all - provided data. -

- )} + {showValidateUrl && ( -

- setShortUrlData({ ...shortUrlData, validateUrl })} - > - Validate URL - -

+ setShortUrlData({ ...shortUrlData, validateUrl })} + > + Validate URL + + )} + {showCrawlableControl && ( + setShortUrlData({ ...shortUrlData, crawlable })} + > + Make it crawlable + )} {!isEdit && (

diff --git a/src/short-urls/data/index.ts b/src/short-urls/data/index.ts index c3e5dfea..c0fded8b 100644 --- a/src/short-urls/data/index.ts +++ b/src/short-urls/data/index.ts @@ -1,14 +1,14 @@ -import * as m from 'moment'; import { Nullable, OptionalString } from '../../utils/utils'; export interface EditShortUrlData { longUrl?: string; tags?: string[]; title?: string; - validSince?: m.Moment | string | null; - validUntil?: m.Moment | string | null; + validSince?: Date | string | null; + validUntil?: Date | string | null; maxVisits?: number | null; validateUrl?: boolean; + crawlable?: boolean; } export interface ShortUrlData extends EditShortUrlData { @@ -29,6 +29,7 @@ export interface ShortUrl { tags: string[]; domain: string | null; title?: string | null; + crawlable?: boolean; } export interface ShortUrlMeta { diff --git a/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx b/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx new file mode 100644 index 00000000..91dff0bc --- /dev/null +++ b/src/short-urls/helpers/ShortUrlFormCheckboxGroup.tsx @@ -0,0 +1,39 @@ +import { ChangeEvent, FC, useRef } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; +import { UncontrolledTooltip } from 'reactstrap'; +import Checkbox from '../../utils/Checkbox'; + +interface ShortUrlFormCheckboxGroupProps { + checked?: boolean; + onChange?: (checked: boolean, e: ChangeEvent) => void; + infoTooltip?: string; +} + +const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => { + const ref = useRef(); + + return ( + <> + { + ref.current = el; + }} + > + + + ref.current) as any} placement="right">{tooltip} + + ); +}; + +export const ShortUrlFormCheckboxGroup: FC = ( + { children, infoTooltip, checked, onChange }, +) => ( +

+ + {children} + + {infoTooltip && } +

+); diff --git a/src/short-urls/helpers/ShortUrlsRow.tsx b/src/short-urls/helpers/ShortUrlsRow.tsx index db40e670..e04710c9 100644 --- a/src/short-urls/helpers/ShortUrlsRow.tsx +++ b/src/short-urls/helpers/ShortUrlsRow.tsx @@ -1,6 +1,5 @@ -import { isEmpty } from 'ramda'; import { FC, useEffect, useRef } from 'react'; -import Moment from 'react-moment'; +import { isEmpty } from 'ramda'; import { ExternalLink } from 'react-external-link'; import ColorGenerator from '../../utils/services/ColorGenerator'; import { StateFlagTimeout } from '../../utils/helpers/hooks'; @@ -8,6 +7,7 @@ import Tag from '../../tags/helpers/Tag'; import { SelectedServer } from '../../servers/data'; import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon'; import { ShortUrl } from '../data'; +import { Time } from '../../utils/Time'; import ShortUrlVisitsCount from './ShortUrlVisitsCount'; import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu'; import './ShortUrlsRow.scss'; @@ -53,7 +53,7 @@ const ShortUrlsRow = ( return ( - {shortUrl.dateCreated} +
- {isOrphanVisits && ( - - )} +
{visits.length > 0 && ( diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 9eb0eef2..ed791f67 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -1,29 +1,34 @@ import { useEffect, useMemo, useState, useRef } from 'react'; -import Moment from 'react-moment'; import classNames from 'classnames'; import { min, splitEvery } from 'ramda'; import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon, faCheck as checkIcon, + faRobot as botIcon, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UncontrolledTooltip } from 'reactstrap'; import SimplePaginator from '../common/SimplePaginator'; import SearchField from '../utils/SearchField'; import { determineOrderDir, OrderDir } from '../utils/utils'; import { prettify } from '../utils/helpers/numbers'; +import { supportsBotVisits } from '../utils/helpers/features'; +import { SelectedServer } from '../servers/data'; +import { Time } from '../utils/Time'; import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import './VisitsTable.scss'; -interface VisitsTableProps { +export interface VisitsTableProps { visits: NormalizedVisit[]; selectedVisits?: NormalizedVisit[]; setSelectedVisits: (visits: NormalizedVisit[]) => void; matchMedia?: (query: string) => MediaQueryList; isOrphanVisits?: boolean; + selectedServer: SelectedServer; } -type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl'; +type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot'; interface Order { field?: OrderableFields; @@ -58,6 +63,7 @@ const VisitsTable = ({ visits, selectedVisits = [], setSelectedVisits, + selectedServer, matchMedia = window.matchMedia, isOrphanVisits = false, }: VisitsTableProps) => { @@ -69,10 +75,11 @@ const VisitsTable = ({ const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]); const isFirstLoad = useRef(true); - const [ page, setPage ] = useState(1); const end = page * PAGE_SIZE; const start = end - PAGE_SIZE; + const supportsBots = supportsBotVisits(selectedServer); + const fullSizeColSpan = 7 + Number(supportsBots) + Number(isOrphanVisits); const orderByColumn = (field: OrderableFields) => () => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) }); @@ -102,13 +109,19 @@ const VisitsTable = ({ setSelectedVisits( selectedVisits.length < resultSet.total ? resultSet.visitsGroups.flat() : [], )} > 0 })} /> + {supportsBots && ( + + + {renderOrderIcon('potentialBot')} + + )} Date {renderOrderIcon('date')} @@ -141,7 +154,7 @@ const VisitsTable = ({ )} - + @@ -149,7 +162,7 @@ const VisitsTable = ({ {!resultSet.visitsGroups[page - 1]?.length && ( - + No visits found with current filtering @@ -169,9 +182,19 @@ const VisitsTable = ({ {isSelected && } - - {visit.date} - + {supportsBots && ( + + {visit.potentialBot && ( + <> + + + Potentially a visit from a bot or crawler + + + )} + + )} +