Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c75a3a4073 | ||
|
|
e68643108a | ||
|
|
8a7a51be2f | ||
|
|
f5e92c6897 |
10
.eslintrc
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"extends": [
|
||||||
|
"@shlinkio/js-coding-standard"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["src/service*.ts"]
|
||||||
|
}
|
||||||
24
.github/DISCUSSION_TEMPLATE/q-a.yml
vendored
Normal file
@ -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
@ -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.
|
||||||
|
-->
|
||||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -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
|
||||||
|
|||||||
24
.github/dependabot.yml
vendored
@ -12,21 +12,12 @@ updates:
|
|||||||
fontawesome:
|
fontawesome:
|
||||||
patterns:
|
patterns:
|
||||||
- '@fortawesome/*'
|
- '@fortawesome/*'
|
||||||
eslint:
|
|
||||||
patterns:
|
|
||||||
- '@shlinkio/eslint-config-js-coding-standard'
|
|
||||||
- 'typescript-eslint'
|
|
||||||
- '*eslint-plugin*'
|
|
||||||
- 'eslint'
|
|
||||||
shlink:
|
shlink:
|
||||||
patterns:
|
patterns:
|
||||||
- '@shlinkio/*'
|
- '@shlinkio/*'
|
||||||
react:
|
types:
|
||||||
patterns:
|
patterns:
|
||||||
- 'react'
|
- '@types/*'
|
||||||
- 'react-dom'
|
|
||||||
- '@types/react'
|
|
||||||
- '@types/react-dom'
|
|
||||||
testing:
|
testing:
|
||||||
patterns:
|
patterns:
|
||||||
- '@testing-library/*'
|
- '@testing-library/*'
|
||||||
@ -38,13 +29,10 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- 'vitest'
|
- 'vitest'
|
||||||
- '@vitest/*'
|
- '@vitest/*'
|
||||||
workbox:
|
ignore:
|
||||||
patterns:
|
# Bootstrap can introduce visual breaking changes on styles
|
||||||
- 'workbox*'
|
# Ignore it, since the plan is to remove it anyway
|
||||||
tailwindcss:
|
- dependency-name: 'bootstrap'
|
||||||
patterns:
|
|
||||||
- 'tailwindcss'
|
|
||||||
- '@tailwindcss/*'
|
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: docker
|
||||||
directory: '/'
|
directory: '/'
|
||||||
schedule:
|
schedule:
|
||||||
|
|||||||
10
.github/workflows/ci-docker-image-build.yml
vendored
@ -1,10 +0,0 @@
|
|||||||
name: Test docker image build
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- 'Dockerfile'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-docker-image:
|
|
||||||
uses: shlinkio/github-actions/.github/workflows/docker-image-build-ci.yml@main
|
|
||||||
3
.github/workflows/ci.yml
vendored
@ -11,6 +11,5 @@ jobs:
|
|||||||
ci:
|
ci:
|
||||||
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 20.7
|
||||||
publish-coverage: true
|
publish-coverage: true
|
||||||
install-playwright: true
|
|
||||||
|
|||||||
10
.github/workflows/deploy-preview.yml
vendored
@ -5,23 +5,23 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 22.10
|
node-version: 20.7
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci && \
|
npm ci && \
|
||||||
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
node ./scripts/set-homepage.cjs /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||||
node --run build
|
npm run build
|
||||||
- name: Deploy preview
|
- name: Deploy preview
|
||||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
2
.github/workflows/docker-image-build.yml
vendored
@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: shlinkio/github-actions/.github/workflows/docker-publish-image.yml@main
|
uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
image-name: shlinkio/shlink-web-client
|
image-name: shlinkio/shlink-web-client
|
||||||
|
|||||||
8
.github/workflows/publish-release.yml
vendored
@ -7,14 +7,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v3
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 22.10
|
node-version: 20.7
|
||||||
- name: Generate release assets
|
- name: Generate release assets
|
||||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||||
- name: Publish release with assets
|
- name: Publish release with assets
|
||||||
|
|||||||
4
.gitignore
vendored
@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
/dist
|
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
||||||
|
docker-compose.override.yml
|
||||||
|
home
|
||||||
public/servers.json*
|
public/servers.json*
|
||||||
|
|||||||
5
.stylelintrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"@shlinkio/stylelint-config-css-coding-standard"
|
||||||
|
]
|
||||||
|
}
|
||||||
271
CHANGELOG.md
@ -4,275 +4,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## [4.6.2] - 2025-11-15
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#878](https://github.com/shlinkio/shlink-web-component/issues/878) Fix real-time updates interval setting being ignored.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.6.1] - 2025-11-15
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* [#802](https://github.com/shlinkio/shlink-web-client/issues/802) Improve dependency injection in components.
|
|
||||||
* Stop injecting redux state and actions.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fix small UI issues.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.6.0] - 2025-11-12
|
|
||||||
### Added
|
|
||||||
* [shlink-web-component#839](https://github.com/shlinkio/shlink-web-component/issues/839) Allow filtering short URLs by excluded tags when using Shlink >=4.6.0
|
|
||||||
* [shlink-web-component#838](https://github.com/shlinkio/shlink-web-component/issues/838) Allow filtering tag, orphan and non-orphan visits by domain, when using Shlink >=4.6.0
|
|
||||||
* [shlink-web-component#784](https://github.com/shlinkio/shlink-web-component/issues/784) Add optional `long-url` query parameter to short URL creation to prefill the long URL programmatically.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* Drop support for Shlink older than 4.0.0
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
|
|
||||||
## [4.5.1] - 2025-08-13
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [#1637](https://github.com/shlinkio/shlink-web-client/issues/1637) Fix brand color used in PWA
|
|
||||||
* [#1636](https://github.com/shlinkio/shlink-web-client/issues/1636) Make sure sidebar toggle is rendered only in sections where the sidebar exists.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.5.0] - 2025-08-08
|
|
||||||
### Added
|
|
||||||
* [shlink-web-component#755](https://github.com/shlinkio/shlink-web-component/issues/755) Add support for `any-value-query-param` and `valueless-query-param` redirect conditions when using Shlink >=4.5.0.
|
|
||||||
* [shlink-web-component#756](https://github.com/shlinkio/shlink-web-component/issues/756) Add support for desktop device types on device redirect conditions, when using Shlink >=4.5.0.
|
|
||||||
* [shlink-web-component#713](https://github.com/shlinkio/shlink-web-component/issues/713) Expose a new `ShlinkSidebarToggleButton` component that can be used to customize the location of the sidebar toggle, rather than making it assume there's a header bar and position it there.
|
|
||||||
* [shlink-web-component#657](https://github.com/shlinkio/shlink-web-component/issues/657) Allow visits table columns to be customized via settings, and add a new optional "Region" column.
|
|
||||||
|
|
||||||
As a side effect, the "Show user agent" toggle has been removed from the list, as this can now be globally configured in the settings.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* Update to FontAwesome 7
|
|
||||||
* Update to Recharts 3
|
|
||||||
* Update to `@shlinkio/shlink-web-component` 0.16.1
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#698](https://github.com/shlinkio/shlink-web-component/issues/698) Fix line chart selection triggering after clicking a dot in the chart. It now works only when dragging while the mouse is clicked.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.4.1] - 2025-06-23
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* [shlink-web-component#661](https://github.com/shlinkio/shlink-web-component/issues/661) and [#1571](https://github.com/shlinkio/shlink-web-client/issues/1571) Fully replace bootstrap with tailwind.
|
|
||||||
* Add the new light theme brand color.
|
|
||||||
* Update to `@shlinkio/shlink-frontend-kit` 1.0.0 and `@shlinkio/shlink-web-component` 0.15
|
|
||||||
* Replace reactstrap nav bar with `NavBar` component from `@shlinkio/shlink-frontend-kit`
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
|
|
||||||
## [4.4.0] - 2025-04-20
|
|
||||||
### Added
|
|
||||||
* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) can now be forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set
|
|
||||||
* [shlink-web-component#637](https://github.com/shlinkio/shlink-web-component/pull/637) QR codes are now generated client-side, without hitting Shlink.
|
|
||||||
* [shlink-web-component#641](https://github.com/shlinkio/shlink-web-component/issues/641) It is now possible to provide any logo to be used with QR codes.
|
|
||||||
* [shlink-web-component#640](https://github.com/shlinkio/shlink-web-component/issues/640) Allow default QR code settings to be handled via app settings.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* Update to `react-router` 7.0
|
|
||||||
* Update to `@shlinkio/shlink-frontend-kit` 0.8.x
|
|
||||||
* Update to `@shlinkio/shlink-web-component` 0.13.x
|
|
||||||
* Update to `@shlinkio/shlink-js-sdk` 2.0.0
|
|
||||||
* Add `eslint-plugin-react-compiler`
|
|
||||||
* Run unit tests in a headless browser using vitest browser mode and playwright.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
|
|
||||||
## [4.3.0] - 2024-11-30
|
|
||||||
### Added
|
|
||||||
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
|
|
||||||
|
|
||||||
This can improve sharing a predefined set of servers cia servers.json, env vars, or simply export and import your servers in some other device, and then be able to share server URLs which continue working.
|
|
||||||
|
|
||||||
All existing servers will keep their generated IDs in existing devices for backwards compatibility, but newly created servers will use the new approach.
|
|
||||||
|
|
||||||
* [shlink-web-component#491](https://github.com/shlinkio/shlink-web-component/issues/491) Add support for colors in QR code configurator.
|
|
||||||
* [shlink-web-component#515](https://github.com/shlinkio/shlink-web-component/issues/515) Add support for geolocation redirect conditions, when using Shlink 4.3 or newer.
|
|
||||||
* [shlink-web-component#514](https://github.com/shlinkio/shlink-web-component/issues/514) Allow filtering short URLs list by domain, when using Shlink 4.3 or newer.
|
|
||||||
* [shlink-web-component#520](https://github.com/shlinkio/shlink-web-component/issues/520) Allow navigating from domains list to short URLs list filtered by one domain, when using Shlink 4.3 or newer.
|
|
||||||
* [shlink-web-component#517](https://github.com/shlinkio/shlink-web-component/issues/517) Update list of known domains when a short URL is created with a new domain.
|
|
||||||
* [shlink-web-component#292](https://github.com/shlinkio/shlink-web-component/issues/292) Add icon in short URLs list indicating if a short URL has redirect rules.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#504](https://github.com/shlinkio/shlink-web-component/issues/504) Fix fallback interval not causing new visits to be loaded.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.2.2] - 2024-10-19
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* Update to `@shlinkio/shlink-frontend-kit` 0.6.0
|
|
||||||
* Update to `@shlinkio/shlink-web-component` 0.10.1
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#475](https://github.com/shlinkio/shlink-web-component/issues/475) Fix incorrect amount of dots being displayed in line charts when the difference in days/weeks/months is rounded up.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.2.1] - 2024-10-09
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [#1325](https://github.com/shlinkio/shlink-web-client/issues/1325) Get dependency on `uuid` package back, as `crypto.randomUUID()` can only be used in [secure contexts](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts).
|
|
||||||
* [shlink-web-component#461](https://github.com/shlinkio/shlink-web-component/issues/461) Ensure `shortUrlsList.confirmDeletion` setting is `true` in any case, except when explicitly set to `false`.
|
|
||||||
* [shlink-web-component#237](https://github.com/shlinkio/shlink-web-component/issues/237) Set darker color for previous period in charts, when light theme is enabled.
|
|
||||||
* [shlink-web-component#246](https://github.com/shlinkio/shlink-web-component/issues/246) Fix selected date range not reflected in visits comparison date range selector, when selecting it in the line chart via drag'n'drop.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.2.0] - 2024-10-07
|
|
||||||
### Added
|
|
||||||
* [shlink-web-component#411](https://github.com/shlinkio/shlink-web-component/issues/411) Add support for `ip-address` redirect conditions when Shlink server is >=4.2
|
|
||||||
* [shlink-web-component#196](https://github.com/shlinkio/shlink-web-component/issues/196) Allow active date range to be changed by selecting a range in visits and visits-comparison line charts.
|
|
||||||
* [shlink-web-component#307](https://github.com/shlinkio/shlink-web-component/issues/307) Add new setting to disable short URL deletions confirmation.
|
|
||||||
* [shlink-web-component#435](https://github.com/shlinkio/shlink-web-component/issues/435) Allow toggling between displaying raw user agent and parsed browser/OS in visits table.
|
|
||||||
* [shlink-web-component#197](https://github.com/shlinkio/shlink-web-component/issues/197) Allow line charts to be expanded to the full size of the viewport, both in individual visits views, and when comparing visits.
|
|
||||||
* [shlink-web-component#382](https://github.com/shlinkio/shlink-web-component/issues/382) Initialize QR code modal with all params unset, so that they fall back to the server defaults. Additionally, allow them to be unset if desired.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* Use `ShlinkWebSettings` from `@shlinkio/shlink-web-component` to replace local settings UI.
|
|
||||||
* Update to `@shlinkio/eslint-config-js-coding-standard` 3.0, and migrate to ESLint flat config.
|
|
||||||
* Remove dependency on `uuid` package, and use `crypto.randomUUID()` instead.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
|
|
||||||
## [4.1.2] - 2024-04-17
|
|
||||||
### Added
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* Use new reusable workflow to publish docker image
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#244](https://github.com/shlinkio/shlink-web-component/issues/244) Display `visitedUrl` in visits table if the visit object has it, regardless of it being an orphan visit or not.
|
|
||||||
* [shlink-web-component#327](https://github.com/shlinkio/shlink-web-component/issues/327) Ensure orphan visits type is sent to the server, to enable server-side filtering when consumed Shlink supports it.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.1.1] - 2024-04-11
|
|
||||||
### Added
|
|
||||||
* [shlink-web-component#293](https://github.com/shlinkio/shlink-web-component/issues/293) Allow ordering redirect rules via drag'n'drop.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
* Update JS coding standard
|
|
||||||
* [#1132](https://github.com/shlinkio/shlink-web-client/issues/1132) Add warning message in "validate URLs" setting, indicating it is ignored when consuming Shlink >=4.0.0.
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* [shlink-web-component#294](https://github.com/shlinkio/shlink-web-component/issues/294) Make sure "validate URL" control is not displayed in short URL creation/edition, when consuming Shlink >=4.0.0.
|
|
||||||
* [#1130](https://github.com/shlinkio/shlink-web-client/issues/1130) Fix importing servers in Firefox for Android when the CSV file contains spaces.
|
|
||||||
* [#1133](https://github.com/shlinkio/shlink-web-client/issues/1133) Fix Shlink versions alignment in server error pages.
|
|
||||||
|
|
||||||
|
|
||||||
## [4.1.0] - 2024-03-17
|
## [4.1.0] - 2024-03-17
|
||||||
### Added
|
### Added
|
||||||
* [#1079](https://github.com/shlinkio/shlink-web-client/issues/1079) Add support Shlink 4.0.0.
|
* [#1079](https://github.com/shlinkio/shlink-web-client/issues/1079) Add support Shlink 4.0.0.
|
||||||
@ -439,7 +170,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
|
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on misconfigured servers, after editing their params to set proper values.
|
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
|
||||||
|
|
||||||
|
|
||||||
## [3.8.2] - 2022-12-17
|
## [3.8.2] - 2022-12-17
|
||||||
|
|||||||
@ -14,13 +14,16 @@ Because of this, the only actual dependencies are [docker](https://docs.docker.c
|
|||||||
|
|
||||||
The first thing you need to do is fork the repository, and clone it in your local machine.
|
The first thing you need to do is fork the repository, and clone it in your local machine.
|
||||||
|
|
||||||
Then simply run `docker compose up` and you will have the project exposed in port `3000` (http://localhost:3000).
|
Then you will have to follow these steps:
|
||||||
|
|
||||||
> The first time the container is created, the project dependencies will be installed and the container may take a bit longer to start.
|
* Copy the file `docker-compose.override.yml.dist` by also removing the `dist` extension.
|
||||||
|
* Start-up the project by running `docker compose up`.
|
||||||
|
|
||||||
|
Once this is finished, you will have the project exposed in port `3000` (http://localhost:3000).
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
This project is a [react](https://react.dev/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
This project is a [react](https://reactjs.org/) & [redux](https://redux.js.org/) application, built with [typescript](https://www.typescriptlang.org/), which is distributed as a 100% client-side progressive web application.
|
||||||
|
|
||||||
This is the basic project structure:
|
This is the basic project structure:
|
||||||
|
|
||||||
@ -36,7 +39,7 @@ shlink-web-client
|
|||||||
```
|
```
|
||||||
|
|
||||||
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
* `config`: It contains some configuration scripts, used during testing, linting and building of the project.
|
||||||
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc.).
|
* `public`: Will act as the application document root once built, and contains some static assets (favicons, images, etc).
|
||||||
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
* `scripts`: It has some of the CLI scripts used to run tests or building.
|
||||||
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
* `src`: Contains the main source code of the application, including both web components, SASS stylesheets and files with logic.
|
||||||
* `test`: Contains the project tests.
|
* `test`: Contains the project tests.
|
||||||
@ -45,16 +48,20 @@ shlink-web-client
|
|||||||
|
|
||||||
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||||
|
|
||||||
* `./indocker node --run lint`: Checks coding styles are fulfilled in JS/TS files.
|
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
|
||||||
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files.
|
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
||||||
* `./indocker node --run test`: Runs unit tests with Jest.
|
* `./indocker npm run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
||||||
|
* `./indocker npm run lint:js:fix`: Fixes coding styles in JS/TS files.
|
||||||
|
* `./indocker npm run lint:css:fix`: Fixes coding styles in stylesheets.
|
||||||
|
* `./indocker npm run test`: Runs unit tests with Jest.
|
||||||
|
* `./indocker npm run mutate`: Runs mutation tests with StrykerJS (this command can be very slow).
|
||||||
|
|
||||||
## Building the project
|
## Building the project
|
||||||
|
|
||||||
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
The source code in this project cannot be run directly in a web browser, you need to build it first.
|
||||||
|
|
||||||
* `./indocker node --run run build`: Builds the project for production using [vite](https://vite.dev/), generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
* `./indocker npm run build`: Builds the project using a combination of `webpack`, `babel` and `tsc`, generating the final static files. The content is placed in the `build` folder, which is automatically created if it does not exist.
|
||||||
* `./indocker node --run run preview`: Serves the static files inside the `build` folder in a random port. Useful to test the content built with previous command.
|
* `./indocker npm run serve:build`: Serves the static files inside the `build` folder in port 5000 (http://localhost:5000). Useful to test the content built with previous command.
|
||||||
|
|
||||||
## Pull request process
|
## Pull request process
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
FROM node:25.2-alpine AS node
|
FROM node:21.7-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION=${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
RUN cd /shlink-web-client && npm ci && node --run build
|
RUN cd /shlink-web-client && npm ci && npm run build
|
||||||
|
|
||||||
FROM nginxinc/nginx-unprivileged:1.29-alpine
|
FROM nginxinc/nginx-unprivileged:1.25-alpine
|
||||||
ARG UID=101
|
ARG UID=101
|
||||||
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,8 @@
|
|||||||
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
|
|
||||||
[](https://fosstodon.org/@shlinkio)
|
[](https://fosstodon.org/@shlinkio)
|
||||||
[](https://bsky.app/profile/shlink.io)
|
[](https://twitter.com/shlinkio)
|
||||||
|
[](https://bsky.app/profile/shlinkio.bsky.social)
|
||||||
[](https://slnk.to/donate)
|
[](https://slnk.to/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
@ -30,7 +31,7 @@ The application runs 100% in the browser, so you can safely access any shlink in
|
|||||||
|
|
||||||
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
|
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the [shlinkio/shlink-web-client](https://hub.docker.com/r/shlinkio/shlink-web-client/) image and do it.
|
||||||
|
|
||||||
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 8080.
|
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
|
||||||
|
|
||||||
### Self-hosted
|
### Self-hosted
|
||||||
|
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
FROM mcr.microsoft.com/playwright:v1.57.0-noble
|
|
||||||
|
|
||||||
ENV NODE_VERSION 22.14
|
|
||||||
ENV TINI_VERSION v0.19.0
|
|
||||||
|
|
||||||
# Install Node.js
|
|
||||||
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
|
|
||||||
\. "$HOME/.nvm/nvm.sh" && \
|
|
||||||
nvm install ${NODE_VERSION}
|
|
||||||
|
|
||||||
# Install tini
|
|
||||||
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
|
|
||||||
RUN chmod +x /sbin/tini
|
|
||||||
# Set tini as the entry point, as node does not properly handle signals
|
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
|
||||||
2
dist/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
9
docker-compose.override.yml.dist
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
shlink_web_client_node:
|
||||||
|
user: 1000:1000
|
||||||
|
volumes:
|
||||||
|
- /etc/passwd:/etc/passwd:ro
|
||||||
|
- /etc/group:/etc/group:ro
|
||||||
|
- ./home:/home/alejandro
|
||||||
@ -1,12 +1,10 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
|
image: node:20.7-alpine
|
||||||
build:
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
context: .
|
|
||||||
dockerfile: ./dev.Dockerfile
|
|
||||||
working_dir: /home/shlink/www
|
|
||||||
command: /bin/sh -c "npm install && npm run start"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import shlink from '@shlinkio/eslint-config-js-coding-standard';
|
|
||||||
|
|
||||||
/* eslint-disable-next-line no-restricted-exports */
|
|
||||||
export default shlink;
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
20104
package-lock.json
generated
120
package.json
@ -7,81 +7,79 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint src test config/test *.config.{js,ts}",
|
"lint": "npm run lint:css && npm run lint:js",
|
||||||
"lint:fix": "node --run lint -- --fix",
|
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||||
|
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||||
|
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||||
|
"lint:css:fix": "npm run lint:css -- --fix",
|
||||||
|
"lint:js:fix": "npm run lint:js -- --fix",
|
||||||
"types": "tsc",
|
"types": "tsc",
|
||||||
"start": "vite serve --host=0.0.0.0",
|
"start": "vite serve --host=0.0.0.0",
|
||||||
"preview": "vite preview --host=0.0.0.0",
|
"preview": "vite preview --host=0.0.0.0",
|
||||||
"build": "node --run types && vite build && node scripts/replace-version.mjs",
|
"build": "npm run types && vite build && node scripts/replace-version.mjs",
|
||||||
"build:dist": "node --run build && node scripts/create-dist-file.mjs",
|
"build:dist": "npm run build && node scripts/create-dist-file.mjs",
|
||||||
"test": "vitest run --run",
|
"test": "vitest run --run",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
"test:ci": "node --run test -- --coverage",
|
"test:ci": "npm run test -- --coverage",
|
||||||
"test:verbose": "node --run test -- --verbose"
|
"test:verbose": "npm run test -- --verbose"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
"@fortawesome/free-brands-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/react-fontawesome": "^3.1.1",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@json2csv/plainjs": "^7.0.6",
|
"@json2csv/plainjs": "^7.0.6",
|
||||||
"@reduxjs/toolkit": "^2.11.0",
|
"@reduxjs/toolkit": "^2.2.1",
|
||||||
"@shlinkio/data-manipulation": "^1.0.4",
|
"@shlinkio/data-manipulation": "^1.0.3",
|
||||||
"@shlinkio/shlink-frontend-kit": "^1.4.0",
|
"@shlinkio/shlink-frontend-kit": "^0.5.0",
|
||||||
"@shlinkio/shlink-js-sdk": "^3.0.1",
|
"@shlinkio/shlink-js-sdk": "^1.0.0",
|
||||||
"@shlinkio/shlink-web-component": "^0.17.1",
|
"@shlinkio/shlink-web-component": "^0.6.0",
|
||||||
"@vitest/browser-playwright": "^4.0.15",
|
"bootstrap": "5.2.3",
|
||||||
"bottlejs": "^2.0.1",
|
"bottlejs": "^2.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.0",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.0",
|
||||||
"csvtojson": "^2.0.14",
|
"csvtojson": "^2.0.10",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^3.5.0",
|
||||||
"react": "^19.2.3",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^18.2.0",
|
||||||
"react-external-link": "^2.6.1",
|
"react-external-link": "^2.2.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.1.0",
|
||||||
"react-router": "^7.10.1",
|
"react-router-dom": "^6.22.3",
|
||||||
|
"reactstrap": "^9.2.2",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"workbox-core": "^7.4.0",
|
"uuid": "^9.0.1",
|
||||||
"workbox-expiration": "^7.4.0",
|
"workbox-core": "^7.0.0",
|
||||||
"workbox-precaching": "^7.4.0",
|
"workbox-expiration": "^7.0.0",
|
||||||
"workbox-routing": "^7.4.0",
|
"workbox-precaching": "^7.0.0",
|
||||||
"workbox-strategies": "^7.4.0"
|
"workbox-routing": "^7.0.0",
|
||||||
|
"workbox-strategies": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0",
|
"@shlinkio/eslint-config-js-coding-standard": "~2.3.0",
|
||||||
"@stylistic/eslint-plugin": "^5.6.1",
|
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/react": "^14.2.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@total-typescript/shoehorn": "^0.1.2",
|
"@total-typescript/shoehorn": "^0.1.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react-dom": "^18.2.22",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/uuid": "^9.0.8",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@vitest/browser": "^4.0.3",
|
"@vitest/coverage-v8": "^1.4.0",
|
||||||
"@vitest/coverage-v8": "^4.0.15",
|
"adm-zip": "^0.5.12",
|
||||||
"adm-zip": "^0.5.16",
|
"axe-core": "^4.8.4",
|
||||||
"axe-core": "^4.11.0",
|
"chalk": "^5.3.0",
|
||||||
"chalk": "^5.6.2",
|
"eslint": "^8.57.0",
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"eslint-plugin-import": "^2.32.0",
|
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
||||||
"eslint-plugin-react": "^7.37.5",
|
|
||||||
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"playwright": "^1.57.0",
|
"jsdom": "^24.0.0",
|
||||||
"tailwindcss": "^4.1.3",
|
"sass": "^1.72.0",
|
||||||
"typescript": "^5.9.3",
|
"stylelint": "^15.11.0",
|
||||||
"typescript-eslint": "^8.48.1",
|
"typescript": "^5.4.2",
|
||||||
"vite": "^7.2.6",
|
"vite": "^5.1.6",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^0.19.4",
|
||||||
"vitest": "^4.0.3"
|
"vitest": "^1.2.2"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 662 B After Width: | Height: | Size: 642 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 1.1 KiB |
@ -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 |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 319 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 420 B After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 509 B After Width: | Height: | Size: 437 B |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 570 B After Width: | Height: | Size: 466 B |
|
Before Width: | Height: | Size: 642 B After Width: | Height: | Size: 551 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 799 B After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 834 B After Width: | Height: | Size: 684 B |
|
Before Width: | Height: | Size: 908 B After Width: | Height: | Size: 750 B |
|
Before Width: | Height: | Size: 973 B After Width: | Height: | Size: 783 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 984 B |
@ -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) {
|
||||||
|
|||||||
@ -4,14 +4,11 @@ set -e
|
|||||||
|
|
||||||
ME=$(basename $0)
|
ME=$(basename $0)
|
||||||
|
|
||||||
# In order to allow people to pre-configure a server in their shlink-web-client instance via env vars, this function
|
|
||||||
# dumps a servers.json file based on the values provided via env vars
|
|
||||||
setup_single_shlink_server() {
|
setup_single_shlink_server() {
|
||||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||||
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
||||||
local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}"
|
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
|
||||||
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_single_shlink_server
|
setup_single_shlink_server
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
6
src/api/services/provideServices.ts
Normal 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
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/app/App.tsx
@ -1,71 +1,100 @@
|
|||||||
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Route, Routes, useLocation } from 'react-router';
|
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
import { Home } from '../common/Home';
|
|
||||||
import { MainHeader } from '../common/MainHeader';
|
|
||||||
import { NotFound } from '../common/NotFound';
|
import { NotFound } from '../common/NotFound';
|
||||||
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { CreateServer } from '../servers/CreateServer';
|
import type { ServersMap } from '../servers/data';
|
||||||
import { EditServer } from '../servers/EditServer';
|
import type { AppSettings } from '../settings/reducers/settings';
|
||||||
import { ManageServers } from '../servers/ManageServers';
|
|
||||||
import { useLoadRemoteServers } from '../servers/reducers/remoteServers';
|
|
||||||
import { useSettings } from '../settings/reducers/settings';
|
|
||||||
import { Settings } from '../settings/Settings';
|
|
||||||
import { forceUpdate } from '../utils/helpers/sw';
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
import { useAppUpdated } from './reducers/appUpdates';
|
import './App.scss';
|
||||||
|
|
||||||
export const App: FC = () => {
|
type AppProps = {
|
||||||
const { appUpdated, resetAppUpdate } = useAppUpdated();
|
fetchServers: () => void;
|
||||||
|
servers: ServersMap;
|
||||||
|
settings: AppSettings;
|
||||||
|
resetAppUpdate: () => void;
|
||||||
|
appUpdated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
useLoadRemoteServers();
|
type AppDeps = {
|
||||||
|
MainHeader: FC;
|
||||||
|
Home: FC;
|
||||||
|
ShlinkWebComponentContainer: FC;
|
||||||
|
CreateServer: FC;
|
||||||
|
EditServer: FC;
|
||||||
|
Settings: FC;
|
||||||
|
ManageServers: FC;
|
||||||
|
ShlinkVersionsContainer: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: FCWithDeps<AppProps, AppDeps> = (
|
||||||
|
{ fetchServers, servers, settings, appUpdated, resetAppUpdate },
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
MainHeader,
|
||||||
|
Home,
|
||||||
|
ShlinkWebComponentContainer,
|
||||||
|
CreateServer,
|
||||||
|
EditServer,
|
||||||
|
Settings,
|
||||||
|
ManageServers,
|
||||||
|
ShlinkVersionsContainer,
|
||||||
|
} = useDependencies(App);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const initialServers = useRef(servers);
|
||||||
const isHome = location.pathname === '/';
|
const isHome = location.pathname === '/';
|
||||||
|
|
||||||
const { settings } = useSettings();
|
useEffect(() => {
|
||||||
|
// Try to fetch the remote servers if the list is empty at first
|
||||||
|
// We use a ref because we don't care if the servers list becomes empty later
|
||||||
|
if (Object.keys(initialServers.current).length === 0) {
|
||||||
|
fetchServers();
|
||||||
|
}
|
||||||
|
}, [fetchServers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
|
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
|
||||||
}, [settings.ui?.theme]);
|
}, [settings.ui?.theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="container-fluid app-container">
|
||||||
<>
|
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
|
|
||||||
<div className="h-full pt-(--header-height)">
|
<div className="app">
|
||||||
<div
|
<div className={clsx('shlink-wrapper', { 'd-flex d-md-block align-items-center': isHome })}>
|
||||||
data-testid="shlink-wrapper"
|
|
||||||
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',
|
||||||
|
]);
|
||||||
|
|||||||
@ -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 };
|
|
||||||
};
|
|
||||||
|
|||||||
14
src/app/services/provideServices.ts
Normal 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);
|
||||||
|
};
|
||||||
17
src/common/AppUpdateBanner.scss
Normal 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);
|
||||||
|
}
|
||||||
@ -1,46 +1,34 @@
|
|||||||
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { clsx } from 'clsx';
|
import type { MouseEventHandler } from 'react';
|
||||||
import type { FC } from 'react';
|
import { forwardRef, useCallback } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { Alert, Button } from 'reactstrap';
|
||||||
|
import './AppUpdateBanner.scss';
|
||||||
|
|
||||||
interface AppUpdateBannerProps {
|
interface AppUpdateBannerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
toggle: MouseEventHandler<any>;
|
||||||
forceUpdate: () => void;
|
forceUpdate: Function;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
|
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
|
||||||
const { flag: isUpdating, setToTrue: setUpdating } = useToggle();
|
const [isUpdating,, setUpdating] = useToggle();
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
setUpdating();
|
setUpdating();
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}, [forceUpdate, setUpdating]);
|
}, [forceUpdate, setUpdating]);
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
|
||||||
role="alert"
|
<h4 className="mb-4">This app has just been updated!</h4>
|
||||||
className={clsx(
|
<p className="mb-0">
|
||||||
'w-[700px] max-w-[calc(100%-30px)]',
|
|
||||||
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Card.Header className="flex items-center justify-between">
|
|
||||||
<h5>This app has just been updated!</h5>
|
|
||||||
<CloseButton onClick={onClose} />
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
|
|
||||||
Restart it to enjoy the new features.
|
Restart it to enjoy the new features.
|
||||||
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
|
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
||||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
|
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
||||||
{isUpdating && <>Restarting...</>}
|
{isUpdating && <>Restarting...</>}
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Body>
|
</p>
|
||||||
</Card>
|
</Alert>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
export type ErrorLayoutProps = PropsWithChildren<{
|
|
||||||
title: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
|
|
||||||
<div className="pt-4">
|
|
||||||
<SimpleCard className="p-4 w-full lg:w-[65%] m-auto">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
{children}
|
|
||||||
</SimpleCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
58
src/common/Home.scss
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
@import 'node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
||||||
|
@import '../utils/mixins/vertical-align';
|
||||||
|
|
||||||
|
$mainCardWidth: 720px;
|
||||||
|
$fiveColumnsSize: .4167; // 12 / 5 -> Can't use "/" operator in latest dart-sass
|
||||||
|
|
||||||
|
.home {
|
||||||
|
position: relative;
|
||||||
|
padding-top: 15px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
padding-top: 0;
|
||||||
|
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__logo-wrapper {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__logo {
|
||||||
|
@include vertical-align();
|
||||||
|
|
||||||
|
width: calc(#{$mainCardWidth * $fiveColumnsSize} - 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__main-card {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: $mainCardWidth;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
@include vertical-align();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__title-wrapper {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home__servers-container {
|
||||||
|
@media (min-width: $mdMin) {
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,70 +1,66 @@
|
|||||||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer';
|
import { Card, Row } from 'reactstrap';
|
||||||
import { useServers } from '../servers/reducers/servers';
|
import type { ServersMap } from '../servers/data';
|
||||||
import { ServersListGroup } from '../servers/ServersListGroup';
|
import { ServersListGroup } from '../servers/ServersListGroup';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './Home.scss';
|
||||||
|
|
||||||
export const Home: FC = withoutSelectedServer(() => {
|
interface HomeProps {
|
||||||
|
servers: ServersMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { servers } = useServers();
|
|
||||||
const serversList = Object.values(servers);
|
const serversList = Object.values(servers);
|
||||||
const hasServers = serversList.length > 0;
|
const hasServers = serversList.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to redirect to the first server marked as auto-connect
|
// Try to redirect to the first server marked as auto-connect
|
||||||
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
if (autoConnectServer) {
|
autoConnectServer && navigate(`/server/${autoConnectServer.id}`);
|
||||||
navigate(`/server/${autoConnectServer.id}`);
|
|
||||||
}
|
|
||||||
}, [serversList, navigate]);
|
}, [serversList, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 w-full">
|
<div className="home">
|
||||||
<Card className="mx-auto max-w-[720px] overflow-hidden">
|
<Card className="home__main-card">
|
||||||
<div className="flex flex-col md:flex-row">
|
<Row className="g-0">
|
||||||
<div className="p-6 hidden md:flex items-center w-[40%]">
|
<div className="col-md-5 d-none d-md-block">
|
||||||
<div className="w-full">
|
<div className="home__logo-wrapper">
|
||||||
|
<div className="home__logo">
|
||||||
<ShlinkLogo />
|
<ShlinkLogo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
|
<div className="col-md-7 home__servers-container">
|
||||||
<h1
|
<div className="home__title-wrapper">
|
||||||
className={clsx(
|
<h1 className="home__title">Welcome!</h1>
|
||||||
'p-4 text-center border-lm-border dark:border-dm-border',
|
</div>
|
||||||
{ 'border-b': !hasServers },
|
<ServersListGroup embedded servers={serversList}>
|
||||||
)}
|
{!hasServers && (
|
||||||
>
|
<div className="p-4 text-center">
|
||||||
Welcome!
|
<p className="mb-5">This application will help you manage your Shlink servers.</p>
|
||||||
</h1>
|
|
||||||
{hasServers ? <ServersListGroup servers={serversList} /> : (
|
|
||||||
<div className="p-6 text-center flex flex-col gap-12 text-xl">
|
|
||||||
<p>This application will help you manage your Shlink servers.</p>
|
|
||||||
<p>
|
<p>
|
||||||
<Button to="/server/create" size="lg" inline>
|
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
||||||
<FontAwesomeIcon icon={faPlus} widthAuto /> Add a server
|
<FontAwesomeIcon icon={faPlus} /> <span className="ms-1">Add a server</span>
|
||||||
</Button>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p className="mb-0 mt-5">
|
||||||
<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-1">Learn more about Shlink</span> <FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
|
||||||
</small>
|
</small>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</ServersListGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
24
src/common/MainHeader.scss
Normal 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);
|
||||||
|
}
|
||||||
@ -1,33 +1,54 @@
|
|||||||
import { faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { NavBar } from '@shlinkio/shlink-frontend-kit';
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { useEffect } from 'react';
|
||||||
import { ServersDropdown } from '../servers/ServersDropdown';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
|
import './MainHeader.scss';
|
||||||
|
|
||||||
export const MainHeader: FC = () => {
|
type MainHeaderDeps = {
|
||||||
const { pathname } = useLocation();
|
ServersDropdown: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MainHeader: FCWithDeps<{}, MainHeaderDeps> = () => {
|
||||||
|
const { ServersDropdown } = useDependencies(MainHeader);
|
||||||
|
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
|
||||||
|
const location = useLocation();
|
||||||
|
const { pathname } = location;
|
||||||
|
|
||||||
|
// In mobile devices, collapse the navbar when location changes
|
||||||
|
useEffect(collapse, [location, collapse]);
|
||||||
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
|
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavBar
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
className="[&]:fixed top-0 z-900"
|
<NavbarBrand tag={Link} to="/">
|
||||||
brand={(
|
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
||||||
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
|
</NavbarBrand>
|
||||||
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
|
|
||||||
</Link>
|
<NavbarToggler onClick={toggleCollapse}>
|
||||||
)}
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
>
|
</NavbarToggler>
|
||||||
<NavBar.MenuItem
|
|
||||||
to={settingsPath}
|
<Collapse navbar isOpen={isNotCollapsed}>
|
||||||
active={pathname.startsWith(settingsPath)}
|
<Nav navbar className="ms-auto">
|
||||||
className="flex items-center gap-1.5"
|
<NavItem>
|
||||||
>
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
</NavBar.MenuItem>
|
</NavLink>
|
||||||
|
</NavItem>
|
||||||
<ServersDropdown />
|
<ServersDropdown />
|
||||||
</NavBar>
|
</Nav>
|
||||||
|
</Collapse>
|
||||||
|
</Navbar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);
|
||||||
|
|||||||
9
src/common/NoMenuLayout.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,6 @@
|
|||||||
import { clsx } from 'clsx';
|
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
export type NoMenuLayoutProps = PropsWithChildren & {
|
export const NoMenuLayout: FC<PropsWithChildren<unknown>> = ({ children }) => (
|
||||||
className?: string;
|
<div className="no-menu-wrapper container-xl">{children}</div>
|
||||||
};
|
|
||||||
|
|
||||||
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
|
||||||
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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's back button to navigate to the page you have previously come from, or just press this
|
Use your browser'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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} /> - </>
|
||||||
)}
|
)}
|
||||||
|
|||||||
9
src/common/ShlinkVersionsContainer.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +1,26 @@
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { useMemo } from 'react';
|
||||||
import { useSelectedServer } from '../servers/reducers/selectedServer';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import type { SelectedServer } from '../servers/data';
|
||||||
import { ShlinkVersions } from './ShlinkVersions';
|
import { ShlinkVersions } from './ShlinkVersions';
|
||||||
|
import './ShlinkVersionsContainer.scss';
|
||||||
|
|
||||||
|
export type ShlinkVersionsContainerProps = {
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHLINK_CONTAINER_PATH_PATTERN = /^\/server\/[a-zA-Z0-9-]*\/(?!edit)/;
|
||||||
|
|
||||||
|
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const withPadding = useMemo(() => SHLINK_CONTAINER_PATH_PATTERN.test(pathname), [pathname]);
|
||||||
|
|
||||||
|
const classes = clsx('text-center', {
|
||||||
|
'shlink-versions-container--with-sidebar': withPadding,
|
||||||
|
});
|
||||||
|
|
||||||
export const ShlinkVersionsContainer = () => {
|
|
||||||
const { selectedServer } = useSelectedServer();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classes}>
|
||||||
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
|
||||||
>
|
|
||||||
<ShlinkVersions selectedServer={selectedServer} />
|
<ShlinkVersions selectedServer={selectedServer} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,37 +1,39 @@
|
|||||||
import type { TagColorsStorage } from '@shlinkio/shlink-web-component';
|
import type { Settings, ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
|
||||||
import {
|
|
||||||
ShlinkSidebarToggleButton,
|
|
||||||
ShlinkSidebarVisibilityProvider,
|
|
||||||
ShlinkWebComponent,
|
|
||||||
} from '@shlinkio/shlink-web-component';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
|
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
|
||||||
import { withDependencies } from '../container/context';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import { ServerError } from '../servers/helpers/ServerError';
|
import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer';
|
||||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||||
import { useSelectedServer } from '../servers/reducers/selectedServer';
|
|
||||||
import { useSettings } from '../settings/reducers/settings';
|
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
|
|
||||||
export type ShlinkWebComponentContainerProps = {
|
type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
|
||||||
TagColorsStorage: TagColorsStorage;
|
settings: Settings;
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShlinkWebComponentContainerBase: FC<
|
type ShlinkWebComponentContainerDeps = {
|
||||||
ShlinkWebComponentContainerProps
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
|
TagColorsStorage: TagColorsStorage,
|
||||||
|
ShlinkWebComponent: ShlinkWebComponentType,
|
||||||
|
ServerError: FC,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShlinkWebComponentContainer: FCWithDeps<
|
||||||
|
ShlinkWebComponentContainerProps,
|
||||||
|
ShlinkWebComponentContainerDeps
|
||||||
// FIXME Using `memo` here to solve a flickering effect in charts.
|
// FIXME Using `memo` here to solve a flickering effect in charts.
|
||||||
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
|
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
|
||||||
// extra rendering there.
|
// extra rendering there.
|
||||||
// This should be revisited at some point.
|
// This should be revisited at some point.
|
||||||
> = withSelectedServer(memo(({
|
> = withSelectedServer(memo(({ selectedServer, settings }) => {
|
||||||
|
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 +41,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',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
37
src/common/services/provideServices.ts
Normal 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);
|
||||||
|
};
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,32 +1,42 @@
|
|||||||
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { IContainer } from 'bottlejs';
|
||||||
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
|
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { buildShlinkApiClient } from '../api/services/ShlinkApiClientBuilder';
|
import { connect as reduxConnect } from 'react-redux';
|
||||||
import { ServersExporter } from '../servers/services/ServersExporter';
|
import { provideServices as provideApiServices } from '../api/services/provideServices';
|
||||||
import { ServersImporter } from '../servers/services/ServersImporter';
|
import { provideServices as provideAppServices } from '../app/services/provideServices';
|
||||||
import { csvToJson, jsonToCsv } from '../utils/helpers/csvjson';
|
import { provideServices as provideCommonServices } from '../common/services/provideServices';
|
||||||
import { LocalStorage } from '../utils/services/LocalStorage';
|
import { provideServices as provideServersServices } from '../servers/services/provideServices';
|
||||||
import { TagColorsStorage } from '../utils/services/TagColorsStorage';
|
import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
|
||||||
|
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
|
||||||
|
import type { ConnectDecorator } from './types';
|
||||||
|
|
||||||
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
|
|
||||||
export const { container } = bottle;
|
export const { container } = bottle;
|
||||||
|
|
||||||
bottle.constant('window', window);
|
const lazyService = <T extends Function, K>(cont: IContainer, serviceName: string) =>
|
||||||
bottle.constant('console', console);
|
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
|
||||||
bottle.constant('fetch', window.fetch.bind(window));
|
|
||||||
bottle.service('HttpClient', FetchHttpClient, 'fetch');
|
|
||||||
|
|
||||||
bottle.constant('localStorage', window.localStorage);
|
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
|
||||||
bottle.service('Storage', LocalStorage, 'localStorage');
|
...map,
|
||||||
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
|
// Wrap actual action service in a function so that it is lazily created the first time it is called
|
||||||
|
[actionName]: lazyService(container, actionName),
|
||||||
|
});
|
||||||
|
|
||||||
bottle.constant('csvToJson', csvToJson);
|
const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries(
|
||||||
bottle.constant('jsonToCsv', jsonToCsv);
|
propsToPick.map((key) => [key, obj[key]]),
|
||||||
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle);
|
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
|
||||||
|
reduxConnect(
|
||||||
|
propsFromState ? pickProps(propsFromState) : null,
|
||||||
|
actionServiceNames.reduce(mapActionService, {}),
|
||||||
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
|
provideAppServices(bottle, connect);
|
||||||
|
provideCommonServices(bottle, connect);
|
||||||
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
|
provideApiServices(bottle);
|
||||||
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
|
provideServersServices(bottle, connect);
|
||||||
|
provideUtilsServices(bottle);
|
||||||
|
provideSettingsServices(bottle, connect);
|
||||||
|
|||||||
25
src/container/store.ts
Normal file
@ -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
@ -0,0 +1,13 @@
|
|||||||
|
import type { Settings } from '@shlinkio/shlink-web-component';
|
||||||
|
import type { SelectedServer, ServersMap } from '../servers/data';
|
||||||
|
|
||||||
|
export interface ShlinkState {
|
||||||
|
servers: ServersMap;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
|
settings: Settings;
|
||||||
|
appUpdated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
|
||||||
|
|
||||||
|
export type GetState = () => ShlinkState;
|
||||||
29
src/container/utils.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { IContainer } from 'bottlejs';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
||||||
|
|
||||||
|
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
||||||
|
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
|
||||||
|
return depsRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
|
||||||
|
Component: CompType,
|
||||||
|
deps: ReadonlyArray<keyof CompType>,
|
||||||
|
) {
|
||||||
|
return (container: IContainer, console = globalThis.console) => {
|
||||||
|
deps.forEach((dep) => {
|
||||||
|
const resolvedDependency = container[dep as string];
|
||||||
|
if (!resolvedDependency && process.env.NODE_ENV !== 'production') {
|
||||||
|
console.error(`[Debug] Could not find "${dep as string}" dependency in container`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
Component[dep] = resolvedDependency;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Component;
|
||||||
|
};
|
||||||
|
}
|
||||||
4
src/index.scss
Normal file
@ -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';
|
||||||
@ -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( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
<ContainerProvider value={container}>
|
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter basename={pack.homepage}>
|
<BrowserRouter basename={pack.homepage}>
|
||||||
<ErrorHandler>
|
<ErrorHandler>
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
@ -1,84 +1,89 @@
|
|||||||
import type { ResultProps, TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Button, Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { Result, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { withDependencies } from '../container/context';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import type { ServerData } from './data';
|
import type { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
import { ensureUniqueIds } from './helpers';
|
|
||||||
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
import { ImportServersBtn } from './helpers/ImportServersBtn';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
|
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
export type CreateServerProps = {
|
type CreateServerProps = {
|
||||||
|
createServers: (servers: ServerWithId[]) => void;
|
||||||
|
servers: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateServerDeps = {
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>;
|
||||||
useTimeoutToggle: TimeoutToggle;
|
useTimeoutToggle: TimeoutToggle;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
|
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
<div className="mt-4">
|
<div className="mt-3">
|
||||||
<Result variant={variant}>
|
<Result type={type}>
|
||||||
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||||
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||||
</Result>
|
</Result>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTimeoutToggle }) => {
|
const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
|
||||||
const { servers, createServers } = useServers();
|
const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const hasServers = !!Object.keys(servers).length;
|
const hasServers = !!Object.keys(servers).length;
|
||||||
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
|
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||||
const [serverData, setServerData] = useState<ServerData>();
|
const [serverData, setServerData] = useState<ServerData>();
|
||||||
const saveNewServer = useCallback((newServerData: ServerData) => {
|
const saveNewServer = useCallback((theServerData: ServerData) => {
|
||||||
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
|
const id = uuid();
|
||||||
|
|
||||||
createServers([newServerWithUniqueId]);
|
createServers([{ ...theServerData, id }]);
|
||||||
navigate(`/server/${newServerWithUniqueId.id}`);
|
navigate(`/server/${id}`);
|
||||||
}, [createServers, navigate, servers]);
|
}, [createServers, navigate]);
|
||||||
const onSubmit = useCallback((newServerData: ServerData) => {
|
|
||||||
setServerData(newServerData);
|
useEffect(() => {
|
||||||
|
if (!serverData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const serverExists = Object.values(servers).some(
|
const serverExists = Object.values(servers).some(
|
||||||
({ url, apiKey }) => newServerData.url === url && newServerData.apiKey === apiKey,
|
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (serverExists) {
|
serverExists ? toggleConfirmModal() : saveNewServer(serverData);
|
||||||
toggleConfirmModal();
|
}, [saveNewServer, serverData, servers, toggleConfirmModal]);
|
||||||
} else {
|
|
||||||
saveNewServer(newServerData);
|
|
||||||
}
|
|
||||||
}, [saveNewServer, servers, toggleConfirmModal]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title="Add new server" onSubmit={onSubmit}>
|
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
||||||
{!hasServers && (
|
{!hasServers && (
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
|
||||||
)}
|
)}
|
||||||
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||||
<Button type="submit">Create server</Button>
|
<Button outline color="primary" className="ms-2">Create server</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|
||||||
{serversImported && <ImportResult variant="success" />}
|
{serversImported && <ImportResult type="success" />}
|
||||||
{errorImporting && <ImportResult variant="error" />}
|
{errorImporting && <ImportResult type="error" />}
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
open={isConfirmModalOpen}
|
isOpen={isConfirmModalOpen}
|
||||||
duplicatedServers={serverData ? [serverData] : []}
|
duplicatedServers={serverData ? [serverData] : []}
|
||||||
onClose={goBack}
|
onDiscard={goBack}
|
||||||
onConfirm={() => serverData && saveNewServer(serverData)}
|
onSave={() => serverData && saveNewServer(serverData)}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export const CreateServer = withDependencies(CreateServerBase, ['useTimeoutToggle']);
|
export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']);
|
||||||
|
|||||||
@ -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']);
|
||||||
|
|||||||
@ -1,35 +1,42 @@
|
|||||||
import type { ExitAction } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import { CardModal } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
export type DeleteServerModalProps = {
|
export interface DeleteServerModalProps {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
onClose: (confirmed: boolean) => void;
|
toggle: () => void;
|
||||||
open: boolean;
|
isOpen: boolean;
|
||||||
};
|
redirectHome?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
|
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
||||||
const { deleteServer } = useServers();
|
deleteServer: (server: ServerWithId) => void;
|
||||||
const onClosed = useCallback((exitAction: ExitAction) => {
|
}
|
||||||
if (exitAction === 'confirm') {
|
|
||||||
deleteServer(server);
|
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||||
|
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||||
|
) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const doDelete = useRef<boolean>(false);
|
||||||
|
const toggleAndDelete = () => {
|
||||||
|
doDelete.current = true;
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
|
const onClosed = () => {
|
||||||
|
if (!doDelete.current) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [deleteServer, server]);
|
|
||||||
|
deleteServer(server);
|
||||||
|
redirectHome && navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardModal
|
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||||
open={open}
|
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
||||||
title="Remove server"
|
<ModalBody>
|
||||||
variant="danger"
|
|
||||||
onClose={() => onClose(false)}
|
|
||||||
onConfirm={() => onClose(true)}
|
|
||||||
onClosed={onClosed}
|
|
||||||
confirmText="Delete"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||||
<p>
|
<p>
|
||||||
<i>
|
<i>
|
||||||
@ -37,7 +44,11 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
|
|||||||
You can create it again at any moment.
|
You can create it again at any moment.
|
||||||
</i>
|
</i>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</ModalBody>
|
||||||
</CardModal>
|
<ModalFooter>
|
||||||
|
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||||
|
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,17 +1,26 @@
|
|||||||
import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
|
import { componentFactory } from '../container/utils';
|
||||||
|
import { useGoBack, useParsedQuery } from '../utils/helpers/hooks';
|
||||||
import type { ServerData } from './data';
|
import type { ServerData } from './data';
|
||||||
import { isServerWithId } from './data';
|
import { isServerWithId } from './data';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
import type { WithSelectedServerProps } from './helpers/withSelectedServer';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
import { useSelectedServer } from './reducers/selectedServer';
|
|
||||||
import { useServers } from './reducers/servers';
|
|
||||||
|
|
||||||
export const EditServer: FC = withSelectedServer(() => {
|
type EditServerProps = WithSelectedServerProps & {
|
||||||
const { editServer } = useServers();
|
editServer: (serverId: string, serverData: ServerData) => void;
|
||||||
const { selectServer, selectedServer } = useSelectedServer();
|
};
|
||||||
|
|
||||||
|
type EditServerDeps = {
|
||||||
|
ServerError: FC;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServer((
|
||||||
|
{ editServer, selectedServer, selectServer },
|
||||||
|
) => {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
|
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
|
||||||
|
|
||||||
@ -21,22 +30,22 @@ export const EditServer: FC = withSelectedServer(() => {
|
|||||||
|
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
editServer(selectedServer.id, serverData);
|
editServer(selectedServer.id, serverData);
|
||||||
if (reconnect === 'true') {
|
reconnect === 'true' && selectServer(selectedServer.id);
|
||||||
selectServer(selectedServer.id);
|
|
||||||
}
|
|
||||||
goBack();
|
goBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm
|
<ServerForm
|
||||||
title={<>Edit "{selectedServer.name}"</>}
|
title={<h5 className="mb-0">Edit "{selectedServer.name}"</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']);
|
||||||
|
|||||||
@ -1,84 +1,102 @@
|
|||||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit';
|
import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Row } from 'reactstrap';
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { withDependencies } from '../container/context';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { ImportServersBtn } from './helpers/ImportServersBtn';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
|
import type { ServersMap } from './data';
|
||||||
import { ManageServersRow } from './ManageServersRow';
|
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { useServers } from './reducers/servers';
|
import type { ManageServersRowProps } from './ManageServersRow';
|
||||||
import type { ServersExporter } from './services/ServersExporter';
|
import type { ServersExporter } from './services/ServersExporter';
|
||||||
|
|
||||||
export type ManageServersProps = {
|
type ManageServersProps = {
|
||||||
|
servers: ServersMap;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ManageServersDeps = {
|
||||||
ServersExporter: ServersExporter;
|
ServersExporter: ServersExporter;
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>;
|
||||||
useTimeoutToggle: TimeoutToggle;
|
useTimeoutToggle: TimeoutToggle;
|
||||||
|
ManageServersRow: FC<ManageServersRowProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
|
const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
|
||||||
|
const {
|
||||||
ServersExporter: serversExporter,
|
ServersExporter: serversExporter,
|
||||||
|
ImportServersBtn,
|
||||||
useTimeoutToggle,
|
useTimeoutToggle,
|
||||||
}) => {
|
ManageServersRow,
|
||||||
const { servers } = useServers();
|
} = useDependencies(ManageServers);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const allServers = Object.values(servers);
|
||||||
const allServers = useMemo(() => Object.values(servers), [servers]);
|
const [serversList, setServersList] = useState(allServers);
|
||||||
const filteredServers = useMemo(
|
const filterServers = (searchTerm: string) => setServersList(
|
||||||
() => allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
|
allServers.filter(({ name, url }) => `${name} ${url}`.toLowerCase().match(searchTerm.toLowerCase())),
|
||||||
[allServers, searchTerm],
|
|
||||||
);
|
);
|
||||||
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
||||||
|
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
|
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
|
useEffect(() => {
|
||||||
|
setServersList(Object.values(servers));
|
||||||
|
}, [servers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout className="flex flex-col gap-y-4">
|
<NoMenuLayout>
|
||||||
<SearchInput onChange={setSearchTerm} />
|
<SearchField className="mb-3" onChange={filterServers} />
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-2">
|
<Row className="mb-3">
|
||||||
<div className="flex gap-2">
|
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||||
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
|
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||||
{filteredServers.length > 0 && (
|
{allServers.length > 0 && (
|
||||||
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
|
<Button outline className="ms-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||||
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
|
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button className="md:ml-auto" to="/server/create">
|
<div className="col-md-6 text-md-end d-flex d-md-block">
|
||||||
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server
|
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||||
|
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<SimpleCard className="card">
|
<SimpleCard>
|
||||||
<Table header={(
|
<table className="table table-hover responsive-table mb-0">
|
||||||
<Table.Row>
|
<thead className="responsive-table__header">
|
||||||
{hasAutoConnect && (
|
<tr>
|
||||||
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell>
|
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
|
||||||
)}
|
<th>Name</th>
|
||||||
<Table.Cell>Name</Table.Cell>
|
<th>Base URL</th>
|
||||||
<Table.Cell>Base URL</Table.Cell>
|
<th><span className="sr-only">Options</span></th>
|
||||||
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
|
</tr>
|
||||||
</Table.Row>
|
</thead>
|
||||||
)}>
|
<tbody>
|
||||||
{!filteredServers.length && (
|
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||||
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
|
{serversList.map((server) => (
|
||||||
)}
|
|
||||||
{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 className="mt-3">
|
||||||
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
|
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);
|
export const ManageServersFactory = componentFactory(ManageServers, [
|
||||||
|
'ServersExporter',
|
||||||
|
'ImportServersBtn',
|
||||||
|
'useTimeoutToggle',
|
||||||
|
'ManageServersRow',
|
||||||
|
]);
|
||||||
|
|||||||
@ -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']);
|
||||||
|
|||||||
@ -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']);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
49
src/servers/ServersListGroup.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||