Compare commits

..

4 Commits

Author SHA1 Message Date
Alejandro Celaya
c75a3a4073
Merge pull request #1103 from shlinkio/develop
Release 4.1.0
2024-03-17 12:26:34 +01:00
Alejandro Celaya
e68643108a
Merge pull request #1049 from shlinkio/develop
Release 4.0.1
2024-02-01 08:58:14 +01:00
Alejandro Celaya
8a7a51be2f
Merge pull request #1045 from shlinkio/develop
Release 4.0.0
2024-01-29 19:11:22 +01:00
Alejandro Celaya
f5e92c6897
Merge pull request #848 from shlinkio/develop
Release 3.10.2
2023-07-09 10:17:23 +02:00
190 changed files with 14496 additions and 11581 deletions

10
.eslintrc Normal file
View File

@ -0,0 +1,10 @@
{
"root": true,
"extends": [
"@shlinkio/js-coding-standard"
],
"parserOptions": {
"project": "./tsconfig.json"
},
"ignorePatterns": ["src/service*.ts"]
}

24
.github/DISCUSSION_TEMPLATE/q-a.yml vendored Normal file
View File

@ -0,0 +1,24 @@
title: 'Q&A'
body:
- type: input
validations:
required: true
attributes:
label: shlink-web-client version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you use shlink-web-client
options:
- https://app.shlink.io
- Docker image
- Self-hosted
- Other (explain in summary)
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe your issue, question or request here. -->'

7
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,7 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

View File

@ -2,4 +2,4 @@ blank_issues_enabled: true
contact_links: contact_links:
- name: Question - Support - name: Question - Support
about: Do you need help setting up or using shlink-web-client? about: Do you need help setting up or using shlink-web-client?
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted url: https://github.com/shlinkio/shlink-web-client/discussions/new?category=q-a

View File

@ -12,21 +12,12 @@ updates:
fontawesome: fontawesome:
patterns: patterns:
- '@fortawesome/*' - '@fortawesome/*'
eslint:
patterns:
- '@shlinkio/eslint-config-js-coding-standard'
- 'typescript-eslint'
- '*eslint-plugin*'
- 'eslint'
shlink: shlink:
patterns: patterns:
- '@shlinkio/*' - '@shlinkio/*'
react: types:
patterns: patterns:
- 'react' - '@types/*'
- 'react-dom'
- '@types/react'
- '@types/react-dom'
testing: testing:
patterns: patterns:
- '@testing-library/*' - '@testing-library/*'
@ -38,13 +29,10 @@ updates:
patterns: patterns:
- 'vitest' - 'vitest'
- '@vitest/*' - '@vitest/*'
workbox: ignore:
patterns: # Bootstrap can introduce visual breaking changes on styles
- 'workbox*' # Ignore it, since the plan is to remove it anyway
tailwindcss: - dependency-name: 'bootstrap'
patterns:
- 'tailwindcss'
- '@tailwindcss/*'
- package-ecosystem: docker - package-ecosystem: docker
directory: '/' directory: '/'
schedule: schedule:

View File

@ -1,10 +0,0 @@
name: Test docker image build
on:
pull_request:
paths:
- 'Dockerfile'
jobs:
build-docker-image:
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main

View File

@ -11,6 +11,5 @@ jobs:
ci: ci:
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
with: with:
node-version: 22.x node-version: 20.7
publish-coverage: true publish-coverage: true
install-playwright: true

View File

@ -5,23 +5,23 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
continue-on-error: true continue-on-error: true
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
- name: Use node.js - name: Use node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 22.10 node-version: 20.7
- name: Build - name: Build
run: | run: |
npm ci && \ npm ci && \
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \ node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
node --run build npm run build
- name: Deploy preview - name: Deploy preview
uses: shlinkio/deploy-preview-action@v1.0.1 uses: shlinkio/deploy-preview-action@v1.0.1
with: with:

View File

@ -7,7 +7,7 @@ on:
jobs: jobs:
build: build:
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
secrets: inherit secrets: inherit
with: with:
image-name: shlinkio/shlink-web-client image-name: shlinkio/shlink-web-client

View File

@ -7,14 +7,14 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: Use node.js - name: Use node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v3
with: with:
node-version: 22.10 node-version: 20.7
- name: Generate release assets - name: Generate release assets
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
- name: Publish release with assets - name: Publish release with assets

4
.gitignore vendored
View File

@ -7,7 +7,9 @@
# production # production
/build /build
/dist
npm-debug.log* npm-debug.log*
docker-compose.override.yml
home
public/servers.json* public/servers.json*

5
.stylelintrc Normal file
View File

@ -0,0 +1,5 @@
{
"extends": [
"@shlinkio/stylelint-config-css-coding-standard"
]
}

View File

@ -4,275 +4,6 @@ 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). 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).
## [4.6.2] - 2025-11-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#878](https://github.com/shlinkio/shlink-web-component/issues/878) Fix real-time updates interval setting being ignored.
## [4.6.1] - 2025-11-15
### Added
* *Nothing*
### Changed
* [#802](https://github.com/shlinkio/shlink-web-client/issues/802) Improve dependency injection in components.
* Stop injecting redux state and actions.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* Fix small UI issues.
## [4.6.0] - 2025-11-12
### Added
* [shlink-web-component#839](https://github.com/shlinkio/shlink-web-component/issues/839) Allow filtering short URLs by excluded tags when using Shlink >=4.6.0
* [shlink-web-component#838](https://github.com/shlinkio/shlink-web-component/issues/838) Allow filtering tag, orphan and non-orphan visits by domain, when using Shlink >=4.6.0
* [shlink-web-component#784](https://github.com/shlinkio/shlink-web-component/issues/784) Add optional `long-url` query parameter to short URL creation to prefill the long URL programmatically.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* Drop support for Shlink older than 4.0.0
### Fixed
* *Nothing*
## [4.5.1] - 2025-08-13
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1637](https://github.com/shlinkio/shlink-web-client/issues/1637) Fix brand color used in PWA
* [#1636](https://github.com/shlinkio/shlink-web-client/issues/1636) Make sure sidebar toggle is rendered only in sections where the sidebar exists.
## [4.5.0] - 2025-08-08
### Added
* [shlink-web-component#755](https://github.com/shlinkio/shlink-web-component/issues/755) Add support for `any-value-query-param` and `valueless-query-param` redirect conditions when using Shlink >=4.5.0.
* [shlink-web-component#756](https://github.com/shlinkio/shlink-web-component/issues/756) Add support for desktop device types on device redirect conditions, when using Shlink >=4.5.0.
* [shlink-web-component#713](https://github.com/shlinkio/shlink-web-component/issues/713) Expose a new `ShlinkSidebarToggleButton` component that can be used to customize the location of the sidebar toggle, rather than making it assume there's a header bar and position it there.
* [shlink-web-component#657](https://github.com/shlinkio/shlink-web-component/issues/657) Allow visits table columns to be customized via settings, and add a new optional "Region" column.
As a side effect, the "Show user agent" toggle has been removed from the list, as this can now be globally configured in the settings.
### Changed
* Update to FontAwesome 7
* Update to Recharts 3
* Update to `@shlinkio/shlink-web-component` 0.16.1
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#698](https://github.com/shlinkio/shlink-web-component/issues/698) Fix line chart selection triggering after clicking a dot in the chart. It now works only when dragging while the mouse is clicked.
## [4.4.1] - 2025-06-23
### Added
* *Nothing*
### Changed
* [shlink-web-component#661](https://github.com/shlinkio/shlink-web-component/issues/661) and [#1571](https://github.com/shlinkio/shlink-web-client/issues/1571) Fully replace bootstrap with tailwind.
* Add the new light theme brand color.
* Update to `@shlinkio/shlink-frontend-kit` 1.0.0 and `@shlinkio/shlink-web-component` 0.15
* Replace reactstrap nav bar with `NavBar` component from `@shlinkio/shlink-frontend-kit`
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.4.0] - 2025-04-20
### Added
* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) can now be forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set
* [shlink-web-component#637](https://github.com/shlinkio/shlink-web-component/pull/637) QR codes are now generated client-side, without hitting Shlink.
* [shlink-web-component#641](https://github.com/shlinkio/shlink-web-component/issues/641) It is now possible to provide any logo to be used with QR codes.
* [shlink-web-component#640](https://github.com/shlinkio/shlink-web-component/issues/640) Allow default QR code settings to be handled via app settings.
### Changed
* Update to `react-router` 7.0
* Update to `@shlinkio/shlink-frontend-kit` 0.8.x
* Update to `@shlinkio/shlink-web-component` 0.13.x
* Update to `@shlinkio/shlink-js-sdk` 2.0.0
* Add `eslint-plugin-react-compiler`
* Run unit tests in a headless browser using vitest browser mode and playwright.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.3.0] - 2024-11-30
### Added
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
This can improve sharing a predefined set of servers cia servers.json, env vars, or simply export and import your servers in some other device, and then be able to share server URLs which continue working.
All existing servers will keep their generated IDs in existing devices for backwards compatibility, but newly created servers will use the new approach.
* [shlink-web-component#491](https://github.com/shlinkio/shlink-web-component/issues/491) Add support for colors in QR code configurator.
* [shlink-web-component#515](https://github.com/shlinkio/shlink-web-component/issues/515) Add support for geolocation redirect conditions, when using Shlink 4.3 or newer.
* [shlink-web-component#514](https://github.com/shlinkio/shlink-web-component/issues/514) Allow filtering short URLs list by domain, when using Shlink 4.3 or newer.
* [shlink-web-component#520](https://github.com/shlinkio/shlink-web-component/issues/520) Allow navigating from domains list to short URLs list filtered by one domain, when using Shlink 4.3 or newer.
* [shlink-web-component#517](https://github.com/shlinkio/shlink-web-component/issues/517) Update list of known domains when a short URL is created with a new domain.
* [shlink-web-component#292](https://github.com/shlinkio/shlink-web-component/issues/292) Add icon in short URLs list indicating if a short URL has redirect rules.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#504](https://github.com/shlinkio/shlink-web-component/issues/504) Fix fallback interval not causing new visits to be loaded.
## [4.2.2] - 2024-10-19
### Added
* *Nothing*
### Changed
* Update to `@shlinkio/shlink-frontend-kit` 0.6.0
* Update to `@shlinkio/shlink-web-component` 0.10.1
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#475](https://github.com/shlinkio/shlink-web-component/issues/475) Fix incorrect amount of dots being displayed in line charts when the difference in days/weeks/months is rounded up.
## [4.2.1] - 2024-10-09
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1325](https://github.com/shlinkio/shlink-web-client/issues/1325) Get dependency on `uuid` package back, as `crypto.randomUUID()` can only be used in [secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).
* [shlink-web-component#461](https://github.com/shlinkio/shlink-web-component/issues/461) Ensure `shortUrlsList.confirmDeletion` setting is `true` in any case, except when explicitly set to `false`.
* [shlink-web-component#237](https://github.com/shlinkio/shlink-web-component/issues/237) Set darker color for previous period in charts, when light theme is enabled.
* [shlink-web-component#246](https://github.com/shlinkio/shlink-web-component/issues/246) Fix selected date range not reflected in visits comparison date range selector, when selecting it in the line chart via drag'n'drop.
## [4.2.0] - 2024-10-07
### Added
* [shlink-web-component#411](https://github.com/shlinkio/shlink-web-component/issues/411) Add support for `ip-address` redirect conditions when Shlink server is >=4.2
* [shlink-web-component#196](https://github.com/shlinkio/shlink-web-component/issues/196) Allow active date range to be changed by selecting a range in visits and visits-comparison line charts.
* [shlink-web-component#307](https://github.com/shlinkio/shlink-web-component/issues/307) Add new setting to disable short URL deletions confirmation.
* [shlink-web-component#435](https://github.com/shlinkio/shlink-web-component/issues/435) Allow toggling between displaying raw user agent and parsed browser/OS in visits table.
* [shlink-web-component#197](https://github.com/shlinkio/shlink-web-component/issues/197) Allow line charts to be expanded to the full size of the viewport, both in individual visits views, and when comparing visits.
* [shlink-web-component#382](https://github.com/shlinkio/shlink-web-component/issues/382) Initialize QR code modal with all params unset, so that they fall back to the server defaults. Additionally, allow them to be unset if desired.
### Changed
* Use `ShlinkWebSettings` from `@shlinkio/shlink-web-component` to replace local settings UI.
* Update to `@shlinkio/eslint-config-js-coding-standard` 3.0, and migrate to ESLint flat config.
* Remove dependency on `uuid` package, and use `crypto.randomUUID()` instead.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.1.2] - 2024-04-17
### Added
* *Nothing*
### Changed
* Use new reusable workflow to publish docker image
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#244](https://github.com/shlinkio/shlink-web-component/issues/244) Display `visitedUrl` in visits table if the visit object has it, regardless of it being an orphan visit or not.
* [shlink-web-component#327](https://github.com/shlinkio/shlink-web-component/issues/327) Ensure orphan visits type is sent to the server, to enable server-side filtering when consumed Shlink supports it.
## [4.1.1] - 2024-04-11
### Added
* [shlink-web-component#293](https://github.com/shlinkio/shlink-web-component/issues/293) Allow ordering redirect rules via drag'n'drop.
### Changed
* Update JS coding standard
* [#1132](https://github.com/shlinkio/shlink-web-client/issues/1132) Add warning message in "validate URLs" setting, indicating it is ignored when consuming Shlink >=4.0.0.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#294](https://github.com/shlinkio/shlink-web-component/issues/294) Make sure "validate URL" control is not displayed in short URL creation/edition, when consuming Shlink >=4.0.0.
* [#1130](https://github.com/shlinkio/shlink-web-client/issues/1130) Fix importing servers in Firefox for Android when the CSV file contains spaces.
* [#1133](https://github.com/shlinkio/shlink-web-client/issues/1133) Fix Shlink versions alignment in server error pages.
## [4.1.0] - 2024-03-17 ## [4.1.0] - 2024-03-17
### Added ### Added
* [#1079](https://github.com/shlinkio/shlink-web-client/issues/1079) Add support Shlink 4.0.0. * [#1079](https://github.com/shlinkio/shlink-web-client/issues/1079) Add support Shlink 4.0.0.
@ -439,7 +170,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0. * [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
### Fixed ### Fixed
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on misconfigured servers, after editing their params to set proper values. * [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
## [3.8.2] - 2022-12-17 ## [3.8.2] - 2022-12-17

View File

@ -14,13 +14,16 @@ Because of this, the only actual dependencies are [docker](https://docs.docker.c
The first thing you need to do is fork the repository, and clone it in your local machine. The first thing you need to do is fork the repository, and clone it in your local machine.
Then simply run `docker compose up` and you will have the project exposed in port `3000` (http://localhost:3000). Then you will have to follow these steps:
> The first time the container is created, the project dependencies will be installed and the container may take a bit longer to start. * Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
* Start-up the project by running `docker compose up`.
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
## Project structure ## Project structure
This project is a [react](https://react.dev/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application. This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
This is the basic project structure: This is the basic project structure:
@ -36,7 +39,7 @@ shlink-web-client
``` ```
* `config`: It contains some configuration scripts, used during testing, linting and building of the project. * `config`: It contains some configuration scripts, used during testing, linting and building of the project.
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc.). * `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
* `scripts`: It has some of the CLI scripts used to run tests or building. * `scripts`: It has some of the CLI scripts used to run tests or building.
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic. * `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
* `test`: Contains the project tests. * `test`: Contains the project tests.
@ -45,16 +48,20 @@ shlink-web-client
> Note: The `indocker` shell script is a helper used to run commands inside the docker container. > Note: The `indocker` shell script is a helper used to run commands inside the docker container.
* `./indocker node --run lint`: Checks coding styles are fulfilled in JS/TS files. * `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files. * `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
* `./indocker node --run test`: Runs unit tests with Jest. * `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
* `./indocker npm run test`: Runs unit tests with Jest.
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
## Building the project ## Building the project
The source code in this project cannot be run directly in a web browser, you need to build it first. The source code in this project cannot be run directly in a web browser, you need to build it first.
* `./indocker node --run run build`: Builds the project for production using [vite](https://vite.dev/), generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist. * `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
* `./indocker node --run run preview`: Serves the static files inside the `build` folder in a random port. Useful to test the content built with previous command. * `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
## Pull request process ## Pull request process

View File

@ -1,10 +1,10 @@
FROM node:25.2-alpine AS node FROM node:21.7-alpine as node
COPY . /shlink-web-client COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION=${VERSION} ENV VERSION ${VERSION}
RUN cd /shlink-web-client && npm ci && node --run build RUN cd /shlink-web-client && npm ci && npm run build
FROM nginxinc/nginx-unprivileged:1.29-alpine FROM nginxinc/nginx-unprivileged:1.25-alpine
ARG UID=101 ARG UID=101
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View File

@ -7,7 +7,8 @@
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE) [![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlink.io) [![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio)
[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate) [![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io). A ReactJS-based progressive web application for [Shlink](https://shlink.io).
@ -30,7 +31,7 @@ The application runs 100% in the browser, so you can safely access any shlink in
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it. If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 8080. It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
### Self-hosted ### Self-hosted

View File

@ -1,9 +1,24 @@
import '@testing-library/jest-dom/vitest'; import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react'; import { cleanup } from '@testing-library/react';
import axe from 'axe-core';
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
axe.configure({
checks: [
{
// Disable color contrast checking, as it doesn't work in jsdom
id: 'color-contrast',
enabled: false,
},
],
});
// Clear all mocks and cleanup DOM after every test // Clear all mocks and cleanup DOM after every test
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
cleanup(); cleanup();
}); });
HTMLCanvasElement.prototype.getContext = (() => {}) as any;
(global as any).scrollTo = () => {};
(global as any).matchMedia = () => ({ matches: false });

View File

@ -1,15 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.57.0-noble
ENV NODE_VERSION 22.14
ENV TINI_VERSION v0.19.0
# Install Node.js
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
\. "$HOME/.nvm/nvm.sh" && \
nvm install ${NODE_VERSION}
# Install tini
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
RUN chmod +x /sbin/tini
# Set tini as the entry point, as node does not properly handle signals
ENTRYPOINT ["/sbin/tini", "--"]

2
dist/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,9 @@
version: '3'
services:
shlink_web_client_node:
user: 1000:1000
volumes:
- /etc/passwd:/etc/passwd:ro
- /etc/group:/etc/group:ro
- ./home:/home/alejandro

View File

@ -1,12 +1,10 @@
version: '3'
services: services:
shlink_web_client_node: shlink_web_client_node:
container_name: shlink_web_client_node container_name: shlink_web_client_node
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user image: node:20.7-alpine
build: command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
context: .
dockerfile: ./dev.Dockerfile
working_dir: /home/shlink/www
command: /bin/sh -c "npm install && npm run start"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www
ports: ports:

View File

@ -1,4 +0,0 @@
import shlink from '@shlinkio/eslint-config-js-coding-standard';
/* eslint-disable-next-line no-restricted-exports */
export default shlink;

View File

@ -3,8 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2078CF" media="(prefers-color-scheme: light)"> <meta name="theme-color" content="#4696e5">
<meta name="theme-color" content="#0B2D4E" media="(prefers-color-scheme: dark)">
<!-- <!--
manifest.json provides metadata used when your web app is added to the manifest.json provides metadata used when your web app is added to the
@ -85,7 +84,7 @@
<noscript> <noscript>
You need to enable JavaScript to run this app. You need to enable JavaScript to run this app.
</noscript> </noscript>
<div id="root" class="h-full"></div> <div id="root"></div>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>

View File

@ -1,13 +1,12 @@
import { BRAND_COLOR_LM } from '@shlinkio/shlink-frontend-kit';
import type { ManifestOptions } from 'vite-plugin-pwa'; import type { ManifestOptions } from 'vite-plugin-pwa';
export const manifest: Partial<ManifestOptions> = { export const manifest: Partial<ManifestOptions> = {
short_name: 'Shlink', short_name: 'Shlink',
name: 'Shlink Web Client', name: 'Shlink',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',
theme_color: BRAND_COLOR_LM, // Toolbar color theme_color: '#4696e5',
background_color: BRAND_COLOR_LM, // Splash screen background color background_color: '#4696e5',
icons: [ icons: [
{ {
src: './icons/icon-16x16.png', src: './icons/icon-16x16.png',

20016
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,81 +7,79 @@
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "eslint src test config/test *.config.{js,ts}", "lint": "npm run lint:css && npm run lint:js",
"lint:fix": "node --run lint -- --fix", "lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint --ext .js,.ts,.tsx src test",
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
"lint:css:fix": "npm run lint:css -- --fix",
"lint:js:fix": "npm run lint:js -- --fix",
"types": "tsc", "types": "tsc",
"start": "vite serve --host=0.0.0.0", "start": "vite serve --host=0.0.0.0",
"preview": "vite preview --host=0.0.0.0", "preview": "vite preview --host=0.0.0.0",
"build": "node --run types && vite build && node scripts/replace-version.mjs", "build": "npm run types && vite build && node scripts/replace-version.mjs",
"build:dist": "node --run build && node scripts/create-dist-file.mjs", "build:dist": "npm run build && node scripts/create-dist-file.mjs",
"test": "vitest run --run", "test": "vitest run --run",
"test:watch": "vitest --watch", "test:watch": "vitest --watch",
"test:ci": "node --run test -- --coverage", "test:ci": "npm run test -- --coverage",
"test:verbose": "node --run test -- --verbose" "test:verbose": "npm run test -- --verbose"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0", "@fortawesome/fontawesome-free": "^6.5.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^3.1.1", "@fortawesome/react-fontawesome": "^0.2.0",
"@json2csv/plainjs": "^7.0.6", "@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.11.0", "@reduxjs/toolkit": "^2.2.1",
"@shlinkio/data-manipulation": "^1.0.4", "@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^1.4.0", "@shlinkio/shlink-frontend-kit": "^0.5.0",
"@shlinkio/shlink-js-sdk": "^3.0.1", "@shlinkio/shlink-js-sdk": "^1.0.0",
"@shlinkio/shlink-web-component": "^0.17.1", "@shlinkio/shlink-web-component": "^0.6.0",
"@vitest/browser-playwright": "^4.0.15", "bootstrap": "5.2.3",
"bottlejs": "^2.0.1", "bottlejs": "^2.0.1",
"clsx": "^2.1.1", "clsx": "^2.1.0",
"compare-versions": "^6.1.1", "compare-versions": "^6.1.0",
"csvtojson": "^2.0.14", "csvtojson": "^2.0.10",
"date-fns": "^4.1.0", "date-fns": "^3.5.0",
"react": "^19.2.3", "react": "^18.2.0",
"react-dom": "^19.2.3", "react-dom": "^18.2.0",
"react-external-link": "^2.6.1", "react-external-link": "^2.2.0",
"react-redux": "^9.2.0", "react-redux": "^9.1.0",
"react-router": "^7.10.1", "react-router-dom": "^6.22.3",
"reactstrap": "^9.2.2",
"redux-localstorage-simple": "^2.5.1", "redux-localstorage-simple": "^2.5.1",
"workbox-core": "^7.4.0", "uuid": "^9.0.1",
"workbox-expiration": "^7.4.0", "workbox-core": "^7.0.0",
"workbox-precaching": "^7.4.0", "workbox-expiration": "^7.0.0",
"workbox-routing": "^7.4.0", "workbox-precaching": "^7.0.0",
"workbox-strategies": "^7.4.0" "workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0", "@shlinkio/eslint-config-js-coding-standard": "~2.3.0",
"@stylistic/eslint-plugin": "^5.6.1", "@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
"@tailwindcss/vite": "^4.1.17", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.2.1",
"@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.5.2",
"@testing-library/user-event": "^14.6.1",
"@total-typescript/shoehorn": "^0.1.2", "@total-typescript/shoehorn": "^0.1.2",
"@types/node": "^24.10.1", "@types/react": "^18.2.66",
"@types/react": "^19.2.7", "@types/react-dom": "^18.2.22",
"@types/react-dom": "^19.2.3", "@types/uuid": "^9.0.8",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^4.2.1",
"@vitest/browser": "^4.0.3", "@vitest/coverage-v8": "^1.4.0",
"@vitest/coverage-v8": "^4.0.15", "adm-zip": "^0.5.12",
"adm-zip": "^0.5.16", "axe-core": "^4.8.4",
"axe-core": "^4.11.0", "chalk": "^5.3.0",
"chalk": "^5.6.2", "eslint": "^8.57.0",
"eslint": "^9.39.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0", "history": "^5.3.0",
"playwright": "^1.57.0", "jsdom": "^24.0.0",
"tailwindcss": "^4.1.3", "sass": "^1.72.0",
"typescript": "^5.9.3", "stylelint": "^15.11.0",
"typescript-eslint": "^8.48.1", "typescript": "^5.4.2",
"vite": "^7.2.6", "vite": "^5.1.6",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^0.19.4",
"vitest": "^4.0.3" "vitest": "^1.2.2"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,8 +1 @@
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"><g fill="#4595e3"><path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" /><path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" /><path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" /><path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" /></g></svg>
<g fill="#2078CF">
<path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z"/>
<path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"/>
<path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"/>
<path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 B

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 984 B

View File

@ -1,20 +1,23 @@
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
import chalk from 'chalk'; import chalk from 'chalk';
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import fs from 'fs'; import fs from 'fs';
function zipDist(version) { function zipDist(version) {
const fileBaseName = `shlink-web-client_${version}_dist`; const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
const versionFileName = `./dist/${fileBaseName}.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`)); console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip(); const zip = new AdmZip();
try { try {
if (fs.existsSync(versionFileName)) { if (fs.existsSync(versionFileName)) {
fs.unlinkSync(versionFileName); fs.unlink(versionFileName);
} }
zip.addLocalFolder('./build', fileBaseName); zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName); zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated')); console.log(chalk.green('Dist file properly generated'));
} catch (e) { } catch (e) {

View File

@ -4,14 +4,11 @@ set -e
ME=$(basename $0) ME=$(basename $0)
# In order to allow people to pre-configure a server in their shlink-web-client instance via env vars, this function
# dumps a servers.json file based on the values provided via env vars
setup_single_shlink_server() { setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0 [ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0 [ -n "$SHLINK_SERVER_API_KEY" ] || return 0
local name="${SHLINK_SERVER_NAME:-Shlink}" local name="${SHLINK_SERVER_NAME:-Shlink}"
local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}" echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
} }
setup_single_shlink_server setup_single_shlink_server

View File

@ -1,11 +1,13 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk'; import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
import type { GetState } from '../../container/types';
import type { ServerWithId } from '../../servers/data'; import type { ServerWithId } from '../../servers/data';
import { hasServerData } from '../../servers/data'; import { hasServerData } from '../../servers/data';
import type { GetState } from '../../store';
const apiClients: Map<string, ShlinkApiClient> = new Map(); const apiClients: Record<string, ShlinkApiClient> = {};
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
typeof getStateOrSelectedServer === 'function';
const getSelectedServerFromState = (getState: GetState): ServerWithId => { const getSelectedServerFromState = (getState: GetState): ServerWithId => {
const { selectedServer } = getState(); const { selectedServer } = getState();
if (!hasServerData(selectedServer)) { if (!hasServerData(selectedServer)) {
@ -16,22 +18,13 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
}; };
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => { export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
const { url: baseUrl, apiKey, forwardCredentials } = typeof getStateOrSelectedServer === 'function' const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer)
? getSelectedServerFromState(getStateOrSelectedServer) ? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer; : getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`; const serverKey = `${apiKey}_${baseUrl}`;
const existingApiClient = apiClients.get(serverKey);
if (existingApiClient) { const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl });
return existingApiClient; apiClients[serverKey] = apiClient;
}
const apiClient = new ShlinkApiClient(
httpClient,
{ apiKey, baseUrl },
{ requestCredentials: forwardCredentials ? 'include' : undefined },
);
apiClients.set(serverKey, apiClient);
return apiClient; return apiClient;
}; };

View File

@ -0,0 +1,6 @@
import type Bottle from 'bottlejs';
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
export const provideServices = (bottle: Bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
};

26
src/app/App.scss Normal file
View File

@ -0,0 +1,26 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.app-container {
height: 100%;
}
.app {
padding-top: $headerHeight;
height: 100%;
}
.shlink-wrapper {
min-height: 100%;
padding-bottom: $footer-height + $footer-margin;
margin-bottom: -($footer-height + $footer-margin);
}
.shlink-footer {
height: $footer-height;
margin-top: $footer-margin;
padding: 0;
@media (min-width: $mdMin) {
padding: 0 15px;
}
}

View File

@ -1,71 +1,100 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation } from 'react-router'; import { Route, Routes, useLocation } from 'react-router-dom';
import { AppUpdateBanner } from '../common/AppUpdateBanner'; import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { Home } from '../common/Home';
import { MainHeader } from '../common/MainHeader';
import { NotFound } from '../common/NotFound'; import { NotFound } from '../common/NotFound';
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer'; import type { FCWithDeps } from '../container/utils';
import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer'; import { componentFactory, useDependencies } from '../container/utils';
import { CreateServer } from '../servers/CreateServer'; import type { ServersMap } from '../servers/data';
import { EditServer } from '../servers/EditServer'; import type { AppSettings } from '../settings/reducers/settings';
import { ManageServers } from '../servers/ManageServers';
import { useLoadRemoteServers } from '../servers/reducers/remoteServers';
import { useSettings } from '../settings/reducers/settings';
import { Settings } from '../settings/Settings';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
import { useAppUpdated } from './reducers/appUpdates'; import './App.scss';
export const App: FC = () => { type AppProps = {
const { appUpdated, resetAppUpdate } = useAppUpdated(); fetchServers: () => void;
servers: ServersMap;
settings: AppSettings;
resetAppUpdate: () => void;
appUpdated: boolean;
};
useLoadRemoteServers(); type AppDeps = {
MainHeader: FC;
Home: FC;
ShlinkWebComponentContainer: FC;
CreateServer: FC;
EditServer: FC;
Settings: FC;
ManageServers: FC;
ShlinkVersionsContainer: FC;
};
const App: FCWithDeps<AppProps, AppDeps> = (
{ fetchServers, servers, settings, appUpdated, resetAppUpdate },
) => {
const {
MainHeader,
Home,
ShlinkWebComponentContainer,
CreateServer,
EditServer,
Settings,
ManageServers,
ShlinkVersionsContainer,
} = useDependencies(App);
const location = useLocation(); const location = useLocation();
const initialServers = useRef(servers);
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
const { settings } = useSettings(); useEffect(() => {
// Try to fetch the remote servers if the list is empty at first
// We use a ref because we don't care if the servers list becomes empty later
if (Object.keys(initialServers.current).length === 0) {
fetchServers();
}
}, [fetchServers]);
useEffect(() => { useEffect(() => {
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme()); changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
}, [settings.ui?.theme]); }, [settings.ui?.theme]);
return ( return (
<div className="h-full"> <div className="container-fluid app-container">
<> <MainHeader />
<MainHeader />
<div className="h-full pt-(--header-height)"> <div className="app">
<div <div className={clsx('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
data-testid="shlink-wrapper" <Routes>
className={clsx( <Route index element={<Home />} />
'min-h-full pb-[calc(var(--footer-height)+var(--footer-margin))] -mb-[calc(var(--footer-height)+var(--footer-margin))]', <Route path="/settings/*" element={<Settings />} />
{ 'flex items-center pt-4': isHome }, <Route path="/manage-servers" element={<ManageServers />} />
)} <Route path="/server/create" element={<CreateServer />} />
> <Route path="/server/:serverId/edit" element={<EditServer />} />
<Routes> <Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
<Route index element={<Home />} /> <Route path="*" element={<NotFound />} />
<Route path="/settings"> </Routes>
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
</Route>
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId">
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<div className="h-(--footer-height) mt-(--footer-margin) md:px-4">
<ShlinkVersionsContainer />
</div>
</div> </div>
</>
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} /> <div className="shlink-footer">
<ShlinkVersionsContainer />
</div>
</div>
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
</div> </div>
); );
}; };
export const AppFactory = componentFactory(App, [
'MainHeader',
'Home',
'ShlinkWebComponentContainer',
'CreateServer',
'EditServer',
'Settings',
'ManageServers',
'ShlinkVersionsContainer',
]);

View File

@ -1,6 +1,4 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../store';
const { actions, reducer } = createSlice({ const { actions, reducer } = createSlice({
name: 'shlink/appUpdates', name: 'shlink/appUpdates',
@ -14,12 +12,3 @@ const { actions, reducer } = createSlice({
export const { appUpdateAvailable, resetAppUpdate } = actions; export const { appUpdateAvailable, resetAppUpdate } = actions;
export const appUpdatesReducer = reducer; export const appUpdatesReducer = reducer;
export const useAppUpdated = () => {
const dispatch = useAppDispatch();
const appUpdateAvailable = useCallback(() => dispatch(actions.appUpdateAvailable()), [dispatch]);
const resetAppUpdate = useCallback(() => dispatch(actions.resetAppUpdate()), [dispatch]);
const appUpdated = useAppSelector((state) => state.appUpdated);
return { appUpdated, appUpdateAvailable, resetAppUpdate };
};

View File

@ -0,0 +1,14 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { AppFactory } from '../App';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.factory('App', AppFactory);
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
// Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
};

View File

@ -0,0 +1,17 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/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);
}

View File

@ -1,46 +1,34 @@
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons'; import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit'; import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx'; import type { MouseEventHandler } from 'react';
import type { FC } from 'react'; import { forwardRef, useCallback } from 'react';
import { useCallback } from 'react'; import { Alert, Button } from 'reactstrap';
import './AppUpdateBanner.scss';
interface AppUpdateBannerProps { interface AppUpdateBannerProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; toggle: MouseEventHandler<any>;
forceUpdate: () => void; forceUpdate: Function;
} }
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => { export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
const { flag: isUpdating, setToTrue: setUpdating } = useToggle(); const [isUpdating,, setUpdating] = useToggle();
const update = useCallback(() => { const update = useCallback(() => {
setUpdating(); setUpdating();
forceUpdate(); forceUpdate();
}, [forceUpdate, setUpdating]); }, [forceUpdate, setUpdating]);
if (!isOpen) {
return null;
}
return ( return (
<Card <Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
role="alert" <h4 className="mb-4">This app has just been updated!</h4>
className={clsx( <p className="mb-0">
'w-[700px] max-w-[calc(100%-30px)]',
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
)}
>
<Card.Header className="flex items-center justify-between">
<h5>This app has just been updated!</h5>
<CloseButton onClick={onClose} />
</Card.Header>
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
Restart it to enjoy the new features. Restart it to enjoy the new features.
<Button disabled={isUpdating} variant="secondary" solid onClick={update}> <Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>} {!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
{isUpdating && <>Restarting...</>} {isUpdating && <>Restarting...</>}
</Button> </Button>
</Card.Body> </p>
</Card> </Alert>
); );
}; });

View File

@ -1,7 +1,7 @@
import { Button } from '@shlinkio/shlink-frontend-kit'; import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { PropsWithChildren, ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { Component } from 'react'; import { Component } from 'react';
import { ErrorLayout } from './ErrorLayout'; import { Button } from 'reactstrap';
type ErrorHandlerProps = PropsWithChildren<{ type ErrorHandlerProps = PropsWithChildren<{
location?: typeof window.location; location?: typeof window.location;
@ -33,11 +33,14 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
if (hasError) { if (hasError) {
return ( return (
<ErrorLayout title="Oops! This is awkward :S"> <div className="home">
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p> <SimpleCard className="p-4">
<br /> <h1>Oops! This is awkward :S</h1>
<Button size="lg" onClick={() => location.reload()}>Take me back</Button> <p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
</ErrorLayout> <br />
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</SimpleCard>
</div>
); );
} }

View File

@ -1,15 +0,0 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren } from 'react';
export type ErrorLayoutProps = PropsWithChildren<{
title: string;
}>;
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
<div className="pt-4">
<SimpleCard className="p-4 w-full lg:w-[65%] m-auto">
<h2>{title}</h2>
{children}
</SimpleCard>
</div>
);

58
src/common/Home.scss Normal file
View File

@ -0,0 +1,58 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@import '../utils/mixins/vertical-align';
$mainCardWidth: 720px;
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
.home {
position: relative;
padding-top: 15px;
width: 100%;
@media (min-width: $mdMin) {
padding-top: 0;
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
}
}
.home__logo-wrapper {
padding: 1.5rem !important;
height: 100% !important;
min-height: 300px;
}
.home__logo {
@include vertical-align();
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
}
.home__main-card {
margin: 0 auto;
max-width: $mainCardWidth;
@media (min-width: $mdMin) {
@include vertical-align();
}
}
.home__title-wrapper {
padding: 1.5rem !important;
border-bottom: 1px solid var(--border-color);
}
.home__title {
text-align: center;
font-size: 1.75rem;
margin: 0;
@media (min-width: $mdMin) {
font-size: 2.2rem;
}
}
.home__servers-container {
@media (min-width: $mdMin) {
border-left: 1px solid var(--border-color);
}
}

View File

@ -1,70 +1,66 @@
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router-dom';
import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer'; import { Card, Row } from 'reactstrap';
import { useServers } from '../servers/reducers/servers'; import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup'; import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
export const Home: FC = withoutSelectedServer(() => { interface HomeProps {
servers: ServersMap;
}
export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { servers } = useServers();
const serversList = Object.values(servers); const serversList = Object.values(servers);
const hasServers = serversList.length > 0; const hasServers = serversList.length > 0;
useEffect(() => { useEffect(() => {
// Try to redirect to the first server marked as auto-connect // Try to redirect to the first server marked as auto-connect
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect); const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
if (autoConnectServer) { autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
navigate(`/server/${autoConnectServer.id}`);
}
}, [serversList, navigate]); }, [serversList, navigate]);
return ( return (
<div className="px-3 w-full"> <div className="home">
<Card className="mx-auto max-w-[720px] overflow-hidden"> <Card className="home__main-card">
<div className="flex flex-col md:flex-row"> <Row className="g-0">
<div className="p-6 hidden md:flex items-center w-[40%]"> <div className="col-md-5 d-none d-md-block">
<div className="w-full"> <div className="home__logo-wrapper">
<ShlinkLogo /> <div className="home__logo">
<ShlinkLogo />
</div>
</div> </div>
</div> </div>
<div className="col-md-7 home__servers-container">
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow"> <div className="home__title-wrapper">
<h1 <h1 className="home__title">Welcome!</h1>
className={clsx( </div>
'p-4 text-center border-lm-border dark:border-dm-border', <ServersListGroup embedded servers={serversList}>
{ 'border-b': !hasServers }, {!hasServers && (
<div className="p-4 text-center">
<p className="mb-5">This application will help you manage your Shlink servers.</p>
<p>
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
</Link>
</p>
<p className="mb-0 mt-5">
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="me-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p>
</div>
)} )}
> </ServersListGroup>
Welcome!
</h1>
{hasServers ? <ServersListGroup servers={serversList} /> : (
<div className="p-6 text-center flex flex-col gap-12 text-xl">
<p>This application will help you manage your Shlink servers.</p>
<p>
<Button to="/server/create" size="lg" inline>
<FontAwesomeIcon icon={faPlus} widthAuto /> Add a server
</Button>
</p>
<p>
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="mr-2">Learn more about Shlink</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
</p>
</div>
)}
</div> </div>
</div> </Row>
</Card> </Card>
</div> </div>
); );
}); };

View File

@ -0,0 +1,24 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.main-header.main-header {
color: white;
background-color: var(--brand-color) !important;
.navbar-brand {
color: inherit !important;
}
}
.main-header__brand-logo {
width: 26px;
margin-right: 5px;
}
.main-header__toggle-icon {
width: 20px;
transition: transform 300ms;
}
.main-header__toggle-icon--opened {
transform: rotate(180deg);
}

View File

@ -1,33 +1,54 @@
import { faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons'; import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { NavBar } from '@shlinkio/shlink-frontend-kit'; import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link, useLocation } from 'react-router'; import { useEffect } from 'react';
import { ServersDropdown } from '../servers/ServersDropdown'; import { Link, useLocation } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './MainHeader.scss';
export const MainHeader: FC = () => { type MainHeaderDeps = {
const { pathname } = useLocation(); ServersDropdown: FC;
};
const MainHeader: FCWithDeps<{}, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader);
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
const location = useLocation();
const { pathname } = location;
// In mobile devices, collapse the navbar when location changes
useEffect(collapse, [location, collapse]);
const settingsPath = '/settings'; const settingsPath = '/settings';
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
return ( return (
<NavBar <Navbar color="primary" dark fixed="top" className="main-header" expand="md">
className="[&]:fixed top-0 z-900" <NavbarBrand tag={Link} to="/">
brand={( <ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2"> </NavbarBrand>
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
</Link> <NavbarToggler onClick={toggleCollapse}>
)} <FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
> </NavbarToggler>
<NavBar.MenuItem
to={settingsPath} <Collapse navbar isOpen={isNotCollapsed}>
active={pathname.startsWith(settingsPath)} <Nav navbar className="ms-auto">
className="flex items-center gap-1.5" <NavItem>
> <NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} /> Settings <FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavBar.MenuItem> </NavLink>
<ServersDropdown /> </NavItem>
</NavBar> <ServersDropdown />
</Nav>
</Collapse>
</Navbar>
); );
}; };
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);

View File

@ -0,0 +1,9 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.no-menu-wrapper {
padding: 15px 0 0;
@media (min-width: $mdMin) {
padding: 30px 20px 20px;
}
}

View File

@ -1,12 +1,6 @@
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss';
export type NoMenuLayoutProps = PropsWithChildren & { export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (
className?: string; <div className="no-menu-wrapper container-xl">{children}</div>
};
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>
{children}
</div>
); );

View File

@ -1,16 +1,19 @@
import { Button } from '@shlinkio/shlink-frontend-kit'; import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { ErrorLayout } from './ErrorLayout'; import { Link } from 'react-router-dom';
type NotFoundProps = PropsWithChildren<{ to?: string }>; type NotFoundProps = PropsWithChildren<{ to?: string }>;
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => ( export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<ErrorLayout title="Oops! We could not find requested route."> <div className="home">
<p> <SimpleCard className="p-4">
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this <h2>Oops! We could not find requested route.</h2>
button. <p>
</p> Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
<br /> button.
<Button inline to={to} size="lg">{children}</Button> </p>
</ErrorLayout> <br />
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
</SimpleCard>
</div>
); );

View File

@ -1,6 +1,6 @@
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router-dom';
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => { export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
const location = useLocation(); const location = useLocation();

View File

@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
} }
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => ( const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-gray-500"> <ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
<b>{version}</b> <b>{version}</b>
</ExternalLink> </ExternalLink>
); );
@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
const normalizedClientVersion = normalizeVersion(clientVersion); const normalizedClientVersion = normalizeVersion(clientVersion);
return ( return (
<small className="text-gray-500"> <small className="text-muted">
{isReachableServer(selectedServer) && ( {isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </> <>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
)} )}

View File

@ -0,0 +1,9 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.shlink-versions-container--with-sidebar {
margin-left: 0;
@media (min-width: $mdMin) {
margin-left: $asideMenuWidth;
}
}

View File

@ -1,14 +1,26 @@
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { isReachableServer } from '../servers/data'; import { useMemo } from 'react';
import { useSelectedServer } from '../servers/reducers/selectedServer'; import { useLocation } from 'react-router-dom';
import type { SelectedServer } from '../servers/data';
import { ShlinkVersions } from './ShlinkVersions'; import { ShlinkVersions } from './ShlinkVersions';
import './ShlinkVersionsContainer.scss';
export type ShlinkVersionsContainerProps = {
selectedServer: SelectedServer;
};
const SHLINK_CONTAINER_PATH_PATTERN = /^\/server\/[a-zA-Z0-9-]*\/(?!edit)/;
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
const { pathname } = useLocation();
const withPadding = useMemo(() => SHLINK_CONTAINER_PATH_PATTERN.test(pathname), [pathname]);
const classes = clsx('text-center', {
'shlink-versions-container--with-sidebar': withPadding,
});
export const ShlinkVersionsContainer = () => {
const { selectedServer } = useSelectedServer();
return ( return (
<div <div className={classes}>
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<ShlinkVersions selectedServer={selectedServer} /> <ShlinkVersions selectedServer={selectedServer} />
</div> </div>
); );

View File

@ -1,37 +1,39 @@
import type { TagColorsStorage } from '@shlinkio/shlink-web-component'; import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import {
ShlinkSidebarToggleButton,
ShlinkSidebarVisibilityProvider,
ShlinkWebComponent,
} from '@shlinkio/shlink-web-component';
import type { FC } from 'react'; import type { FC } from 'react';
import { memo } from 'react'; import { memo } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder'; import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
import { withDependencies } from '../container/context'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { ServerError } from '../servers/helpers/ServerError'; import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer';
import { withSelectedServer } from '../servers/helpers/withSelectedServer'; import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSelectedServer } from '../servers/reducers/selectedServer';
import { useSettings } from '../settings/reducers/settings';
import { NotFound } from './NotFound'; import { NotFound } from './NotFound';
export type ShlinkWebComponentContainerProps = { type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
TagColorsStorage: TagColorsStorage; settings: Settings;
buildShlinkApiClient: ShlinkApiClientBuilder;
}; };
const ShlinkWebComponentContainerBase: FC< type ShlinkWebComponentContainerDeps = {
ShlinkWebComponentContainerProps buildShlinkApiClient: ShlinkApiClientBuilder,
TagColorsStorage: TagColorsStorage,
ShlinkWebComponent: ShlinkWebComponentType,
ServerError: FC,
};
const ShlinkWebComponentContainer: FCWithDeps<
ShlinkWebComponentContainerProps,
ShlinkWebComponentContainerDeps
// FIXME Using `memo` here to solve a flickering effect in charts. // FIXME Using `memo` here to solve a flickering effect in charts.
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the // memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
// extra rendering there. // extra rendering there.
// This should be revisited at some point. // This should be revisited at some point.
> = withSelectedServer(memo(({ > = withSelectedServer(memo(({ selectedServer, settings }) => {
buildShlinkApiClient, const {
TagColorsStorage: tagColorsStorage, buildShlinkApiClient,
}) => { TagColorsStorage: tagColorsStorage,
const { selectedServer } = useSelectedServer(); ShlinkWebComponent,
const { settings } = useSettings(); ServerError,
} = useDependencies(ShlinkWebComponentContainer);
if (!isReachableServer(selectedServer)) { if (!isReachableServer(selectedServer)) {
return <ServerError />; return <ServerError />;
@ -39,24 +41,22 @@ const ShlinkWebComponentContainerBase: FC<
const routesPrefix = `/server/${selectedServer.id}`; const routesPrefix = `/server/${selectedServer.id}`;
return ( return (
<ShlinkSidebarVisibilityProvider> <ShlinkWebComponent
<ShlinkSidebarToggleButton className="fixed top-3.5 left-3 z-901" /> serverVersion={selectedServer.version}
<ShlinkWebComponent apiClient={buildShlinkApiClient(selectedServer)}
serverVersion={selectedServer.version} settings={settings}
apiClient={buildShlinkApiClient(selectedServer)} routesPrefix={routesPrefix}
settings={settings} tagColorsStorage={tagColorsStorage}
routesPrefix={routesPrefix} createNotFound={(nonPrefixedHomePath) => (
tagColorsStorage={tagColorsStorage} <NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
createNotFound={(nonPrefixedHomePath: string) => ( )}
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound> />
)}
autoSidebarToggle={false}
/>
</ShlinkSidebarVisibilityProvider>
); );
})); }));
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [ export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
'buildShlinkApiClient', 'buildShlinkApiClient',
'TagColorsStorage', 'TagColorsStorage',
'ShlinkWebComponent',
'ServerError',
]); ]);

View File

@ -1,11 +1,11 @@
import { brandColor } from '@shlinkio/shlink-frontend-kit'; import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit';
export interface ShlinkLogoProps { export interface ShlinkLogoProps {
color?: string; color?: string;
className?: string; className?: string;
} }
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => ( export const ShlinkLogo = ({ color = MAIN_COLOR, className }: ShlinkLogoProps) => (
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill={color}> <g fill={color}>
<path <path

View File

@ -0,0 +1,37 @@
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/browser';
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ErrorHandler } from '../ErrorHandler';
import { Home } from '../Home';
import { MainHeaderFactory } from '../MainHeader';
import { ScrollToTop } from '../ScrollToTop';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.constant('window', window);
bottle.constant('console', console);
bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', FetchHttpClient, 'fetch');
// Components
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
bottle.factory('MainHeader', MainHeaderFactory);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
bottle.serviceFactory('ShlinkWebComponent', () => ShlinkWebComponent);
bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory);
bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer']));
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer']));
bottle.serviceFactory('ErrorHandler', () => ErrorHandler);
};

View File

@ -1,63 +0,0 @@
import type { IContainer } from 'bottlejs';
import { type ComponentType, createContext, useContext } from 'react';
const ContainerContext = createContext<IContainer | null>(null);
export const ContainerProvider = ContainerContext.Provider;
const useContainer = (wrapperName: string): IContainer => {
const container = useContext(ContainerContext);
if (!container) {
throw new Error(`You cannot use "${wrapperName}" outside of a ContainerProvider`);
}
return container;
};
/**
* Hook used to extract dependencies from the container in other hooks.
*/
export const useDependencies = <T extends unknown[]>(...names: string[]): T => {
const container = useContainer('useDependencies');
return names.map((name) => {
const dependency = container[name];
if (!dependency) {
throw new Error(`Dependency with name "${name}" not found in container`);
}
return dependency;
}) as T;
};
type Optionalize<P, K extends keyof P> = Omit<P, K> & Partial<Pick<P, K>>;
/**
* Higher Order Component used to inject services into components as props.
* All dependencies become optional props so that they can still be explicitly set in tests if desired.
*/
export function withDependencies<
Props extends Record<string, unknown>,
DependencyName extends string & keyof Props,
>(
Component: ComponentType<Props>,
dependencyNames: DependencyName[],
): ComponentType<Optionalize<Props, DependencyName>> {
function Wrapper(props: Omit<Props, DependencyName>) {
const container = useContext(ContainerContext);
// Inject services, unless they have been overridden by props passed from
// the parent component.
const dependencies: Partial<Record<DependencyName, unknown>> = {};
for (const dependency of dependencyNames) {
if (!(dependency in props)) {
dependencies[dependency] = container?.[dependency];
}
}
const propsWithServices = { ...dependencies, ...props } as Props;
return <Component {...propsWithServices} />;
}
return Wrapper;
}

View File

@ -1,32 +1,42 @@
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit'; import type { IContainer } from 'bottlejs';
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
import Bottle from 'bottlejs'; import Bottle from 'bottlejs';
import { buildShlinkApiClient } from '../api/services/ShlinkApiClientBuilder'; import { connect as reduxConnect } from 'react-redux';
import { ServersExporter } from '../servers/services/ServersExporter'; import { provideServices as provideApiServices } from '../api/services/provideServices';
import { ServersImporter } from '../servers/services/ServersImporter'; import { provideServices as provideAppServices } from '../app/services/provideServices';
import { csvToJson, jsonToCsv } from '../utils/helpers/csvjson'; import { provideServices as provideCommonServices } from '../common/services/provideServices';
import { LocalStorage } from '../utils/services/LocalStorage'; import { provideServices as provideServersServices } from '../servers/services/provideServices';
import { TagColorsStorage } from '../utils/services/TagColorsStorage'; import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
import type { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
const bottle = new Bottle(); const bottle = new Bottle();
export const { container } = bottle; export const { container } = bottle;
bottle.constant('window', window); const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
bottle.constant('console', console); (...args: any[]) => (cont[serviceName] as T)(...args) as K;
bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', FetchHttpClient, 'fetch');
bottle.constant('localStorage', window.localStorage); const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
bottle.service('Storage', LocalStorage, 'localStorage'); ...map,
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage'); // Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName),
});
bottle.constant('csvToJson', csvToJson); const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries(
bottle.constant('jsonToCsv', jsonToCsv); propsToPick.map((key) => [key, obj[key]]),
);
bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle); const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
reduxConnect(
propsFromState ? pickProps(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient'); provideAppServices(bottle, connect);
provideCommonServices(bottle, connect);
bottle.service('ServersImporter', ServersImporter, 'csvToJson'); provideApiServices(bottle);
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv'); provideServersServices(bottle, connect);
provideUtilsServices(bottle);
provideSettingsServices(bottle, connect);

25
src/container/store.ts Normal file
View File

@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import type { RLSOptions } from 'redux-localstorage-simple';
import { load, save } from 'redux-localstorage-simple';
import { initReducers } from '../reducers';
import { migrateDeprecatedSettings } from '../settings/helpers';
import type { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV === 'production';
const localStorageConfig: RLSOptions = {
states: ['settings', 'servers'],
namespace: 'shlink',
namespaceSeparator: '.',
debounce: 300,
};
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
export const setUpStore = (container: IContainer) => configureStore({
devTools: !isProduction,
reducer: initReducers(container),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
.concat(save(localStorageConfig)),
});

13
src/container/types.ts Normal file
View File

@ -0,0 +1,13 @@
import type { Settings } from '@shlinkio/shlink-web-component';
import type { SelectedServer, ServersMap } from '../servers/data';
export interface ShlinkState {
servers: ServersMap;
selectedServer: SelectedServer;
settings: Settings;
appUpdated: boolean;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
export type GetState = () => ShlinkState;

29
src/container/utils.ts Normal file
View File

@ -0,0 +1,29 @@
import type { IContainer } from 'bottlejs';
import type { FC } from 'react';
import { useRef } from 'react';
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
return depsRef.current;
}
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
Component: CompType,
deps: ReadonlyArray<keyof CompType>,
) {
return (container: IContainer, console = globalThis.console) => {
deps.forEach((dep) => {
const resolvedDependency = container[dep as string];
if (!resolvedDependency && process.env.NODE_ENV !== 'production') {
console.error(`[Debug] Could not find "${dep as string}" dependency in container`);
}
// eslint-disable-next-line no-param-reassign
Component[dep] = resolvedDependency;
});
return Component;
};
}

4
src/index.scss Normal file
View File

@ -0,0 +1,4 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
@import 'node_modules/bootstrap/scss/bootstrap.scss';
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
@import 'node_modules/@shlinkio/shlink-web-component/dist/index';

View File

@ -1,31 +1,25 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router-dom';
import pack from '../package.json'; import pack from '../package.json';
import { App } from './app/App';
import { appUpdateAvailable } from './app/reducers/appUpdates';
import { ErrorHandler } from './common/ErrorHandler';
import { ScrollToTop } from './common/ScrollToTop';
import { container } from './container'; import { container } from './container';
import { ContainerProvider } from './container/context'; import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; import { register as registerServiceWorker } from './serviceWorkerRegistration';
import { setUpStore } from './store'; import './index.scss';
import './tailwind.css';
const store = setUpStore(); const store = setUpStore(container);
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
<ContainerProvider value={container}> <Provider store={store}>
<Provider store={store}> <BrowserRouter basename={pack.homepage}>
<BrowserRouter basename={pack.homepage}> <ErrorHandler>
<ErrorHandler> <ScrollToTop>
<ScrollToTop> <App />
<App /> </ScrollToTop>
</ScrollToTop> </ErrorHandler>
</ErrorHandler> </BrowserRouter>
</BrowserRouter> </Provider>,
</Provider>
</ContainerProvider>,
); );
// Learn more about service workers: https://cra.link/PWA // Learn more about service workers: https://cra.link/PWA

View File

@ -1,12 +1,12 @@
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { appUpdatesReducer } from '../app/reducers/appUpdates';
import { selectedServerReducer } from '../servers/reducers/selectedServer';
import { serversReducer } from '../servers/reducers/servers'; import { serversReducer } from '../servers/reducers/servers';
import { settingsReducer } from '../settings/reducers/settings'; import { settingsReducer } from '../settings/reducers/settings';
export const initReducers = () => combineReducers({ export const initReducers = (container: IContainer) => combineReducers({
appUpdated: appUpdatesReducer, appUpdated: appUpdatesReducer,
servers: serversReducer, servers: serversReducer,
selectedServer: selectedServerReducer, selectedServer: container.selectedServerReducer,
settings: settingsReducer, settings: settingsReducer,
}); });

View File

@ -1,84 +1,89 @@
import type { ResultProps, TimeoutToggle } from '@shlinkio/shlink-frontend-kit'; import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, useToggle } from '@shlinkio/shlink-frontend-kit'; import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router-dom';
import { Button } from 'reactstrap';
import { v4 as uuid } from 'uuid';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { withDependencies } from '../container/context'; import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData } from './data'; import type { ServerData, ServersMap, ServerWithId } from './data';
import { ensureUniqueIds } from './helpers';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal'; import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
import { ImportServersBtn } from './helpers/ImportServersBtn'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
import { useServers } from './reducers/servers';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
export type CreateServerProps = { type CreateServerProps = {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type CreateServerDeps = {
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle; useTimeoutToggle: TimeoutToggle;
}; };
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => ( const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<div className="mt-4"> <div className="mt-3">
<Result variant={variant}> <Result type={type}>
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'} {type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'} {type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result> </Result>
</div> </div>
); );
const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTimeoutToggle }) => { const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
const { servers, createServers } = useServers(); const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
const navigate = useNavigate(); const navigate = useNavigate();
const goBack = useGoBack(); const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length; const hasServers = !!Object.keys(servers).length;
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle(); const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>(); const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((newServerData: ServerData) => { const saveNewServer = useCallback((theServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]); const id = uuid();
createServers([newServerWithUniqueId]); createServers([{ ...theServerData, id }]);
navigate(`/server/${newServerWithUniqueId.id}`); navigate(`/server/${id}`);
}, [createServers, navigate, servers]); }, [createServers, navigate]);
const onSubmit = useCallback((newServerData: ServerData) => {
setServerData(newServerData); useEffect(() => {
if (!serverData) {
return;
}
const serverExists = Object.values(servers).some( const serverExists = Object.values(servers).some(
({ url, apiKey }) => newServerData.url === url && newServerData.apiKey === apiKey, ({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
); );
if (serverExists) { serverExists ? toggleConfirmModal() : saveNewServer(serverData);
toggleConfirmModal(); }, [saveNewServer, serverData, servers, toggleConfirmModal]);
} else {
saveNewServer(newServerData);
}
}, [saveNewServer, servers, toggleConfirmModal]);
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm title="Add new server" onSubmit={onSubmit}> <ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
{!hasServers && ( {!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} /> <ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
)} )}
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>} {hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button type="submit">Create server</Button> <Button outline color="primary" className="ms-2">Create server</Button>
</ServerForm> </ServerForm>
{serversImported && <ImportResult variant="success" />} {serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult variant="error" />} {errorImporting && <ImportResult type="error" />}
<DuplicatedServersModal <DuplicatedServersModal
open={isConfirmModalOpen} isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [serverData] : []} duplicatedServers={serverData ? [serverData] : []}
onClose={goBack} onDiscard={goBack}
onConfirm={() => serverData && saveNewServer(serverData)} onSave={() => serverData && saveNewServer(serverData)}
/> />
</NoMenuLayout> </NoMenuLayout>
); );
}); };
export const CreateServer = withDependencies(CreateServerBase, ['useTimeoutToggle']); export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']);

View File

@ -1,30 +1,39 @@
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit'; import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react'; import type { FCWithDeps } from '../container/utils';
import { useNavigate } from 'react-router'; import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import { DeleteServerModal } from './DeleteServerModal'; import type { DeleteServerModalProps } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{ export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId; server: ServerWithId;
className?: string;
textClassName?: string;
}>; }>;
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => { type DeleteServerButtonDeps = {
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); DeleteServerModal: FC<DeleteServerModalProps>;
const navigate = useNavigate(); };
const onClose = useCallback((confirmed: boolean) => {
hideModal(); const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
if (confirmed) { { server, className, children, textClassName },
navigate('/'); ) => {
} const { DeleteServerModal } = useDependencies(DeleteServerButton);
}, [hideModal, navigate]); const [isModalOpen, , showModal, hideModal] = useToggle();
return ( return (
<> <>
<button type="button" className="text-danger hover:underline" onClick={showModal}> <button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
{children} {!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
<span className={textClassName}>{children ?? 'Remove this server'}</span>
</button> </button>
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
</> </>
); );
}; };
export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']);

View File

@ -1,35 +1,42 @@
import type { ExitAction } from '@shlinkio/shlink-frontend-kit';
import { CardModal } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useCallback } from 'react'; import { useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import { useServers } from './reducers/servers';
export type DeleteServerModalProps = { export interface DeleteServerModalProps {
server: ServerWithId; server: ServerWithId;
onClose: (confirmed: boolean) => void; toggle: () => void;
open: boolean; isOpen: boolean;
}; redirectHome?: boolean;
}
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => { interface DeleteServerModalConnectProps extends DeleteServerModalProps {
const { deleteServer } = useServers(); deleteServer: (server: ServerWithId) => void;
const onClosed = useCallback((exitAction: ExitAction) => { }
if (exitAction === 'confirm') {
deleteServer(server); export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
{ server, toggle, isOpen, deleteServer, redirectHome = true },
) => {
const navigate = useNavigate();
const doDelete = useRef<boolean>(false);
const toggleAndDelete = () => {
doDelete.current = true;
toggle();
};
const onClosed = () => {
if (!doDelete.current) {
return;
} }
}, [deleteServer, server]);
deleteServer(server);
redirectHome && navigate('/');
};
return ( return (
<CardModal <Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
open={open} <ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
title="Remove server" <ModalBody>
variant="danger"
onClose={() => onClose(false)}
onConfirm={() => onClose(true)}
onClosed={onClosed}
confirmText="Delete"
>
<div className="flex flex-col gap-y-4">
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p> <p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p> <p>
<i> <i>
@ -37,7 +44,11 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
You can create it again at any moment. You can create it again at any moment.
</i> </i>
</p> </p>
</div> </ModalBody>
</CardModal> <ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
</ModalFooter>
</Modal>
); );
}; };

View File

@ -1,17 +1,26 @@
import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { useGoBack } from '../utils/helpers/hooks'; import type { FCWithDeps } from '../container/utils';
import { componentFactory } from '../container/utils';
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
import type { ServerData } from './data'; import type { ServerData } from './data';
import { isServerWithId } from './data'; import { isServerWithId } from './data';
import { ServerForm } from './helpers/ServerForm'; import { ServerForm } from './helpers/ServerForm';
import type { WithSelectedServerProps } from './helpers/withSelectedServer';
import { withSelectedServer } from './helpers/withSelectedServer'; import { withSelectedServer } from './helpers/withSelectedServer';
import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export const EditServer: FC = withSelectedServer(() => { type EditServerProps = WithSelectedServerProps & {
const { editServer } = useServers(); editServer: (serverId: string, serverData: ServerData) => void;
const { selectServer, selectedServer } = useSelectedServer(); };
type EditServerDeps = {
ServerError: FC;
};
const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServer((
{ editServer, selectedServer, selectServer },
) => {
const goBack = useGoBack(); const goBack = useGoBack();
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>(); const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
@ -21,22 +30,22 @@ export const EditServer: FC = withSelectedServer(() => {
const handleSubmit = (serverData: ServerData) => { const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData); editServer(selectedServer.id, serverData);
if (reconnect === 'true') { reconnect === 'true' && selectServer(selectedServer.id);
selectServer(selectedServer.id);
}
goBack(); goBack();
}; };
return ( return (
<NoMenuLayout> <NoMenuLayout>
<ServerForm <ServerForm
title={<>Edit &quot;{selectedServer.name}&quot;</>} title={<h5 className="mb-0">Edit &quot;{selectedServer.name}&quot;</h5>}
initialValues={selectedServer} initialValues={selectedServer}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<Button variant="secondary" onClick={goBack}>Cancel</Button> <Button outline className="me-2" onClick={goBack}>Cancel</Button>
<Button type="submit">Save</Button> <Button outline color="primary">Save</Button>
</ServerForm> </ServerForm>
</NoMenuLayout> </NoMenuLayout>
); );
}); });
export const EditServerFactory = componentFactory(EditServer, ['ServerError']);

View File

@ -1,84 +1,102 @@
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons'; import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit'; import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit'; import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button, Row } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout'; import { NoMenuLayout } from '../common/NoMenuLayout';
import { withDependencies } from '../container/context'; import type { FCWithDeps } from '../container/utils';
import { ImportServersBtn } from './helpers/ImportServersBtn'; import { componentFactory, useDependencies } from '../container/utils';
import { withoutSelectedServer } from './helpers/withoutSelectedServer'; import type { ServersMap } from './data';
import { ManageServersRow } from './ManageServersRow'; import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { useServers } from './reducers/servers'; import type { ManageServersRowProps } from './ManageServersRow';
import type { ServersExporter } from './services/ServersExporter'; import type { ServersExporter } from './services/ServersExporter';
export type ManageServersProps = { type ManageServersProps = {
servers: ServersMap;
};
type ManageServersDeps = {
ServersExporter: ServersExporter; ServersExporter: ServersExporter;
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle; useTimeoutToggle: TimeoutToggle;
ManageServersRow: FC<ManageServersRowProps>;
}; };
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
ServersExporter: serversExporter, const {
useTimeoutToggle, ServersExporter: serversExporter,
}) => { ImportServersBtn,
const { servers } = useServers(); useTimeoutToggle,
const [searchTerm, setSearchTerm] = useState(''); ManageServersRow,
const allServers = useMemo(() => Object.values(servers), [servers]); } = useDependencies(ManageServers);
const filteredServers = useMemo( const allServers = Object.values(servers);
() => allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())), const [serversList, setServersList] = useState(allServers);
[allServers, searchTerm], const filterServers = (searchTerm: string) => setServersList(
allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
); );
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect); const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME }); useEffect(() => {
setServersList(Object.values(servers));
}, [servers]);
return ( return (
<NoMenuLayout className="flex flex-col gap-y-4"> <NoMenuLayout>
<SearchInput onChange={setSearchTerm} /> <SearchField className="mb-3" onChange={filterServers} />
<div className="flex flex-col md:flex-row gap-2"> <Row className="mb-3">
<div className="flex gap-2"> <div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn> <ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && ( {allServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}> <Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers <FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button> </Button>
)} )}
</div> </div>
<Button className="md:ml-auto" to="/server/create"> <div className="col-md-6 text-md-end d-flex d-md-block">
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server <Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
</Button> <FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</div> </Button>
</div>
</Row>
<SimpleCard className="card"> <SimpleCard>
<Table header={( <table className="table table-hover responsive-table mb-0">
<Table.Row> <thead className="responsive-table__header">
{hasAutoConnect && ( <tr>
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell> {hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
)} <th>Name</th>
<Table.Cell>Name</Table.Cell> <th>Base URL</th>
<Table.Cell>Base URL</Table.Cell> <th><span className="sr-only">Options</span></th>
<Table.Cell><span className="sr-only">Options</span></Table.Cell> </tr>
</Table.Row> </thead>
)}> <tbody>
{!filteredServers.length && ( {!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row> {serversList.map((server) => (
)} <ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
{filteredServers.map((server) => ( ))}
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} /> </tbody>
))} </table>
</Table>
</SimpleCard> </SimpleCard>
{errorImporting && ( {errorImporting && (
<div> <div className="mt-3">
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result> <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
</div> </div>
)} )}
</NoMenuLayout> </NoMenuLayout>
); );
}); };
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']); export const ManageServersFactory = componentFactory(ManageServers, [
'ServersExporter',
'ImportServersBtn',
'useTimeoutToggle',
'ManageServersRow',
]);

View File

@ -1,43 +1,48 @@
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons'; import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router-dom';
import { UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import { ManageServersRowDropdown } from './ManageServersRowDropdown'; import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export type ManageServersRowProps = { export type ManageServersRowProps = {
server: ServerWithId; server: ServerWithId;
hasAutoConnect: boolean; hasAutoConnect: boolean;
}; };
export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoConnect }) => { type ManageServersRowDeps = {
const { anchor, tooltip } = useTooltip(); ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
};
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
return ( return (
<Table.Row className="relative"> <tr className="responsive-table__row">
{hasAutoConnect && ( {hasAutoConnect && (
<Table.Cell columnName="Auto-connect"> <td className="responsive-table__cell" data-th="Auto-connect">
{server.autoConnect && ( {server.autoConnect && (
<> <>
<FontAwesomeIcon <FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
icon={checkIcon} <UncontrolledTooltip target="autoConnectIcon" placement="right">
className="text-lm-brand dark:text-dm-brand" Auto-connect to this server
{...anchor} </UncontrolledTooltip>
data-testid="auto-connect"
/>
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
</> </>
)} )}
</Table.Cell> </td>
)} )}
<Table.Cell className="font-bold" columnName="Name"> <th className="responsive-table__cell" data-th="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link> <Link to={`/server/${server.id}`}>{server.name}</Link>
</Table.Cell> </th>
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell> <td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0"> <td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} /> <ManageServersRowDropdown server={server} />
</Table.Cell> </td>
</Table.Row> </tr>
); );
}; };
export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);

View File

@ -6,42 +6,55 @@ import {
faPlug as connectIcon, faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { RowDropdown, useToggle } from '@shlinkio/shlink-frontend-kit'; import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import { DeleteServerModal } from './DeleteServerModal'; import type { DeleteServerModalProps } from './DeleteServerModal';
import { useServers } from './reducers/servers';
export type ManageServersRowDropdownProps = { export type ManageServersRowDropdownProps = {
server: ServerWithId; server: ServerWithId;
}; };
export const ManageServersRowDropdown: FC<ManageServersRowDropdownProps> = ({ server }) => { type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & {
const { setAutoConnect } = useServers(); setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); };
type ManageServersRowDropdownDeps = {
DeleteServerModal: FC<DeleteServerModalProps>
};
const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps, ManageServersRowDropdownDeps> = (
{ server, setAutoConnect },
) => {
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
const [isModalOpen,, showModal, hideModal] = useToggle();
const serverUrl = `/server/${server.id}`; const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server; const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon; const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return ( return (
<> <RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
<RowDropdown menuAlignment="right"> <DropdownItem tag={Link} to={serverUrl}>
<RowDropdown.Item to={serverUrl} className="gap-1.5"> <FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
<FontAwesomeIcon icon={connectIcon} /> Connect </DropdownItem>
</RowDropdown.Item> <DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<RowDropdown.Item to={`${serverUrl}/edit`} className="gap-1.5"> <FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
<FontAwesomeIcon icon={editIcon} /> Edit server </DropdownItem>
</RowDropdown.Item> <DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
<RowDropdown.Item onClick={() => setAutoConnect(server, !isAutoConnect)} className="gap-1.5"> <FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
<FontAwesomeIcon icon={autoConnectIcon} /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect </DropdownItem>
</RowDropdown.Item> <DropdownItem divider tag="hr" />
<RowDropdown.Separator /> <DropdownItem className="dropdown-item--danger" onClick={showModal}>
<RowDropdown.Item className="[&]:text-danger gap-1.5" onClick={showModal}> <FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
<FontAwesomeIcon icon={deleteIcon} /> Remove server </DropdownItem>
</RowDropdown.Item>
</RowDropdown>
<DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} /> <DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
</> </RowDropdownBtn>
); );
}; };
export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']);

View File

@ -1,39 +1,48 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit'; import { Link } from 'react-router-dom';
import type { FC } from 'react'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data'; import { getServerId } from './data';
import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export const ServersDropdown: FC = () => { export interface ServersDropdownProps {
const { servers } = useServers(); servers: ServersMap;
selectedServer: SelectedServer;
}
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = Object.values(servers); const serversList = Object.values(servers);
const { selectedServer } = useSelectedServer();
const renderServers = () => {
if (serversList.length === 0) {
return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
</DropdownItem>
);
}
return (
<>
{serversList.map(({ name, id }) => (
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
{name}
</DropdownItem>
))}
<DropdownItem divider tag="hr" />
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
</DropdownItem>
</>
);
};
return ( return (
<NavBar.Dropdown buttonContent={( <UncontrolledDropdown nav inNavbar>
<span className="flex items-center gap-1.5"> <DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> Servers <FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
</span> </DropdownToggle>
)}> <DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
{serversList.length === 0 ? ( </UncontrolledDropdown>
<Dropdown.Item to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server
</Dropdown.Item>
) : (
<>
{serversList.map(({ name, id }) => (
<Dropdown.Item key={id} to={`/server/${id}`} selected={getServerId(selectedServer) === id}>
{name}
</Dropdown.Item>
))}
<Dropdown.Separator />
<Dropdown.Item to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> Manage servers
</Dropdown.Item>
</>
)}
</NavBar.Dropdown>
); );
}; };

View File

@ -0,0 +1,49 @@
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@import '../utils/mixins/vertical-align';
@import '../utils/mixins/thin-scroll';
.servers-list__list-group.servers-list__list-group {
width: 100%;
}
.servers-list__list-group:not(.servers-list__list-group--embedded) {
max-width: 400px;
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
}
.servers-list__server-item.servers-list__server-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item:not(:hover) {
color: $mainColor;
}
.servers-list__server-item:hover {
background-color: var(--secondary-color);
}
.servers-list__server-item-icon {
@include vertical-align();
right: 1rem;
}
.servers-list__list-group--embedded.servers-list__list-group--embedded {
border-radius: 0;
border-top: 1px solid var(--border-color);
@media (min-width: $mdMin) {
max-height: 220px;
overflow-x: auto;
@include thin-scroll();
}
.servers-list__server-item {
border: none;
border-bottom: 1px solid var(--border-color);
}
}

Some files were not shown because too many files have changed in this diff Show More