Compare commits

..

10 Commits

Author SHA1 Message Date
Alejandro Celaya
5aa113ec16 Merge pull request #1387 from shlinkio/develop
Release 4.3.0
2024-11-30 10:48:52 +01:00
Alejandro Celaya
2e438f9814 Merge pull request #1351 from shlinkio/develop
Release 4.2.2
2024-10-19 12:15:18 +02:00
Alejandro Celaya
9a798c20c0 Merge pull request #1329 from shlinkio/develop
Release 4.2.1
2024-10-09 14:33:56 +02:00
Alejandro Celaya
9e1a803b8d Merge pull request #1324 from shlinkio/develop
Release 4.2.0
2024-10-07 09:54:43 +02:00
Alejandro Celaya
d18ebf8911 Merge pull request #1147 from shlinkio/develop
Release 4.1.2
2024-04-17 09:59:22 +02:00
Alejandro Celaya
5c2e99cba1 Merge pull request #1137 from shlinkio/develop
Release 4.1.1
2024-04-11 09:24:15 +02:00
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
160 changed files with 12137 additions and 8789 deletions

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:
- name: Question - Support
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

@@ -21,12 +21,9 @@ updates:
shlink:
patterns:
- '@shlinkio/*'
react:
types:
patterns:
- 'react'
- 'react-dom'
- '@types/react'
- '@types/react-dom'
- '@types/*'
testing:
patterns:
- '@testing-library/*'
@@ -41,10 +38,10 @@ updates:
workbox:
patterns:
- 'workbox*'
tailwindcss:
patterns:
- 'tailwindcss'
- '@tailwindcss/*'
ignore:
# Bootstrap can introduce visual breaking changes on styles
# Ignore it, since the plan is to remove it anyway
- dependency-name: 'bootstrap'
- package-ecosystem: docker
directory: '/'
schedule:

View File

@@ -13,4 +13,3 @@ jobs:
with:
node-version: 22.x
publish-coverage: true
install-playwright: true

View File

@@ -5,7 +5,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
continue-on-error: true
steps:
- name: Checkout code
@@ -16,7 +16,7 @@ jobs:
- name: Use node.js
uses: actions/setup-node@v4
with:
node-version: 22.10
node-version: 22.x
- name: Build
run: |
npm ci && \

View File

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

5
.stylelintrc Normal file
View File

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

View File

@@ -4,164 +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).
## [4.7.0] - 2026-02-04
### Added
* [shlink-web-component] Add support for Shlink 5.0.0, by supporting date-based redirect conditions.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [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.
@@ -456,7 +298,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.
### 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

View File

@@ -45,8 +45,11 @@ shlink-web-client
> 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 node --run lint:fix`: Fixes coding styles in JS/TS files.
* `./indocker node --run lint`: Checks coding styles are fulfilled, both in JS/TS files and in stylesheets.
* `./indocker node --run lint:js`: Checks coding styles are fulfilled in JS/TS files.
* `./indocker node --run lint:css`: Checks coding styles are fulfilled in stylesheets.
* `./indocker node --run lint:js:fix`: Fixes coding styles in JS/TS files.
* `./indocker node --run lint:css:fix`: Fixes coding styles in stylesheets.
* `./indocker node --run test`: Runs unit tests with Jest.
## Building the project

View File

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

View File

@@ -1,9 +1,24 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import axe from 'axe-core';
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
afterEach(() => {
vi.clearAllMocks();
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.58.2-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", "--"]

View File

@@ -2,11 +2,8 @@ services:
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
build:
context: .
dockerfile: ./dev.Dockerfile
working_dir: /home/shlink/www
command: /bin/sh -c "npm install && npm run start"
image: node:22.10-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www
ports:

View File

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

View File

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

16653
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,12 @@
"license": "MIT",
"type": "module",
"scripts": {
"lint": "eslint src test config/test *.config.{js,ts}",
"lint:fix": "node --run lint -- --fix",
"lint": "node --run lint:css && node --run lint:js",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:js": "eslint src test config/test",
"lint:fix": "node --run lint:css:fix && node --run lint:js:fix",
"lint:css:fix": "node --run lint:css -- --fix",
"lint:js:fix": "node --run lint:js -- --fix",
"types": "tsc",
"start": "vite serve --host=0.0.0.0",
"preview": "vite preview --host=0.0.0.0",
@@ -20,68 +24,67 @@
"test:verbose": "node --run test -- --verbose"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.2.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@fortawesome/fontawesome-free": "^6.7.1",
"@fortawesome/fontawesome-svg-core": "^6.7.1",
"@fortawesome/free-brands-svg-icons": "^6.7.1",
"@fortawesome/free-regular-svg-icons": "^6.7.1",
"@fortawesome/free-solid-svg-icons": "^6.7.1",
"@fortawesome/react-fontawesome": "^0.2.2",
"@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.11.2",
"@shlinkio/data-manipulation": "^1.0.4",
"@shlinkio/shlink-frontend-kit": "^1.4.0",
"@shlinkio/shlink-js-sdk": "^3.1.0",
"@shlinkio/shlink-web-component": "^0.18.0",
"@vitest/browser-playwright": "^4.0.18",
"@reduxjs/toolkit": "^2.4.0",
"@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.6.0",
"@shlinkio/shlink-js-sdk": "^1.3.0",
"@shlinkio/shlink-web-component": "^0.11.0",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"csvtojson": "^2.0.14",
"csvtojson": "^2.0.10",
"date-fns": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-external-link": "^2.6.1",
"react-redux": "^9.2.0",
"react-router": "^7.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-external-link": "^2.3.1",
"react-redux": "^9.1.2",
"react-router-dom": "^6.28.0",
"reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1",
"workbox-core": "^7.4.0",
"workbox-expiration": "^7.4.0",
"workbox-precaching": "^7.4.0",
"workbox-routing": "^7.4.0",
"workbox-strategies": "^7.4.0"
"workbox-core": "^7.3.0",
"workbox-expiration": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
},
"devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0",
"@stylistic/eslint-plugin": "^5.7.1",
"@tailwindcss/vite": "^4.2.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@shlinkio/eslint-config-js-coding-standard": "~3.2.1",
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
"@stylistic/eslint-plugin": "^2.11.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@total-typescript/shoehorn": "^0.1.2",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/browser": "^4.0.3",
"@vitest/coverage-v8": "^4.0.18",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^2.1.6",
"adm-zip": "^0.5.16",
"axe-core": "^4.11.1",
"chalk": "^5.6.2",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"axe-core": "^4.10.2",
"chalk": "^5.3.0",
"eslint": "^9.16.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-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0",
"playwright": "^1.58.2",
"tailwindcss": "^4.1.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.3"
"jsdom": "^25.0.1",
"sass": "^1.81.0",
"stylelint": "^15.11.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"vite": "^6.0.1",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^2.0.2"
},
"browserslist": [
">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">
<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>
<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>

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 AdmZip from 'adm-zip';
import fs from 'fs';
function zipDist(version) {
const fileBaseName = `shlink-web-client_${version}_dist`;
const versionFileName = `./dist/${fileBaseName}.zip`;
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
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);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {

View File

@@ -10,8 +10,7 @@ setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
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}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
}
setup_single_shlink_server

View File

@@ -1,11 +1,13 @@
import type { HttpClient } 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 { 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 { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
@@ -16,22 +18,13 @@ const getSelectedServerFromState = (getState: 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)
: getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
const existingApiClient = apiClients.get(serverKey);
const serverKey = `${apiKey}_${baseUrl}`;
if (existingApiClient) {
return existingApiClient;
}
const apiClient = new ShlinkApiClient(
httpClient,
{ apiKey, baseUrl },
{ requestCredentials: forwardCredentials ? 'include' : undefined },
);
apiClients.set(serverKey, apiClient);
const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl });
apiClients[serverKey] = 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 type { Settings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Route, Routes, useLocation } from 'react-router';
import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { Home } from '../common/Home';
import { MainHeader } from '../common/MainHeader';
import { NotFound } from '../common/NotFound';
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer';
import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer';
import { CreateServer } from '../servers/CreateServer';
import { EditServer } from '../servers/EditServer';
import { ManageServers } from '../servers/ManageServers';
import { useLoadRemoteServers } from '../servers/reducers/remoteServers';
import { useSettings } from '../settings/reducers/settings';
import { Settings } from '../settings/Settings';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data';
import { forceUpdate } from '../utils/helpers/sw';
import { useAppUpdated } from './reducers/appUpdates';
import './App.scss';
export const App: FC = () => {
const { appUpdated, resetAppUpdate } = useAppUpdated();
type AppProps = {
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
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 initialServers = useRef(servers);
const isHome = location.pathname === '/';
const { settings } = useSettings();
useEffect(() => {
// Try to fetch the remote servers if the list is empty during first render.
// 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(() => {
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
}, [settings.ui?.theme]);
return (
<div className="h-full">
<>
<MainHeader />
<div className="container-fluid app-container">
<MainHeader />
<div className="h-full pt-(--header-height)">
<div
data-testid="shlink-wrapper"
className={clsx(
'min-h-full pb-[calc(var(--footer-height)+var(--footer-margin))] -mb-[calc(var(--footer-height)+var(--footer-margin))]',
{ 'flex items-center pt-4': isHome },
)}
>
<Routes>
<Route index element={<Home />} />
<Route path="/settings">
{['', '*'].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 className="app">
<div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}>
<Routes>
<Route index element={<Home />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</>
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
<div className="shlink-footer">
<ShlinkVersionsContainer />
</div>
</div>
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
</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 { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../store';
const { actions, reducer } = createSlice({
name: 'shlink/appUpdates',
@@ -14,12 +12,3 @@ const { actions, reducer } = createSlice({
export const { appUpdateAvailable, resetAppUpdate } = actions;
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useCallback } from 'react';
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { MouseEventHandler } from 'react';
import { forwardRef, useCallback } from 'react';
import { Alert, Button } from 'reactstrap';
import './AppUpdateBanner.scss';
interface AppUpdateBannerProps {
isOpen: boolean;
onClose: () => void;
toggle: MouseEventHandler<any>;
forceUpdate: () => void;
}
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
const { flag: isUpdating, setToTrue: setUpdating } = useToggle();
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
const [isUpdating,, setUpdating] = useToggle();
const update = useCallback(() => {
setUpdating();
forceUpdate();
}, [forceUpdate, setUpdating]);
if (!isOpen) {
return null;
}
return (
<Card
role="alert"
className={clsx(
'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">
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
<h4 className="mb-4">This app has just been updated!</h4>
<p className="mb-0">
Restart it to enjoy the new features.
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
{isUpdating && <>Restarting...</>}
</Button>
</Card.Body>
</Card>
</p>
</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 { Component } from 'react';
import { ErrorLayout } from './ErrorLayout';
import { Button } from 'reactstrap';
type ErrorHandlerProps = PropsWithChildren<{
location?: typeof window.location;
@@ -33,11 +33,14 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
if (hasError) {
return (
<ErrorLayout title="Oops! This is awkward :S">
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br />
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
</ErrorLayout>
<div className="home">
<SimpleCard className="p-4">
<h1>Oops! This is awkward :S</h1>
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<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>
);

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

@@ -0,0 +1,15 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.home__title {
font-size: 1.75rem;
@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,19 +1,21 @@
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
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 { ExternalLink } from 'react-external-link';
import { useNavigate } from 'react-router';
import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer';
import { useServers } from '../servers/reducers/servers';
import { Link, useNavigate } from 'react-router-dom';
import { Card } from 'reactstrap';
import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup';
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 { servers } = useServers();
const serversList = Object.values(servers);
const hasServers = serversList.length > 0;
@@ -26,45 +28,45 @@ export const Home: FC = withoutSelectedServer(() => {
}, [serversList, navigate]);
return (
<div className="px-3 w-full">
<Card className="mx-auto max-w-[720px] overflow-hidden">
<div className="flex flex-col md:flex-row">
<div className="p-6 hidden md:flex items-center w-[40%]">
<div className="w-full">
<div className="w-100">
<Card className="mx-auto" style={{ maxWidth: '720px' }}>
<div className="d-flex flex-column flex-md-row">
<div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
<div className="w-100">
<ShlinkLogo />
</div>
</div>
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
<div className="home__servers-container flex-grow-1">
<h1
className={clsx(
'p-4 text-center border-lm-border dark:border-dm-border',
{ 'border-b': !hasServers },
)}
className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
style={{ borderColor: 'var(--border-color) !important' }}
>
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>
)}
<ServersListGroup embedded servers={serversList}>
{!hasServers && (
<div className="p-4 text-center d-flex flex-column gap-5">
<p className="mb-0">This application will help you manage your Shlink servers.</p>
<p className="mb-0">
<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">
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="me-2">Learn more about Shlink</span>
<FontAwesomeIcon icon={faExternalLinkAlt}/>
</small>
</ExternalLink>
</p>
</div>
)}
</ServersListGroup>
</div>
</div>
</Card>
</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 { NavBar } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { Link, useLocation } from 'react-router';
import { ServersDropdown } from '../servers/ServersDropdown';
import { useEffect } from 'react';
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 './MainHeader.scss';
export const MainHeader: FC = () => {
const { pathname } = useLocation();
type MainHeaderDeps = {
ServersDropdown: FC;
};
const MainHeader: FCWithDeps<unknown, 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 toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
return (
<NavBar
className="[&]:fixed top-0 z-900"
brand={(
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
</Link>
)}
>
<NavBar.MenuItem
to={settingsPath}
active={pathname.startsWith(settingsPath)}
className="flex items-center gap-1.5"
>
<FontAwesomeIcon icon={cogsIcon} /> Settings
</NavBar.MenuItem>
<ServersDropdown />
</NavBar>
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<NavbarBrand tag={Link} to="/">
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={toggleCollapse}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
</NavbarToggler>
<Collapse navbar isOpen={isNotCollapsed}>
<Nav navbar className="ms-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<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,11 @@
import { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react';
import './NoMenuLayout.scss';
export type NoMenuLayoutProps = PropsWithChildren & {
className?: string;
};
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>
<div className={clsx('no-menu-wrapper container-xl', 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 { ErrorLayout } from './ErrorLayout';
import { Link } from 'react-router-dom';
type NotFoundProps = PropsWithChildren<{ to?: string }>;
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<ErrorLayout title="Oops! We could not find requested route.">
<p>
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p>
<br />
<Button inline to={to} size="lg">{children}</Button>
</ErrorLayout>
<div className="home">
<SimpleCard className="p-4">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p>
<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 { useEffect } from 'react';
import { useLocation } from 'react-router';
import { useLocation } from 'react-router-dom';
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
const location = useLocation();

View File

@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
}
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>
</ExternalLink>
);
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
const normalizedClientVersion = normalizeVersion(clientVersion);
return (
<small className="text-gray-500">
<small className="text-muted">
{isReachableServer(selectedServer) && (
<>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,15 +1,19 @@
import { clsx } from 'clsx';
import type { SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data';
import { useSelectedServer } from '../servers/reducers/selectedServer';
import { ShlinkVersions } from './ShlinkVersions';
import './ShlinkVersionsContainer.scss';
export const ShlinkVersionsContainer = () => {
const { selectedServer } = useSelectedServer();
return (
<div
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<ShlinkVersions selectedServer={selectedServer} />
</div>
);
export type ShlinkVersionsContainerProps = {
selectedServer: SelectedServer;
};
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
<div
className={clsx('text-center', {
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
})}
>
<ShlinkVersions selectedServer={selectedServer} />
</div>
);

View File

@@ -1,37 +1,40 @@
import type { TagColorsStorage } from '@shlinkio/shlink-web-component';
import {
ShlinkSidebarToggleButton,
ShlinkSidebarVisibilityProvider,
ShlinkWebComponent,
} from '@shlinkio/shlink-web-component';
import type { ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { memo } from 'react';
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 { ServerError } from '../servers/helpers/ServerError';
import type { WithSelectedServerProps } 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';
export type ShlinkWebComponentContainerProps = {
TagColorsStorage: TagColorsStorage;
buildShlinkApiClient: ShlinkApiClientBuilder;
type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
settings: Settings;
};
const ShlinkWebComponentContainerBase: FC<
ShlinkWebComponentContainerProps
type ShlinkWebComponentContainerDeps = {
buildShlinkApiClient: ShlinkApiClientBuilder,
TagColorsStorage: TagColorsStorage,
ShlinkWebComponent: ShlinkWebComponentType,
ServerError: FC,
};
const ShlinkWebComponentContainer: FCWithDeps<
ShlinkWebComponentContainerProps,
ShlinkWebComponentContainerDeps
// 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
// extra rendering there.
// This should be revisited at some point.
> = withSelectedServer(memo(({
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
}) => {
const { selectedServer } = useSelectedServer();
const { settings } = useSettings();
> = withSelectedServer(memo(({ selectedServer, settings }) => {
const {
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
ShlinkWebComponent,
ServerError,
} = useDependencies(ShlinkWebComponentContainer);
if (!isReachableServer(selectedServer)) {
return <ServerError />;
@@ -39,24 +42,22 @@ const ShlinkWebComponentContainerBase: FC<
const routesPrefix = `/server/${selectedServer.id}`;
return (
<ShlinkSidebarVisibilityProvider>
<ShlinkSidebarToggleButton className="fixed top-3.5 left-3 z-901" />
<ShlinkWebComponent
serverVersion={selectedServer.version}
apiClient={buildShlinkApiClient(selectedServer)}
settings={settings}
routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath: string) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)}
autoSidebarToggle={false}
/>
</ShlinkSidebarVisibilityProvider>
<ShlinkWebComponent
serverVersion={selectedServer.version}
apiClient={buildShlinkApiClient(selectedServer)}
settings={settings}
routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)}
/>
);
}));
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
'buildShlinkApiClient',
'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 {
color?: 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">
<g fill={color}>
<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 { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
import type { IContainer } from 'bottlejs';
import Bottle from 'bottlejs';
import { buildShlinkApiClient } from '../api/services/ShlinkApiClientBuilder';
import { ServersExporter } from '../servers/services/ServersExporter';
import { ServersImporter } from '../servers/services/ServersImporter';
import { csvToJson, jsonToCsv } from '../utils/helpers/csvjson';
import { LocalStorage } from '../utils/services/LocalStorage';
import { TagColorsStorage } from '../utils/services/TagColorsStorage';
import { connect as reduxConnect } from 'react-redux';
import { provideServices as provideApiServices } from '../api/services/provideServices';
import { provideServices as provideAppServices } from '../app/services/provideServices';
import { provideServices as provideCommonServices } from '../common/services/provideServices';
import { provideServices as provideServersServices } from '../servers/services/provideServices';
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, (...args: unknown[]) => unknown>;
const bottle = new Bottle();
export const { container } = bottle;
bottle.constant('window', window);
bottle.constant('console', console);
bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', FetchHttpClient, 'fetch');
const lazyService = <T extends (...args: unknown[]) => unknown, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
bottle.constant('localStorage', window.localStorage);
bottle.service('Storage', LocalStorage, 'localStorage');
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map,
// 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);
bottle.constant('jsonToCsv', jsonToCsv);
const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries(
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');
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
provideAppServices(bottle, connect);
provideCommonServices(bottle, connect);
provideApiServices(bottle);
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/settings';
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;

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

@@ -0,0 +1,28 @@
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`);
}
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 { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
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 { ContainerProvider } from './container/context';
import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import { setUpStore } from './store';
import './tailwind.css';
import './index.scss';
const store = setUpStore();
const store = setUpStore(container);
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render(
<ContainerProvider value={container}>
<Provider store={store}>
<BrowserRouter basename={pack.homepage}>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>
</ContainerProvider>,
<Provider store={store}>
<BrowserRouter basename={pack.homepage}>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>,
);
// Learn more about service workers: https://cra.link/PWA

View File

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

View File

@@ -1,42 +1,48 @@
import type { ResultProps, TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate } from 'react-router-dom';
import { Button } from 'reactstrap';
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 type { ServerData } from './data';
import type { ServerData, ServersMap, ServerWithId } from './data';
import { ensureUniqueIds } from './helpers';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
import { ImportServersBtn } from './helpers/ImportServersBtn';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerForm } from './helpers/ServerForm';
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
import { useServers } from './reducers/servers';
const SHOW_IMPORT_MSG_TIME = 4000;
export type CreateServerProps = {
type CreateServerProps = {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type CreateServerDeps = {
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle;
};
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
<div className="mt-4">
<Result variant={variant}>
{variant === '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.'}
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
<div className="mt-3">
<Result type={type}>
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
</Result>
</div>
);
const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTimeoutToggle }) => {
const { servers, createServers } = useServers();
const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
const navigate = useNavigate();
const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length;
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((newServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
@@ -60,25 +66,25 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
return (
<NoMenuLayout>
<ServerForm title="Add new server" onSubmit={onSubmit}>
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={onSubmit}>
{!hasServers && (
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
)}
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
<Button type="submit">Create server</Button>
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
<Button outline color="primary" className="ms-2">Create server</Button>
</ServerForm>
{serversImported && <ImportResult variant="success" />}
{errorImporting && <ImportResult variant="error" />}
{serversImported && <ImportResult type="success" />}
{errorImporting && <ImportResult type="error" />}
<DuplicatedServersModal
open={isConfirmModalOpen}
isOpen={isConfirmModalOpen}
duplicatedServers={serverData ? [serverData] : []}
onClose={goBack}
onConfirm={() => serverData && saveNewServer(serverData)}
onDiscard={goBack}
onSave={() => serverData && saveNewServer(serverData)}
/>
</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 { clsx } from 'clsx';
import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data';
import { DeleteServerModal } from './DeleteServerModal';
import type { DeleteServerModalProps } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId;
className?: string;
textClassName?: string;
}>;
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => {
hideModal();
if (confirmed) {
navigate('/');
}
}, [hideModal, navigate]);
type DeleteServerButtonDeps = {
DeleteServerModal: FC<DeleteServerModalProps>;
};
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
{ server, className, children, textClassName },
) => {
const { DeleteServerModal } = useDependencies(DeleteServerButton);
const [isModalOpen, , showModal, hideModal] = useToggle();
return (
<>
<button type="button" className="text-danger hover:underline" onClick={showModal}>
{children}
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
<span className={textClassName}>{children ?? 'Remove this server'}</span>
</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,44 @@
import type { ExitAction } from '@shlinkio/shlink-frontend-kit';
import { CardModal } from '@shlinkio/shlink-frontend-kit';
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 { useServers } from './reducers/servers';
export type DeleteServerModalProps = {
export interface DeleteServerModalProps {
server: ServerWithId;
onClose: (confirmed: boolean) => void;
open: boolean;
};
toggle: () => void;
isOpen: boolean;
redirectHome?: boolean;
}
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
const { deleteServer } = useServers();
const onClosed = useCallback((exitAction: ExitAction) => {
if (exitAction === 'confirm') {
deleteServer(server);
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
deleteServer: (server: ServerWithId) => void;
}
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);
if (redirectHome) {
navigate('/');
}
};
return (
<CardModal
open={open}
title="Remove server"
variant="danger"
onClose={() => onClose(false)}
onConfirm={() => onClose(true)}
onClosed={onClosed}
confirmText="Delete"
>
<div className="flex flex-col gap-y-4">
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
<ModalBody>
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
<i>
@@ -37,7 +46,11 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
You can create it again at any moment.
</i>
</p>
</div>
</CardModal>
</ModalBody>
<ModalFooter>
<Button color="link" onClick={toggle}>Cancel</Button>
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
</ModalFooter>
</Modal>
);
};

View File

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

View File

@@ -1,29 +1,39 @@
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
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 { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from 'reactstrap';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { withDependencies } from '../container/context';
import { ImportServersBtn } from './helpers/ImportServersBtn';
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
import { ManageServersRow } from './ManageServersRow';
import { useServers } from './reducers/servers';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from './data';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import type { ManageServersRowProps } from './ManageServersRow';
import type { ServersExporter } from './services/ServersExporter';
export type ManageServersProps = {
type ManageServersProps = {
servers: ServersMap;
};
type ManageServersDeps = {
ServersExporter: ServersExporter;
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle;
ManageServersRow: FC<ManageServersRowProps>;
};
const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
ServersExporter: serversExporter,
useTimeoutToggle,
}) => {
const { servers } = useServers();
const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
const {
ServersExporter: serversExporter,
ImportServersBtn,
useTimeoutToggle,
ManageServersRow,
} = useDependencies(ManageServers);
const [searchTerm, setSearchTerm] = useState('');
const allServers = useMemo(() => Object.values(servers), [servers]);
const filteredServers = useMemo(
@@ -31,54 +41,57 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
[allServers, searchTerm],
);
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
return (
<NoMenuLayout className="flex flex-col gap-y-4">
<SearchInput onChange={setSearchTerm} />
<NoMenuLayout className="d-flex flex-column gap-3">
<SearchField onChange={setSearchTerm} />
<div className="flex flex-col md:flex-row gap-2">
<div className="flex gap-2">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
<div className="d-flex flex-column flex-md-row gap-2">
<div className="d-flex gap-2">
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
<Button outline className="flex-fill" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
</Button>
)}
</div>
<Button className="md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server
<Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
</Button>
</div>
<SimpleCard className="card">
<Table header={(
<Table.Row>
{hasAutoConnect && (
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell>
)}
<Table.Cell>Name</Table.Cell>
<Table.Cell>Base URL</Table.Cell>
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
</Table.Row>
)}>
{!filteredServers.length && (
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
)}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
))}
</Table>
<SimpleCard>
<table className="table table-hover responsive-table mb-0">
<thead className="responsive-table__header">
<tr>
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
<th>Name</th>
<th>Base URL</th>
<th><span className="sr-only">Options</span></th>
</tr>
</thead>
<tbody>
{!filteredServers.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
))}
</tbody>
</table>
</SimpleCard>
{errorImporting && (
<div>
<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>
)}
</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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
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 { ManageServersRowDropdown } from './ManageServersRowDropdown';
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export type ManageServersRowProps = {
server: ServerWithId;
hasAutoConnect: boolean;
};
export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoConnect }) => {
const { anchor, tooltip } = useTooltip();
type ManageServersRowDeps = {
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
};
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
return (
<Table.Row className="relative">
<tr className="responsive-table__row">
{hasAutoConnect && (
<Table.Cell columnName="Auto-connect">
<td className="responsive-table__cell" data-th="Auto-connect">
{server.autoConnect && (
<>
<FontAwesomeIcon
icon={checkIcon}
className="text-lm-brand dark:text-dm-brand"
{...anchor}
data-testid="auto-connect"
/>
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server
</UncontrolledTooltip>
</>
)}
</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>
</Table.Cell>
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell>
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
</th>
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
<td className="responsive-table__cell text-end">
<ManageServersRowDropdown server={server} />
</Table.Cell>
</Table.Row>
</td>
</tr>
);
};
export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);

View File

@@ -6,42 +6,55 @@ import {
faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons';
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 { 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 { DeleteServerModal } from './DeleteServerModal';
import { useServers } from './reducers/servers';
import type { DeleteServerModalProps } from './DeleteServerModal';
export type ManageServersRowDropdownProps = {
server: ServerWithId;
};
export const ManageServersRowDropdown: FC<ManageServersRowDropdownProps> = ({ server }) => {
const { setAutoConnect } = useServers();
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & {
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
};
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 { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return (
<>
<RowDropdown menuAlignment="right">
<RowDropdown.Item to={serverUrl} className="gap-1.5">
<FontAwesomeIcon icon={connectIcon} /> Connect
</RowDropdown.Item>
<RowDropdown.Item to={`${serverUrl}/edit`} className="gap-1.5">
<FontAwesomeIcon icon={editIcon} /> Edit server
</RowDropdown.Item>
<RowDropdown.Item onClick={() => setAutoConnect(server, !isAutoConnect)} className="gap-1.5">
<FontAwesomeIcon icon={autoConnectIcon} /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</RowDropdown.Item>
<RowDropdown.Separator />
<RowDropdown.Item className="[&]:text-danger gap-1.5" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} /> Remove server
</RowDropdown.Item>
</RowDropdown>
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
<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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data';
import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export const ServersDropdown: FC = () => {
const { servers } = useServers();
export interface ServersDropdownProps {
servers: ServersMap;
selectedServer: SelectedServer;
}
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
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 (
<NavBar.Dropdown buttonContent={(
<span className="flex items-center gap-1.5">
<FontAwesomeIcon icon={serverIcon} /> Servers
</span>
)}>
{serversList.length === 0 ? (
<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>
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
</DropdownToggle>
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};

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);
}
}

View File

@@ -1,41 +1,35 @@
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { Link } from 'react-router';
import type { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import type { ServerWithId } from './data';
import './ServersListGroup.scss';
type ServersListGroupProps = {
type ServersListGroupProps = PropsWithChildren<{
servers: ServerWithId[];
borderless?: boolean;
};
embedded?: boolean;
}>;
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<Link
to={`/server/${id}`}
className={clsx(
'servers-list__server-item',
'flex items-center justify-between gap-x-2 px-4 py-3',
'rounded-none hover:bg-lm-secondary hover:dark:bg-dm-secondary',
'border-b last:border-0 border-lm-border dark:border-dm-border',
)}
>
<span className="truncate">{name}</span>
<FontAwesomeIcon icon={chevronIcon} />
</Link>
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>
);
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
servers.length > 0 && (
<div
data-testid="list"
className={clsx(
'w-full border-lm-border dark:border-dm-border',
'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin',
{ 'border-y': !borderless },
)}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</div>
)
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
<>
{children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
{servers.length > 0 && (
<ListGroup
data-testid="list"
tag="div"
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup>
)}
</>
);

View File

@@ -4,7 +4,6 @@ export interface ServerData {
name: string;
url: string;
apiKey: string;
forwardCredentials?: boolean;
}
export interface ServerWithId extends ServerData {
@@ -45,31 +44,4 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
/**
* Expose values that represent provided server, in a way that can be serialized in JSON or CSV strings.
*/
export const serializeServer = ({ name, url, apiKey, forwardCredentials }: ServerData): Record<string, string> => ({
name,
url,
apiKey,
forwardCredentials: forwardCredentials ? 'true' : 'false',
});
const validateServerData = (server: any): server is ServerData =>
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
/**
* Provided a record, it picks the right properties to build a ServerData object.
* @throws Error If any of the required ServerData properties is missing.
*/
export const deserializeServer = (potentialServer: Record<string, unknown>): ServerData => {
const { forwardCredentials, ...serverData } = potentialServer;
if (!validateServerData(serverData)) {
throw new Error('Server is missing required "url", "apiKey" and/or "name" properties');
}
return {
...serverData,
forwardCredentials: forwardCredentials === 'true',
};
};
export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey });

View File

@@ -1,42 +1,41 @@
import { CardModal } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerData } from '../data';
export type DuplicatedServersModalProps = {
interface DuplicatedServersModalProps {
duplicatedServers: ServerData[];
open: boolean;
onClose: () => void;
onConfirm: () => void;
};
isOpen: boolean;
onDiscard: () => void;
onSave: () => void;
}
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ open, duplicatedServers, onClose, onConfirm },
{ isOpen, duplicatedServers, onDiscard, onSave },
) => {
const hasMultipleServers = duplicatedServers.length > 1;
return (
<CardModal
size="lg"
title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
open={open}
onClose={onClose}
onConfirm={onConfirm}
confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="list-disc my-4 pl-5">
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li>
</Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul>
<span>
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
</span>
</CardModal>
<Modal centered isOpen={isOpen}>
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
<ModalBody>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul>
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>
<li>API key: <b>{apiKey}</b></li>
</Fragment>
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul>
<span>
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
</span>
</ModalBody>
<ModalFooter>
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
<Button color="primary" onClick={onSave}>Save anyway</Button>
</ModalFooter>
</Modal>
);
};

View File

@@ -1,107 +1,113 @@
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Tooltip, useToggle, useTooltip } from '@shlinkio/shlink-frontend-kit';
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react';
import { withDependencies } from '../../container/context';
import type { ServerData } from '../data';
import { useServers } from '../reducers/servers';
import { Button, UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data';
import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void;
onError?: (error: Error) => void;
onImportError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom';
className?: string;
// Injected
ServersImporter: ServersImporter
}>;
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
type ImportServersBtnConnectProps = ImportServersBtnProps & {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type ImportServersBtnDeps = {
ServersImporter: ServersImporter
};
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
createServers,
servers,
children,
onImport,
onError = () => {},
onImport = () => {},
onImportError = () => {},
tooltipPlacement = 'bottom',
className = '',
ServersImporter: serversImporter,
}) => {
const { createServers, servers } = useServers();
const fileInputRef = useRef<HTMLInputElement>(null);
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });
const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
const ref = useElementRef<HTMLInputElement>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const newServersCreatedRef = useRef(false);
const [isModalOpen,, showModal, hideModal] = useToggle();
const importedServersRef = useRef<ServerWithId[]>([]);
const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData);
onImport();
}, [createServers, onImport]);
const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0])
.then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
// Immediately create new servers
newServersCreatedRef.current = newServers.length > 0;
createServers(ensureUniqueIds(servers, newServers));
importedServersRef.current = ensureUniqueIds(servers, importedServers);
newServersRef.current = ensureUniqueIds(servers, newServers);
// For duplicated servers, ask for confirmation
if (duplicatedServers.length > 0) {
if (duplicatedServers.length === 0) {
create(importedServersRef.current);
} else {
setDuplicatedServers(duplicatedServers);
showModal();
} else {
onImport?.();
}
})
.then(() => {
// Reset file input after processing file
(target as { value: string | null }).value = null;
})
.catch(onError),
[createServers, onError, onImport, servers, serversImporter, showModal],
.catch(onImportError),
[create, onImportError, servers, serversImporter, showModal],
);
const createDuplicatedServers = useCallback(() => {
createServers(ensureUniqueIds(servers, duplicatedServers));
const createAllServers = useCallback(() => {
create(importedServersRef.current);
hideModal();
onImport?.();
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
const discardDuplicatedServers = useCallback(() => {
}, [create, hideModal]);
const createNonDuplicatedServers = useCallback(() => {
create(newServersRef.current);
hideModal();
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
if (newServersCreatedRef.current) {
onImport?.();
}
}, [hideModal, onImport]);
}, [create, hideModal]);
return (
<>
<Button variant="secondary" className={className} onClick={() => fileInputRef.current?.click()} {...anchor}>
<FontAwesomeIcon icon={importIcon} widthAuto /> {children ?? 'Import from file'}
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
</Button>
<Tooltip {...tooltip}>
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
</Tooltip>
</UncontrolledTooltip>
<input
type="file"
accept=".csv"
className="hidden"
className="d-none"
aria-hidden
tabIndex={-1}
ref={fileInputRef}
ref={ref}
onChange={onFile}
data-testid="csv-file-input"
/>
<DuplicatedServersModal
open={isModalOpen}
isOpen={isModalOpen}
duplicatedServers={duplicatedServers}
onClose={discardDuplicatedServers}
onConfirm={createDuplicatedServers}
onDiscard={createNonDuplicatedServers}
onSave={createAllServers}
/>
</>
);
};
export const ImportServersBtn = withDependencies(ImportServersBtnBase, ['ServersImporter']);
export const ImportServersBtnFactory = componentFactory(ImportServersBtn, ['ServersImporter']);

View File

@@ -0,0 +1,17 @@
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
.server-error__container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.server-error__delete-btn {
color: $dangerColor;
font-weight: inherit;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View File

@@ -1,21 +1,31 @@
import { Card, Message } from '@shlinkio/shlink-frontend-kit';
import { Message } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Link } from 'react-router';
import { Link } from 'react-router-dom';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { SelectedServer, ServersMap } from '../data';
import { isServerWithId } from '../data';
import { DeleteServerButton } from '../DeleteServerButton';
import { useSelectedServer } from '../reducers/selectedServer';
import { useServers } from '../reducers/servers';
import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { ServersListGroup } from '../ServersListGroup';
import './ServerError.scss';
export const ServerError: FC = () => {
const { servers } = useServers();
const { selectedServer } = useSelectedServer();
type ServerErrorProps = {
servers: ServersMap;
selectedServer: SelectedServer;
};
type ServerErrorDeps = {
DeleteServerButton: FC<DeleteServerButtonProps>;
};
const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, selectedServer }) => {
const { DeleteServerButton } = useDependencies(ServerError);
return (
<NoMenuLayout>
<div className="flex flex-col items-center gap-y-4 md:gap-y-8">
<Message className="w-full lg:w-[80%]" variant="error">
<div className="server-error__container flex-column">
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<>
@@ -25,23 +35,25 @@ export const ServerError: FC = () => {
)}
</Message>
<p className="text-xl">
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</p>
<Card className="w-full max-w-100 overflow-hidden">
<ServersListGroup borderless servers={Object.values(servers)} />
</Card>
<ServersListGroup servers={Object.values(servers)}>
<p className="mb-md-3">
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</p>
</ServersListGroup>
{isServerWithId(selectedServer) && (
<p className="text-xl">
Alternatively, if you think you may have misconfigured this server, you
can <DeleteServerButton server={selectedServer}>remove
it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p>
<div className="container mt-3 mt-md-5">
<p className="fs-5 fw-normal lh-sm">
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p>
</div>
)}
</div>
</NoMenuLayout>
);
};
export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']);

View File

@@ -1,15 +1,7 @@
import {
Checkbox,
Details,
Label,
LabelledInput,
LabelledRevealablePasswordInput,
SimpleCard,
useToggle,
} from '@shlinkio/shlink-frontend-kit';
import { InputFormGroup, SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { useState } from 'react';
import { usePreventDefault } from '../../utils/utils';
import { handleEventPreventingDefault } from '../../utils/utils';
import type { ServerData } from '../data';
type ServerFormProps = PropsWithChildren<{
@@ -22,42 +14,17 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
const [name, setName] = useState(initialValues?.name ?? '');
const [url, setUrl] = useState(initialValues?.url ?? '');
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle(
initialValues?.forwardCredentials ?? false,
);
const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials }));
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
return (
<form name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="mb-4" bodyClassName="flex flex-col gap-y-3" title={title}>
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
<LabelledRevealablePasswordInput
label="API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
required
/>
<Details summary="Advanced options">
<div className="flex flex-col gap-0.5">
<Label className="flex items-center gap-x-1.5 cursor-pointer">
<Checkbox onChange={toggleForwardCredentials} checked={forwardCredentials} />
Forward credentials to this server on every request.
</Label>
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
and password.
</small>
<small className="pl-5.5 text-gray-600 dark:text-gray-400">
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict
value for <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
</small>
</div>
</Details>
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="mb-3" title={title}>
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
</SimpleCard>
<div className="flex items-center justify-end gap-x-2">{children}</div>
<div className="text-end">{children}</div>
</form>
);
};

View File

@@ -1,16 +1,29 @@
import { Message } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router';
import { useParams } from 'react-router-dom';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { useDependencies } from '../../container/utils';
import type { SelectedServer } from '../data';
import { isNotFoundServer } from '../data';
import { useSelectedServer } from '../reducers/selectedServer';
import { ServerError } from './ServerError';
export function withSelectedServer<T extends object>(WrappedComponent: FC<T>) {
const ComponentWrapper: FC<T> = (props) => {
export type WithSelectedServerProps = {
selectServer: (serverId: string) => void;
selectedServer: SelectedServer;
};
type WithSelectedServerPropsDeps = {
ServerError: FC;
};
export function withSelectedServer<T extends object>(
WrappedComponent: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps>,
) {
const ComponentWrapper: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps> = (props) => {
const { ServerError } = useDependencies(ComponentWrapper);
const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = useSelectedServer();
const { selectServer, selectedServer } = props;
useEffect(() => {
if (params.serverId) {

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