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: contact_links:
- name: Question - Support - name: Question - Support
about: Do you need help setting up or using shlink-web-client? about: Do you need help setting up or using shlink-web-client?
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted url: https://github.com/shlinkio/shlink-web-client/discussions/new?category=q-a

View File

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

View File

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

View File

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

View File

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

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). 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 ## [4.3.0] - 2024-11-30
### Added ### 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. * [#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. * [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
### Fixed ### Fixed
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on misconfigured servers, after editing their params to set proper values. * [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
## [3.8.2] - 2022-12-17 ## [3.8.2] - 2022-12-17

View File

@@ -45,8 +45,11 @@ shlink-web-client
> Note: The `indocker` shell script is a helper used to run commands inside the docker container. > Note: The `indocker` shell script is a helper used to run commands inside the docker container.
* `./indocker node --run lint`: Checks coding styles are fulfilled in JS/TS files. * `./indocker node --run lint`: Checks coding styles are fulfilled, both in JS/TS files and in stylesheets.
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files. * `./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. * `./indocker node --run test`: Runs unit tests with Jest.
## Building the project ## 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 COPY . /shlink-web-client
ARG VERSION="latest" ARG VERSION="latest"
ENV VERSION=${VERSION} ENV VERSION=${VERSION}
RUN cd /shlink-web-client && npm ci && node --run build RUN cd /shlink-web-client && npm ci && node --run build
FROM nginxinc/nginx-unprivileged:1.29-alpine FROM nginxinc/nginx-unprivileged:1.27-alpine
ARG UID=101 ARG UID=101
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>" LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View File

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

View File

@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/playwright:v1.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: shlink_web_client_node:
container_name: shlink_web_client_node container_name: shlink_web_client_node
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
build: image: node:22.10-alpine
context: . command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
dockerfile: ./dev.Dockerfile
working_dir: /home/shlink/www
command: /bin/sh -c "npm install && npm run start"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www
ports: ports:

View File

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

View File

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

16605
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 B

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 984 B

View File

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

View File

@@ -10,8 +10,7 @@ setup_single_shlink_server() {
[ -n "$SHLINK_SERVER_URL" ] || return 0 [ -n "$SHLINK_SERVER_URL" ] || return 0
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0 [ -n "$SHLINK_SERVER_API_KEY" ] || return 0
local name="${SHLINK_SERVER_NAME:-Shlink}" local name="${SHLINK_SERVER_NAME:-Shlink}"
local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}" echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
} }
setup_single_shlink_server setup_single_shlink_server

View File

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

View File

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

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

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

View File

@@ -1,71 +1,100 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit'; import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation } from 'react-router'; import { Route, Routes, useLocation } from 'react-router-dom';
import { AppUpdateBanner } from '../common/AppUpdateBanner'; import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { Home } from '../common/Home';
import { MainHeader } from '../common/MainHeader';
import { NotFound } from '../common/NotFound'; import { NotFound } from '../common/NotFound';
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer'; import type { FCWithDeps } from '../container/utils';
import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer'; import { componentFactory, useDependencies } from '../container/utils';
import { CreateServer } from '../servers/CreateServer'; import type { ServersMap } from '../servers/data';
import { EditServer } from '../servers/EditServer';
import { ManageServers } from '../servers/ManageServers';
import { useLoadRemoteServers } from '../servers/reducers/remoteServers';
import { useSettings } from '../settings/reducers/settings';
import { Settings } from '../settings/Settings';
import { forceUpdate } from '../utils/helpers/sw'; import { forceUpdate } from '../utils/helpers/sw';
import { useAppUpdated } from './reducers/appUpdates'; import './App.scss';
export const App: FC = () => { type AppProps = {
const { appUpdated, resetAppUpdate } = useAppUpdated(); fetchServers: () => void;
servers: ServersMap;
settings: 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 location = useLocation();
const initialServers = useRef(servers);
const isHome = location.pathname === '/'; const isHome = location.pathname === '/';
const { settings } = useSettings(); useEffect(() => {
// Try to fetch the remote servers if the list is empty 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(() => { useEffect(() => {
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme()); changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
}, [settings.ui?.theme]); }, [settings.ui?.theme]);
return ( return (
<div className="h-full"> <div className="container-fluid app-container">
<>
<MainHeader /> <MainHeader />
<div className="h-full pt-(--header-height)"> <div className="app">
<div <div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}>
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> <Routes>
<Route index element={<Home />} /> <Route index element={<Home />} />
<Route path="/settings"> <Route path="/settings/*" element={<Settings />} />
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
</Route>
<Route path="/manage-servers" element={<ManageServers />} /> <Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} /> <Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} /> <Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId"> <Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
</Route>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</div> </div>
<div className="h-(--footer-height) mt-(--footer-margin) md:px-4"> <div className="shlink-footer">
<ShlinkVersionsContainer /> <ShlinkVersionsContainer />
</div> </div>
</div> </div>
</>
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} /> <AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
</div> </div>
); );
}; };
export const AppFactory = componentFactory(App, [
'MainHeader',
'Home',
'ShlinkWebComponentContainer',
'CreateServer',
'EditServer',
'Settings',
'ManageServers',
'ShlinkVersionsContainer',
]);

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
@import '../utils/mixins/horizontal-align';
.app-update-banner.app-update-banner {
@include horizontal-align();
position: fixed;
top: $headerHeight - 25px;
padding: 0 4rem 0 0;
z-index: 1040;
margin: 0;
color: var(--text-color);
text-align: center;
width: 700px;
max-width: calc(100% - 30px);
box-shadow: 0 0 1rem var(--brand-color);
}

View File

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

View File

@@ -1,7 +1,7 @@
import { Button } from '@shlinkio/shlink-frontend-kit'; import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import type { PropsWithChildren, ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { Component } from 'react'; import { Component } from 'react';
import { ErrorLayout } from './ErrorLayout'; import { Button } from 'reactstrap';
type ErrorHandlerProps = PropsWithChildren<{ type ErrorHandlerProps = PropsWithChildren<{
location?: typeof window.location; location?: typeof window.location;
@@ -33,11 +33,14 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
if (hasError) { if (hasError) {
return ( return (
<ErrorLayout title="Oops! This is awkward :S"> <div className="home">
<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> <p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
<br /> <br />
<Button size="lg" onClick={() => location.reload()}>Take me back</Button> <Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
</ErrorLayout> </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 { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link'; import { ExternalLink } from 'react-external-link';
import { useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router-dom';
import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer'; import { Card } from 'reactstrap';
import { useServers } from '../servers/reducers/servers'; import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup'; import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo'; import { ShlinkLogo } from './img/ShlinkLogo';
import './Home.scss';
export const Home: FC = withoutSelectedServer(() => { interface HomeProps {
servers: ServersMap;
}
export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { servers } = useServers();
const serversList = Object.values(servers); const serversList = Object.values(servers);
const hasServers = serversList.length > 0; const hasServers = serversList.length > 0;
@@ -26,45 +28,45 @@ export const Home: FC = withoutSelectedServer(() => {
}, [serversList, navigate]); }, [serversList, navigate]);
return ( return (
<div className="px-3 w-full"> <div className="w-100">
<Card className="mx-auto max-w-[720px] overflow-hidden"> <Card className="mx-auto" style={{ maxWidth: '720px' }}>
<div className="flex flex-col md:flex-row"> <div className="d-flex flex-column flex-md-row">
<div className="p-6 hidden md:flex items-center w-[40%]"> <div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
<div className="w-full"> <div className="w-100">
<ShlinkLogo /> <ShlinkLogo />
</div> </div>
</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 <h1
className={clsx( className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
'p-4 text-center border-lm-border dark:border-dm-border', style={{ borderColor: 'var(--border-color) !important' }}
{ 'border-b': !hasServers },
)}
> >
Welcome! Welcome!
</h1> </h1>
{hasServers ? <ServersListGroup servers={serversList} /> : ( <ServersListGroup embedded servers={serversList}>
<div className="p-6 text-center flex flex-col gap-12 text-xl"> {!hasServers && (
<p>This application will help you manage your Shlink servers.</p> <div className="p-4 text-center d-flex flex-column gap-5">
<p> <p className="mb-0">This application will help you manage your Shlink servers.</p>
<Button to="/server/create" size="lg" inline> <p className="mb-0">
<FontAwesomeIcon icon={faPlus} widthAuto /> Add a server <Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
</Button> <FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span>
</Link>
</p> </p>
<p> <p className="mb-0">
<ExternalLink href="https://shlink.io/documentation"> <ExternalLink href="https://shlink.io/documentation">
<small> <small>
<span className="mr-2">Learn more about Shlink</span> <span className="me-2">Learn more about Shlink</span>
<FontAwesomeIcon icon={faExternalLinkAlt} /> <FontAwesomeIcon icon={faExternalLinkAlt}/>
</small> </small>
</ExternalLink> </ExternalLink>
</p> </p>
</div> </div>
)} )}
</ServersListGroup>
</div> </div>
</div> </div>
</Card> </Card>
</div> </div>
); );
}); };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,19 @@
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data'; import { isReachableServer } from '../servers/data';
import { useSelectedServer } from '../servers/reducers/selectedServer';
import { ShlinkVersions } from './ShlinkVersions'; import { ShlinkVersions } from './ShlinkVersions';
import './ShlinkVersionsContainer.scss';
export const ShlinkVersionsContainer = () => { export type ShlinkVersionsContainerProps = {
const { selectedServer } = useSelectedServer(); selectedServer: SelectedServer;
return ( };
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
<div <div
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })} className={clsx('text-center', {
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
})}
> >
<ShlinkVersions selectedServer={selectedServer} /> <ShlinkVersions selectedServer={selectedServer} />
</div> </div>
); );
};

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,13 @@
import type { Settings } from '@shlinkio/shlink-web-component/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,21 +1,16 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router-dom';
import pack from '../package.json'; import pack from '../package.json';
import { App } from './app/App';
import { appUpdateAvailable } from './app/reducers/appUpdates';
import { ErrorHandler } from './common/ErrorHandler';
import { ScrollToTop } from './common/ScrollToTop';
import { container } from './container'; import { container } from './container';
import { ContainerProvider } from './container/context'; import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration'; import { register as registerServiceWorker } from './serviceWorkerRegistration';
import { setUpStore } from './store'; import './index.scss';
import './tailwind.css';
const store = setUpStore(); const store = setUpStore(container);
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<ContainerProvider value={container}>
<Provider store={store}> <Provider store={store}>
<BrowserRouter basename={pack.homepage}> <BrowserRouter basename={pack.homepage}>
<ErrorHandler> <ErrorHandler>
@@ -24,8 +19,7 @@ createRoot(document.getElementById('root')!).render(
</ScrollToTop> </ScrollToTop>
</ErrorHandler> </ErrorHandler>
</BrowserRouter> </BrowserRouter>
</Provider> </Provider>,
</ContainerProvider>,
); );
// Learn more about service workers: https://cra.link/PWA // Learn more about service workers: https://cra.link/PWA

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,39 +1,48 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons'; import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit'; import { Link } from 'react-router-dom';
import type { FC } from 'react'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data'; import { getServerId } from './data';
import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export const ServersDropdown: FC = () => { export interface ServersDropdownProps {
const { servers } = useServers(); servers: ServersMap;
selectedServer: SelectedServer;
}
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = Object.values(servers); const serversList = Object.values(servers);
const { selectedServer } = useSelectedServer();
const renderServers = () => {
if (serversList.length === 0) {
return (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
</DropdownItem>
);
}
return ( 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 }) => ( {serversList.map(({ name, id }) => (
<Dropdown.Item key={id} to={`/server/${id}`} selected={getServerId(selectedServer) === id}> <DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
{name} {name}
</Dropdown.Item> </DropdownItem>
))} ))}
<Dropdown.Separator /> <DropdownItem divider tag="hr" />
<Dropdown.Item to="/manage-servers"> <DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> Manage servers <FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Manage servers</span>
</Dropdown.Item> </DropdownItem>
</> </>
)} );
</NavBar.Dropdown> };
return (
<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 { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import type { FC } from 'react'; import type { FC, PropsWithChildren } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import type { ServerWithId } from './data'; import type { ServerWithId } from './data';
import './ServersListGroup.scss';
type ServersListGroupProps = { type ServersListGroupProps = PropsWithChildren<{
servers: ServerWithId[]; servers: ServerWithId[];
borderless?: boolean; embedded?: boolean;
}; }>;
const ServerListItem = ({ id, name }: { id: string; name: string }) => ( const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<Link <ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
to={`/server/${id}`} {name}
className={clsx( <FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
'servers-list__server-item', </ListGroupItem>
'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>
); );
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => ( export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
servers.length > 0 && ( <>
<div {children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
{servers.length > 0 && (
<ListGroup
data-testid="list" data-testid="list"
className={clsx( tag="div"
'w-full border-lm-border dark:border-dm-border', className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
'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} />)} {servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</div> </ListGroup>
) )}
</>
); );

View File

@@ -4,7 +4,6 @@ export interface ServerData {
name: string; name: string;
url: string; url: string;
apiKey: string; apiKey: string;
forwardCredentials?: boolean;
} }
export interface ServerWithId extends ServerData { 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 : ''); export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
/** export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey });
* 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',
};
};

View File

@@ -1,32 +1,26 @@
import { CardModal } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react'; import type { FC } from 'react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import type { ServerData } from '../data'; import type { ServerData } from '../data';
export type DuplicatedServersModalProps = { interface DuplicatedServersModalProps {
duplicatedServers: ServerData[]; duplicatedServers: ServerData[];
open: boolean; isOpen: boolean;
onClose: () => void; onDiscard: () => void;
onConfirm: () => void; onSave: () => void;
}; }
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = ( export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
{ open, duplicatedServers, onClose, onConfirm }, { isOpen, duplicatedServers, onDiscard, onSave },
) => { ) => {
const hasMultipleServers = duplicatedServers.length > 1; const hasMultipleServers = duplicatedServers.length > 1;
return ( return (
<CardModal <Modal centered isOpen={isOpen}>
size="lg" <ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
title={`Duplicated server${hasMultipleServers ? 's' : ''}`} <ModalBody>
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> <p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="list-disc my-4 pl-5"> <ul>
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? ( {duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}> <Fragment key={index}>
<li>URL: <b>{url}</b></li> <li>URL: <b>{url}</b></li>
@@ -35,8 +29,13 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))} ) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
</ul> </ul>
<span> <span>
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}? {hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
</span> </span>
</CardModal> </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 { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Tooltip, useToggle, useTooltip } from '@shlinkio/shlink-frontend-kit'; import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { ChangeEvent, FC, PropsWithChildren } from 'react'; import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { withDependencies } from '../../container/context'; import { Button, UncontrolledTooltip } from 'reactstrap';
import type { ServerData } from '../data'; import type { FCWithDeps } from '../../container/utils';
import { useServers } from '../reducers/servers'; import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data';
import type { ServersImporter } from '../services/ServersImporter'; import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal'; import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers, ensureUniqueIds } from './index'; import { dedupServers, ensureUniqueIds } from './index';
export type ImportServersBtnProps = PropsWithChildren<{ export type ImportServersBtnProps = PropsWithChildren<{
onImport?: () => void; onImport?: () => void;
onError?: (error: Error) => void; onImportError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom'; tooltipPlacement?: 'top' | 'bottom';
className?: string; 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, children,
onImport, onImport = () => {},
onError = () => {}, onImportError = () => {},
tooltipPlacement = 'bottom', tooltipPlacement = 'bottom',
className = '', className = '',
ServersImporter: serversImporter,
}) => { }) => {
const { createServers, servers } = useServers(); const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
const fileInputRef = useRef<HTMLInputElement>(null); const ref = useElementRef<HTMLInputElement>();
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]); const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle(); const [isModalOpen,, showModal, hideModal] = useToggle();
const newServersCreatedRef = useRef(false);
const importedServersRef = useRef<ServerWithId[]>([]);
const newServersRef = useRef<ServerWithId[]>([]);
const create = useCallback((serversData: ServerWithId[]) => {
createServers(serversData);
onImport();
}, [createServers, onImport]);
const onFile = useCallback( const onFile = useCallback(
async ({ target }: ChangeEvent<HTMLInputElement>) => async ({ target }: ChangeEvent<HTMLInputElement>) =>
serversImporter.importServersFromFile(target.files?.[0]) serversImporter.importServersFromFile(target.files?.[0])
.then((importedServers) => { .then((importedServers) => {
const { duplicatedServers, newServers } = dedupServers(servers, importedServers); const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
// Immediately create new servers importedServersRef.current = ensureUniqueIds(servers, importedServers);
newServersCreatedRef.current = newServers.length > 0; newServersRef.current = ensureUniqueIds(servers, newServers);
createServers(ensureUniqueIds(servers, newServers));
// For duplicated servers, ask for confirmation if (duplicatedServers.length === 0) {
if (duplicatedServers.length > 0) { create(importedServersRef.current);
} else {
setDuplicatedServers(duplicatedServers); setDuplicatedServers(duplicatedServers);
showModal(); showModal();
} else {
onImport?.();
} }
}) })
.then(() => { .then(() => {
// Reset file input after processing file // Reset file input after processing file
(target as { value: string | null }).value = null; (target as { value: string | null }).value = null;
}) })
.catch(onError), .catch(onImportError),
[createServers, onError, onImport, servers, serversImporter, showModal], [create, onImportError, servers, serversImporter, showModal],
); );
const createDuplicatedServers = useCallback(() => { const createAllServers = useCallback(() => {
createServers(ensureUniqueIds(servers, duplicatedServers)); create(importedServersRef.current);
hideModal(); hideModal();
onImport?.(); }, [create, hideModal]);
}, [createServers, duplicatedServers, hideModal, onImport, servers]); const createNonDuplicatedServers = useCallback(() => {
const discardDuplicatedServers = useCallback(() => { create(newServersRef.current);
hideModal(); hideModal();
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport }, [create, hideModal]);
if (newServersCreatedRef.current) {
onImport?.();
}
}, [hideModal, onImport]);
return ( return (
<> <>
<Button variant="secondary" className={className} onClick={() => fileInputRef.current?.click()} {...anchor}> <Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
<FontAwesomeIcon icon={importIcon} widthAuto /> {children ?? 'Import from file'} <FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
</Button> </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. You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
</Tooltip> </UncontrolledTooltip>
<input <input
type="file" type="file"
accept=".csv" accept=".csv"
className="hidden" className="d-none"
aria-hidden aria-hidden
tabIndex={-1} ref={ref}
ref={fileInputRef}
onChange={onFile} onChange={onFile}
data-testid="csv-file-input" data-testid="csv-file-input"
/> />
<DuplicatedServersModal <DuplicatedServersModal
open={isModalOpen} isOpen={isModalOpen}
duplicatedServers={duplicatedServers} duplicatedServers={duplicatedServers}
onClose={discardDuplicatedServers} onDiscard={createNonDuplicatedServers}
onConfirm={createDuplicatedServers} 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 type { FC } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router-dom';
import { NoMenuLayout } from '../../common/NoMenuLayout'; 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 { isServerWithId } from '../data';
import { DeleteServerButton } from '../DeleteServerButton'; import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { useSelectedServer } from '../reducers/selectedServer';
import { useServers } from '../reducers/servers';
import { ServersListGroup } from '../ServersListGroup'; import { ServersListGroup } from '../ServersListGroup';
import './ServerError.scss';
export const ServerError: FC = () => { type ServerErrorProps = {
const { servers } = useServers(); servers: ServersMap;
const { selectedServer } = useSelectedServer(); selectedServer: SelectedServer;
};
type ServerErrorDeps = {
DeleteServerButton: FC<DeleteServerButtonProps>;
};
const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, selectedServer }) => {
const { DeleteServerButton } = useDependencies(ServerError);
return ( return (
<NoMenuLayout> <NoMenuLayout>
<div className="flex flex-col items-center gap-y-4 md:gap-y-8"> <div className="server-error__container flex-column">
<Message className="w-full lg:w-[80%]" variant="error"> <Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'} {!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && ( {isServerWithId(selectedServer) && (
<> <>
@@ -25,23 +35,25 @@ export const ServerError: FC = () => {
)} )}
</Message> </Message>
<p className="text-xl"> <ServersListGroup servers={Object.values(servers)}>
<p className="mb-md-3">
These are the Shlink servers currently configured. Choose one of These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>. them or <Link to="/server/create">add a new one</Link>.
</p> </p>
<Card className="w-full max-w-100 overflow-hidden"> </ServersListGroup>
<ServersListGroup borderless servers={Object.values(servers)} />
</Card>
{isServerWithId(selectedServer) && ( {isServerWithId(selectedServer) && (
<p className="text-xl"> <div className="container mt-3 mt-md-5">
Alternatively, if you think you may have misconfigured this server, you <p className="fs-5 fw-normal lh-sm">
can <DeleteServerButton server={selectedServer}>remove Alternatively, if you think you may have miss-configured this server, you
it</DeleteServerButton> or&nbsp; 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>. <Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
</p> </p>
</div>
)} )}
</div> </div>
</NoMenuLayout> </NoMenuLayout>
); );
}; };
export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']);

View File

@@ -1,15 +1,7 @@
import { import { InputFormGroup, SimpleCard } from '@shlinkio/shlink-frontend-kit';
Checkbox,
Details,
Label,
LabelledInput,
LabelledRevealablePasswordInput,
SimpleCard,
useToggle,
} from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren, ReactNode } from 'react'; import type { FC, PropsWithChildren, ReactNode } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { usePreventDefault } from '../../utils/utils'; import { handleEventPreventingDefault } from '../../utils/utils';
import type { ServerData } from '../data'; import type { ServerData } from '../data';
type ServerFormProps = PropsWithChildren<{ type ServerFormProps = PropsWithChildren<{
@@ -22,42 +14,17 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
const [name, setName] = useState(initialValues?.name ?? ''); const [name, setName] = useState(initialValues?.name ?? '');
const [url, setUrl] = useState(initialValues?.url ?? ''); const [url, setUrl] = useState(initialValues?.url ?? '');
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? ''); const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle( const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
initialValues?.forwardCredentials ?? false,
);
const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials }));
return ( return (
<form name="serverForm" onSubmit={handleSubmit}> <form className="server-form" name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="mb-4" bodyClassName="flex flex-col gap-y-3" title={title}> <SimpleCard className="mb-3" title={title}>
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required /> <InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required /> <InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
<LabelledRevealablePasswordInput <InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
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>
</SimpleCard> </SimpleCard>
<div className="flex items-center justify-end gap-x-2">{children}</div> <div className="text-end">{children}</div>
</form> </form>
); );
}; };

View File

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

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