Compare commits

..

381 Commits

Author SHA1 Message Date
Alejandro Celaya
14e31ed2c3 Merge pull request #330 from acelaya-forks/feature/fix-switch-alignment
Removed hardcoded display: inline for BooleanControls
2020-10-31 17:28:19 +01:00
Alejandro Celaya
ff1fb0dd12 Removed hardcoded display: inline for BooleanControls 2020-10-31 17:18:51 +01:00
Alejandro Celaya
2e6a35181d Merge pull request #329 from acelaya-forks/feature/fix-too-long-cache
Feature/fix too long cache
2020-10-31 13:47:43 +01:00
Alejandro Celaya
22cca598ca Updated changelog 2020-10-31 13:38:37 +01:00
Alejandro Celaya
de906bf370 Added proper caching rules to nginx config included in docker image 2020-10-31 13:36:53 +01:00
Alejandro Celaya
498594c31b Deleted service worker 2020-10-31 13:22:39 +01:00
Alejandro Celaya
cfbd246cfc Merge pull request #327 from acelaya-forks/feature/dart-sass
Feature/dart sass
2020-10-31 13:07:52 +01:00
Alejandro Celaya
3f91c556e4 Explicitly installed node 14 in scrutinizer env 2020-10-31 13:00:09 +01:00
Alejandro Celaya
4d1622607c Enabled all platforms back on docker image builds 2020-10-31 12:34:42 +01:00
Alejandro Celaya
eacdee293c Replaced node-sass with dart-sass 2020-10-31 12:27:24 +01:00
Alejandro Celaya
f4b115cffd Merge pull request #326 from acelaya-forks/feature/node-14
Updated to node 14
2020-10-31 12:08:35 +01:00
Alejandro Celaya
7dcd623513 Updated to node 14 2020-10-31 11:58:07 +01:00
Alejandro Celaya
8b00d1aaae Updated reference from travis-ci.org to travis-ci.com 2020-10-31 09:11:43 +01:00
Alejandro Celaya
facfd33e96 Merge pull request #319 from acelaya-forks/feature/calendar-z-index
Feature/calendar z index
2020-10-03 11:28:20 +02:00
Alejandro Celaya
a841dc7531 Updated changelog 2020-10-03 11:23:08 +02:00
Alejandro Celaya
28ebd55b69 Fixed z-index in react-datepicker 2020-10-03 11:22:21 +02:00
Alejandro Celaya
3eade5a0c0 Merge pull request #318 from acelaya-forks/feature/manifest-basic-auth
Feature/manifest basic auth
2020-10-03 11:10:57 +02:00
Alejandro Celaya
caf74cd87b Updated changelog 2020-10-03 11:03:17 +02:00
Alejandro Celaya
049510f513 Added crossorigin=use-credentials to manifest.json, so that credentials are passed and it is propery downloaded 2020-10-03 11:00:56 +02:00
Alejandro Celaya
b151b7eedb Added missing condition for github release to work on travis build 2020-09-20 12:46:51 +02:00
Alejandro Celaya
4e22e9c092 Added script step to publish release travis job 2020-09-20 12:38:47 +02:00
Alejandro Celaya
793883148a Added v2.6 to changelog 2020-09-20 12:07:16 +02:00
Alejandro Celaya
8acb7bea24 Merge pull request #310 from acelaya-forks/feature/line-chart-highlights
Feature/line chart highlights
2020-09-20 12:05:06 +02:00
Alejandro Celaya
335cceeb82 Fixed coding styles 2020-09-20 11:58:40 +02:00
Alejandro Celaya
bf7455ad6e Updated changelog 2020-09-20 11:49:19 +02:00
Alejandro Celaya
421cc5b718 Put together all chart-related helper functions 2020-09-20 11:46:07 +02:00
Alejandro Celaya
78d97a64aa Added visits highlightning capabilities to line chart 2020-09-20 11:43:24 +02:00
Alejandro Celaya
749c757cbd Removed unneeded condition on travis deploy step, as the same condition is on the parent job 2020-09-20 10:43:21 +02:00
Alejandro Celaya
faf9554286 Added lines between job definitions in travis config 2020-09-19 18:52:49 +02:00
Alejandro Celaya
b7a0be3872 Allowed mutation testing step to fail without failing the whole build 2020-09-19 18:08:16 +02:00
Alejandro Celaya
cff8046ff8 Merge pull request #308 from acelaya-forks/feature/contributing
Added CONTRIBUTING.md file
2020-09-19 17:42:28 +02:00
Alejandro Celaya
af1289752d Added reference to the CONTRIBUTING file from the README file 2020-09-19 17:42:06 +02:00
Alejandro Celaya
b06d9d3bc7 Added CONTRIBUTING.md file 2020-09-19 17:32:54 +02:00
Alejandro Celaya
b2904189ef Merge pull request #307 from acelaya-forks/feature/parallel-builds
Feature/parallel builds
2020-09-19 17:04:52 +02:00
Alejandro Celaya
5c639d241b Disabled docker image build in arm archs, as it fails with node-sass 2020-09-19 16:58:01 +02:00
Alejandro Celaya
984e9f32a5 Split all scripts in travis build into individual jobs 2020-09-19 16:57:35 +02:00
Alejandro Celaya
59d23b584a Merge pull request #304 from acelaya-forks/feature/gh-action-docker-build
Feature/gh action docker build
2020-09-19 16:35:29 +02:00
Alejandro Celaya
a7d865228a Updated name of branch in which docker build action needs to run 2020-09-19 16:25:33 +02:00
Alejandro Celaya
260ff716d7 Updated changelog 2020-09-19 16:24:43 +02:00
Alejandro Celaya
9001a3da37 Moved docker build to github actions, enabling multi-arch support 2020-09-19 16:23:39 +02:00
Alejandro Celaya
46db1e39f3 Merge pull request #303 from acelaya-forks/feature/cancel-tags-visits
Feature/cancel tags visits
2020-09-19 11:23:54 +02:00
Alejandro Celaya
6bf3fc0fd5 Updated changelog 2020-09-19 10:51:41 +02:00
Alejandro Celaya
a136543551 Fixed tags visits loading not being cancelled when the visits component gets unmounted 2020-09-19 10:50:49 +02:00
Alejandro Celaya
23046c149c Fixed visits normalization not converting empty strings into null 2020-09-19 10:31:23 +02:00
Alejandro Celaya
2951d0d75e Merge pull request #302 from acelaya-forks/feature/number-formatting
Feature/number formatting
2020-09-19 10:16:54 +02:00
Alejandro Celaya
b52e40edd3 Updated changelog 2020-09-17 18:11:03 +02:00
Alejandro Celaya
51556d76ac Fixed tests 2020-09-17 18:05:26 +02:00
Alejandro Celaya
871868f0a4 Moved common rendering chart labels code to external module for reuse 2020-09-15 22:30:31 +02:00
Alejandro Celaya
67495fa302 Added number formatting to charts 2020-09-15 22:22:56 +02:00
Alejandro Celaya
fc9341f631 Added number formatting to visits line chart 2020-09-13 11:11:17 +02:00
Alejandro Celaya
3fea8b5505 Ensured page numbers in paginators are prettified 2020-09-13 10:03:02 +02:00
Alejandro Celaya
89e3114ef3 Merge pull request #300 from acelaya-forks/feature/default-ordering
Feature/default ordering
2020-09-13 09:54:20 +02:00
Alejandro Celaya
4dc5fad8b8 Fixed coding standards 2020-09-13 09:47:29 +02:00
Alejandro Celaya
2567bdfdf0 Updated changelog 2020-09-13 09:32:02 +02:00
Alejandro Celaya
f36cf1e7b9 Updated short URL list params so that it requests dateCreated DESC ordering by default 2020-09-12 17:59:58 +02:00
Alejandro Celaya
bd88e56331 Merge pull request #299 from acelaya-forks/feature/updates-interval
Feature/updates interval
2020-09-12 13:18:23 +02:00
Alejandro Celaya
fe3e08de0f Fixed event source not being properly closed with new boundToMercureHub HOC 2020-09-12 12:06:53 +02:00
Alejandro Celaya
cfb165d240 Fixed boundToMercureHub HOC so that it clears updates intervals when unmounted 2020-09-12 11:55:49 +02:00
Alejandro Celaya
fa074f91be Updated changelog 2020-09-12 11:35:12 +02:00
Alejandro Celaya
6fc4963663 Replaced redux action to create one visit by action that allows multiple visits at once 2020-09-12 11:31:44 +02:00
Alejandro Celaya
ad437f655e Added support to dispatch all UI actions based on mercure bindings on a specific schedule instead of real time 2020-09-12 08:52:03 +02:00
Alejandro Celaya
9b45513684 Added form controls to set real time updates interval 2020-09-09 19:16:04 +02:00
Alejandro Celaya
5d6d802d64 Moved mercure hub binding from custom hook to HOC 2020-09-06 19:41:15 +02:00
Alejandro Celaya
536d49aac9 Merge pull request #298 from acelaya-forks/feature/always-visible-versions
Feature/always visible versions
2020-09-06 13:20:17 +02:00
Alejandro Celaya
796c9ca140 Fixed changelog 2020-09-06 13:11:33 +02:00
Alejandro Celaya
1b1a1f3230 Created component decorator that resets selected server and used it on Settings 2020-09-06 13:10:30 +02:00
Alejandro Celaya
98d856700c Added missing row wrapping Message component 2020-09-06 12:47:14 +02:00
Alejandro Celaya
b814f500de Moved shlink versions to the outer element so that's always visible 2020-09-06 12:36:17 +02:00
Alejandro Celaya
90abf29db9 Merge pull request #296 from acelaya-forks/feature/typescript
Feature/typescript
2020-09-06 10:29:55 +02:00
Alejandro Celaya
d064eb5f9e Fixed inconsistent type 2020-09-06 10:22:21 +02:00
Alejandro Celaya
58c9ef9b51 Updated dependencies 2020-09-06 10:17:46 +02:00
Alejandro Celaya
125b13e059 Added stryker mutator for TS 2020-09-06 09:46:07 +02:00
Alejandro Celaya
dcdee8b308 Simplified eslint config 2020-09-06 09:32:16 +02:00
Alejandro Celaya
f33d1fca39 Updated pattern for code coverage collection 2020-09-06 09:18:02 +02:00
Alejandro Celaya
7e907ba9b6 Updated changelog 2020-09-05 09:03:40 +02:00
Alejandro Celaya
3cec2efbbd Removed no longer used dependencies 2020-09-05 08:57:50 +02:00
Alejandro Celaya
d4094e66b3 Finished TS migration 2020-09-05 08:49:18 +02:00
Alejandro Celaya
73b854037d Migrated to TS all visits components except the biggest two 2020-09-04 19:33:16 +02:00
Alejandro Celaya
f2e7a2161d Removed duplicated code on mercure-bound components 2020-09-04 19:05:41 +02:00
Alejandro Celaya
260ed3041a Finished migrating visits helpers to TS 2020-09-04 18:43:26 +02:00
Alejandro Celaya
8a146021dd Migrated first charts to TS 2020-09-03 20:34:22 +02:00
Alejandro Celaya
4083592212 Fixed coding styles and ensured linting command applies to ts and tsx files 2020-09-02 20:27:50 +02:00
Alejandro Celaya
f9c57ca659 Migrated first visits helper components to TS 2020-09-02 20:13:31 +02:00
Alejandro Celaya
d0d664ef79 Migrated VisitsParser to TS 2020-09-02 19:32:07 +02:00
Alejandro Celaya
16d96efa4a Finished migrating all remaining utils to TS 2020-08-31 18:38:27 +02:00
Alejandro Celaya
f8ea1ae3d5 Migrated remaining tags-related elements to TS 2020-08-30 20:48:09 +02:00
Alejandro Celaya
18883caa6d Migrated tags helpers to TS 2020-08-30 20:31:31 +02:00
Alejandro Celaya
84fc82b74e Fixed custom slug field not being disabled when selecting a short code length 2020-08-30 19:52:40 +02:00
Alejandro Celaya
8a9c694fbc Migrated all remaining short-url elements to TS 2020-08-30 19:45:17 +02:00
Alejandro Celaya
4b33d39d44 Finished migrating ll short-url helpers to TS 2020-08-30 09:59:14 +02:00
Alejandro Celaya
c0f5d9c12c Finished migrating servers module to TS 2020-08-29 20:20:45 +02:00
Alejandro Celaya
ef630af154 Migrated ShlinkApiClient to TS 2020-08-29 19:51:14 +02:00
Alejandro Celaya
ebd7a76896 Migrated to TS main services except ShlinkApiClient 2020-08-29 18:51:03 +02:00
Alejandro Celaya
64a968711c Migrated all servers services to TS 2020-08-29 14:16:37 +02:00
Alejandro Celaya
aee4c2d02f Migrated to TS all servers helpers 2020-08-29 13:51:53 +02:00
Alejandro Celaya
8cc0695ee9 Refactored ServerError to infer error message based on provided server type guards 2020-08-29 10:53:02 +02:00
Alejandro Celaya
f40ad91ea9 Migrated some common components and their dependencies to TS 2020-08-29 09:19:15 +02:00
Alejandro Celaya
a96539129d Migrated more common components to TS 2020-08-28 20:05:01 +02:00
Alejandro Celaya
dcf72e6818 Finished migrating remaining reducers to TS 2020-08-28 18:33:37 +02:00
Alejandro Celaya
54290d4c9a Migrated ShlinkApiClientBuilder to TS 2020-08-27 22:09:16 +02:00
Alejandro Celaya
eb3775859a Migrated tags reducers to typescripts 2020-08-27 19:12:09 +02:00
Alejandro Celaya
83531666de Migrated to typescript the most complex reducer in the project 2020-08-27 18:31:56 +02:00
Alejandro Celaya
f3a2535e2f Defined visits TS types 2020-08-27 17:56:48 +02:00
Alejandro Celaya
f283dc8569 Migrated short URL helper modal components to TS 2020-08-26 20:37:36 +02:00
Alejandro Celaya
b19bbee7fc More components migrated to TS 2020-08-26 20:03:23 +02:00
Alejandro Celaya
1b03d04318 Migrated more short-url reducers to TS 2020-08-26 18:55:40 +02:00
Alejandro Celaya
6696fb13d6 Created redux test 2020-08-25 20:23:12 +02:00
Alejandro Celaya
f04aece7df Removed dependency on redux-actions for all reducers already migrated to typescript 2020-08-25 19:42:15 +02:00
Alejandro Celaya
d8f3952920 Migrated first short URL reducers to typescript 2020-08-24 18:52:52 +02:00
Alejandro Celaya
fefa4e7848 Migrated settings module to TS 2020-08-24 17:32:20 +02:00
Alejandro Celaya
0b4a348969 Migrated remoteServers reducer to TS 2020-08-23 11:58:43 +02:00
Alejandro Celaya
3e2fee0df5 Migrated selectedServer test to typescript 2020-08-23 10:58:43 +02:00
Alejandro Celaya
294888454d Renamed NewServerData to ServerData, as it's used in other contexts too 2020-08-23 10:52:37 +02:00
Alejandro Celaya
1b7e1e2b5b Tweaked server types and data 2020-08-23 10:51:42 +02:00
Alejandro Celaya
dc78138066 Migrate servers reducer to typescript 2020-08-23 10:20:31 +02:00
Alejandro Celaya
87e64e5899 Migrated first reducer to typescript, adding also type for the shared app state 2020-08-23 09:52:09 +02:00
Alejandro Celaya
e193a692e8 Migrated all service providers to typescript 2020-08-23 09:03:44 +02:00
Alejandro Celaya
2eba607874 More elements migrated to typescript 2020-08-22 19:03:25 +02:00
Alejandro Celaya
62df46d648 Refactored many helpers to Typescript 2020-08-22 18:32:48 +02:00
Alejandro Celaya
7c67fa4149 Migrate CreateServer component to Typescript 2020-08-22 17:58:44 +02:00
Alejandro Celaya
2db85c2783 Migrated to typescript first component getting another component with props injected 2020-08-22 13:41:54 +02:00
Alejandro Celaya
39663ba936 Migrated to TS first component where some dependency was being injected 2020-08-22 11:20:27 +02:00
Alejandro Celaya
eefea0c37b Added babel plugins to support latest TS functionalities 2020-08-22 11:00:11 +02:00
Alejandro Celaya
d65a6ba970 Migrated to Typescript a file which is imported in JS files 2020-08-22 09:48:55 +02:00
Alejandro Celaya
524b0a74c6 Migrated first component and test to typescript 2020-08-22 09:15:05 +02:00
Alejandro Celaya
72de9d4ff8 Added first Typescript files 2020-08-22 08:47:19 +02:00
Alejandro Celaya
a91f1b3bd4 Fixed coding styles 2020-08-22 08:10:31 +02:00
Alejandro Celaya
343a93b984 Installed TS and updated linter 2020-08-22 08:06:41 +02:00
Alejandro Celaya
8be17cce8a Merge pull request #291 from acelaya-forks/feature/toggle-switch
Feature/toggle switch
2020-07-14 16:23:23 +02:00
Alejandro Celaya
d2f818c1ea Moved common code between Checkbox and ToggleSwitch to child component 2020-07-14 16:14:16 +02:00
Alejandro Celaya
a675d60d59 Added new ToggleSwitch component 2020-07-14 16:05:00 +02:00
Alejandro Celaya
2d96c21b50 Updated changelog 2020-07-09 17:46:44 +02:00
Alejandro Celaya
6f6ba9e34d Merge pull request #290 from MartinH0/patch-1
Added Links to Version Info
2020-07-09 17:42:39 +02:00
Alejandro Celaya
e6efda5563 Fixed wrong use of quotes 2020-07-09 17:36:26 +02:00
Alejandro Celaya
b1df1652bf Fixed ShlinkVersions test 2020-07-09 17:34:29 +02:00
Alejandro Celaya
474239c151 Moved version link to common component, and fixed coding styles 2020-07-09 17:17:19 +02:00
MartinH0
feeb212259 Update ShlinkVersions.js 2020-07-09 15:54:45 +02:00
MartinH0
90245016a0 Update ShlinkVersions.js
Hope this works now, but tests obviously fails bc it does not expect a Link
2020-07-09 15:47:09 +02:00
MartinH0
8c7616c3a7 Update ShlinkVersions.js 2020-07-09 15:33:58 +02:00
MartinH0
ea84ce9c41 Update ShlinkVersions.js 2020-07-09 15:25:53 +02:00
MartinH0
c4730ec92d Update ShlinkVersions.js 2020-07-09 15:18:48 +02:00
MartinH0
76b3d573c0 Update ShlinkVersions.js 2020-07-09 15:15:01 +02:00
MartinH0
b96f4b7a90 Update ShlinkVersions.js
Changed back ExternalLink against docs to normal closing Tag.
2020-07-09 15:04:14 +02:00
MartinH0
2a0def262d Update ShlinkVersions.js 2020-07-09 14:53:15 +02:00
MartinH0
897e35f0b8 Update ShlinkVersions.js 2020-07-09 14:43:53 +02:00
MartinH0
1c335506d8 Update ShlinkVersions.js 2020-07-09 12:58:10 +02:00
MartinH0
d46acdbd70 Added Links to Version Info
Actually it would be better if the link is just added if version info is provided. Now the Link is given always.
2020-07-07 22:10:35 +02:00
Alejandro Celaya
026bb4140e Merge branch 'main' of github.com:shlinkio/shlink-web-client into main 2020-07-06 09:31:37 +02:00
Alejandro Celaya
d80f3da55d Updated comment on issue templates 2020-07-06 09:31:11 +02:00
Alejandro Celaya
f18495a4b1 Updated comment fixing line breaks 2020-06-27 09:11:42 +02:00
Alejandro Celaya
f908da78f6 Merge pull request #287 from acelaya-forks/feature/servers-warning
Added warning to pre-configuring servers section
2020-06-22 22:04:55 +02:00
Alejandro Celaya
bc16381c90 Added warning to pre-configuring servers section 2020-06-22 21:57:58 +02:00
Alejandro Celaya
4cb7aa64cf Removed references to master branch 2020-06-20 20:03:00 +02:00
Alejandro Celaya
da6d7aea8b Merge pull request #284 from acelaya-forks/feature/simplify-travis
Removed all conditional checks in travis
2020-06-10 18:50:23 +02:00
Alejandro Celaya
b310d79110 Removed all conditional checks in travis 2020-06-10 18:43:11 +02:00
Alejandro Celaya
e26cdc11c3 Merge pull request #283 from acelaya-forks/feature/chart-legend
Feature/chart legend
2020-06-06 12:27:01 +02:00
Alejandro Celaya
fa54aa3128 Updated changelog 2020-06-06 12:17:45 +02:00
Alejandro Celaya
e31e70039d Created GraphCard test 2020-06-06 12:16:19 +02:00
Alejandro Celaya
cb761dea8f Increased default height for doughnut charts 2020-06-06 12:08:21 +02:00
Alejandro Celaya
949e0da105 Added custom responsive legend to doughnut charts 2020-06-06 11:58:25 +02:00
Alejandro Celaya
770cc59448 Extracted logic to render graph from GraphCard to DefatlChart component 2020-06-06 10:35:13 +02:00
Alejandro Celaya
72dd2bd0a7 Merge pull request #282 from acelaya-forks/feature/mercure-dup-code
Feature/mercure dup code
2020-06-06 09:47:15 +02:00
Alejandro Celaya
54733eaa18 Updated changelog 2020-06-06 09:30:39 +02:00
Alejandro Celaya
52c56f7918 Created custom react hook that binds to mercure topic 2020-06-06 09:29:43 +02:00
Alejandro Celaya
c46d5187c1 Removed duplicated code when binding to mercure by checking if enabled first 2020-06-06 09:24:05 +02:00
Alejandro Celaya
05e3e87653 Merge pull request #281 from acelaya-forks/feature/docker-version
Fixed version not properly provided to docker image
2020-06-06 08:58:41 +02:00
Alejandro Celaya
8b9289ff08 Fixed version not properly provided to docker image 2020-06-06 08:50:37 +02:00
Alejandro Celaya
16ffbcfbc0 Merge pull request #278 from acelaya-forks/feature/fix-default-grouping
Feature/fix default grouping
2020-05-31 20:30:32 +02:00
Alejandro Celaya
d825b6e174 Updated changelog 2020-05-31 20:17:59 +02:00
Alejandro Celaya
73e55cc742 Replaced if/else by functional matcher 2020-05-31 20:16:15 +02:00
Alejandro Celaya
32cc1cc580 Improved logic to determine default grouping for line chart based on how old the visits are 2020-05-31 20:03:59 +02:00
Alejandro Celaya
e00574553f Moved some helper components for visits to visits/helpers 2020-05-31 17:51:52 +02:00
Alejandro Celaya
984c1ea716 Added v2.5.0 to changelog 2020-05-31 12:00:50 +02:00
Alejandro Celaya
df38cf6ca9 Merge pull request #275 from acelaya-forks/feature/remove-class-components
Feature/remove class components
2020-05-31 11:51:08 +02:00
Alejandro Celaya
1b60b0e2a8 Updated SortableBarGraph to be a functional component 2020-05-31 11:36:56 +02:00
Alejandro Celaya
11f9c7c507 Updated TagsSelector to be a functional component 2020-05-31 11:19:53 +02:00
Alejandro Celaya
ebe649aaac Updated EditTagModal to be a functional component 2020-05-31 11:06:23 +02:00
Alejandro Celaya
656b68d422 Updated DeleteTagConfirmModal to be a functional component 2020-05-31 10:45:18 +02:00
Alejandro Celaya
cd1f186e28 Updated EditTagsModal to be a functional component 2020-05-31 10:31:00 +02:00
Alejandro Celaya
d0b3edaa2f Updated DeleteShortUrlModal to be a functional component 2020-05-31 10:23:13 +02:00
Alejandro Celaya
2268b85ade Updated CreateShortUrlResult to be a functional component 2020-05-31 10:16:09 +02:00
Alejandro Celaya
d7e3b7b912 Updated ImportServersBtn to be a functional component 2020-05-31 09:58:27 +02:00
Alejandro Celaya
4bd83eecfb Updated ScrollToTop to be a functional component 2020-05-31 09:50:00 +02:00
Alejandro Celaya
b7fd2308ad Merge pull request #274 from acelaya-forks/feature/stryker3
Updated to stryker 3
2020-05-31 09:34:02 +02:00
Alejandro Celaya
a6958941ad Updated to stryker 3 2020-05-31 09:27:42 +02:00
Alejandro Celaya
c98b28ff0f Merge pull request #273 from acelaya-forks/feature/charts-improvements
Feature/charts improvements
2020-05-31 09:19:02 +02:00
Alejandro Celaya
6a372badfa Improved labels displayed in charts when visits are highlighted 2020-05-31 09:07:21 +02:00
Alejandro Celaya
b6ab9a1bdd Improved memoization of grouped visits for line chart 2020-05-31 08:55:52 +02:00
Alejandro Celaya
daf9e7cf64 Merge pull request #272 from acelaya-forks/feature/line-chart-improvements
Some improvements on LineChartCard
2020-05-30 17:52:55 +02:00
Alejandro Celaya
ef42dcd666 Simplified code and removed duplication 2020-05-30 17:43:13 +02:00
Alejandro Celaya
1b6028ae6d Some improvements on LineChartCard 2020-05-30 17:39:08 +02:00
Alejandro Celaya
9340512980 Merge pull request #271 from acelaya-forks/feature/line-chart
Feature/line chart
2020-05-30 10:54:27 +02:00
Alejandro Celaya
9d0b4cc065 Updated changelog 2020-05-30 10:43:18 +02:00
Alejandro Celaya
c5cb0dcb26 Added test for LineChartCard 2020-05-30 10:41:46 +02:00
Alejandro Celaya
a42f5ab13e Set fixed height for time-based line chart 2020-05-30 10:26:52 +02:00
Alejandro Celaya
68b0577526 Added dynamic grouping to time-based line chart 2020-05-30 09:57:21 +02:00
Alejandro Celaya
61867366e7 Created first version of the time-based visits chart 2020-05-30 09:25:15 +02:00
Alejandro Celaya
c670d86955 Merge pull request #270 from acelaya-forks/feature/parallel-docker-build
Feature/parallel docker build
2020-05-17 10:37:34 +02:00
Alejandro Celaya
4565a64cd8 Rolled back node version for scrutinizer 2020-05-17 10:28:49 +02:00
Alejandro Celaya
f36e42d9c1 Fixed travis config error 2020-05-17 10:16:53 +02:00
Alejandro Celaya
0a3a97242b Simplified docker image building, as it will now be run in a different job as the dist file release 2020-05-17 10:13:33 +02:00
Alejandro Celaya
68253c3bc4 Disabled multi-arch building until everything is compatible 2020-05-17 10:12:39 +02:00
Alejandro Celaya
544384d85e Updated runtimne versions 2020-05-17 10:04:05 +02:00
Alejandro Celaya
91daec852f Added parallel docker image multi-arch building 2020-05-17 10:02:36 +02:00
Alejandro Celaya
dcc5b9cc8c Merge pull request #268 from acelaya-forks/feature/tag-visits
Feature/tag visits
2020-05-13 20:41:39 +02:00
Alejandro Celaya
1d26cd93fb Added real time updates to tags list page 2020-05-13 18:32:27 +02:00
Alejandro Celaya
e47dfaf36f Changed paginable charts so that they use 50 items per page by default 2020-05-11 19:44:27 +02:00
Alejandro Celaya
09e2c69e46 Ensured visits by tag route does not work for old Shlink servers 2020-05-11 19:40:19 +02:00
Alejandro Celaya
07d3567244 Added progress bar to visits page when loading a lot of visits 2020-05-11 19:32:42 +02:00
Alejandro Celaya
9bdbe90716 Ensured state is properly reset when starting, finisihing or failing to load visits 2020-05-11 18:55:35 +02:00
Alejandro Celaya
02a4380f7c Updated changelog 2020-05-10 20:36:03 +02:00
Alejandro Celaya
4e483dc5d4 Created TagVisits test 2020-05-10 20:30:19 +02:00
Alejandro Celaya
52631e629e Created TagVisitsHeader test 2020-05-10 20:23:45 +02:00
Alejandro Celaya
3a53298417 Improved visits pages titles 2020-05-10 20:17:17 +02:00
Alejandro Celaya
fb0f14fc16 Created header for visits by tag section 2020-05-10 19:49:58 +02:00
Alejandro Celaya
7a94b1730d Created common component for visits header 2020-05-10 19:37:00 +02:00
Alejandro Celaya
f856bc218a Created tagVisits reducer test 2020-05-10 19:12:18 +02:00
Alejandro Celaya
bfbb21e1cc Created page for tag visit stats 2020-05-10 19:02:58 +02:00
Alejandro Celaya
18e18f533b Extracted visits charts elements into reusable component 2020-05-10 17:49:55 +02:00
Alejandro Celaya
6eead70511 Merge pull request #266 from acelaya-forks/feature/tags-list-improvements
Feature/tags list improvements
2020-05-10 11:34:48 +02:00
Alejandro Celaya
6fd30ed51a Improved how tags are exposed by the ApiClient when listing tags 2020-05-10 11:20:40 +02:00
Alejandro Celaya
67c674f073 Updated changelog 2020-05-10 11:15:24 +02:00
Alejandro Celaya
289d8784c0 Converted TagCard component into functional component 2020-05-10 11:12:22 +02:00
Alejandro Celaya
18e026e4ca Updated tags list to display visits and short URLs when remote shlink version allows it 2020-05-10 10:57:49 +02:00
Alejandro Celaya
8741f42fe8 Merge pull request #264 from acelaya-forks/feature/charts-precision
Feature/charts precision
2020-05-07 11:21:51 +02:00
Alejandro Celaya
665d6209d9 Updated changelog 2020-05-07 11:10:10 +02:00
Alejandro Celaya
59fda29894 Added precision 0 to charts, to avoid having decimals 2020-05-07 11:06:14 +02:00
Alejandro Celaya
61c027f9a1 Merge pull request #263 from acelaya-forks/feature/minor-improvements
Minor improvements
2020-05-03 20:23:24 +02:00
Alejandro Celaya
241c9b73b0 Minor improvements 2020-05-03 20:16:21 +02:00
Alejandro Celaya
85dc1d0825 Merge pull request #260 from acelaya-forks/feature/responsive-visits-table
Feature/responsive visits table
2020-04-28 19:08:20 +02:00
Alejandro Celaya
e38887aa26 Ensured side menu is not swippoed when horizontally scrolling visits table 2020-04-28 18:54:58 +02:00
Alejandro Celaya
54fec79945 First minor improvements to the visits table responsiveness 2020-04-27 18:04:24 +02:00
Alejandro Celaya
fad0bf1c9d Merge pull request #258 from acelaya-forks/feature/redux-localstorage
Feature/redux localstorage
2020-04-27 13:49:05 +02:00
Alejandro Celaya
be2f86050f Updated changelog 2020-04-27 13:37:54 +02:00
Alejandro Celaya
a7f941e8e4 Deleted no-longer-needed ServersService 2020-04-27 13:21:07 +02:00
Alejandro Celaya
b08c6748c7 Moved remote servers loading to separated action 2020-04-27 12:54:52 +02:00
Alejandro Celaya
bdd7932e07 Refactored ServersDropdown into functional component 2020-04-27 12:30:17 +02:00
Alejandro Celaya
bcf5dcf180 Converted server handling actions into regular actions 2020-04-27 11:30:51 +02:00
Alejandro Celaya
8b2cbf7aea Some minor refactorings 2020-04-27 10:52:19 +02:00
Alejandro Celaya
277b5e43f8 Flatten model holding list of servers 2020-04-27 10:49:55 +02:00
Alejandro Celaya
7dd6a31609 Deleted SettingsService, as the task is not transparently handled by a redux middleware 2020-04-26 19:07:47 +02:00
Alejandro Celaya
86bf1515d4 Added redux middleware to save parts of the store in the local storage transparently 2020-04-26 19:04:17 +02:00
Alejandro Celaya
bbc47b387e Created single reducer to handle settings 2020-04-26 13:00:27 +02:00
Alejandro Celaya
3953e98a77 Merge pull request #257 from acelaya-forks/feature/navigate-back
Feature/navigate back
2020-04-26 12:02:54 +02:00
Alejandro Celaya
09b8bd501d Converted TagsList component into functional component 2020-04-26 11:48:08 +02:00
Alejandro Celaya
6bddaaa055 Added cancel button to edit server page 2020-04-26 10:56:27 +02:00
Alejandro Celaya
dd728d4d13 Added back button to visits stats page 2020-04-26 10:43:00 +02:00
Alejandro Celaya
9ba8bc8f3d Merge pull request #256 from acelaya-forks/feature/settings-page
Feature/settings page
2020-04-25 11:19:19 +02:00
Alejandro Celaya
16dee3664b Ensured mercure updates are not set even if supported, when they have been disabled 2020-04-25 10:37:50 +02:00
Alejandro Celaya
6fcf588bfd Updated changelog 2020-04-25 10:23:51 +02:00
Alejandro Celaya
6a6c427b0e Added unit tests for settings business logic elements 2020-04-25 10:22:17 +02:00
Alejandro Celaya
41f885d8ec Created settings page and reducers to handle real-time updates config 2020-04-25 09:49:54 +02:00
Alejandro Celaya
7516ca8dd9 Created settings page and converted MainHeader into functional component 2020-04-18 20:58:35 +02:00
Alejandro Celaya
aa59a95f91 Merge pull request #251 from acelaya-forks/feature/real-time-updates
Feature/real time updates
2020-04-18 20:57:07 +02:00
Alejandro Celaya
8a5161c0e8 Updated changelog 2020-04-18 20:36:49 +02:00
Alejandro Celaya
d8ae69e861 Added test for mercure info helpers 2020-04-18 12:49:03 +02:00
Alejandro Celaya
a485d0b507 Added token expired handling to mercure binding 2020-04-18 12:26:00 +02:00
Alejandro Celaya
ed40b79c8d Added more tests covering new use cases 2020-04-18 12:09:51 +02:00
Alejandro Celaya
91488ae294 Fixed visits count not handling separated tooltiups 2020-04-18 11:03:49 +02:00
Alejandro Celaya
a22a1938c1 Added automatic refresh on mercure events 2020-04-18 10:50:01 +02:00
Alejandro Celaya
0f73cb9f8c Converted short URLs list in functional component 2020-04-17 17:39:30 +02:00
Alejandro Celaya
f3129399de Added EventSource connection to mercure hub possible 2020-04-17 17:11:52 +02:00
Alejandro Celaya
37e6c27461 Created mercure info reducer and loaded info when server is reachable 2020-04-17 15:51:18 +02:00
Alejandro Celaya
d231ed3ede Merge pull request #247 from acelaya-forks/feature/user-agent-improvements
Feature/user agent improvements
2020-04-10 20:06:57 +02:00
Alejandro Celaya
cf6f9028f2 Some more improvements on how chart height is calculated 2020-04-10 19:57:33 +02:00
Alejandro Celaya
7cf49d2c1a Increased minimum charts height 2020-04-10 19:47:42 +02:00
Alejandro Celaya
e37fb1b4bd Updated changelog 2020-04-10 19:29:57 +02:00
Alejandro Celaya
faf5d0bf7b Unified function parsing user agent for browser and os 2020-04-10 19:22:13 +02:00
Alejandro Celaya
6fede88072 Added dependency on bowser to have a more accurate browser and OS detection 2020-04-10 19:16:44 +02:00
Alejandro Celaya
87ffbefa61 Merge pull request #246 from acelaya-forks/feature/create-improvements
Feature/create improvements
2020-04-10 18:50:09 +02:00
Alejandro Celaya
f33ae17781 Updated changelog 2020-04-10 18:43:16 +02:00
Alejandro Celaya
2a2bae6d1a Improved short URL creation 2020-04-10 18:42:08 +02:00
Alejandro Celaya
eb65e99024 Merge pull request #244 from acelaya-forks/feature/chart-visit-highlighting
Feature/chart visit highlighting
2020-04-10 15:21:07 +02:00
Alejandro Celaya
52dbeb6201 Optimized visits parser to act over the normalized list of visits 2020-04-10 14:59:12 +02:00
Alejandro Celaya
fafe920b7b Ensured highlighted stats are properly sorted and paginated on charts that support that 2020-04-10 14:38:31 +02:00
Alejandro Celaya
9d1e48ee90 Updated main list paginator to be sticky 2020-04-10 13:42:21 +02:00
Alejandro Celaya
3851342e1b Added button to reset visits selection 2020-04-10 13:27:01 +02:00
Alejandro Celaya
b863c2e19d Used cursor pointer in bar charts 2020-04-10 13:04:39 +02:00
Alejandro Celaya
ed584d19e5 Ensured charts datasets have a unique label 2020-04-10 12:57:14 +02:00
Alejandro Celaya
73256dcf5b Handled toggling between highlighted chart bars 2020-04-10 12:53:54 +02:00
Alejandro Celaya
c67a23c988 Added support to disable date inputs 2020-04-10 12:25:06 +02:00
Alejandro Celaya
8f42e65ccd Allowed visits to be selected on charts so that they get highlighted on the rest of the charts 2020-04-10 11:59:53 +02:00
Alejandro Celaya
05deb1aff0 Merge pull request #242 from acelaya-forks/feature/visits-table
Feature/visits table
2020-04-09 11:23:51 +02:00
Alejandro Celaya
a74b7cdfad Updated changelog 2020-04-09 11:00:27 +02:00
Alejandro Celaya
1c3119ee76 Allowed multiple selection on visits table 2020-04-09 10:56:54 +02:00
Alejandro Celaya
ca52911e42 Added VisitsTable test 2020-04-09 10:21:38 +02:00
Alejandro Celaya
9177bc7cef Tested how hilghlighted data behaves on GraphCards 2020-04-09 09:44:14 +02:00
Alejandro Celaya
310831a26a Converted ShortUrlVisits in functional component 2020-04-07 22:33:41 +02:00
Alejandro Celaya
8a486d991b Implemented some improvements and fixes on how visits table is split and calculated 2020-04-05 18:04:15 +02:00
Alejandro Celaya
b79333393b Converted SearchField component into funcitonal component 2020-04-05 16:18:08 +02:00
Alejandro Celaya
cb7062bb95 Created fake border with before and after pseudoelements for sticky table cells 2020-04-05 16:02:42 +02:00
Alejandro Celaya
94c5b2c471 Improved useToggle hook so that it also returns enabler and disabler 2020-04-05 12:18:41 +02:00
Alejandro Celaya
66bf26f1dc Improved highlighted data calculation so that it works with values different than 1 2020-04-05 11:57:39 +02:00
Alejandro Celaya
f5cc1abe75 Ensured info for selected visit in visits table gets highlighted in bar charts 2020-04-04 20:16:20 +02:00
Alejandro Celaya
bd4255108d Improved VisitsTable performance by memoizing visits lists 2020-04-04 12:58:04 +02:00
Alejandro Celaya
06b63d1af2 Improved rendering of visits table on mobile devices 2020-04-04 12:09:17 +02:00
Alejandro Celaya
2bd70fb9e6 Fixed unit tests 2020-04-04 10:36:38 +02:00
Alejandro Celaya
e6034dfb14 Created VisitsTable 2020-04-03 23:00:57 +02:00
Alejandro Celaya
c8ba6764c2 Merge pull request #238 from acelaya-forks/feature/edit-long-url
Feature/edit long url
2020-03-30 21:36:20 +02:00
Alejandro Celaya
19337d6c05 Added tests for elements regarding short URL edition 2020-03-30 21:26:30 +02:00
Alejandro Celaya
a6ad3c2d4d Updated changelog 2020-03-30 21:01:54 +02:00
Alejandro Celaya
b0dd885c09 Converted ShortUrlsRowMenu into functional component 2020-03-30 21:01:01 +02:00
Alejandro Celaya
2235592308 Fixed ShortUrlsRowMenu test 2020-03-30 20:50:31 +02:00
Alejandro Celaya
1219a16261 Ensured short URLs list is updated after editing the long URL of a short URL 2020-03-30 20:47:33 +02:00
Alejandro Celaya
7949e224e0 Created modal to edit the loing URL behind a short URL 2020-03-30 20:42:58 +02:00
Alejandro Celaya
ab2f311bb7 Merge pull request #237 from acelaya-forks/feature/short-code-length
Feature/short code length
2020-03-29 19:49:09 +02:00
Alejandro Celaya
a5aab43666 Updated changelog 2020-03-29 19:41:29 +02:00
Alejandro Celaya
74ebd4e572 Converted CreateShortUrl to functional component 2020-03-29 19:36:45 +02:00
Alejandro Celaya
bd29670108 Added short code length field to form to create short URLs 2020-03-29 18:55:41 +02:00
Alejandro Celaya
9a20b4428d Merge pull request #236 from acelaya-forks/feature/progressive-paginator
Feature/progressive paginator
2020-03-28 17:52:35 +01:00
Alejandro Celaya
d7da8521ce Created helper functions to determine the key and if a page is disabled on a progressive paginator 2020-03-28 17:43:09 +01:00
Alejandro Celaya
bab3b252c1 Updated changelog 2020-03-28 17:35:02 +01:00
Alejandro Celaya
7f05c5c2da Split utils module into several helpers modules 2020-03-28 17:33:27 +01:00
Alejandro Celaya
2d5c2779c3 Moved helper functions to render progressive paginators to a common place 2020-03-28 17:25:12 +01:00
Alejandro Celaya
06db4f6556 Used progressive pagination for the short URLs list 2020-03-28 17:19:33 +01:00
Alejandro Celaya
ea5ec63a22 Ensured all branches build the docker image 2020-03-22 09:34:24 +01:00
Alejandro Celaya
f46e737e77 Merge pull request #233 from acelaya-forks/feature/fix-docker-build-condition
Fixed docker build condition so that it's run for any branch or tag a…
2020-03-21 13:39:31 +01:00
Alejandro Celaya
6e63bdaafa Fixed docker build condition so that it's run for any branch or tag as long as it is not a PR 2020-03-21 08:43:35 +01:00
Alejandro Celaya
79ccef9f7e Avoid latest docker to be build when building a tag 2020-03-21 06:59:37 +01:00
Alejandro Celaya
a9653b3674 Merge pull request #232 from acelaya-forks/feature/improve-docker-build
Feature/improve docker build
2020-03-20 09:23:35 +01:00
Alejandro Celaya
b5a188e802 Improved building process so that already generated dist files are reused when building docker image is possible 2020-03-20 09:12:43 +01:00
Alejandro Celaya
38fc402b16 Improved docker build script to avoid duplicating code 2020-03-20 07:12:07 +01:00
Alejandro Celaya
584d1ec1ce Fixed conditional in docker build script 2020-03-19 20:39:34 +01:00
Alejandro Celaya
2ca7faa457 Merge pull request #230 from acelaya-forks/feature/travis-docker-build
Added docker image building as a deployment step for travis
2020-03-19 20:32:00 +01:00
Alejandro Celaya
03806abda0 Changed build steps so that mutation testing a docker build are only run on pull request builds 2020-03-19 20:26:35 +01:00
Alejandro Celaya
18d125430d Added docker image building as a deployment step for travis 2020-03-19 20:04:30 +01:00
Alejandro Celaya
f57f6b7745 Merge pull request #228 from acelaya-forks/feature/memoize-server-version
Feature/memoize server version
2020-03-16 19:01:33 +01:00
Alejandro Celaya
75ff2b8f40 Added app gif to readme 2020-03-16 18:53:06 +01:00
Alejandro Celaya
2ec04c0121 Fixed test by using different serverId every time, preventing memoization 2020-03-16 18:51:04 +01:00
Alejandro Celaya
5145a41dac Memoized the loading of the server version, assuming it will not change at runtime 2020-03-16 13:34:24 +01:00
Alejandro Celaya
25c67f1c3e Merge pull request #227 from acelaya-forks/feature/edit-servers
Feature/edit servers
2020-03-15 14:32:30 +01:00
Alejandro Celaya
77b9181150 Replaced hardcoded color by sass var 2020-03-15 14:23:57 +01:00
Alejandro Celaya
e4f7ded8e2 Updated changelog 2020-03-15 14:04:33 +01:00
Alejandro Celaya
35a62f1fb1 Added link to edit existing servers 2020-03-15 14:03:41 +01:00
Alejandro Celaya
24f2deda46 Moved common code to handle currently selected server to HOC 2020-03-15 13:43:12 +01:00
Alejandro Celaya
5d8af1a0e5 Simplified EditServer component by wrapping ServerForm 2020-03-15 12:02:19 +01:00
Alejandro Celaya
6d44ac1e0c Created common component that can be used both for create and edit servers 2020-03-15 11:59:07 +01:00
Alejandro Celaya
fb0ebddf28 Created component to edit existing servers 2020-03-15 11:29:20 +01:00
Alejandro Celaya
0aebaa4da1 Extracted logic to render horizontal form groups to their own components 2020-03-15 10:50:05 +01:00
Alejandro Celaya
f6baedc655 Converted CreateServer into functional component 2020-03-15 10:33:23 +01:00
Alejandro Celaya
7db222664d Fixed tests 2020-03-15 09:56:16 +01:00
Alejandro Celaya
8223f0fd64 Undone weird changes in package lock file 2020-03-15 09:43:42 +01:00
Alejandro Celaya
f44ec42f51 Added links to delete and edit the server when a server could not be reached 2020-03-15 09:17:33 +01:00
Alejandro Celaya
dab75ab6a9 Updated badges 2020-03-10 21:53:21 +01:00
Alejandro Celaya
01672b88e1 Merge pull request #222 from acelaya-forks/feature/server-not-found
Feature/server not found
2020-03-08 13:17:56 +01:00
Alejandro Celaya
78dc297022 Updated changelog 2020-03-08 13:05:15 +01:00
Alejandro Celaya
c8cf75fa28 Created ServerError test 2020-03-08 13:04:21 +01:00
Alejandro Celaya
b011b4e1d8 Fixed tests 2020-03-08 12:57:01 +01:00
Alejandro Celaya
9804a2d18d Added list of servers connected to store in ServerError component 2020-03-08 12:50:42 +01:00
Alejandro Celaya
d1a5ee43e9 Created components to display errors when loading a server 2020-03-08 12:41:18 +01:00
Alejandro Celaya
febecab33c Migrated Home component to a functional component 2020-03-08 11:35:06 +01:00
Alejandro Celaya
99042c0979 Extracted servers list group from home component to a reusable component 2020-03-08 11:16:57 +01:00
Alejandro Celaya
6395e4e00b Improved NotFount component so that link text is passed as children 2020-03-08 10:28:04 +01:00
Alejandro Celaya
4a69907ca3 Fixed generation of component keys to make them render properly 2020-03-08 10:16:45 +01:00
Alejandro Celaya
c8d682cc98 Handled loading server in just one place, and added error handling for loading servers 2020-03-08 10:00:25 +01:00
Alejandro Celaya
f4cc8d3a0c Fixed default value for vertically aligned items 2020-03-07 12:07:51 +01:00
Alejandro Celaya
6ac89334fd Merge pull request #220 from acelaya-forks/feature/improvements
Feature/improvements
2020-03-06 21:56:20 +01:00
Alejandro Celaya
f55d3a66aa Converted ShortUrlsRow component into a functional component 2020-03-06 21:44:03 +01:00
Alejandro Celaya
972eafab34 Updated changelog 2020-03-06 21:26:19 +01:00
Alejandro Celaya
fba156b271 Moved copy-to-clipboard control next to short URL 2020-03-06 21:25:30 +01:00
Alejandro Celaya
96d538db15 Replaced Unknown by Direct for traffic comming from undetermined referrers 2020-03-06 20:42:22 +01:00
Alejandro Celaya
b89bfa3c1c Merge pull request #215 from acelaya-forks/feature/versions
Feature/versions
2020-03-05 14:20:31 +01:00
Alejandro Celaya
73e3f42614 Added ShlinkVersions test 2020-03-05 13:55:39 +01:00
Alejandro Celaya
e761f5e1bd Updated changelog 2020-03-05 13:45:24 +01:00
Alejandro Celaya
4a6dd66ecd Added scripts to pass version when building docker image 2020-03-05 13:37:07 +01:00
Alejandro Celaya
8e1c6908c6 Updated build script so that it replaces version placeholder when a version is provided 2020-03-05 13:27:57 +01:00
Alejandro Celaya
f59e569e22 Extracted logic to determine app version from function to generate dist file 2020-03-05 13:04:12 +01:00
Alejandro Celaya
be50b24504 Added mechanism to provide a version to shlink-web-client 2020-03-05 12:53:32 +01:00
Alejandro Celaya
c181831a37 Fixed tests 2020-03-05 11:58:35 +01:00
Alejandro Celaya
dbee62ac8c Moved shlink versions component to main container 2020-03-05 11:46:38 +01:00
Alejandro Celaya
1e949b3a22 Added shlink versions to side menu 2020-03-05 11:11:26 +01:00
Alejandro Celaya
b02dcf6c53 Refactored delete server components 2020-03-05 10:18:38 +01:00
Alejandro Celaya
ab7718e335 Removed duplicated code from AsideMenu by creating an AsideMenuItem helper component 2020-03-05 10:03:38 +01:00
Alejandro Celaya
451c77d47f Merge pull request #214 from acelaya-forks/feature/consistent-server-loading
Feature/consistent server loading
2020-03-05 09:32:59 +01:00
Alejandro Celaya
fa0d3d4047 Removed no longer needed async/await when building api client 2020-03-05 09:23:53 +01:00
Alejandro Celaya
397a183f65 Converted MenuLayout into a functional component with hooks 2020-03-05 09:08:50 +01:00
Alejandro Celaya
bc8905ee7f Ensured server is properly loaded before trying to render any children component 2020-03-05 08:59:07 +01:00
Alejandro Celaya
853032ac7f Displayed preloader when a server is being loaded 2020-03-05 08:41:55 +01:00
Alejandro Celaya
3b0e282a52 Merge pull request #211 from acelaya-forks/feature/jest-each
Feature/jest each
2020-02-17 18:32:52 +01:00
Alejandro Celaya
bb28cb3862 Updated changelog 2020-02-17 18:25:21 +01:00
Alejandro Celaya
d0f458bece Uninstalled jest-each and replaced by jest's native each 2020-02-17 18:21:52 +01:00
358 changed files with 19439 additions and 9910 deletions

View File

@@ -1,6 +1,7 @@
./.github
./build
./coverage
./dist
./node_modules
./test
./shlink-web-client.gif
./dist

View File

@@ -1,44 +1,28 @@
{
"extends": [
"adidas-env/browser",
"adidas-env/module",
"adidas-env/node",
"adidas-es6",
"adidas-babel",
"adidas-react"
"@shlinkio/js-coding-standard"
],
"plugins": ["jest"],
"env": {
"jest/globals": true
},
"parserOptions": {
"tsconfigRootDir": ".",
"createDefaultProgram": true
},
"globals": {
"process": true,
"setImmediate": true
},
"settings": {
"react": {
"version": "16.3"
}
},
"rules": {
"comma-dangle": ["error", "always-multiline"],
"no-invalid-this": "off",
"no-console": "warn",
"template-curly-spacing": ["error", "never"],
"no-warning-comments": "off",
"no-magic-numbers": "off",
"no-undefined": "off",
"no-inline-comments": "off",
"indent": ["error", 2, {
"SwitchCase": 1
}
],
"react/jsx-curly-spacing": ["error", "never"],
"react/jsx-indent-props": ["error", 2],
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
"react/jsx-closing-bracket-location": ["error", "tag-aligned"],
"react/no-array-index-key": "off",
"react/no-did-update-set-state": "off",
"react/display-name": "off"
"max-len": ["error", {
"code": 120,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreComments": true
}],
"no-mixed-operators": "off",
"react/display-name": "off",
"@typescript-eslint/require-array-sort-compare": "off"
}
}

View File

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

View File

@@ -5,9 +5,10 @@ labels: bug
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
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.
With that said, please fill in the information requested next. More information might be requested once the issue is open.

View File

@@ -5,9 +5,10 @@ labels: feature
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
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.
With that said, please fill in the information requested next. More information might be requested once the issue is open.

View File

@@ -5,9 +5,10 @@ labels: question
---
<!--
Before opening an issue, just take into account that this is a completely free of charge open source project.
I'm always happy to help and provide support, but some understanding will be required.
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.
With that said, please fill in the information requested next. More information might be requested once the issue is open.

View File

@@ -0,0 +1,24 @@
name: Build docker image
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Install buildx
id: buildx
uses: crazy-max/ghaction-docker-buildx@v1
with:
buildx-version: latest
- name: Login to docker hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Build the image
run: bash ./scripts/docker/build

View File

@@ -1,6 +1,3 @@
build:
environment:
node: v12.14.1
tools:
external_code_coverage:
timeout: 1200

View File

@@ -1,40 +1,55 @@
dist: bionic
language: node_js
node_js:
- "12.14.1"
branches:
only:
- /.*/
cache:
directories:
- node_modules
services:
- docker
node_js:
- '14.15.0'
install:
- npm ci
jobs:
fast_finish: true
allow_failures:
- name: 'Mutation tests'
include:
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep -E 'src\/(.*).(js|ts|jsx|tsx)$' | paste -sd ",")
- name: 'Lint'
install: npm ci
script: npm run lint
script:
- npm run lint
- npm run test:ci
- if [[ -z $TRAVIS_TAG ]]; then docker build -t shlink-web-client:test . ; fi
- if [[ -z $TRAVIS_TAG ]]; then npm run mutate:ci ; fi
- name: 'Unit tests'
install: npm ci
script: npm run test:ci
after_success:
- node_modules/.bin/ocular coverage/clover.xml
after_success:
- node_modules/.bin/ocular coverage/clover.xml
- name: 'Mutation tests'
install: npm ci
before_script:
- echo "Building commit range ${TRAVIS_COMMIT_RANGE}"
- export MUTATION_FILES=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/main} --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
script: npm run mutate:ci
# Before deploying, build dist file for current travis tag
before_deploy:
- npm run build ${TRAVIS_TAG#?}
- name: 'Build docker image'
services:
- docker
script: docker build -t shlink-web-client:test .
deploy:
provider: releases
api_key:
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true
- name: 'Publish release'
if: tag IS present
script: echo "Publishing GitHub release"
before_deploy: npm run build ${TRAVIS_TAG#?}
deploy:
provider: releases
api_key:
secure: jBvPwC7EAbViaNR83rwMSt5XQDK0Iu9rgvEMa7GoyShbHcvUCCPd73Tu9quNpKi6NKsDY3INHgtch3vgonjGNGDGJ+yDyIBzXcvsAX2x3UcHpRbgY12uiINVmQxBI1+OVQB016Nm+cKC/i5Z36K4EmDbYfo+MrKndngM6AjcQFTwI8EwniIMaQgg4gNes//K8NhP5u0c3gwG+Q6jEGnq6uH3kcRgh6/epIZYpQyxjqWqKwF77sgcYj+X2Nf6XxtB5neuCi301UKLoLx8G0skh/Lm6KAIO4s9iIhIFa3UpoF21Ka0TxLpd2JxalLryCnFGlWWE6lxC9Htmc0TeRowJQlGdJXCskJ37xT9MljKY0fwNMu06VS/FUgykuCv+jP3zQu51pKu7Ew7+WeNPjautoOTu54VkdGyHcf2ThBNEyJQuiEwAQe4u7yAxY6R5ovEdvHBSIg4w1E5/Mxy5SMTCUlIAv6H7QQ1X9Z/zJm9HH5KeKz5tsHvQ/RIdSpgHXq/tC8o4Yup/LCFucXfrgvy/8pJoO1UpOlmvm62974NFfo0EG5YWwv6brUqz3QXpMjb8sWqgjltYMYJX3J7WZ34rIc+zt4NAmfhqgczaOC4pUGCiJ8jX3rMWIaQRn1AJ+5V337jL9fNDpTHny4phQjHrMJ1e0HZuNp0Xb5Q8wgqDPM=
file: "./dist/shlink-web-client_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true
on:
tags: true

View File

@@ -4,6 +4,173 @@ 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).
## 2.6.1 - 2020-10-31
#### Added
* *Nothing*
#### Changed
* [#292](https://github.com/shlinkio/shlink-web-client/issues/292) Improved a bit how caching works by removing the service worker and adding proper HTTP caching config on nginx inside docker image.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#316](https://github.com/shlinkio/shlink-web-client/issues/316) Fixed manifest.json file not getting downloaded after passing credentials when the app is protected with basic auth.
* [#311](https://github.com/shlinkio/shlink-web-client/issues/311) Fixed datepicker showing below other components.
* [#306](https://github.com/shlinkio/shlink-web-client/issues/306) Fixed multi-arch docker builds by replacing node-sass with dart-sass.
* [#328](https://github.com/shlinkio/shlink-web-client/issues/328) Fixed toggle switches getting broken in mobile resolutions.
## 2.6.0 - 2020-09-20
#### Added
* [#289](https://github.com/shlinkio/shlink-web-client/issues/289) Client and server version constraints are now links to the corresponding project release notes.
* [#293](https://github.com/shlinkio/shlink-web-client/issues/293) Shlink versions are now always displayed in footer, hiding the server version when there's no connected server.
* [#250](https://github.com/shlinkio/shlink-web-client/issues/250) Added support to group real time updates in fixed intervals.
The settings page now allows to provide the interval in which the UI should get updated, making that happen at once, with all the updates that have happened during that interval.
By default updates are immediately applied if real-time updates are enabled, to keep the behavior as it was.
* [#277](https://github.com/shlinkio/shlink-web-client/issues/277) Added highlighting capabilities to the visits line chart.
#### Changed
* [#150](https://github.com/shlinkio/shlink-web-client/issues/150) The list of short URLs is now ordered by the creation date, showing newest results first.
* [#248](https://github.com/shlinkio/shlink-web-client/issues/248) Numbers displayed application-wide are now prettified.
* [#40](https://github.com/shlinkio/shlink-web-client/issues/40) Migrated project to TypeScript.
* [#297](https://github.com/shlinkio/shlink-web-client/issues/297) Moved docker image building to github actions.
* [#305](https://github.com/shlinkio/shlink-web-client/issues/305) Split travis build so that every step is run in a parallel job.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#295](https://github.com/shlinkio/shlink-web-client/issues/295) Fixed custom slug field not being disabled when selecting a short code length.
* [#301](https://github.com/shlinkio/shlink-web-client/issues/301) Fixed tags visits loading not being cancelled when leaving visits page.
## 2.5.1 - 2020-06-06
#### Added
* *Nothing*
#### Changed
* [#254](https://github.com/shlinkio/shlink-web-client/issues/254) Reduced duplication on code to handle mercure topics binding.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#276](https://github.com/shlinkio/shlink-web-client/issues/276) Fixed default grouping used for visits line chart, making it be dynamic depending on how old the short URL is.
* [#280](https://github.com/shlinkio/shlink-web-client/issues/280) Fixed shlink-web-client version not being properly passed when building stable tags of the docker image.
* [#269](https://github.com/shlinkio/shlink-web-client/issues/269) Fixed doughnut chart legends getting to big and hiding charts on mobile devices.
## 2.5.0 - 2020-05-31
#### Added
* [#148](https://github.com/shlinkio/shlink-web-client/issues/148) Added support for real-time updates when consuming a Shlink version that is integrated with a mercure hub server.
The integration is transparent. When a server is opened, shlink-web-client will try to get the mercure info from it.
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
* [#265](https://github.com/shlinkio/shlink-web-client/issues/265) Updated tags section to allow displaying number of short URLs using every tag and number of visits for all short URLs using the tag.
This will work only when using Shlink v2.2.0 or above. For previous versions, the tags page will continue behaving the same.
* [#261](https://github.com/shlinkio/shlink-web-client/issues/261) Added new page to show visit stats by tag.
This new page will return a "not found" error when the server is lower than v2.2.0, as older versions do not support fetching stats by tag.
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
* [#149](https://github.com/shlinkio/shlink-web-client/issues/149) and [#198](https://github.com/shlinkio/shlink-web-client/issues/198) Added new line chart to visits and tags stats which displays amount of visits during selected time period, grouped by month, week, day or hour.
#### Changed
* [#218](https://github.com/shlinkio/shlink-web-client/issues/218) Added back button to sections not displayed in left menu.
* [#255](https://github.com/shlinkio/shlink-web-client/issues/255) Improved how servers and settings are persisted in the local storage.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#262](https://github.com/shlinkio/shlink-web-client/issues/262) Fixed charts displaying decimal numbers, when visits are absolute and that makes no sense.
## 2.4.0 - 2020-04-10
#### Added
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.
* [#234](https://github.com/shlinkio/shlink-web-client/issues/234) Allowed short code length to be edited on any new short URL when using Shlink 2.1 or higher.
* [#235](https://github.com/shlinkio/shlink-web-client/issues/235) Allowed editing the long URL for any existing short URL when suing Shlink 2.1 or higher.
#### Changed
* [#205](https://github.com/shlinkio/shlink-web-client/issues/205) Replaced `jest-each` package by jet's native `test.each` function.
* [#209](https://github.com/shlinkio/shlink-web-client/issues/209) Replaced `Unknown` by `Direct` for visits from undetermined referrers.
* [#212](https://github.com/shlinkio/shlink-web-client/issues/212) Moved copy-to-clipboard next to short URL.
* [#208](https://github.com/shlinkio/shlink-web-client/issues/208) Short URLs list paginator is now progressive.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#243](https://github.com/shlinkio/shlink-web-client/issues/243) Fixed loading state and resetting on short URL creation form.
* [#239](https://github.com/shlinkio/shlink-web-client/issues/239) Fixed how user agents are parsed, reducing false results.
## 2.3.1 - 2020-02-08
#### Added

72
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,72 @@
# Contributing
This file will guide you through the process of getting to project up and running, in case you want to provide coding contributions.
You will also see how to ensure the code fulfills the expected code checks, and how to create a pull request.
## System dependencies
The project can be run inside a docker container through provided docker-compose configuration.
Because of this, the only actual dependencies are [docker](https://docs.docker.com/get-docker/) and [docker-compose](https://docs.docker.com/compose/install/).
## Setting up the project
The first thing you need to do is fork the repository, and clone it in your local machine.
Then you will have to follow these steps:
* 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
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:
```
shlink-web-client
├── config
├── public
├── scripts
├── src
├── test
├── package.json
└── README.md
```
* `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).
* `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.
* `test`: Contains the project tests.
## Running code checks
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
* `./indocker npm run lint`: Checks coding styles are fulfilled, both in JS/TS files as well as in stylesheets.
* `./indocker npm run lint:js`: Checks coding styles are fulfilled in JS/TS files.
* `./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
The source code in this project cannot be run directly in a web browser, you need to build it first.
* `./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 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
In order to provide pull requests to this project, you should always start by creating a new branch, where you will make all desired changes.
The base branch should always be `main`, and the target branch for the pull request should also be `main`.
Before your branch can be merged, all the checks described in [Running code checks](#running-code-checks) have to be passing. You can verify that manually, or wait for the build to be run automatically after the pull request is created.

View File

@@ -1,8 +1,11 @@
FROM node:12.14.1-alpine as node
FROM node:14.15.0-alpine as node
COPY . /shlink-web-client
RUN cd /shlink-web-client && npm install && npm run build
ARG VERSION="latest"
ENV VERSION ${VERSION}
RUN cd /shlink-web-client && \
npm install && npm run build -- ${VERSION} --no-dist
FROM nginx:1.17.7-alpine
FROM nginx:1.19.3-alpine
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
RUN rm -r /usr/share/nginx/html && rm /etc/nginx/conf.d/default.conf
COPY config/docker/nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -1,15 +1,19 @@
# shlink-web-client
[![Build Status](https://img.shields.io/travis/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.org/shlinkio/shlink-web-client)
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/?branch=master)
[![Build Status](https://img.shields.io/travis/com/shlinkio/shlink-web-client.svg?style=flat-square)](https://travis-ci.com/shlinkio/shlink-web-client)
[![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/shlinkio/shlink-web-client.svg?style=flat-square)](https://scrutinizer-ci.com/g/shlinkio/shlink-web-client/)
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/master/LICENSE)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://acel.me/donate)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
![shlink-web-client](shlink-web-client.gif)
> If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
## Installation
There are three ways in which you can use this application.
@@ -66,6 +70,14 @@ If you are using the shlink-web-client docker image, you can mount the `servers.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
> **Be extremely careful when using this feature.**
>
> Due to shlink-web-client's client-side nature, the file needs to be accessible from the browser.
>
> Because of that, make sure you use this only when you self-host shlink-web-client, and you know only trusted people will have access to it.
>
> Failing to do this could cause your API keys to end up being exposed.
## Serve project in subpath
Official distributable files have been built so that they are served from the root of a domain.

View File

@@ -4,11 +4,26 @@ server {
root /usr/share/nginx/html;
index index.html;
# Expire rules for static content
# HTML files should never be cached. There's only one here, which is the entry point (index.html)
location ~* \.(?:manifest|appcache|html?|xml|json)$ {
expires -1;
}
# Images and other binary assets can be saved for a month
location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
expires 1M;
add_header Cache-Control "public";
}
# JS and CSS files can be saved for a year, as they are always hashed. New versions will include a new hash anyway, forcing the download
location ~* \.(?:css|js)$ {
expires 1y;
add_header Cache-Control "public";
}
# When requesting static paths with extension, try them, and return a 404 if not found
location ~* .+\.(css|js|html|png|jpe?g|gif|bmp|ico|json|csv|otf|eot|svg|svgz|ttf|woff|woff2|ijmap|pdf|tif|map) {
try_files $uri $uri/ =404;
}
# When requesting a path without extension, try it, and return the index if not found
# This allows HTML5 history paths to be handled by the client application
location / {

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
const fs = require('fs');
const path = require('path');
@@ -10,7 +11,7 @@ const { NODE_ENV } = process.env;
if (!NODE_ENV) {
throw new Error(
'The NODE_ENV environment variable is required but was not specified.'
'The NODE_ENV environment variable is required but was not specified.',
);
}
@@ -36,7 +37,7 @@ dotenvFiles.forEach((dotenvFile) => {
require('dotenv-expand')(
require('dotenv').config({
path: dotenvFile,
})
}),
);
}
});
@@ -82,7 +83,7 @@ function getClientEnvironment(publicUrl) {
// This should only be used as an escape hatch. Normally you would put
// images into the `src` and `import` them in code to get their paths.
PUBLIC_URL: publicUrl,
}
},
);
// Stringify all values so we can feed into Webpack DefinePlugin

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
const path = require('path');
const fs = require('fs');

View File

@@ -75,7 +75,7 @@ module.exports = (webpackEnv) => {
loader: MiniCssExtractPlugin.loader,
options: Object.assign(
{},
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined
shouldUseRelativeAssetPaths ? { publicPath: '../../' } : undefined,
),
},
{
@@ -227,7 +227,7 @@ module.exports = (webpackEnv) => {
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
ascii_only: true, // eslint-disable-line @typescript-eslint/camelcase
},
},
@@ -281,7 +281,7 @@ module.exports = (webpackEnv) => {
modules: [ 'node_modules' ].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
process.env.NODE_PATH.split(path.delimiter).filter(Boolean),
),
// These are the reasonable defaults supported by the Node ecosystem.
@@ -372,7 +372,7 @@ module.exports = (webpackEnv) => {
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides'
'babel-preset-react-app/webpack-overrides',
),
plugins: [
@@ -470,7 +470,7 @@ module.exports = (webpackEnv) => {
importLoaders: 2,
sourceMap: isEnvProduction && shouldUseSourceMap,
},
'sass-loader'
'sass-loader',
),
// Don't consider CSS imports dead code even if the
@@ -491,7 +491,7 @@ module.exports = (webpackEnv) => {
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
},
'sass-loader'
'sass-loader',
),
},
@@ -544,8 +544,8 @@ module.exports = (webpackEnv) => {
minifyURLs: true,
},
}
: undefined
)
: undefined,
),
),
// Inlines the webpack runtime script. This script is too small to warrant
@@ -668,7 +668,7 @@ module.exports = (webpackEnv) => {
fs: 'empty',
net: 'empty',
tls: 'empty',
child_process: 'empty',
child_process: 'empty', // eslint-disable-line @typescript-eslint/camelcase
},
// Turn off performance processing because we utilize

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
const fs = require('fs');
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');

View File

@@ -3,7 +3,7 @@ version: '3'
services:
shlink_web_client_node:
container_name: shlink_web_client_node
image: node:12.14.1-alpine
image: node:14.15.0-alpine
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www

View File

@@ -1,12 +1,12 @@
module.exports = {
coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [
'src/**/*.js',
'src/**/*.{js,ts,tsx}',
'!src/registerServiceWorker.js',
'!src/index.js',
'!src/reducers/index.js',
'!src/**/provideServices.js',
'!src/container/*.js',
'!src/index.ts',
'!src/reducers/index.ts',
'!src/**/provideServices.ts',
'!src/container/*.ts',
],
resolver: 'jest-pnp-resolver',
setupFiles: [
@@ -17,9 +17,9 @@ module.exports = {
testEnvironment: 'jsdom',
testURL: 'http://localhost',
transform: {
'^.+\\.(js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.(ts|tsx|js|jsx|mjs)$': '<rootDir>/node_modules/babel-jest',
'^.+\\.css$': '<rootDir>/config/jest/cssTransform.js',
'^(?!.*\\.(js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
'^(?!.*\\.(ts|tsx|js|jsx|mjs|css|json)$)': '<rootDir>/config/jest/fileTransform.js',
},
transformIgnorePatterns: [
'[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$',

12235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
{
"name": "shlink-web-client",
"description": "A React-based progressive web application for shlink",
"version": "2.3.0",
"private": false,
"homepage": "",
"repository": "https://github.com/shlinkio/shlink-web-client",
"license": "MIT",
"scripts": {
"lint": "npm run lint:js && npm run lint:css",
"lint:js": "eslint src test scripts config",
"lint:js": "eslint --ext .js,.ts,.tsx src test scripts config",
"lint:js:fix": "npm run lint:js -- --fix",
"lint:css": "stylelint src/*.scss src/**/*.scss",
"lint:css:fix": "npm run lint:css -- --fix",
@@ -22,59 +21,81 @@
"mutate:ci": "npm run mutate -- --mutate=$MUTATION_FILES"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.11.2",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-regular-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"@fortawesome/fontawesome-free": "^5.14.0",
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-regular-svg-icons": "^5.14.0",
"@fortawesome/free-solid-svg-icons": "^5.14.0",
"@fortawesome/react-fontawesome": "^0.1.11",
"array-filter": "^1.0.0",
"array-map": "^0.0.0",
"array-reduce": "^0.0.0",
"axios": "^0.19.0",
"bootstrap": "^4.3.1",
"bottlejs": "^1.7.2",
"chart.js": "^2.8.0",
"axios": "^0.20.0",
"bootstrap": "^4.5.2",
"bottlejs": "^2.0.0",
"bowser": "^2.10.0",
"chart.js": "^2.9.3",
"classnames": "^2.2.6",
"compare-versions": "^3.5.1",
"compare-versions": "^3.6.0",
"csvjson": "^5.1.0",
"leaflet": "^1.5.1",
"moment": "^2.24.0",
"event-source-polyfill": "^1.0.17",
"leaflet": "^1.7.1",
"moment": "^2.27.0",
"promise": "^8.0.3",
"prop-types": "^15.7.2",
"qs": "^6.9.0",
"ramda": "^0.26.1",
"react": "^16.10.2",
"react-autosuggest": "^9.4.3",
"react-chartjs-2": "^2.8.0",
"react-color": "^2.17.3",
"react-copy-to-clipboard": "^5.0.1",
"qs": "^6.9.4",
"ramda": "^0.27.1",
"react": "^16.13.1",
"react-autosuggest": "^10.0.2",
"react-chartjs-2": "^2.10.0",
"react-color": "^2.18.1",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "~1.5.0",
"react-dom": "^16.10.2",
"react-external-link": "^1.0.0",
"react-leaflet": "^2.4.0",
"react-moment": "^0.9.5",
"react-redux": "^7.1.1",
"react-router-dom": "^5.1.2",
"react-swipeable": "^5.4.0",
"react-dom": "^16.13.1",
"react-external-link": "^1.1.1",
"react-leaflet": "^2.7.0",
"react-moment": "^0.9.7",
"react-redux": "^7.2.1",
"react-router-dom": "^5.2.0",
"react-swipeable": "^5.5.1",
"react-tagsinput": "^3.19.0",
"reactstrap": "^8.0.1",
"redux": "^4.0.4",
"redux-actions": "^2.6.5",
"redux-localstorage-simple": "^2.2.0",
"redux-thunk": "^2.3.0",
"uuid": "^3.3.3"
},
"devDependencies": {
"@babel/core": "^7.6.2",
"@stryker-mutator/core": "^2.1.0",
"@stryker-mutator/html-reporter": "^2.1.0",
"@stryker-mutator/javascript-mutator": "^2.1.0",
"@stryker-mutator/jest-runner": "^2.1.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4",
"@babel/plugin-proposal-optional-chaining": "^7.11.0",
"@shlinkio/eslint-config-js-coding-standard": "~1.1.0",
"@stryker-mutator/core": "^3.2.4",
"@stryker-mutator/typescript": "^3.2.4",
"@stryker-mutator/jest-runner": "^3.2.4",
"@svgr/webpack": "^4.3.3",
"@types/chart.js": "^2.9.24",
"@types/classnames": "^2.2.10",
"@types/enzyme": "^3.10.5",
"@types/jest": "^26.0.10",
"@types/leaflet": "^1.5.17",
"@types/moment": "^2.13.0",
"@types/qs": "^6.9.4",
"@types/ramda": "^0.27.14",
"@types/react": "^16.9.46",
"@types/react-autosuggest": "^10.0.0",
"@types/react-color": "^2.17.4",
"@types/react-copy-to-clipboard": "^4.3.0",
"@types/react-datepicker": "~1.8.0",
"@types/react-dom": "^16.9.8",
"@types/react-leaflet": "^2.5.2",
"@types/react-redux": "^7.1.9",
"@types/react-router-dom": "^5.1.5",
"@types/react-tagsinput": "^3.19.7",
"@types/reactstrap": "^8.5.1",
"@types/uuid": "^8.3.0",
"adm-zip": "^0.4.13",
"autoprefixer": "^9.6.3",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"babel-jest": "^26.3.0",
"babel-loader": "^8.0.6",
"babel-plugin-named-asset-import": "^0.3.4",
"babel-preset-react-app": "^9.0.2",
@@ -83,32 +104,22 @@
"case-sensitive-paths-webpack-plugin": "^2.2.0",
"chalk": "^2.4.2",
"css-loader": "^3.2.0",
"dart-sass": "^1.25.0",
"dotenv": "^8.1.0",
"dotenv-expand": "^5.1.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
"eslint": "^5.11.1",
"eslint-config-adidas-babel": "^1.1.0",
"eslint-config-adidas-env": "^1.1.0",
"eslint-config-adidas-es6": "^1.2.0",
"eslint-config-adidas-react": "^1.1.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"eslint": "^6.8.0",
"eslint-loader": "^3.0.2",
"eslint-plugin-import": "^2.18.2",
"eslint-plugin-jest": "^22.17.0",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.16.0",
"file-loader": "^4.2.0",
"fork-ts-checker-webpack-plugin-alt": "^0.4.14",
"fs-extra": "^8.1.0",
"html-webpack-plugin": "^4.0.0-beta.8",
"identity-obj-proxy": "^3.0.0",
"jest": "^24.9.0",
"jest-each": "^24.9.0",
"jest-pnp-resolver": "^1.2.1",
"jest-resolve": "^24.9.0",
"jest": "^26.4.2",
"jest-pnp-resolver": "^1.2.2",
"jest-resolve": "^26.4.0",
"mini-css-extract-plugin": "^0.8.0",
"node-sass": "^4.12.0",
"object-assign": "^4.1.1",
"ocular.js": "^0.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
@@ -119,20 +130,24 @@
"postcss-preset-env": "^6.7.0",
"postcss-safe-parser": "^4.0.1",
"raf": "^3.4.1",
"react-app-polyfill": "^1.0.4",
"react-app-polyfill": "^1.0.6",
"react-dev-utils": "^9.1.0",
"resolve": "^1.12.0",
"sass-loader": "^8.0.0",
"serve": "^11.2.0",
"sass": "^1.28.0",
"sass-loader": "^10.0.2",
"serve": "^11.3.2",
"stryker-cli": "^1.0.0",
"style-loader": "^1.0.0",
"stylelint": "^9.10.1",
"stylelint-config-adidas": "^1.2.1",
"style-loader": "^1.2.1",
"stylelint": "^13.7.0",
"stylelint-config-adidas": "^1.3.0",
"stylelint-config-adidas-bem": "^1.2.0",
"stylelint-config-recommended-scss": "^4.0.0",
"stylelint-scss": "^3.11.1",
"stylelint-config-recommended-scss": "^4.2.0",
"stylelint-scss": "^3.18.0",
"sw-precache-webpack-plugin": "^0.11.5",
"terser-webpack-plugin": "^2.1.2",
"ts-jest": "^26.3.0",
"ts-mockery": "^1.2.0",
"typescript": "^3.9.7",
"url-loader": "^2.2.0",
"webpack": "^4.41.0",
"webpack-dev-server": "^3.8.2",
@@ -143,6 +158,10 @@
"babel": {
"presets": [
"react-app"
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator"
]
},
"browserslist": [

View File

@@ -9,7 +9,7 @@
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials">
<!-- FavIcon itself -->
<link rel="icon" type="image/x-icon" href="%PUBLIC_URL%/favicon.ico">

View File

@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
@@ -14,7 +14,6 @@ process.on('unhandledRejection', (err) => {
// Ensure environment variables are read.
require('../config/env');
const path = require('path');
const chalk = require('chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
@@ -22,7 +21,6 @@ const bfj = require('bfj');
const AdmZip = require('adm-zip');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
@@ -30,7 +28,6 @@ const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const { measureFileSizesBeforeBuild, printFileSizesAfterBuild } = FileSizeReporter;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // eslint-disable-line
@@ -46,7 +43,9 @@ if (!checkRequiredFiles([ paths.appHtml, paths.appIndexJs ])) {
// Process CLI arguments
const argvSliceStart = 2;
const argv = process.argv.slice(argvSliceStart);
const writeStatsJson = argv.indexOf('--stats') !== -1;
const writeStatsJson = argv.includes('--stats');
const withoutDist = argv.includes('--no-dist');
const { version, hasVersion } = getVersionFromArgs(argv);
// Generate configuration
const config = configFactory('production');
@@ -76,15 +75,16 @@ checkBrowsers(paths.appPath, isInteractive)
console.log(
`\nSearch for the ${
chalk.underline(chalk.yellow('keywords'))
} to learn more about each warning.`
} to learn more about each warning.`,
);
console.log(
`To ignore, add ${
chalk.cyan('// eslint-disable-next-line')
} to the line before.\n`
} to the line before.\n`,
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
hasVersion && replaceVersionPlaceholder(version);
}
console.log('File sizes after gzip:\n');
@@ -93,31 +93,17 @@ checkBrowsers(paths.appPath, isInteractive)
previousFileSizes,
paths.appBuild,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE
WARN_AFTER_CHUNK_GZIP_SIZE,
);
console.log();
const appPackage = require(paths.appPackageJson);
const { publicUrl } = paths;
const { output: { publicPath } } = config;
const buildFolder = path.relative(process.cwd(), paths.appBuild);
printHostingInstructions(
appPackage,
publicUrl,
publicPath,
buildFolder,
useYarn
);
},
(err) => {
console.log(chalk.red('Failed to compile.\n'));
printBuildError(err);
process.exit(1);
}
},
)
.then(zipDist)
.then(() => hasVersion && !withoutDist && zipDist(version))
.catch((err) => {
if (err && err.message) {
console.log(err.message);
@@ -147,7 +133,7 @@ function build(previousFileSizes) {
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
stats.toJson({ all: false, warnings: true, errors: true }),
);
}
if (messages.errors.length) {
@@ -168,8 +154,8 @@ function build(previousFileSizes) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n' +
'Most CI servers set it automatically.\n'
)
'Most CI servers set it automatically.\n',
),
);
return reject(new Error(messages.warnings.join('\n\n')));
@@ -200,15 +186,7 @@ function copyPublicFolder() {
});
}
function zipDist() {
const minArgsToContainVersion = 3;
// If no version was provided, do nothing
if (process.argv.length < minArgsToContainVersion) {
return;
}
const [ , , version ] = process.argv;
function zipDist(version) {
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
@@ -226,4 +204,24 @@ function zipDist() {
console.log(chalk.red('An error occurred while generating dist file'));
console.log(e);
}
console.log();
}
function getVersionFromArgs(argv) {
const [ version ] = argv;
return { version, hasVersion: !!version };
}
function replaceVersionPlaceholder(version) {
const staticJsFilesPath = './build/static/js';
const versionPlaceholder = '%_VERSION_%';
const isMainFile = (file) => file.startsWith('main.') && file.endsWith('.js');
const [ mainJsFile ] = fs.readdirSync(staticJsFilesPath).filter(isMainFile);
const filePath = `${staticJsFilesPath}/${mainJsFile}`;
const fileContent = fs.readFileSync(filePath, 'utf-8');
const replaced = fileContent.replace(versionPlaceholder, version);
fs.writeFileSync(filePath, replaced, 'utf-8');
}

25
scripts/docker/build Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -ex
PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
DOCKER_IMAGE="shlinkio/shlink-web-client"
if [[ "$GITHUB_REF" == *"main"* ]]; then
docker buildx build --push \
--platform ${PLATFORMS} \
-t ${DOCKER_IMAGE}:latest .
# If ref is not main, then this is a tag. Build that docker tag and also "stable"
else
VERSION=${GITHUB_REF#refs/tags/v}
TAGS="-t ${DOCKER_IMAGE}:${VERSION}"
# Push stable tag only if this is not an alpha or beta release
[[ $GITHUB_REF != *"alpha"* && $GITHUB_REF != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
docker buildx build --push \
--build-arg VERSION=${VERSION} \
--platform ${PLATFORMS} \
${TAGS} .
fi

View File

@@ -1,4 +1,4 @@
/* eslint-disable no-console */
/* eslint-disable no-console, @typescript-eslint/prefer-nullish-coalescing, @typescript-eslint/promise-function-async, @typescript-eslint/prefer-optional-chain */
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
@@ -49,15 +49,15 @@ if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST)
)}`
)
chalk.bold(process.env.HOST),
)}`,
),
);
console.log(
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.'
'If this was unintentional, check that you haven\'t mistakenly set it in your shell.',
);
console.log(
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`
`Learn more here: ${chalk.yellow('http://bit.ly/CRA-advanced-config')}`,
);
console.log();
}
@@ -91,7 +91,7 @@ checkBrowsers(paths.appPath, isInteractive)
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
urls.lanUrlForConfig,
);
const devServer = new WebpackDevServer(compiler, serverConfig);

12
shlink-web-client.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
declare module 'event-source-polyfill' {
export const EventSourcePolyfill: any;
}
declare module 'csvjson' {
export declare class CsvJson {
public toObject<T>(content: string): T[];
public toCSV<T>(data: T[], options: { headers: string }): string;
}
}
declare module '*.png'

BIN
shlink-web-client.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import './App.scss';
import NotFound from './common/NotFound';
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<div className="container-fluid app-container">
<MainHeader />
<div className="app">
<Switch>
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
</div>
</div>
);
export default App;

View File

@@ -8,3 +8,19 @@
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;
}
}

52
src/App.tsx Normal file
View File

@@ -0,0 +1,52 @@
import React, { useEffect, FC } from 'react';
import { Route, Switch } from 'react-router-dom';
import NotFound from './common/NotFound';
import { ServersMap } from './servers/data';
import './App.scss';
interface AppProps {
fetchServers: Function;
servers: ServersMap;
}
const App = (
MainHeader: FC,
Home: FC,
MenuLayout: FC,
CreateServer: FC,
EditServer: FC,
Settings: FC,
ShlinkVersions: FC,
) => ({ fetchServers, servers }: AppProps) => {
// On first load, try to fetch the remote servers if the list is empty
useEffect(() => {
if (Object.keys(servers).length === 0) {
fetchServers();
}
}, []);
return (
<div className="container-fluid app-container">
<MainHeader />
<div className="app">
<div className="shlink-wrapper">
<Switch>
<Route exact path="/" component={Home} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/server/create" component={CreateServer} />
<Route exact path="/server/:serverId/edit" component={EditServer} />
<Route path="/server/:serverId" component={MenuLayout} />
<Route component={NotFound} />
</Switch>
</div>
<div className="shlink-footer text-center text-md-right">
<ShlinkVersions />
</div>
</div>
</div>
);
};
export default App;

View File

@@ -1,73 +0,0 @@
import { faList as listIcon, faLink as createIcon, faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = {
className: '',
showOnMobile: false,
};
const propTypes = {
selectedServer: serverType,
className: PropTypes.string,
showOnMobile: PropTypes.bool,
};
const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive}
>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`}
>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</NavLink>
<NavLink
className="aside-menu__item"
activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`}
>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</NavLink>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
server={selectedServer}
/>
</nav>
</aside>
);
};
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
return AsideMenu;
};
export default AsideMenu;

View File

@@ -18,7 +18,7 @@ $asideMenuMobileWidth: 280px;
@media (min-width: $mdMin) {
padding: 30px 15px 15px;
border-right: 1px solid #eee;
border-right: 1px solid #eeeeee;
}
@media (max-width: $smMax) {
@@ -51,27 +51,30 @@ $asideMenuMobileWidth: 280px;
}
.aside-menu__item--selected {
color: #fff;
color: #ffffff;
background-color: $mainColor;
}
.aside-menu__item--selected:hover {
color: #fff;
color: #ffffff;
background-color: $mainColor;
}
.aside-menu__item--divider {
border-bottom: 1px solid #eee;
border-bottom: 1px solid #eeeeee;
margin: 20px 0;
}
.aside-menu__item--danger {
color: $dangerColor;
}
.aside-menu__item--push {
margin-top: auto;
}
.aside-menu__item--danger:hover {
color: #fff;
color: #ffffff;
background-color: $dangerColor;
}

77
src/common/AsideMenu.tsx Normal file
View File

@@ -0,0 +1,77 @@
import {
faList as listIcon,
faLink as createIcon,
faTags as tagsIcon,
faPen as editIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { FC } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import classNames from 'classnames';
import { Location } from 'history';
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
import { ServerWithId } from '../servers/data';
import './AsideMenu.scss';
export interface AsideMenuProps {
selectedServer: ServerWithId;
className?: string;
showOnMobile?: boolean;
}
interface AsideMenuItemProps extends NavLinkProps {
to: string;
className?: string;
}
const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...rest }) => (
<NavLink
className={classNames('aside-menu__item', className)}
activeClassName="aside-menu__item--selected"
to={to}
{...rest}
>
{children}
</NavLink>
);
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
{ selectedServer, className, showOnMobile = false }: AsideMenuProps,
) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classNames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
});
const shortUrlsIsActive = (_: null, location: Location) => location.pathname.match('/list-short-urls') !== null;
const buildPath = (suffix: string) => `/server/${serverId}${suffix}`;
return (
<aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav">
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
<FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/create-short-url')}>
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/manage-tags')}>
<FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span>
</AsideMenuItem>
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
<FontAwesomeIcon icon={editIcon} />
<span className="aside-menu__item-text">Edit this server</span>
</AsideMenuItem>
<DeleteServerButton
className="aside-menu__item aside-menu__item--danger"
textClassName="aside-menu__item-text"
server={selectedServer}
/>
</nav>
</aside>
);
};
export default AsideMenu;

View File

@@ -1,30 +1,31 @@
import React from 'react';
import * as PropTypes from 'prop-types';
import './ErrorHandler.scss';
import React, { ReactNode } from 'react';
import { Button } from 'reactstrap';
import './ErrorHandler.scss';
// FIXME Replace with typescript: (window, console)
const ErrorHandler = ({ location }, { error }) => class ErrorHandler extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
interface ErrorHandlerState {
hasError: boolean;
}
constructor(props) {
const ErrorHandler = (
{ location }: Window,
{ error }: Console,
) => class ErrorHandler extends React.Component<any, ErrorHandlerState> {
public constructor(props: object) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
public static getDerivedStateFromError(): ErrorHandlerState {
return { hasError: true };
}
componentDidCatch(e) {
public componentDidCatch(e: Error): void {
if (process.env.NODE_ENV !== 'development') {
error(e);
}
}
render() {
public render(): ReactNode | undefined {
if (this.state.hasError) {
return (
<div className="error-handler">

View File

@@ -1,52 +0,0 @@
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types';
import './Home.scss';
export default class Home extends React.Component {
static propTypes = {
resetSelectedServer: PropTypes.func,
servers: PropTypes.object,
};
componentDidMount() {
this.props.resetSelectedServer();
}
render() {
const { servers: { list, loading } } = this.props;
const servers = values(list);
const hasServers = !isEmpty(servers);
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro">
{!loading && hasServers && <span>Please, select a server.</span>}
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{loading && <span>Trying to load servers...</span>}
</h5>
{!loading && hasServers && (
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => (
<ListGroupItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
className="home__servers-item"
>
{name}
<FontAwesomeIcon icon={chevronIcon} className="home__servers-item-icon" />
</ListGroupItem>
))}
</ListGroup>
)}
</div>
);
}
}

View File

@@ -1,9 +1,8 @@
@import '../utils/base';
@import '../utils/mixins/vertical-align';
.home {
text-align: center;
height: calc(100vh - #{$headerHeight});
height: calc(100vh - #{$headerHeight} - #{($footer-height + $footer-margin)});
display: flex;
align-items: center;
justify-content: center;
@@ -17,21 +16,3 @@
font-size: 2.2rem;
}
}
.home__servers-list {
margin-top: 1rem;
width: 100%;
max-width: 400px;
}
.home__servers-item.home__servers-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.home__servers-item-icon {
@include vertical-align();
right: 1rem;
}

27
src/common/Home.tsx Normal file
View File

@@ -0,0 +1,27 @@
import React from 'react';
import { isEmpty, values } from 'ramda';
import { Link } from 'react-router-dom';
import ServersListGroup from '../servers/ServersListGroup';
import './Home.scss';
import { ServersMap } from '../servers/data';
export interface HomeProps {
servers: ServersMap;
}
const Home = ({ servers }: HomeProps) => {
const serversList = values(servers);
const hasServers = !isEmpty(serversList);
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<ServersListGroup servers={serversList}>
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
</ServersListGroup>
</div>
);
};
export default Home;

View File

@@ -1,65 +0,0 @@
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import { Link } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import shlinkLogo from './shlink-logo-white.png';
import './MainHeader.scss';
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
static propTypes = {
location: PropTypes.object,
};
state = { isOpen: false };
handleToggle = () => {
this.setState(({ isOpen }) => ({
isOpen: !isOpen,
}));
};
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.setState({ isOpen: false });
}
}
render() {
const { location } = this.props;
const createServerPath = '/server/create';
const toggleClass = classnames('main-header__toggle-icon', {
'main-header__toggle-icon--opened': this.state.isOpen,
});
return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<NavbarBrand tag={Link} to="/">
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={this.handleToggle}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
</NavbarToggler>
<Collapse navbar isOpen={this.state.isOpen}>
<Nav navbar className="ml-auto">
<NavItem>
<NavLink
tag={Link}
to={createServerPath}
active={location.pathname === createServerPath}
>
<FontAwesomeIcon icon={plusIcon} />&nbsp; Add server
</NavLink>
</NavItem>
<ServersDropdown />
</Nav>
</Collapse>
</Navbar>
);
}
};
export default MainHeader;

51
src/common/MainHeader.tsx Normal file
View File

@@ -0,0 +1,51 @@
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { FC, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import classNames from 'classnames';
import { RouteComponentProps } from 'react-router';
import { useToggle } from '../utils/helpers/hooks';
import shlinkLogo from './shlink-logo-white.png';
import './MainHeader.scss';
const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps) => {
const [ isOpen, toggleOpen, , close ] = useToggle();
const { pathname } = location;
useEffect(close, [ location ]);
const createServerPath = '/server/create';
const settingsPath = '/settings';
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
return (
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
<NavbarBrand tag={Link} to="/">
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={toggleOpen}>
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
</NavbarToggler>
<Collapse navbar isOpen={isOpen}>
<Nav navbar className="ml-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
<FontAwesomeIcon icon={plusIcon} />&nbsp; Add server
</NavLink>
</NavItem>
<ServersDropdown />
</Nav>
</Collapse>
</Navbar>
);
};
export default MainHeader;

View File

@@ -1,110 +0,0 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import NotFound from './NotFound';
import './MenuLayout.scss';
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
class MenuLayout extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
state = { showSideBar: false };
componentDidMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer, match } = this.props;
const { params: { serverId } } = match;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
this.setState({ showSideBar });
};
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
};
export default MenuLayout;

View File

@@ -32,3 +32,12 @@
.menu-layout__burger-icon--active {
color: white;
}
.menu-layout__container {
padding: 20px 0 0;
min-height: 100%;
@media (min-width: $mdMin) {
padding: 30px 15px 0;
}
}

80
src/common/MenuLayout.tsx Normal file
View File

@@ -0,0 +1,80 @@
import React, { FC, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { EventData, Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useToggle } from '../utils/helpers/hooks';
import { versionMatch } from '../utils/helpers/version';
import { isReachableServer } from '../servers/data';
import NotFound from './NotFound';
import { AsideMenuProps } from './AsideMenu';
import './MenuLayout.scss';
const MenuLayout = (
TagsList: FC,
ShortUrls: FC,
AsideMenu: FC<AsideMenuProps>,
CreateShortUrl: FC,
ShortUrlVisits: FC,
TagVisits: FC,
ServerError: FC,
) => withSelectedServer(({ location, selectedServer }) => {
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
useEffect(() => hideSidebar(), [ location ]);
if (!isReachableServer(selectedServer)) {
return <ServerError />;
}
const addTagsVisitsRoute = versionMatch(selectedServer.version, { minVersion: '2.2.0' });
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': sidebarVisible,
});
const swipeMenuIfNoModalExists = (callback: () => void) => (e: EventData) => {
const swippedOnVisitsTable = (e.event.composedPath() as HTMLElement[]).some(
({ classList }) => classList?.contains('visits-table'),
);
if (swippedOnVisitsTable || document.querySelector('.modal')) {
return;
}
callback();
};
return (
<React.Fragment>
<FontAwesomeIcon icon={burgerIcon} className={burgerClasses} onClick={toggleSidebar} />
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(hideSidebar)}
onSwipedRight={swipeMenuIfNoModalExists(showSidebar)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={sidebarVisible} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => hideSidebar()}>
<div className="menu-layout__container">
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
{addTagsVisitsRoute && <Route exact path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
/>
</Switch>
</div>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}, ServerError);
export default MenuLayout;

View File

@@ -0,0 +1,3 @@
.no-menu-wrapper {
padding: 40px 20px 20px;
}

View File

@@ -0,0 +1,6 @@
import React, { FC } from 'react';
import './NoMenuLayout.scss';
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
export default NoMenuLayout;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import * as PropTypes from 'prop-types';
const propTypes = {
to: PropTypes.string,
btnText: PropTypes.string,
};
const NotFound = ({ to = '/', btnText = 'Home' }) => (
<div className="home">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser{'\''}s back button to navigate to the page you have previously come from, or just press this button.
</p>
<br />
<Link to={to} className="btn btn-outline-primary btn-lg">{btnText}</Link>
</div>
);
NotFound.propTypes = propTypes;
export default NotFound;

20
src/common/NotFound.tsx Normal file
View File

@@ -0,0 +1,20 @@
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
interface NotFoundProps {
to?: string;
}
const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
<div className="home">
<h2>Oops! We could not find requested route.</h2>
<p>
Use your browser&apos;s back button to navigate to the page you have previously come from, or just press this
button.
</p>
<br />
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
</div>
);
export default NotFound;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const ScrollToTop = ({ scrollTo }) => class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
children: PropTypes.node,
};
componentDidUpdate({ location: prevLocation }) {
const { location } = this.props;
if (location !== prevLocation) {
scrollTo(0, 0);
}
}
render() {
return this.props.children;
}
};
export default ScrollToTop;

View File

@@ -0,0 +1,12 @@
import React, { PropsWithChildren, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
const ScrollToTop = () => ({ location, children }: PropsWithChildren<RouteComponentProps>) => {
useEffect(() => {
scrollTo(0, 0);
}, [ location ]);
return <React.Fragment>{children}</React.Fragment>;
};
export default ScrollToTop;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import classNames from 'classnames';
import { pipe } from 'ramda';
import { ExternalLink } from 'react-external-link';
import { versionToPrintable, versionToSemVer } from '../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../servers/data';
const SHLINK_WEB_CLIENT_VERSION = '%_VERSION_%';
const normalizeVersion = pipe(versionToSemVer(), versionToPrintable);
export interface ShlinkVersionsProps {
selectedServer: SelectedServer;
clientVersion?: string;
className?: string;
}
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
<b>{version}</b>
</ExternalLink>
);
const ShlinkVersions = (
{ selectedServer, className, clientVersion = SHLINK_WEB_CLIENT_VERSION }: ShlinkVersionsProps,
) => {
const normalizedClientVersion = normalizeVersion(clientVersion);
return (
<small className={classNames('text-muted', className)}>
{isReachableServer(selectedServer) &&
<React.Fragment>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </React.Fragment>
}
Client: <VersionLink project="shlink-web-client" version={normalizedClientVersion} />
</small>
);
};
export default ShlinkVersions;

View File

@@ -1,65 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import { range, max, min } from 'ramda';
import './SimplePaginator.scss';
const propTypes = {
pagesCount: PropTypes.number.isRequired,
currentPage: PropTypes.number.isRequired,
setCurrentPage: PropTypes.func.isRequired,
};
export const ellipsis = '...';
const pagination = (currentPage, pageCount) => {
const delta = 2;
const pages = range(
max(delta, currentPage - delta),
min(pageCount - 1, currentPage + delta) + 1
);
if (currentPage - delta > delta) {
pages.unshift(ellipsis);
}
if (currentPage + delta < pageCount - 1) {
pages.push(ellipsis);
}
pages.unshift(1);
pages.push(pageCount);
return pages;
};
const SimplePaginator = ({ pagesCount, currentPage, setCurrentPage }) => {
if (pagesCount < 2) {
return null;
}
const onClick = (page) => () => setCurrentPage(page);
return (
<Pagination listClassName="flex-wrap justify-content-center mb-0 simple-paginator">
<PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem>
{pagination(currentPage, pagesCount).map((page, index) => (
<PaginationItem
key={page !== ellipsis ? page : `${page}_${index}`}
active={page === currentPage}
disabled={page === ellipsis}
>
<PaginationLink tag="span" onClick={onClick(page)}>{page}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
</PaginationItem>
</Pagination>
);
};
SimplePaginator.propTypes = propTypes;
export default SimplePaginator;

View File

@@ -0,0 +1,48 @@
import React, { FC } from 'react';
import classNames from 'classnames';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import {
pageIsEllipsis,
keyForPage,
NumberOrEllipsis,
progressivePagination,
prettifyPageNumber,
} from '../utils/helpers/pagination';
import './SimplePaginator.scss';
interface SimplePaginatorProps {
pagesCount: number;
currentPage: number;
setCurrentPage: (currentPage: number) => void;
centered?: boolean;
}
const SimplePaginator: FC<SimplePaginatorProps> = ({ pagesCount, currentPage, setCurrentPage, centered = true }) => {
if (pagesCount < 2) {
return null;
}
const onClick = (page: NumberOrEllipsis) => () => !pageIsEllipsis(page) && setCurrentPage(page);
return (
<Pagination listClassName={classNames('flex-wrap mb-0 simple-paginator', { 'justify-content-center': centered })}>
<PaginationItem disabled={currentPage <= 1}>
<PaginationLink previous tag="span" onClick={onClick(currentPage - 1)} />
</PaginationItem>
{progressivePagination(currentPage, pagesCount).map((pageNumber, index) => (
<PaginationItem
key={keyForPage(pageNumber, index)}
disabled={pageIsEllipsis(pageNumber)}
active={currentPage === pageNumber}
>
<PaginationLink tag="span" onClick={onClick(pageNumber)}>{prettifyPageNumber(pageNumber)}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={onClick(currentPage + 1)} />
</PaginationItem>
</Pagination>
);
};
export default SimplePaginator;

View File

@@ -1,6 +1,6 @@
.react-tagsinput {
background-color: #fff;
border: 1px solid #ccc;
background-color: #ffffff;
border: 1px solid #cccccc;
border-radius: .25rem;
overflow: hidden;
min-height: 2.6rem;
@@ -22,7 +22,7 @@
margin: 0 5px 6px 0;
padding: 6px 8px;
line-height: 1;
color: #fff;
color: #ffffff;
}
.react-tagsinput-remove {
@@ -33,7 +33,7 @@
.react-tagsinput-tag span:before {
content: '\2715';
color: #fff;
color: #ffffff;
}
.react-tagsinput-input {

View File

@@ -1,21 +1,26 @@
import Bottle, { Decorator } from 'bottlejs';
import ScrollToTop from '../ScrollToTop';
import MainHeader from '../MainHeader';
import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
import ErrorHandler from '../ErrorHandler';
import ShlinkVersions from '../ShlinkVersions';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('window', global.window);
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
bottle.constant('window', (global as any).window);
bottle.constant('console', global.console);
bottle.serviceFactory('ScrollToTop', ScrollToTop, 'window');
bottle.serviceFactory('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory(
@@ -25,13 +30,18 @@ const provideServices = (bottle, connect, withRouter) => {
'ShortUrls',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits'
'ShortUrlVisits',
'TagVisits',
'ServerError',
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
bottle.serviceFactory('ShlinkVersions', () => ShlinkVersions);
bottle.decorator('ShlinkVersions', connect([ 'selectedServer' ]));
bottle.serviceFactory('ErrorHandler', ErrorHandler, 'window', 'console');
};

View File

@@ -1,4 +1,4 @@
import Bottle from 'bottlejs';
import Bottle, { IContainer } from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
@@ -9,24 +9,29 @@ import provideServersServices from '../servers/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices';
import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices';
import provideMercureServices from '../mercure/services/provideServices';
import provideSettingsServices from '../settings/services/provideServices';
import { ConnectDecorator } from './types';
type LazyActionMap = Record<string, Function>;
const bottle = new Bottle();
const { container } = bottle;
const lazyService = (container, serviceName) => (...args) => container[serviceName](...args);
const mapActionService = (map, actionName) => ({
const lazyService = (container: IContainer, serviceName: string) => (...args: any[]) => container[serviceName](...args);
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName),
});
const connect = (propsFromState, actionServiceNames = []) =>
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
reduxConnect(
propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {})
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings', 'ShlinkVersions');
bottle.decorator('App', connect([ 'servers' ], [ 'fetchServers' ]));
provideCommonServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect);
@@ -34,5 +39,7 @@ provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
provideMercureServices(bottle);
provideSettingsServices(bottle, connect);
export default container;

View File

@@ -1,13 +0,0 @@
import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import reducers from '../reducers';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
));
export default store;

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

@@ -0,0 +1,20 @@
import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import { save, load, RLSOptions } from 'redux-localstorage-simple';
import reducers from '../reducers';
const isProduction = process.env.NODE_ENV !== 'production';
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const localStorageConfig: RLSOptions = {
states: [ 'settings', 'servers' ],
namespace: 'shlink',
namespaceSeparator: '.',
debounce: 300,
};
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
applyMiddleware(save(localStorageConfig), ReduxThunk),
));
export default store;

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

@@ -0,0 +1,40 @@
import { MercureInfo } from '../mercure/reducers/mercureInfo';
import { SelectedServer, ServersMap } from '../servers/data';
import { Settings } from '../settings/reducers/settings';
import { ShortUrlMetaEdition } from '../short-urls/reducers/shortUrlMeta';
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
import { ShortUrlTags } from '../short-urls/reducers/shortUrlTags';
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
import { TagDeletion } from '../tags/reducers/tagDelete';
import { TagEdition } from '../tags/reducers/tagEdit';
import { TagsList } from '../tags/reducers/tagsList';
import { ShortUrlDetail } from '../visits/reducers/shortUrlDetail';
import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits';
export interface ShlinkState {
servers: ServersMap;
selectedServer: SelectedServer;
shortUrlsList: ShortUrlsList;
shortUrlsListParams: ShortUrlsListParams;
shortUrlCreationResult: ShortUrlCreation;
shortUrlDeletion: ShortUrlDeletion;
shortUrlTags: ShortUrlTags;
shortUrlMeta: ShortUrlMetaEdition;
shortUrlEdition: ShortUrlEdition;
shortUrlVisits: ShortUrlVisits;
tagVisits: TagVisits;
shortUrlDetail: ShortUrlDetail;
tagsList: TagsList;
tagDelete: TagDeletion;
tagEdit: TagEdition;
mercureInfo: MercureInfo;
settings: Settings;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
export type GetState = () => ShlinkState;

View File

@@ -10,10 +10,6 @@ body,
outline: none !important;
}
.nowrap {
white-space: nowrap;
}
.bg-main {
background-color: $mainColor !important;
}
@@ -28,16 +24,8 @@ body,
color: inherit !important;
}
.shlink-container {
padding: 20px 0;
@media (min-width: $mdMin) {
padding: 30px 15px;
}
}
.badge-main {
color: #fff;
color: #ffffff;
background-color: $mainColor;
}
@@ -46,6 +34,10 @@ body,
display: block !important;
}
.react-datepicker-popper {
z-index: 2;
}
.navbar-brand {
@media (max-width: $smMax) {
margin: 0 auto !important;
@@ -56,11 +48,13 @@ body,
cursor: pointer;
}
.paddingless {
padding: 0;
.indivisible {
white-space: nowrap;
}
.indivisible {
.text-ellipsis {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
@@ -71,3 +65,7 @@ body,
background-color: darken($mainColor, 12%);
}
}
.progress-bar {
background-color: $mainColor;
}

View File

@@ -1,13 +1,12 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { homepage } from '../package.json';
import registerServiceWorker from './registerServiceWorker';
import container from './container';
import store from './container/store';
import { fixLeafletIcons } from './utils/utils';
import { fixLeafletIcons } from './utils/helpers/leaflet';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'react-datepicker/dist/react-datepicker.css';
import 'leaflet/dist/leaflet.css';
import './common/react-tagsinput.scss';
@@ -28,6 +27,5 @@ render(
</ErrorHandler>
</BrowserRouter>
</Provider>,
document.getElementById('root')
document.getElementById('root'),
);
registerServiceWorker();

View File

@@ -0,0 +1,41 @@
import React, { FC, useEffect } from 'react';
import { pipe } from 'ramda';
import { CreateVisit } from '../../visits/types';
import { MercureInfo } from '../reducers/mercureInfo';
import { bindToMercureTopic } from './index';
export interface MercureBoundProps {
createNewVisits: (createdVisits: CreateVisit[]) => void;
loadMercureInfo: Function;
mercureInfo: MercureInfo;
}
export function boundToMercureHub<T = {}>(
WrappedComponent: FC<MercureBoundProps & T>,
getTopicForProps: (props: T) => string,
) {
const pendingUpdates = new Set<CreateVisit>();
return (props: MercureBoundProps & T) => {
const { createNewVisits, loadMercureInfo, mercureInfo } = props;
const { interval } = mercureInfo;
useEffect(() => {
const onMessage = (visit: CreateVisit) => interval ? pendingUpdates.add(visit) : createNewVisits([ visit ]);
const closeEventSource = bindToMercureTopic(mercureInfo, getTopicForProps(props), onMessage, loadMercureInfo);
if (!interval) {
return closeEventSource;
}
const timer = setInterval(() => {
createNewVisits([ ...pendingUpdates ]);
pendingUpdates.clear();
}, interval * 1000 * 60);
return pipe(() => clearInterval(timer), () => closeEventSource?.());
}, [ mercureInfo ]);
return <WrappedComponent {...props} />;
};
}

View File

@@ -0,0 +1,24 @@
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
import { MercureInfo } from '../reducers/mercureInfo';
export const bindToMercureTopic = <T>(mercureInfo: MercureInfo, topic: string, onMessage: (message: T) => void, onTokenExpired: Function) => { // eslint-disable-line max-len
const { mercureHubUrl, token, loading, error } = mercureInfo;
if (loading || error || !mercureHubUrl) {
return undefined;
}
const hubUrl = new URL(mercureHubUrl);
hubUrl.searchParams.append('topic', topic);
const es = new EventSource(hubUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
es.onmessage = ({ data }: { data: string }) => onMessage(JSON.parse(data) as T);
es.onerror = ({ status }: { status: number }) => status === 401 && onTokenExpired();
return () => es.close();
};

View File

@@ -0,0 +1,54 @@
import { Action, Dispatch } from 'redux';
import { ShlinkMercureInfo } from '../../utils/services/types';
import { GetState } from '../../container/types';
import { buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
/* eslint-enable padding-line-between-statements */
export interface MercureInfo {
token?: string;
mercureHubUrl?: string;
interval?: number;
loading: boolean;
error: boolean;
}
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo & { interval?: number };
const initialState: MercureInfo = {
loading: true,
error: false,
};
export default buildReducer<MercureInfo, GetMercureInfoAction>({
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
[GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }),
}, initialState);
export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
() => async (dispatch: Dispatch, getState: GetState) => {
dispatch({ type: GET_MERCURE_INFO_START });
const { settings } = getState();
const { mercureInfo } = buildShlinkApiClient(getState);
if (!settings.realTimeUpdates.enabled) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
return;
}
try {
const info = await mercureInfo();
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info });
} catch (e) {
dispatch({ type: GET_MERCURE_INFO_ERROR });
}
};

View File

@@ -0,0 +1,9 @@
import Bottle from 'bottlejs';
import { loadMercureInfo } from '../reducers/mercureInfo';
const provideServices = (bottle: Bottle) => {
// Actions
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
};
export default provideServices;

View File

@@ -1,5 +1,5 @@
import { combineReducers } from 'redux';
import serversReducer from '../servers/reducers/server';
import serversReducer from '../servers/reducers/servers';
import selectedServerReducer from '../servers/reducers/selectedServer';
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
@@ -7,13 +7,18 @@ import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
import shortUrlTagsReducer from '../short-urls/reducers/shortUrlTags';
import shortUrlMetaReducer from '../short-urls/reducers/shortUrlMeta';
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
import tagVisitsReducer from '../visits/reducers/tagVisits';
import shortUrlDetailReducer from '../visits/reducers/shortUrlDetail';
import tagsListReducer from '../tags/reducers/tagsList';
import tagDeleteReducer from '../tags/reducers/tagDelete';
import tagEditReducer from '../tags/reducers/tagEdit';
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
import settingsReducer from '../settings/reducers/settings';
import { ShlinkState } from '../container/types';
export default combineReducers({
export default combineReducers<ShlinkState>({
servers: serversReducer,
selectedServer: selectedServerReducer,
shortUrlsList: shortUrlsListReducer,
@@ -22,9 +27,13 @@ export default combineReducers({
shortUrlDeletion: shortUrlDeletionReducer,
shortUrlTags: shortUrlTagsReducer,
shortUrlMeta: shortUrlMetaReducer,
shortUrlEdition: shortUrlEditionReducer,
shortUrlVisits: shortUrlVisitsReducer,
tagVisits: tagVisitsReducer,
shortUrlDetail: shortUrlDetailReducer,
tagsList: tagsListReducer,
tagDelete: tagDeleteReducer,
tagEdit: tagEditReducer,
mercureInfo: mercureInfoReducer,
settings: settingsReducer,
});

View File

@@ -1,124 +0,0 @@
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
/* eslint no-console: "off" */
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
return navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
}
// Is not local host. Just register service worker
return registerValidSW(swUrl);
});
}
}
function registerValidSW(swUrl) {
return navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl)
.then((response) => {
const NOT_FOUND_STATUS = 404;
// Ensure service worker exists, and that we really are getting a JS file.
if (
response.status === NOT_FOUND_STATUS ||
response.headers.get('content-type').indexOf('javascript') === -1
) {
// No service worker found. Probably a different app. Reload the page.
return navigator.serviceWorker.ready.then((registration) =>
registration.unregister().then(() => {
window.location.reload();
}));
}
// Service worker found. Proceed as normal.
return registerValidSW(swUrl);
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.unregister();
}).catch(() => {});
}
}

View File

@@ -1,91 +0,0 @@
import { assoc, dissoc, pipe } from 'ramda';
import React from 'react';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import './CreateServer.scss';
const SHOW_IMPORT_MSG_TIME = 4000;
const CreateServer = (ImportServersBtn, stateFlagTimeout) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
resetSelectedServer: PropTypes.func,
};
state = {
name: '',
url: '',
apiKey: '',
serversImported: false,
};
handleSubmit = (e) => {
e.preventDefault();
const { createServer, history: { push } } = this.props;
const server = pipe(
assoc('id', uuid()),
dissoc('serversImported')
)(this.state);
createServer(server);
push(`/server/${server.id}/list-short-urls/1`);
};
componentDidMount() {
this.props.resetSelectedServer();
}
render() {
const renderInputGroup = (id, placeholder, type = 'text') => (
<div className="form-group row">
<label htmlFor={id} className="col-lg-1 col-md-2 col-form-label create-server__label">
{placeholder}:
</label>
<div className="col-lg-11 col-md-10">
<input
type={type}
className="form-control"
id={id}
placeholder={placeholder}
value={this.state[id]}
required
onChange={(e) => this.setState({ [id]: e.target.value })}
/>
</div>
</div>
);
return (
<div className="create-server">
<form onSubmit={this.handleSubmit}>
{renderInputGroup('name', 'Name')}
{renderInputGroup('url', 'URL', 'url')}
{renderInputGroup('apiKey', 'API key')}
<div className="text-right">
<ImportServersBtn
onImport={() => stateFlagTimeout(this.setState.bind(this), 'serversImported', true, SHOW_IMPORT_MSG_TIME)}
/>
<button className="btn btn-outline-primary">Create server</button>
</div>
{this.state.serversImported && (
<div className="row create-server__import-success-msg">
<div className="col-md-10 offset-md-1">
<div className="p-2 mt-3 bg-main text-white text-center">
Servers properly imported. You can now select one from the list :)
</div>
</div>
</div>
)}
</form>
</div>
);
}
};
export default CreateServer;

View File

@@ -1,9 +1,5 @@
@import '../utils/base';
.create-server {
padding: 40px 20px;
}
.create-server__label {
font-weight: 700;
cursor: pointer;

View File

@@ -0,0 +1,58 @@
import React, { FC } from 'react';
import { v4 as uuid } from 'uuid';
import { RouterProps } from 'react-router';
import classNames from 'classnames';
import NoMenuLayout from '../common/NoMenuLayout';
import { StateFlagTimeout } from '../utils/helpers/hooks';
import { ServerForm } from './helpers/ServerForm';
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerData, ServerWithId } from './data';
import './CreateServer.scss';
const SHOW_IMPORT_MSG_TIME = 4000;
interface CreateServerProps extends RouterProps {
createServer: (server: ServerWithId) => void;
}
const Result: FC<{ type: 'success' | 'error' }> = ({ children, type }) => (
<div className="row">
<div className="col-md-10 offset-md-1">
<div
className={classNames('p-2 mt-3 text-white text-center', {
'bg-main': type === 'success',
'bg-danger': type === 'error',
})}
>
{children}
</div>
</div>
</div>
);
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
{ createServer, history: { push } }: CreateServerProps,
) => {
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
const handleSubmit = (serverData: ServerData) => {
const id = uuid();
createServer({ ...serverData, id });
push(`/server/${id}/list-short-urls/1`);
};
return (
<NoMenuLayout>
<ServerForm onSubmit={handleSubmit}>
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
<button className="btn btn-outline-primary">Create server</button>
</ServerForm>
{serversImported && <Result type="success">Servers properly imported. You can now select one from the list :)</Result>}
{errorImporting && <Result type="error">The servers could not be imported. Make sure the format is correct.</Result>}
</NoMenuLayout>
);
};
export default CreateServer;

View File

@@ -1,40 +0,0 @@
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';
const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
static propTypes = {
server: serverType,
className: PropTypes.string,
};
state = { isModalOpen: false };
render() {
const { server, className } = this.props;
return (
<React.Fragment>
<span
className={className}
key="deleteServerBtn"
onClick={() => this.setState({ isModalOpen: true })}
>
<FontAwesomeIcon icon={deleteIcon} />
<span className="aside-menu__item-text">Delete this server</span>
</span>
<DeleteServerModal
isOpen={this.state.isModalOpen}
toggle={() => this.setState(({ isModalOpen }) => ({ isModalOpen: !isModalOpen }))}
server={server}
key="deleteServerModal"
/>
</React.Fragment>
);
}
};
export default DeleteServerButton;

View File

@@ -0,0 +1,31 @@
import React, { FC } from 'react';
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '../utils/helpers/hooks';
import { DeleteServerModalProps } from './DeleteServerModal';
import { ServerWithId } from './data';
export interface DeleteServerButtonProps {
server: ServerWithId;
className?: string;
textClassName?: string;
}
const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<DeleteServerButtonProps> => (
{ server, className, children, textClassName },
) => {
const [ isModalOpen, , showModal, hideModal ] = useToggle();
return (
<React.Fragment>
<span className={className} onClick={showModal}>
{!children && <FontAwesomeIcon icon={deleteIcon} />}
<span className={textClassName}>{children ?? 'Remove this server'}</span>
</span>
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
</React.Fragment>
);
};
export default DeleteServerButton;

View File

@@ -1,19 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { serverType } from './prop-types';
import { RouterProps } from 'react-router';
import { ServerWithId } from './data';
const propTypes = {
toggle: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
server: serverType,
deleteServer: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
export interface DeleteServerModalProps {
server: ServerWithId;
toggle: () => void;
isOpen: boolean;
}
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
deleteServer: (server: ServerWithId) => void;
}
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
const closeModal = () => {
deleteServer(server);
toggle();
@@ -22,12 +22,14 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
return (
<Modal isOpen={isOpen} toggle={toggle} centered>
<ModalHeader toggle={toggle}><span className="text-danger">Delete server</span></ModalHeader>
<ModalHeader toggle={toggle}><span className="text-danger">Remove server</span></ModalHeader>
<ModalBody>
<p>Are you sure you want to delete server <b>{server ? server.name : ''}</b>?</p>
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
No data will be deleted, only the access to that server will be removed from this host.
You can create it again at any moment.
<i>
No data will be deleted, only the access to this server will be removed from this host.
You can create it again at any moment.
</i>
</p>
</ModalBody>
<ModalFooter>
@@ -38,6 +40,4 @@ const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) =>
);
};
DeleteServerModal.propTypes = propTypes;
export default DeleteServerModal;

View File

@@ -0,0 +1,32 @@
import React, { FC } from 'react';
import { Button } from 'reactstrap';
import NoMenuLayout from '../common/NoMenuLayout';
import { ServerForm } from './helpers/ServerForm';
import { withSelectedServer } from './helpers/withSelectedServer';
import { isServerWithId, ServerData } from './data';
interface EditServerProps {
editServer: (serverId: string, serverData: ServerData) => void;
}
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
{ editServer, selectedServer, history: { push, goBack } },
) => {
if (!isServerWithId(selectedServer)) {
return null;
}
const handleSubmit = (serverData: ServerData) => {
editServer(selectedServer.id, serverData);
push(`/server/${selectedServer.id}/list-short-urls/1`);
};
return (
<NoMenuLayout>
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
<Button outline className="mr-2" onClick={goBack}>Cancel</Button>
<Button outline color="primary">Save</Button>
</ServerForm>
</NoMenuLayout>
);
}, ServerError);

View File

@@ -1,64 +0,0 @@
import { isEmpty, values } from 'ramda';
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types';
import { serverType } from './prop-types';
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
static propTypes = {
servers: PropTypes.object,
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
history: PropTypes.shape({
push: PropTypes.func,
}),
};
renderServers = () => {
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
const servers = values(list);
const { push } = this.props.history;
const loadServer = (id) => {
selectServer(id)
.then(() => push(`/server/${id}/list-short-urls/1`))
.catch(() => {});
};
if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
}
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
}
return (
<React.Fragment>
{servers.map(({ name, id }) => (
<DropdownItem key={id} active={selectedServer && selectedServer.id === id} onClick={() => loadServer(id)}>
{name}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem
className="servers-dropdown__export-item"
onClick={() => serversExporter.exportServers()}
>
Export servers
</DropdownItem>
</React.Fragment>
);
};
componentDidMount = this.props.listServers;
render = () => (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};
export default ServersDropdown;

View File

@@ -0,0 +1,49 @@
import { isEmpty, values } from 'ramda';
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import { Link } from 'react-router-dom';
import ServersExporter from './services/ServersExporter';
import { isServerWithId, SelectedServer, ServersMap } from './data';
export interface ServersDropdownProps {
servers: ServersMap;
selectedServer: SelectedServer;
}
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = values(servers);
const renderServers = () => {
if (isEmpty(serversList)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
}
return (
<React.Fragment>
{serversList.map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
to={`/server/${id}/list-short-urls/1`}
active={isServerWithId(selectedServer) && selectedServer.id === id}
>
{name}
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
Export servers
</DropdownItem>
</React.Fragment>
);
};
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};
export default ServersDropdown;

View File

@@ -0,0 +1,18 @@
@import '../utils/mixins/vertical-align';
.servers-list__list-group {
width: 100%;
max-width: 400px;
}
.servers-list__server-item.servers-list__server-item {
text-align: left;
position: relative;
padding: .75rem 2.5rem .75rem 1rem;
}
.servers-list__server-item-icon {
@include vertical-align();
right: 1rem;
}

View File

@@ -0,0 +1,33 @@
import React, { FC } from 'react';
import { ListGroup, ListGroupItem } from 'reactstrap';
import { Link } from 'react-router-dom';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
import './ServersListGroup.scss';
import { ServerWithId } from './data';
interface ServersListGroup {
servers: ServerWithId[];
}
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
<ListGroupItem tag={Link} to={`/server/${id}/list-short-urls/1`} className="servers-list__server-item">
{name}
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
</ListGroupItem>
);
const ServersListGroup: FC<ServersListGroup> = ({ servers, children }) => (
<React.Fragment>
<div className="container">
<h5>{children}</h5>
</div>
{servers.length > 0 && (
<ListGroup className="servers-list__list-group mt-md-3">
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</ListGroup>
)}
</React.Fragment>
);
export default ServersListGroup;

40
src/servers/data/index.ts Normal file
View File

@@ -0,0 +1,40 @@
export interface ServerData {
name: string;
url: string;
apiKey: string;
}
export interface ServerWithId extends ServerData {
id: string;
}
export interface ReachableServer extends ServerWithId {
version: string;
printableVersion: string;
}
export interface NonReachableServer extends ServerWithId {
serverNotReachable: true;
}
export interface NotFoundServer {
serverNotFound: true;
}
export type RegularServer = ReachableServer | NonReachableServer;
export type SelectedServer = RegularServer | NotFoundServer | null;
export type ServersMap = Record<string, ServerWithId>;
export const hasServerData = (server: SelectedServer | ServerData): server is ServerData =>
!!(server as ServerData)?.url && !!(server as ServerData)?.apiKey;
export const isServerWithId = (server: SelectedServer | ServerWithId): server is ServerWithId =>
!!server?.hasOwnProperty('id');
export const isReachableServer = (server: SelectedServer): server is ReachableServer =>
!!server?.hasOwnProperty('printableVersion');
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
!!server?.hasOwnProperty('serverNotFound');

View File

@@ -1,31 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { compareVersions } from '../../utils/utils';
import { serverType } from '../prop-types';
const propTypes = {
minVersion: PropTypes.string,
maxVersion: PropTypes.string,
selectedServer: serverType,
children: PropTypes.node.isRequired,
};
const ForServerVersion = ({ minVersion, maxVersion, selectedServer, children }) => {
if (!selectedServer) {
return null;
}
const { version } = selectedServer;
const matchesMinVersion = !minVersion || compareVersions(version, '>=', minVersion);
const matchesMaxVersion = !maxVersion || compareVersions(version, '<=', maxVersion);
if (!matchesMinVersion || !matchesMaxVersion) {
return null;
}
return <React.Fragment>{children}</React.Fragment>;
};
ForServerVersion.propTypes = propTypes;
export default ForServerVersion;

View File

@@ -0,0 +1,24 @@
import React, { FC } from 'react';
import { versionMatch, Versions } from '../../utils/helpers/version';
import { isReachableServer, SelectedServer } from '../data';
interface ForServerVersionProps extends Versions {
selectedServer: SelectedServer;
}
const ForServerVersion: FC<ForServerVersionProps> = ({ minVersion, maxVersion, selectedServer, children }) => {
if (!isReachableServer(selectedServer)) {
return null;
}
const { version } = selectedServer;
const matchesVersion = versionMatch(version, { maxVersion, minVersion });
if (!matchesVersion) {
return null;
}
return <React.Fragment>{children}</React.Fragment>;
};
export default ForServerVersion;

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import PropTypes from 'prop-types';
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
static defaultProps = {
onImport: () => ({}),
};
static propTypes = {
onImport: PropTypes.func,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
constructor(props) {
super(props);
this.fileRef = props.fileRef || React.createRef();
}
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(createServers)
.then(onImport)
.then(() => {
// Reset input after processing file
target.value = null;
});
return (
<React.Fragment>
<button
type="button"
className="btn btn-outline-secondary mr-2"
id="importBtn"
onClick={() => this.fileRef.current.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>
</UncontrolledTooltip>
<input
type="file"
accept="text/csv"
className="create-server__csv-select"
ref={this.fileRef}
onChange={onChange}
/>
</React.Fragment>
);
}
};
export default ImportServersBtn;

View File

@@ -0,0 +1,54 @@
import React, { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import ServersImporter from '../services/ServersImporter';
import { ServerData } from '../data';
type Ref<T> = RefObject<T> | MutableRefObject<T>;
export interface ImportServersBtnProps {
onImport?: () => void;
onImportError?: () => void;
}
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
createServers: (servers: ServerData[]) => void;
fileRef: Ref<HTMLInputElement>;
}
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
createServers,
fileRef,
onImport = () => {},
onImportError = () => {},
}: ImportServersBtnConnectProps) => {
const ref = fileRef ?? useRef<HTMLInputElement>();
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
importServersFromFile(target.files?.[0])
.then(createServers)
.then(onImport)
.then(() => {
// Reset input after processing file
(target as { value: string | null }).value = null;
})
.catch(onImportError);
return (
<React.Fragment>
<button
type="button"
className="btn btn-outline-secondary mr-2"
id="importBtn"
onClick={() => ref.current?.click()}
>
Import from file
</button>
<UncontrolledTooltip placement="top" target="importBtn">
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
</UncontrolledTooltip>
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
</React.Fragment>
);
};
export default ImportServersBtn;

View File

@@ -0,0 +1,17 @@
@import '../../utils/base';
.server-error__container {
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.server-error__delete-btn {
color: $dangerColor;
cursor: pointer;
}
.server-error__delete-btn:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,45 @@
import React, { FC } from 'react';
import { Link } from 'react-router-dom';
import Message from '../../utils/Message';
import ServersListGroup from '../ServersListGroup';
import { DeleteServerButtonProps } from '../DeleteServerButton';
import { isServerWithId, SelectedServer, ServersMap } from '../data';
import './ServerError.scss';
interface ServerErrorProps {
servers: ServersMap;
selectedServer: SelectedServer;
}
export const ServerError = (DeleteServerButton: FC<DeleteServerButtonProps>): FC<ServerErrorProps> => (
{ servers, selectedServer },
) => (
<div className="server-error__container flex-column">
<div className="row w-100 mb-3 mb-md-5">
<Message type="error">
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<React.Fragment>
<p>Oops! Could not connect to this Shlink server.</p>
Make sure you have internet connection, and the server is properly configured and on-line.
</React.Fragment>
)}
</Message>
</div>
<ServersListGroup servers={Object.values(servers)}>
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</ServersListGroup>
{isServerWithId(selectedServer) && (
<div className="container mt-3 mt-md-5">
<h5>
Alternatively, if you think you may have miss-configured this server, you
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or&nbsp;
<Link to={`/server/${selectedServer.id}/edit`}>edit it</Link>.
</h5>
</div>
)}
</div>
);

View File

@@ -0,0 +1,32 @@
import React, { FC, useEffect, useState } from 'react';
import { HorizontalFormGroup } from '../../utils/HorizontalFormGroup';
import { handleEventPreventingDefault } from '../../utils/utils';
import { ServerData } from '../data';
interface ServerFormProps {
onSubmit: (server: ServerData) => void;
initialValues?: ServerData;
}
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children }) => {
const [ name, setName ] = useState('');
const [ url, setUrl ] = useState('');
const [ apiKey, setApiKey ] = useState('');
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
useEffect(() => {
initialValues && setName(initialValues.name);
initialValues && setUrl(initialValues.url);
initialValues && setApiKey(initialValues.apiKey);
}, [ initialValues ]);
return (
<form onSubmit={handleSubmit}>
<HorizontalFormGroup value={name} onChange={setName}>Name</HorizontalFormGroup>
<HorizontalFormGroup type="url" value={url} onChange={setUrl}>URL</HorizontalFormGroup>
<HorizontalFormGroup value={apiKey} onChange={setApiKey}>API key</HorizontalFormGroup>
<div className="text-right">{children}</div>
</form>
);
};

View File

@@ -0,0 +1,33 @@
import React, { FC, useEffect } from 'react';
import { RouteComponentProps } from 'react-router';
import Message from '../../utils/Message';
import { isNotFoundServer, SelectedServer } from '../data';
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
selectServer: (serverId: string) => void;
selectedServer: SelectedServer;
}
export function withSelectedServer<T = {}>(WrappedComponent: FC<WithSelectedServerProps & T>, ServerError: FC) {
return (props: WithSelectedServerProps & T) => {
const { selectServer, selectedServer, match } = props;
useEffect(() => {
selectServer(match.params.serverId);
}, [ match.params.serverId ]);
if (!selectedServer) {
return (
<div className="row">
<Message loading />
</div>
);
}
if (isNotFoundServer(selectedServer)) {
return <ServerError />;
}
return <WrappedComponent {...props} />;
};
}

View File

@@ -0,0 +1,15 @@
import React, { FC, useEffect } from 'react';
interface WithoutSelectedServerProps {
resetSelectedServer: Function;
}
export function withoutSelectedServer<T = {}>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
return (props: WithoutSelectedServerProps & T) => {
useEffect(() => {
props.resetSelectedServer();
}, []);
return <WrappedComponent {...props} />;
};
}

View File

@@ -1,9 +0,0 @@
import PropTypes from 'prop-types';
export const serverType = PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
url: PropTypes.string,
apiKey: PropTypes.string,
version: PropTypes.string,
});

View File

@@ -0,0 +1,25 @@
import { pipe, prop } from 'ramda';
import { AxiosInstance } from 'axios';
import { Dispatch } from 'redux';
import { homepage } from '../../../package.json';
import { ServerData } from '../data';
import { createServers } from './servers';
const responseToServersList = pipe(
prop<any, any>('data'),
(data: any): ServerData[] => {
if (!Array.isArray(data)) {
throw new Error('Value is not an array');
}
return data as ServerData[];
},
);
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
const remoteList = await get(`${homepage}/servers.json`)
.then(responseToServersList)
.catch(() => []);
dispatch(createServers(remoteList));
};

View File

@@ -1,40 +0,0 @@
import { createAction, handleActions } from 'redux-actions';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionIsValidSemVer } from '../../utils/utils';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
const initialState = null;
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serverId) => async (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)
.catch(() => MIN_FALLBACK_VERSION);
dispatch({
type: SELECT_SERVER,
selectedServer: {
...selectedServer,
version,
},
});
};
export default handleActions({
[RESET_SELECTED_SERVER]: () => initialState,
[SELECT_SERVER]: (state, { selectedServer }) => selectedServer,
}, initialState);

View File

@@ -0,0 +1,89 @@
import { identity, memoizeWith, pipe } from 'ramda';
import { Action, Dispatch } from 'redux';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import { SelectedServer } from '../data';
import { GetState } from '../../container/types';
import { ShlinkHealth } from '../../utils/services/types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../utils/services/ShlinkApiClientBuilder';
/* eslint-disable padding-line-between-statements */
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
export const MIN_FALLBACK_VERSION = '1.0.0';
export const MAX_FALLBACK_VERSION = '999.999.999';
export const LATEST_VERSION_CONSTRAINT = 'latest';
/* eslint-enable padding-line-between-statements */
export interface SelectServerAction extends Action<string> {
selectedServer: SelectedServer;
}
const versionToSemVer = pipe(
(version: string) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version,
toSemVer(MIN_FALLBACK_VERSION),
);
const getServerVersion = memoizeWith(
identity,
async (_serverId: string, health: () => Promise<ShlinkHealth>) => health().then(({ version }) => ({
version: versionToSemVer(version),
printableVersion: versionToPrintable(version),
})),
);
const initialState: SelectedServer = null;
export default buildReducer<SelectedServer, SelectServerAction>({
[RESET_SELECTED_SERVER]: () => initialState,
[SELECT_SERVER]: (_, { selectedServer }) => selectedServer,
}, initialState);
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER);
export const selectServer = (
buildShlinkApiClient: ShlinkApiClientBuilder,
loadMercureInfo: () => Action,
) => (
serverId: string,
) => async (
dispatch: Dispatch,
getState: GetState,
) => {
dispatch(resetSelectedServer());
dispatch(resetShortUrlParams());
const { servers } = getState();
const selectedServer = servers[serverId];
if (!selectedServer) {
dispatch<SelectServerAction>({
type: SELECT_SERVER,
selectedServer: { serverNotFound: true },
});
return;
}
try {
const { health } = buildShlinkApiClient(selectedServer);
const { version, printableVersion } = await getServerVersion(serverId, health);
dispatch<SelectServerAction>({
type: SELECT_SERVER,
selectedServer: {
...selectedServer,
version,
printableVersion,
},
});
dispatch(loadMercureInfo());
} catch (e) {
dispatch<SelectServerAction>({
type: SELECT_SERVER,
selectedServer: { ...selectedServer, serverNotReachable: true },
});
}
};

View File

@@ -1,61 +0,0 @@
import { handleActions } from 'redux-actions';
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
import { v4 as uuid } from 'uuid';
import { homepage } from '../../../package.json';
/* eslint-disable padding-line-between-statements */
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
/* eslint-enable padding-line-between-statements */
const initialState = {
list: {},
loading: false,
};
const assocId = (server) => assoc('id', server.id || uuid(), server);
export default handleActions({
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
}, initialState);
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
dispatch({ type: FETCH_SERVERS_START });
const localList = listServers();
if (!isEmpty(localList)) {
dispatch({ type: FETCH_SERVERS, list: localList });
return;
}
// If local list is empty, try to fetch it remotely (making sure it's an array) and calculate IDs for every server
const getDataAsArrayWithIds = pipe(
prop('data'),
(value) => {
if (!Array.isArray(value)) {
throw new Error('Value is not an array');
}
return value;
},
map(assocId),
);
const remoteList = await get(`${homepage}/servers.json`)
.then(getDataAsArrayWithIds)
.catch(() => []);
createServers(remoteList);
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
};
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = ({ createServers }, listServersAction) => pipe(
map(assocId),
createServers,
listServersAction
);

View File

@@ -0,0 +1,51 @@
import { assoc, dissoc, map, pipe, reduce } from 'ramda';
import { v4 as uuid } from 'uuid';
import { Action } from 'redux';
import { ServerData, ServersMap, ServerWithId } from '../data';
import { buildReducer } from '../../utils/helpers/redux';
/* eslint-disable padding-line-between-statements */
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
/* eslint-enable padding-line-between-statements */
export interface CreateServersAction extends Action<string> {
newServers: ServersMap;
}
const initialState: ServersMap = {};
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
if ((server as ServerWithId).id) {
return server as ServerWithId;
}
return assoc('id', uuid(), server);
};
export default buildReducer<ServersMap, CreateServersAction>({
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
? state
: assoc(serverId, { ...state[serverId], ...serverData }, state),
}, initialState);
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
export const createServers = pipe(
map(serverWithId),
serversListToMap,
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
);
export const createServer = (server: ServerWithId) => createServers([ server ]);
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
type: EDIT_SERVER,
serverId,
serverData,
});
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });

View File

@@ -1,6 +1,9 @@
import { dissoc, head, keys, values } from 'ramda';
import { CsvJson } from 'csvjson';
import LocalStorage from '../../utils/services/LocalStorage';
import { ServersMap } from '../data';
const saveCsv = (window, csv) => {
const saveCsv = (window: Window, csv: string) => {
const { navigator, document } = window;
const filename = 'shlink-servers.csv';
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
@@ -25,14 +28,14 @@ const saveCsv = (window, csv) => {
};
export default class ServersExporter {
constructor(serversService, window, csvjson) {
this.serversService = serversService;
this.window = window;
this.csvjson = csvjson;
}
public constructor(
private readonly storage: LocalStorage,
private readonly window: Window,
private readonly csvjson: CsvJson,
) {}
exportServers = async () => {
const servers = values(this.serversService.listServers()).map(dissoc('id'));
public readonly exportServers = async () => {
const servers = values(this.storage.get<ServersMap>('servers') || {}).map(dissoc('id'));
try {
const csv = this.csvjson.toCSV(servers, {

View File

@@ -1,23 +0,0 @@
export default class ServersImporter {
constructor(csvjson) {
this.csvjson = csvjson;
}
importServersFromFile = (file) => {
if (!file || file.type !== 'text/csv') {
return Promise.reject('No file provided or file is not a CSV');
}
const reader = new FileReader();
return new Promise((resolve) => {
reader.addEventListener('loadend', (e) => {
const content = e.target.result;
const servers = this.csvjson.toObject(content);
resolve(servers);
});
reader.readAsText(file);
});
};
}

View File

@@ -0,0 +1,26 @@
import { CsvJson } from 'csvjson';
import { ServerData } from '../data';
const CSV_MIME_TYPE = 'text/csv';
export default class ServersImporter {
public constructor(private readonly csvjson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
if (!file || file.type !== CSV_MIME_TYPE) {
throw new Error('No file provided or file is not a CSV');
}
const reader = this.fileReaderFactory();
return new Promise((resolve) => {
reader.addEventListener('loadend', (e: ProgressEvent<FileReader>) => {
const content = e.target?.result?.toString() ?? '';
const servers = this.csvjson.toObject<ServerData>(content);
resolve(servers);
});
reader.readAsText(file);
});
};
}

View File

@@ -1,28 +0,0 @@
import { assoc, dissoc, reduce } from 'ramda';
const SERVERS_STORAGE_KEY = 'servers';
export default class ServersService {
constructor(storage) {
this.storage = storage;
}
listServers = () => this.storage.get(SERVERS_STORAGE_KEY) || {};
findServerById = (serverId) => this.listServers()[serverId];
createServer = (server) => this.createServers([ server ]);
createServers = (servers) => {
const allServers = reduce(
(serversObj, server) => assoc(server.id, server, serversObj),
this.listServers(),
servers
);
this.storage.set(SERVERS_STORAGE_KEY, allServers);
};
deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
}

View File

@@ -1,24 +1,32 @@
import csvjson from 'csvjson';
import Bottle, { Decorator } from 'bottlejs';
import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton';
import { EditServer } from '../EditServer';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
import { fetchServers } from '../reducers/remoteServers';
import ForServerVersion from '../helpers/ForServerVersion';
import { ServerError } from '../helpers/ServerError';
import { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import ServersImporter from './ServersImporter';
import ServersService from './ServersService';
import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => {
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
// Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'stateFlagTimeout');
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', withRouter);
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter);
@@ -32,18 +40,22 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
bottle.serviceFactory('ServerError', ServerError, 'DeleteServerButton');
bottle.decorator('ServerError', connect([ 'servers', 'selectedServer' ]));
// Services
bottle.constant('csvjson', csvjson);
bottle.service('ServersImporter', ServersImporter, 'csvjson');
bottle.service('ServersService', ServersService, 'Storage');
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
bottle.constant('fileReaderFactory', () => new FileReader());
bottle.service('ServersImporter', ServersImporter, 'csvjson', 'fileReaderFactory');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'csvjson');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService', 'buildShlinkApiClient');
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
bottle.serviceFactory('createServer', () => createServer);
bottle.serviceFactory('createServers', () => createServers);
bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { Card, CardBody, CardHeader, FormGroup, Input } from 'reactstrap';
import classNames from 'classnames';
import ToggleSwitch from '../utils/ToggleSwitch';
import { Settings } from './reducers/settings';
interface RealTimeUpdatesProps {
settings: Settings;
toggleRealTimeUpdates: (enabled: boolean) => void;
setRealTimeUpdatesInterval: (interval: number) => void;
}
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
const RealTimeUpdates = (
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
) => (
<Card>
<CardHeader>Real-time updates</CardHeader>
<CardBody>
<FormGroup>
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
</ToggleSwitch>
</FormGroup>
<FormGroup className="mb-0">
<label className={classNames({ 'text-muted': !realTimeUpdates.enabled })}>
Real-time updates frequency (in minutes):
</label>
<Input
type="number"
min={0}
placeholder="Immediate"
disabled={!realTimeUpdates.enabled}
value={intervalValue(realTimeUpdates.interval)}
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
/>
{realTimeUpdates.enabled && (
<small className="form-text text-muted">
{realTimeUpdates.interval !== undefined && realTimeUpdates.interval > 0 && (
<span>
Updates will be reflected in the UI every <b>{realTimeUpdates.interval}</b> minute{realTimeUpdates.interval > 1 && 's'}.
</span>
)}
{!realTimeUpdates.interval && 'Updates will be reflected in the UI as soon as they happen.'}
</small>
)}
</FormGroup>
</CardBody>
</Card>
);
export default RealTimeUpdates;

10
src/settings/Settings.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React, { FC } from 'react';
import NoMenuLayout from '../common/NoMenuLayout';
const Settings = (RealTimeUpdates: FC) => () => (
<NoMenuLayout>
<RealTimeUpdates />
</NoMenuLayout>
);
export default Settings;

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