Ver Fonte

Merge pull request #169 from pawelmalak/v2

Version 2.0.0
pawelmalak há 3 anos atrás
pai
commit
7eb8ec228a
100 ficheiros alterados com 2735 adições e 2392 exclusões
  1. 1 0
      .docker/Dockerfile
  2. 11 1
      .docker/Dockerfile.dev
  3. 6 4
      .docker/Dockerfile.multiarch
  4. 2 0
      .docker/docker-compose.yml
  5. 2 3
      .dockerignore
  6. 3 1
      .env
  7. BIN
      .github/_apps.png
  8. BIN
      .github/_bookmarks.png
  9. BIN
      .github/_home.png
  10. BIN
      .github/apps.png
  11. BIN
      .github/bookmarks.png
  12. BIN
      .github/home.png
  13. BIN
      .github/settings.png
  14. 0 0
      .github/themes.png
  15. 22 7
      CHANGELOG.md
  16. 61 58
      README.md
  17. 2 1
      api.js
  18. 1 1
      client/.env
  19. 14 9
      client/package-lock.json
  20. 1 0
      client/package.json
  21. 51 24
      client/src/App.tsx
  22. 18 20
      client/src/components/Apps/AppCard/AppCard.tsx
  23. 55 57
      client/src/components/Apps/AppForm/AppForm.tsx
  24. 3 5
      client/src/components/Apps/AppGrid/AppGrid.tsx
  25. 30 47
      client/src/components/Apps/AppTable/AppTable.tsx
  26. 40 44
      client/src/components/Apps/Apps.tsx
  27. 23 21
      client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx
  28. 0 363
      client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx
  29. 5 7
      client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx
  30. 40 51
      client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx
  31. 76 89
      client/src/components/Bookmarks/Bookmarks.tsx
  32. 260 0
      client/src/components/Bookmarks/Form/BookmarksForm.tsx
  33. 100 0
      client/src/components/Bookmarks/Form/CategoryForm.tsx
  34. 0 0
      client/src/components/Bookmarks/Form/Form.module.css
  35. 44 0
      client/src/components/Bookmarks/Form/Form.tsx
  36. 9 17
      client/src/components/Home/Header/Header.tsx
  37. 13 2
      client/src/components/Home/Header/functions/getDateTime.ts
  38. 41 53
      client/src/components/Home/Home.tsx
  39. 8 17
      client/src/components/NotificationCenter/NotificationCenter.tsx
  40. 13 0
      client/src/components/Routing/ProtectedRoute.tsx
  41. 20 33
      client/src/components/SearchBar/SearchBar.tsx
  42. 9 3
      client/src/components/Settings/AppDetails/AppDetails.module.css
  43. 36 27
      client/src/components/Settings/AppDetails/AppDetails.tsx
  44. 103 0
      client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
  45. 122 0
      client/src/components/Settings/DockerSettings/DockerSettings.tsx
  46. 25 34
      client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx
  47. 18 11
      client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx
  48. 24 42
      client/src/components/Settings/SearchSettings/SearchSettings.tsx
  49. 33 17
      client/src/components/Settings/Settings.tsx
  50. 32 28
      client/src/components/Settings/StyleSettings/StyleSettings.tsx
  51. 35 108
      client/src/components/Settings/UISettings/UISettings.tsx
  52. 24 36
      client/src/components/Settings/WeatherSettings/WeatherSettings.tsx
  53. 18 7
      client/src/components/Settings/settings.json
  54. 8 7
      client/src/components/Themer/ThemePreview.tsx
  55. 14 22
      client/src/components/Themer/Themer.tsx
  56. 12 22
      client/src/components/UI/Buttons/ActionButton/ActionButton.tsx
  57. 8 12
      client/src/components/UI/Buttons/Button/Button.tsx
  58. 6 11
      client/src/components/UI/Forms/InputGroup/InputGroup.tsx
  59. 9 13
      client/src/components/UI/Forms/ModalForm/ModalForm.tsx
  60. 9 9
      client/src/components/UI/Headlines/Headline/Headline.tsx
  61. 5 7
      client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.tsx
  62. 1 3
      client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.tsx
  63. 1 3
      client/src/components/UI/Icons/Icon/Icon.module.css
  64. 4 6
      client/src/components/UI/Icons/Icon/Icon.tsx
  65. 11 18
      client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx
  66. 4 7
      client/src/components/UI/Layout/Layout.tsx
  67. 11 10
      client/src/components/UI/Modal/Modal.tsx
  68. 11 10
      client/src/components/UI/Notification/Notification.tsx
  69. 3 5
      client/src/components/UI/Spinner/Spinner.tsx
  70. 11 11
      client/src/components/UI/Table/Table.tsx
  71. 14 0
      client/src/components/UI/index.ts
  72. 9 20
      client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx
  73. 9 3
      client/src/index.tsx
  74. 7 1
      client/src/interfaces/Api.ts
  75. 6 8
      client/src/interfaces/App.ts
  76. 3 7
      client/src/interfaces/Bookmark.ts
  77. 5 5
      client/src/interfaces/Category.ts
  78. 1 0
      client/src/interfaces/Config.ts
  79. 8 4
      client/src/interfaces/Forms.ts
  80. 0 13
      client/src/interfaces/GlobalState.ts
  81. 1 0
      client/src/interfaces/Route.ts
  82. 0 1
      client/src/interfaces/index.ts
  83. 198 0
      client/src/store/action-creators/app.ts
  84. 85 0
      client/src/store/action-creators/auth.ts
  85. 321 0
      client/src/store/action-creators/bookmark.ts
  86. 156 0
      client/src/store/action-creators/config.ts
  87. 6 0
      client/src/store/action-creators/index.ts
  88. 24 0
      client/src/store/action-creators/notification.ts
  89. 26 0
      client/src/store/action-creators/theme.ts
  90. 45 0
      client/src/store/action-types/index.ts
  91. 0 110
      client/src/store/actions/actionTypes.ts
  92. 11 178
      client/src/store/actions/app.ts
  93. 19 0
      client/src/store/actions/auth.ts
  94. 14 327
      client/src/store/actions/bookmark.ts
  95. 8 133
      client/src/store/actions/config.ts
  96. 86 6
      client/src/store/actions/index.ts
  97. 5 20
      client/src/store/actions/notification.ts
  98. 4 26
      client/src/store/actions/theme.ts
  99. 2 0
      client/src/store/index.ts
  100. 80 106
      client/src/store/reducers/app.ts

+ 1 - 0
Dockerfile → .docker/Dockerfile

@@ -25,5 +25,6 @@ WORKDIR /app
 EXPOSE 5005
 
 ENV NODE_ENV=production
+ENV PASSWORD=flame_password
 
 CMD ["node", "server.js"]

+ 11 - 1
Dockerfile.dev → .docker/Dockerfile.dev

@@ -1,16 +1,26 @@
 FROM node:lts-alpine as build-front
+
 RUN apk add --no-cache curl
+
 WORKDIR /app
+
 COPY ./client .
+
 RUN npm install --production \
     && npm run build
 
 FROM node:lts-alpine
+
 WORKDIR /app
+
 RUN mkdir -p ./public
+
 COPY --from=build-front /app/build/ ./public
 
 COPY package*.json ./
+
 RUN npm install
+
 COPY . .
-CMD ["npm", "run", "skaffold"]
+
+CMD ["npm", "run", "skaffold"]

+ 6 - 4
Dockerfile.multiarch → .docker/Dockerfile.multiarch

@@ -1,10 +1,11 @@
-FROM node:14 as builder
+FROM node:14-alpine3.11 as builder
 
 WORKDIR /app
 
 COPY package*.json ./
 
-RUN npm install --production
+RUN apk --no-cache --virtual build-dependencies add python make g++ \
+    && npm install --production
 
 COPY . .    
 
@@ -16,7 +17,7 @@ RUN mkdir -p ./public ./data \
     && mv ./client/build/* ./public \
     && rm -rf ./client
 
-FROM node:14-alpine
+FROM node:14-alpine3.11
 
 COPY --from=builder /app /app
 
@@ -25,5 +26,6 @@ WORKDIR /app
 EXPOSE 5005
 
 ENV NODE_ENV=production
+ENV PASSWORD=flame_password
 
-CMD ["node", "server.js"]
+CMD ["node", "server.js"]

+ 2 - 0
docker-compose.yml → .docker/docker-compose.yml

@@ -7,4 +7,6 @@ services:
       - /path/to/data:/app/data
     ports:
       - 5005:5005
+    environment:
+      - PASSWORD=flame_password
     restart: unless-stopped

+ 2 - 3
.dockerignore

@@ -1,6 +1,5 @@
 node_modules
-github
+.github
 public
-build.sh
 k8s
-skaffold.yaml
+skaffold.yaml

+ 3 - 1
.env

@@ -1,3 +1,5 @@
 PORT=5005
 NODE_ENV=development
-VERSION=1.7.4
+VERSION=2.0.0
+PASSWORD=flame_password
+SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b

BIN
.github/_apps.png


BIN
.github/_bookmarks.png


BIN
.github/_home.png


BIN
.github/apps.png


BIN
.github/bookmarks.png


BIN
.github/home.png


BIN
.github/settings.png


+ 0 - 0
.github/_themes.png → .github/themes.png


+ 22 - 7
CHANGELOG.md

@@ -1,3 +1,18 @@
+### v2.0.0 (2021-11-15)
+- Added authentication system:
+  - Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33))
+  - User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45))
+  - Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature
+- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110))
+- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138))
+- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157))
+- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158))
+- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164))
+- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170))
+- Added Wikipedia to search queries
+- Updated project wiki
+- Lots of changes and refactors under the hood to make future development easier
+
 ### v1.7.4 (2021-11-08)
 - Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103))
 - Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129))
@@ -62,12 +77,12 @@
 - Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
 - Added changelog file
 
-### v1.6 (2021-07-17)
+### v1.6.0 (2021-07-17)
 - Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
 - Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
 - Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
 
-### v1.5 (2021-06-24)
+### v1.5.0 (2021-06-24)
 - Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
 - Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
 - Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
@@ -75,7 +90,7 @@
 - Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
 - Improved Logger
 
-### v1.4 (2021-06-18)
+### v1.4.0 (2021-06-18)
 - Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
 - Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
 - Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
@@ -84,14 +99,14 @@
 - Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
 - Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
 
-### v1.3 (2021-06-14)
+### v1.3.0 (2021-06-14)
 - Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
 - Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
 - Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
 - Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
 - Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
 
-### v1.2 (2021-06-10)
+### v1.2.0 (2021-06-10)
 - Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
 - Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
 - Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
@@ -100,11 +115,11 @@
 - Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18))
 - Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20))
 
-### v1.1 (2021-06-09)
+### v1.1.0 (2021-06-09)
 - Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
 - Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
 - Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
 - Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
 
-### v1.0 (2021-06-08)
+### v1.0.0 (2021-06-08)
 Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.

+ 61 - 58
README.md

@@ -1,37 +1,19 @@
 # Flame
 
-![Homescreen screenshot](./.github/_home.png)
+![Homescreen screenshot](.github/home.png)
 
 ## Description
 
-Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary.
+Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
 
-## Technology
-
-- Backend
-  - Node.js + Express
-  - Sequelize ORM + SQLite
-- Frontend
-  - React
-  - Redux
-  - TypeScript
-- Deployment
-  - Docker
-  - Kubernetes
-
-## Development
-
-```sh
-# clone repository
-git clone https://github.com/pawelmalak/flame
-cd flame
-
-# run only once
-npm run dev-init
-
-# start backend and frontend development servers
-npm run dev
-```
+## Functionality
+- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
+- 📌 Pin your favourite items to the homescreen for quick and easy access
+- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
+- 🔑 Authentication system to protect your settings, apps and bookmarks
+- 🔨 Dozens of option to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes
+- ☀️ Weather widget with current temperature, cloud coverage and animated weather status
+- 🐳 Docker integration to automatically pick and add apps based on their labels
 
 ## Installation
 
@@ -40,34 +22,36 @@ npm run dev
 [Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
 
 ```sh
-docker pull pawelmalak/flame:latest
+docker pull pawelmalak/flame
 
 # for ARM architecture (e.g. RaspberryPi)
 docker pull pawelmalak/flame:multiarch
+
+# installing specific version
+docker pull pawelmalak/flame:2.0.0
 ```
 
+#### Deployment
+
+```sh
+# run container
+docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password flame
+```
 
 #### Building images
 
 ```sh
 # build image for amd64 only
-docker build -t flame .
+docker build -t flame -f .docker/Dockerfile .
 
 # build multiarch image for amd64, armv7 and arm64
 # building failed multiple times with 2GB memory usage limit so you might want to increase it
 docker buildx build \
   --platform linux/arm/v7,linux/arm64,linux/amd64 \
-  -f Dockerfile.multiarch \
+  -f .docker/Dockerfile.multiarch \
   -t flame:multiarch .
 ```
 
-#### Deployment
-
-```sh
-# run container
-docker run -p 5005:5005 -v /path/to/data:/app/data flame
-```
-
 #### Docker-Compose
 
 ```yaml
@@ -81,6 +65,8 @@ services:
       - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
     ports:
       - 5005:5005
+    environment:
+      - PASSWORD=flame_password
     restart: unless-stopped
 ```
 
@@ -95,39 +81,56 @@ skaffold dev
 
 Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
 
-## Functionality
+## Development
+
+### Technology
+
+- Backend
+  - Node.js + Express
+  - Sequelize ORM + SQLite
+- Frontend
+  - React
+  - Redux
+  - TypeScript
+- Deployment
+  - Docker
+  - Kubernetes
+
+### Creating dev environment
 
-- Applications
-  - Create, update, delete and organize applications using GUI
-  - Pin your favourite apps to the homescreen
+```sh
+# clone repository
+git clone https://github.com/pawelmalak/flame
+cd flame
 
-![Homescreen screenshot](./.github/_apps.png)
+# run only once
+npm run dev-init
 
-- Bookmarks
-  - Create, update, delete and organize bookmarks and categories using GUI
-  - Pin your favourite categories to the homescreen
-  - Import html bookmarks (experimental)
+# start backend and frontend development servers
+npm run dev
+```
 
-![Homescreen screenshot](./.github/_bookmarks.png)
+## Screenshots
 
-- Weather
+![Apps screenshot](.github/apps.png)
 
-  - Get current temperature, cloud coverage and weather status with animated icons
+![Bookmarks screenshot](.github/bookmarks.png)
 
-- Themes
-  - Customize your page by choosing from 15 color themes
+![Settings screenshot](.github/settings.png)
 
-![Homescreen screenshot](./.github/_themes.png)
+![Themes screenshot](.github/themes.png)
 
 ## Usage
 
+### Authentication
+
+Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
+
 ### Search bar
 
 #### Searching
 
-To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
-
-> You can change where to open search results (same/new tab) in the settings
+The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
 
 For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
 
@@ -151,7 +154,7 @@ labels:
 # - flame.icon=custom to make changes in app. ie: custom icon upload
 ```
 
-> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section
+> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker
 
 You can also set up different apps in the same label adding `;` between each one.
 
@@ -199,7 +202,7 @@ metadata:
   - flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
 ```
 
-> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section
+> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
 
 ### Import HTML Bookmarks (Experimental)
 

+ 2 - 1
api.js

@@ -1,6 +1,6 @@
 const { join } = require('path');
 const express = require('express');
-const errorHandler = require('./middleware/errorHandler');
+const { errorHandler } = require('./middleware');
 
 const api = express();
 
@@ -21,6 +21,7 @@ api.use('/api/weather', require('./routes/weather'));
 api.use('/api/categories', require('./routes/category'));
 api.use('/api/bookmarks', require('./routes/bookmark'));
 api.use('/api/queries', require('./routes/queries'));
+api.use('/api/auth', require('./routes/auth'));
 
 // Custom error handler
 api.use(errorHandler);

+ 1 - 1
client/.env

@@ -1 +1 @@
-REACT_APP_VERSION=1.7.4
+REACT_APP_VERSION=2.0.0

+ 14 - 9
client/package-lock.json

@@ -9876,6 +9876,11 @@
         "object.assign": "^4.1.2"
       }
     },
+    "jwt-decode": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
+      "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
+    },
     "killable": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@@ -11123,9 +11128,9 @@
       "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
     },
     "path-parse": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
-      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
     },
     "path-to-regexp": {
       "version": "0.1.7",
@@ -14729,9 +14734,9 @@
       "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
     },
     "tar": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
-      "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
+      "version": "6.1.11",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+      "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
       "requires": {
         "chownr": "^2.0.0",
         "fs-minipass": "^2.0.0",
@@ -14977,9 +14982,9 @@
       "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
     },
     "tmpl": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
-      "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+      "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
     },
     "to-arraybuffer": {
       "version": "1.0.1",

+ 1 - 0
client/package.json

@@ -19,6 +19,7 @@
     "axios": "^0.24.0",
     "external-svg-loader": "^1.3.4",
     "http-proxy-middleware": "^2.0.1",
+    "jwt-decode": "^3.1.2",
     "react": "^17.0.2",
     "react-autosuggest": "^10.1.0",
     "react-beautiful-dnd": "^13.1.0",

+ 51 - 24
client/src/App.tsx

@@ -1,38 +1,67 @@
 import { BrowserRouter, Route, Switch } from 'react-router-dom';
-import { fetchQueries, getConfig, setTheme } from './store/actions';
+import { autoLogin, getConfig } from './store/action-creators';
+import { actionCreators, store } from './store';
 import 'external-svg-loader';
 
-// Redux
-import { store } from './store/store';
-import { Provider } from 'react-redux';
-
 // Utils
-import { checkVersion } from './utility';
+import { checkVersion, decodeToken } from './utility';
 
 // Routes
-import Home from './components/Home/Home';
-import Apps from './components/Apps/Apps';
-import Settings from './components/Settings/Settings';
-import Bookmarks from './components/Bookmarks/Bookmarks';
-import NotificationCenter from './components/NotificationCenter/NotificationCenter';
+import { Home } from './components/Home/Home';
+import { Apps } from './components/Apps/Apps';
+import { Settings } from './components/Settings/Settings';
+import { Bookmarks } from './components/Bookmarks/Bookmarks';
+import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { useEffect } from 'react';
 
-// Load config
+// Get config
 store.dispatch<any>(getConfig());
 
-// Set theme
-if (localStorage.theme) {
-  store.dispatch<any>(setTheme(localStorage.theme));
+// Validate token
+if (localStorage.token) {
+  store.dispatch<any>(autoLogin());
 }
 
-// Check for updates
-checkVersion();
+export const App = (): JSX.Element => {
+  const dispath = useDispatch();
+  const { fetchQueries, setTheme, logout, createNotification } =
+    bindActionCreators(actionCreators, dispath);
+
+  useEffect(() => {
+    // check if token is valid
+    const tokenIsValid = setInterval(() => {
+      if (localStorage.token) {
+        const expiresIn = decodeToken(localStorage.token).exp * 1000;
+        const now = new Date().getTime();
+
+        if (now > expiresIn) {
+          logout();
+          createNotification({
+            title: 'Info',
+            message: 'Session expired. You have been logged out',
+          });
+        }
+      }
+    }, 1000);
+
+    // set theme
+    if (localStorage.theme) {
+      setTheme(localStorage.theme);
+    }
 
-// fetch queries
-store.dispatch<any>(fetchQueries());
+    // check for updated
+    checkVersion();
+
+    // load custom search queries
+    fetchQueries();
+
+    return () => window.clearInterval(tokenIsValid);
+  }, []);
 
-const App = (): JSX.Element => {
   return (
-    <Provider store={store}>
+    <>
       <BrowserRouter>
         <Switch>
           <Route exact path="/" component={Home} />
@@ -42,8 +71,6 @@ const App = (): JSX.Element => {
         </Switch>
       </BrowserRouter>
       <NotificationCenter />
-    </Provider>
+    </>
   );
 };
-
-export default App;

+ 18 - 20
client/src/components/Apps/AppCard/AppCard.tsx

@@ -1,35 +1,41 @@
 import classes from './AppCard.module.css';
-import Icon from '../../UI/Icons/Icon/Icon';
-import { iconParser, urlParser } from '../../../utility';
+import { Icon } from '../../UI';
+import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
 
-import { App, Config, GlobalState } from '../../../interfaces';
-import { connect } from 'react-redux';
+import { App } from '../../../interfaces';
+import { useSelector } from 'react-redux';
+import { State } from '../../../store/reducers';
 
-interface ComponentProps {
+interface Props {
   app: App;
   pinHandler?: Function;
-  config: Config;
 }
 
-const AppCard = (props: ComponentProps): JSX.Element => {
+export const AppCard = (props: Props): JSX.Element => {
+  const { config } = useSelector((state: State) => state.config);
+
   const [displayUrl, redirectUrl] = urlParser(props.app.url);
 
   let iconEl: JSX.Element;
   const { icon } = props.app;
 
-  if (/.(jpeg|jpg|png)$/i.test(icon)) {
+  if (isImage(icon)) {
+    const source = isUrl(icon) ? icon : `/uploads/${icon}`;
+
     iconEl = (
       <img
-        src={`/uploads/${icon}`}
+        src={source}
         alt={`${props.app.name} icon`}
         className={classes.CustomIcon}
       />
     );
-  } else if (/.(svg)$/i.test(icon)) {
+  } else if (isSvg(icon)) {
+    const source = isUrl(icon) ? icon : `/uploads/${icon}`;
+
     iconEl = (
       <div className={classes.CustomIcon}>
         <svg
-          data-src={`/uploads/${icon}`}
+          data-src={source}
           fill="var(--color-primary)"
           className={classes.CustomIcon}
         ></svg>
@@ -42,7 +48,7 @@ const AppCard = (props: ComponentProps): JSX.Element => {
   return (
     <a
       href={redirectUrl}
-      target={props.config.appsSameTab ? '' : '_blank'}
+      target={config.appsSameTab ? '' : '_blank'}
       rel="noreferrer"
       className={classes.AppCard}
     >
@@ -54,11 +60,3 @@ const AppCard = (props: ComponentProps): JSX.Element => {
     </a>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps)(AppCard);

+ 55 - 57
client/src/components/Apps/AppForm/AppForm.tsx

@@ -1,50 +1,46 @@
 import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
-import { connect } from 'react-redux';
-import { addApp, updateApp } from '../../../store/actions';
+import { useDispatch } from 'react-redux';
 import { App, NewApp } from '../../../interfaces';
 
 import classes from './AppForm.module.css';
 
-import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
-import Button from '../../UI/Buttons/Button/Button';
+import { ModalForm, InputGroup, Button } from '../../UI';
+import { inputHandler, newAppTemplate } from '../../../utility';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
 
-interface ComponentProps {
+interface Props {
   modalHandler: () => void;
-  addApp: (formData: NewApp | FormData) => any;
-  updateApp: (id: number, formData: NewApp | FormData) => any;
   app?: App;
 }
 
-const AppForm = (props: ComponentProps): JSX.Element => {
+export const AppForm = ({ app, modalHandler }: Props): JSX.Element => {
+  const dispatch = useDispatch();
+  const { addApp, updateApp } = bindActionCreators(actionCreators, dispatch);
+
   const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
   const [customIcon, setCustomIcon] = useState<File | null>(null);
-  const [formData, setFormData] = useState<NewApp>({
-    name: '',
-    url: '',
-    icon: '',
-  });
+  const [formData, setFormData] = useState<NewApp>(newAppTemplate);
 
   useEffect(() => {
-    if (props.app) {
+    if (app) {
       setFormData({
-        name: props.app.name,
-        url: props.app.url,
-        icon: props.app.icon,
+        ...app,
       });
     } else {
-      setFormData({
-        name: '',
-        url: '',
-        icon: '',
-      });
+      setFormData(newAppTemplate);
     }
-  }, [props.app]);
+  }, [app]);
 
-  const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
-    setFormData({
-      ...formData,
-      [e.target.name]: e.target.value,
+  const inputChangeHandler = (
+    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+    options?: { isNumber?: boolean; isBool?: boolean }
+  ) => {
+    inputHandler<NewApp>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
     });
   };
 
@@ -65,39 +61,34 @@ const AppForm = (props: ComponentProps): JSX.Element => {
       }
       data.append('name', formData.name);
       data.append('url', formData.url);
+      data.append('isPublic', `${formData.isPublic}`);
 
       return data;
     };
 
-    if (!props.app) {
+    if (!app) {
       if (customIcon) {
         const data = createFormData();
-        props.addApp(data);
+        addApp(data);
       } else {
-        props.addApp(formData);
+        addApp(formData);
       }
     } else {
       if (customIcon) {
         const data = createFormData();
-        props.updateApp(props.app.id, data);
+        updateApp(app.id, data);
       } else {
-        props.updateApp(props.app.id, formData);
-        props.modalHandler();
+        updateApp(app.id, formData);
+        modalHandler();
       }
     }
 
-    setFormData({
-      name: '',
-      url: '',
-      icon: '',
-    });
+    setFormData(newAppTemplate);
   };
 
   return (
-    <ModalForm
-      modalHandler={props.modalHandler}
-      formHandler={formSubmitHandler}
-    >
+    <ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
+      {/* NAME */}
       <InputGroup>
         <label htmlFor="name">App Name</label>
         <input
@@ -110,6 +101,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
           onChange={(e) => inputChangeHandler(e)}
         />
       </InputGroup>
+
+      {/* URL */}
       <InputGroup>
         <label htmlFor="url">App URL</label>
         <input
@@ -121,17 +114,9 @@ const AppForm = (props: ComponentProps): JSX.Element => {
           value={formData.url}
           onChange={(e) => inputChangeHandler(e)}
         />
-        <span>
-          <a
-            href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
-            target="_blank"
-            rel="noreferrer"
-          >
-            {' '}
-            Check supported URL formats
-          </a>
-        </span>
       </InputGroup>
+
+      {/* ICON */}
       {!useCustomIcon ? (
         // use mdi icon
         <InputGroup>
@@ -146,7 +131,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
             onChange={(e) => inputChangeHandler(e)}
           />
           <span>
-            Use icon name from MDI.
+            Use icon name from MDI or pass a valid URL.
             <a href="https://materialdesignicons.com/" target="blank">
               {' '}
               Click here for reference
@@ -182,7 +167,22 @@ const AppForm = (props: ComponentProps): JSX.Element => {
           </span>
         </InputGroup>
       )}
-      {!props.app ? (
+
+      {/* VISIBILITY */}
+      <InputGroup>
+        <label htmlFor="isPublic">App visibility</label>
+        <select
+          id="isPublic"
+          name="isPublic"
+          value={formData.isPublic ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>Visible (anyone can access it)</option>
+          <option value={0}>Hidden (authentication required)</option>
+        </select>
+      </InputGroup>
+
+      {!app ? (
         <Button>Add new application</Button>
       ) : (
         <Button>Update application</Button>
@@ -190,5 +190,3 @@ const AppForm = (props: ComponentProps): JSX.Element => {
     </ModalForm>
   );
 };
-
-export default connect(null, { addApp, updateApp })(AppForm);

+ 3 - 5
client/src/components/Apps/AppGrid/AppGrid.tsx

@@ -2,15 +2,15 @@ import classes from './AppGrid.module.css';
 import { Link } from 'react-router-dom';
 import { App } from '../../../interfaces/App';
 
-import AppCard from '../AppCard/AppCard';
+import { AppCard } from '../AppCard/AppCard';
 
-interface ComponentProps {
+interface Props {
   apps: App[];
   totalApps?: number;
   searching: boolean;
 }
 
-const AppGrid = (props: ComponentProps): JSX.Element => {
+export const AppGrid = (props: Props): JSX.Element => {
   let apps: JSX.Element;
 
   if (props.apps.length > 0) {
@@ -49,5 +49,3 @@ const AppGrid = (props: ComponentProps): JSX.Element => {
 
   return apps;
 };
-
-export default AppGrid;

+ 30 - 47
client/src/components/Apps/AppTable/AppTable.tsx

@@ -8,48 +8,45 @@ import {
 import { Link } from 'react-router-dom';
 
 // Redux
-import { connect } from 'react-redux';
-import {
-  pinApp,
-  deleteApp,
-  reorderApps,
-  updateConfig,
-  createNotification,
-} from '../../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
 
 // Typescript
-import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
+import { App } from '../../../interfaces';
 
 // CSS
 import classes from './AppTable.module.css';
 
 // UI
-import Icon from '../../UI/Icons/Icon/Icon';
-import Table from '../../UI/Table/Table';
-
-interface ComponentProps {
-  apps: App[];
-  config: Config;
-  pinApp: (app: App) => void;
-  deleteApp: (id: number) => void;
+import { Icon, Table } from '../../UI';
+import { State } from '../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
+
+interface Props {
   updateAppHandler: (app: App) => void;
-  reorderApps: (apps: App[]) => void;
-  updateConfig: (formData: any) => void;
-  createNotification: (notification: NewNotification) => void;
 }
 
-const AppTable = (props: ComponentProps): JSX.Element => {
+export const AppTable = (props: Props): JSX.Element => {
+  const {
+    apps: { apps },
+    config: { config },
+  } = useSelector((state: State) => state);
+
+  const dispatch = useDispatch();
+  const { pinApp, deleteApp, reorderApps, updateConfig, createNotification } =
+    bindActionCreators(actionCreators, dispatch);
+
   const [localApps, setLocalApps] = useState<App[]>([]);
   const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
 
   // Copy apps array
   useEffect(() => {
-    setLocalApps([...props.apps]);
-  }, [props.apps]);
+    setLocalApps([...apps]);
+  }, [apps]);
 
   // Check ordering
   useEffect(() => {
-    const order = props.config.useOrdering;
+    const order = config.useOrdering;
 
     if (order === 'orderId') {
       setIsCustomOrder(true);
@@ -62,7 +59,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
     );
 
     if (proceed) {
-      props.deleteApp(app.id);
+      deleteApp(app.id);
     }
   };
 
@@ -79,7 +76,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
 
   const dragEndHanlder = (result: DropResult): void => {
     if (!isCustomOrder) {
-      props.createNotification({
+      createNotification({
         title: 'Error',
         message: 'Custom order is disabled',
       });
@@ -95,7 +92,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
     tmpApps.splice(result.destination.index, 0, movedApp);
 
     setLocalApps(tmpApps);
-    props.reorderApps(tmpApps);
+    reorderApps(tmpApps);
   };
 
   return (
@@ -114,7 +111,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
         <Droppable droppableId="apps">
           {(provided) => (
             <Table
-              headers={['Name', 'URL', 'Icon', 'Actions']}
+              headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
               innerRef={provided.innerRef}
             >
               {localApps.map((app: App, index): JSX.Element => {
@@ -143,6 +140,9 @@ const AppTable = (props: ComponentProps): JSX.Element => {
                           <td style={{ width: '200px' }}>{app.name}</td>
                           <td style={{ width: '200px' }}>{app.url}</td>
                           <td style={{ width: '200px' }}>{app.icon}</td>
+                          <td style={{ width: '200px' }}>
+                            {app.isPublic ? 'Visible' : 'Hidden'}
+                          </td>
                           {!snapshot.isDragging && (
                             <td className={classes.TableActions}>
                               <div
@@ -175,9 +175,9 @@ const AppTable = (props: ComponentProps): JSX.Element => {
                               </div>
                               <div
                                 className={classes.TableAction}
-                                onClick={() => props.pinApp(app)}
+                                onClick={() => pinApp(app)}
                                 onKeyDown={(e) =>
-                                  keyboardActionHandler(e, app, props.pinApp)
+                                  keyboardActionHandler(e, app, pinApp)
                                 }
                                 tabIndex={0}
                               >
@@ -205,20 +205,3 @@ const AppTable = (props: ComponentProps): JSX.Element => {
     </Fragment>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    apps: state.app.apps,
-    config: state.config.config,
-  };
-};
-
-const actions = {
-  pinApp,
-  deleteApp,
-  reorderApps,
-  updateConfig,
-  createNotification,
-};
-
-export default connect(mapStateToProps, actions)(AppTable);

+ 40 - 44
client/src/components/Apps/Apps.tsx

@@ -2,56 +2,59 @@ import { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
 
 // Redux
-import { connect } from 'react-redux';
-import { getApps } from '../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
 
 // Typescript
-import { App, GlobalState } from '../../interfaces';
+import { App } from '../../interfaces';
 
 // CSS
 import classes from './Apps.module.css';
 
 // UI
-import { Container } from '../UI/Layout/Layout';
-import Headline from '../UI/Headlines/Headline/Headline';
-import Spinner from '../UI/Spinner/Spinner';
-import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
-import Modal from '../UI/Modal/Modal';
+import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
 
 // Subcomponents
-import AppGrid from './AppGrid/AppGrid';
-import AppForm from './AppForm/AppForm';
-import AppTable from './AppTable/AppTable';
-
-interface ComponentProps {
-  getApps: Function;
-  apps: App[];
-  loading: boolean;
+import { AppGrid } from './AppGrid/AppGrid';
+import { AppForm } from './AppForm/AppForm';
+import { AppTable } from './AppTable/AppTable';
+
+// Utils
+import { appTemplate } from '../../utility';
+import { State } from '../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../store';
+
+interface Props {
   searching: boolean;
 }
 
-const Apps = (props: ComponentProps): JSX.Element => {
-  const { getApps, apps, loading, searching = false } = props;
+export const Apps = (props: Props): JSX.Element => {
+  const {
+    apps: { apps, loading },
+    auth: { isAuthenticated },
+  } = useSelector((state: State) => state);
+
+  const dispatch = useDispatch();
+  const { getApps } = bindActionCreators(actionCreators, dispatch);
 
   const [modalIsOpen, setModalIsOpen] = useState(false);
   const [isInEdit, setIsInEdit] = useState(false);
   const [isInUpdate, setIsInUpdate] = useState(false);
-  const [appInUpdate, setAppInUpdate] = useState<App>({
-    name: 'string',
-    url: 'string',
-    icon: 'string',
-    isPinned: false,
-    orderId: 0,
-    id: 0,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-  });
+  const [appInUpdate, setAppInUpdate] = useState<App>(appTemplate);
 
   useEffect(() => {
-    if (apps.length === 0) {
+    if (!apps.length) {
       getApps();
     }
-  }, [getApps]);
+  }, []);
+
+  // observe if user is authenticated -> set default view if not
+  useEffect(() => {
+    if (!isAuthenticated) {
+      setIsInEdit(false);
+      setModalIsOpen(false);
+    }
+  }, [isAuthenticated]);
 
   const toggleModal = (): void => {
     setModalIsOpen(!modalIsOpen);
@@ -84,16 +87,18 @@ const Apps = (props: ComponentProps): JSX.Element => {
         subtitle={<Link to="/">Go back</Link>}
       />
 
-      <div className={classes.ActionsContainer}>
-        <ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
-        <ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
-      </div>
+      {isAuthenticated && (
+        <div className={classes.ActionsContainer}>
+          <ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
+          <ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
+        </div>
+      )}
 
       <div className={classes.Apps}>
         {loading ? (
           <Spinner />
         ) : !isInEdit ? (
-          <AppGrid apps={apps} searching />
+          <AppGrid apps={apps} searching={props.searching} />
         ) : (
           <AppTable updateAppHandler={toggleUpdate} />
         )}
@@ -101,12 +106,3 @@ const Apps = (props: ComponentProps): JSX.Element => {
     </Container>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    apps: state.app.apps,
-    loading: state.app.loading,
-  };
-};
-
-export default connect(mapStateToProps, { getApps })(Apps);

+ 23 - 21
client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx

@@ -1,17 +1,23 @@
-import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
+import { Fragment } from 'react';
+
+import { useSelector } from 'react-redux';
+import { State } from '../../../store/reducers';
+
+import { Bookmark, Category } from '../../../interfaces';
+
 import classes from './BookmarkCard.module.css';
 
-import Icon from '../../UI/Icons/Icon/Icon';
-import { iconParser, urlParser } from '../../../utility';
-import { Fragment } from 'react';
-import { connect } from 'react-redux';
+import { Icon } from '../../UI';
+
+import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
 
-interface ComponentProps {
+interface Props {
   category: Category;
-  config: Config;
 }
 
-const BookmarkCard = (props: ComponentProps): JSX.Element => {
+export const BookmarkCard = (props: Props): JSX.Element => {
+  const { config } = useSelector((state: State) => state.config);
+
   return (
     <div className={classes.BookmarkCard}>
       <h3>{props.category.name}</h3>
@@ -24,21 +30,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
           if (bookmark.icon) {
             const { icon, name } = bookmark;
 
-            if (/.(jpeg|jpg|png)$/i.test(icon)) {
+            if (isImage(icon)) {
+              const source = isUrl(icon) ? icon : `/uploads/${icon}`;
+
               iconEl = (
                 <div className={classes.BookmarkIcon}>
                   <img
-                    src={`/uploads/${icon}`}
+                    src={source}
                     alt={`${name} icon`}
                     className={classes.CustomIcon}
                   />
                 </div>
               );
-            } else if (/.(svg)$/i.test(icon)) {
+            } else if (isSvg(icon)) {
+              const source = isUrl(icon) ? icon : `/uploads/${icon}`;
+
               iconEl = (
                 <div className={classes.BookmarkIcon}>
                   <svg
-                    data-src={`/uploads/${icon}`}
+                    data-src={source}
                     fill="var(--color-primary)"
                     className={classes.BookmarkIconSvg}
                   ></svg>
@@ -56,7 +66,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
           return (
             <a
               href={redirectUrl}
-              target={props.config.bookmarksSameTab ? '' : '_blank'}
+              target={config.bookmarksSameTab ? '' : '_blank'}
               rel="noreferrer"
               key={`bookmark-${bookmark.id}`}
             >
@@ -69,11 +79,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
     </div>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps)(BookmarkCard);

+ 0 - 363
client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx

@@ -1,363 +0,0 @@
-// React
-import {
-  useState,
-  SyntheticEvent,
-  Fragment,
-  ChangeEvent,
-  useEffect,
-} from 'react';
-
-// Redux
-import { connect } from 'react-redux';
-import {
-  getCategories,
-  addCategory,
-  addBookmark,
-  updateCategory,
-  updateBookmark,
-  createNotification,
-} from '../../../store/actions';
-
-// Typescript
-import {
-  Bookmark,
-  Category,
-  GlobalState,
-  NewBookmark,
-  NewCategory,
-  NewNotification,
-} from '../../../interfaces';
-import { ContentType } from '../Bookmarks';
-
-// UI
-import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
-import Button from '../../UI/Buttons/Button/Button';
-
-// CSS
-import classes from './BookmarkForm.module.css';
-
-interface ComponentProps {
-  modalHandler: () => void;
-  contentType: ContentType;
-  categories: Category[];
-  category?: Category;
-  bookmark?: Bookmark;
-  addCategory: (formData: NewCategory) => void;
-  addBookmark: (formData: NewBookmark | FormData) => void;
-  updateCategory: (id: number, formData: NewCategory) => void;
-  updateBookmark: (
-    id: number,
-    formData: NewBookmark | FormData,
-    category: {
-      prev: number;
-      curr: number;
-    }
-  ) => void;
-  createNotification: (notification: NewNotification) => void;
-}
-
-const BookmarkForm = (props: ComponentProps): JSX.Element => {
-  const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
-  const [customIcon, setCustomIcon] = useState<File | null>(null);
-  const [categoryName, setCategoryName] = useState<NewCategory>({
-    name: '',
-  });
-
-  const [formData, setFormData] = useState<NewBookmark>({
-    name: '',
-    url: '',
-    categoryId: -1,
-    icon: '',
-  });
-
-  // Load category data if provided for editing
-  useEffect(() => {
-    if (props.category) {
-      setCategoryName({ name: props.category.name });
-    } else {
-      setCategoryName({ name: '' });
-    }
-  }, [props.category]);
-
-  // Load bookmark data if provided for editing
-  useEffect(() => {
-    if (props.bookmark) {
-      setFormData({
-        name: props.bookmark.name,
-        url: props.bookmark.url,
-        categoryId: props.bookmark.categoryId,
-        icon: props.bookmark.icon,
-      });
-    } else {
-      setFormData({
-        name: '',
-        url: '',
-        categoryId: -1,
-        icon: '',
-      });
-    }
-  }, [props.bookmark]);
-
-  const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
-    e.preventDefault();
-
-    const createFormData = (): FormData => {
-      const data = new FormData();
-      if (customIcon) {
-        data.append('icon', customIcon);
-      }
-      data.append('name', formData.name);
-      data.append('url', formData.url);
-      data.append('categoryId', `${formData.categoryId}`);
-
-      return data;
-    };
-
-    if (!props.category && !props.bookmark) {
-      // Add new
-      if (props.contentType === ContentType.category) {
-        // Add category
-        props.addCategory(categoryName);
-        setCategoryName({ name: '' });
-      } else if (props.contentType === ContentType.bookmark) {
-        // Add bookmark
-        if (formData.categoryId === -1) {
-          props.createNotification({
-            title: 'Error',
-            message: 'Please select category',
-          });
-          return;
-        }
-
-        if (customIcon) {
-          const data = createFormData();
-          props.addBookmark(data);
-        } else {
-          props.addBookmark(formData);
-        }
-
-        setFormData({
-          name: '',
-          url: '',
-          categoryId: formData.categoryId,
-          icon: '',
-        });
-
-        // setCustomIcon(null);
-      }
-    } else {
-      // Update
-      if (props.contentType === ContentType.category && props.category) {
-        // Update category
-        props.updateCategory(props.category.id, categoryName);
-        setCategoryName({ name: '' });
-      } else if (props.contentType === ContentType.bookmark && props.bookmark) {
-        // Update bookmark
-        if (customIcon) {
-          const data = createFormData();
-          props.updateBookmark(props.bookmark.id, data, {
-            prev: props.bookmark.categoryId,
-            curr: formData.categoryId,
-          });
-        } else {
-          props.updateBookmark(props.bookmark.id, formData, {
-            prev: props.bookmark.categoryId,
-            curr: formData.categoryId,
-          });
-        }
-
-        setFormData({
-          name: '',
-          url: '',
-          categoryId: -1,
-          icon: '',
-        });
-
-        setCustomIcon(null);
-      }
-
-      props.modalHandler();
-    }
-  };
-
-  const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
-    setFormData({
-      ...formData,
-      [e.target.name]: e.target.value,
-    });
-  };
-
-  const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
-    setFormData({
-      ...formData,
-      categoryId: parseInt(e.target.value),
-    });
-  };
-
-  const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
-    if (e.target.files) {
-      setCustomIcon(e.target.files[0]);
-    }
-  };
-
-  let button = <Button>Submit</Button>;
-
-  if (!props.category && !props.bookmark) {
-    if (props.contentType === ContentType.category) {
-      button = <Button>Add new category</Button>;
-    } else {
-      button = <Button>Add new bookmark</Button>;
-    }
-  } else if (props.category) {
-    button = <Button>Update category</Button>;
-  } else if (props.bookmark) {
-    button = <Button>Update bookmark</Button>;
-  }
-
-  return (
-    <ModalForm
-      modalHandler={props.modalHandler}
-      formHandler={formSubmitHandler}
-    >
-      {props.contentType === ContentType.category ? (
-        <Fragment>
-          <InputGroup>
-            <label htmlFor="categoryName">Category Name</label>
-            <input
-              type="text"
-              name="categoryName"
-              id="categoryName"
-              placeholder="Social Media"
-              required
-              value={categoryName.name}
-              onChange={(e) => setCategoryName({ name: e.target.value })}
-            />
-          </InputGroup>
-        </Fragment>
-      ) : (
-        <Fragment>
-          <InputGroup>
-            <label htmlFor="name">Bookmark Name</label>
-            <input
-              type="text"
-              name="name"
-              id="name"
-              placeholder="Reddit"
-              required
-              value={formData.name}
-              onChange={(e) => inputChangeHandler(e)}
-            />
-          </InputGroup>
-          <InputGroup>
-            <label htmlFor="url">Bookmark URL</label>
-            <input
-              type="text"
-              name="url"
-              id="url"
-              placeholder="reddit.com"
-              required
-              value={formData.url}
-              onChange={(e) => inputChangeHandler(e)}
-            />
-            <span>
-              <a
-                href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
-                target="_blank"
-                rel="noreferrer"
-              >
-                {' '}
-                Check supported URL formats
-              </a>
-            </span>
-          </InputGroup>
-          <InputGroup>
-            <label htmlFor="categoryId">Bookmark Category</label>
-            <select
-              name="categoryId"
-              id="categoryId"
-              required
-              onChange={(e) => selectChangeHandler(e)}
-              value={formData.categoryId}
-            >
-              <option value={-1}>Select category</option>
-              {props.categories.map((category: Category): JSX.Element => {
-                return (
-                  <option key={category.id} value={category.id}>
-                    {category.name}
-                  </option>
-                );
-              })}
-            </select>
-          </InputGroup>
-          {!useCustomIcon ? (
-            // mdi
-            <InputGroup>
-              <label htmlFor="icon">Bookmark Icon (optional)</label>
-              <input
-                type="text"
-                name="icon"
-                id="icon"
-                placeholder="book-open-outline"
-                value={formData.icon}
-                onChange={(e) => inputChangeHandler(e)}
-              />
-              <span>
-                Use icon name from MDI.
-                <a href="https://materialdesignicons.com/" target="blank">
-                  {' '}
-                  Click here for reference
-                </a>
-              </span>
-              <span
-                onClick={() => toggleUseCustomIcon(!useCustomIcon)}
-                className={classes.Switch}
-              >
-                Switch to custom icon upload
-              </span>
-            </InputGroup>
-          ) : (
-            // custom
-            <InputGroup>
-              <label htmlFor="icon">Bookmark Icon (optional)</label>
-              <input
-                type="file"
-                name="icon"
-                id="icon"
-                onChange={(e) => fileChangeHandler(e)}
-                accept=".jpg,.jpeg,.png,.svg"
-              />
-              <span
-                onClick={() => {
-                  setCustomIcon(null);
-                  toggleUseCustomIcon(!useCustomIcon);
-                }}
-                className={classes.Switch}
-              >
-                Switch to MDI
-              </span>
-            </InputGroup>
-          )}
-        </Fragment>
-      )}
-      {button}
-    </ModalForm>
-  );
-};
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    categories: state.bookmark.categories,
-  };
-};
-
-const dispatchMap = {
-  getCategories,
-  addCategory,
-  addBookmark,
-  updateCategory,
-  updateBookmark,
-  createNotification,
-};
-
-export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

+ 5 - 7
client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx

@@ -4,19 +4,19 @@ import classes from './BookmarkGrid.module.css';
 
 import { Category } from '../../../interfaces';
 
-import BookmarkCard from '../BookmarkCard/BookmarkCard';
+import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
 
-interface ComponentProps {
+interface Props {
   categories: Category[];
   totalCategories?: number;
   searching: boolean;
 }
 
-const BookmarkGrid = (props: ComponentProps): JSX.Element => {
+export const BookmarkGrid = (props: Props): JSX.Element => {
   let bookmarks: JSX.Element;
 
-  if (props.categories.length > 0) {
-    if (props.searching && props.categories[0].bookmarks.length === 0) {
+  if (props.categories.length) {
+    if (props.searching && !props.categories[0].bookmarks.length) {
       bookmarks = (
         <p className={classes.BookmarksMessage}>
           No bookmarks match your search criteria
@@ -53,5 +53,3 @@ const BookmarkGrid = (props: ComponentProps): JSX.Element => {
 
   return bookmarks;
 };
-
-export default BookmarkGrid;

+ 40 - 51
client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx

@@ -8,45 +8,39 @@ import {
 import { Link } from 'react-router-dom';
 
 // Redux
-import { connect } from 'react-redux';
-import {
-  pinCategory,
-  deleteCategory,
-  deleteBookmark,
-  createNotification,
-  reorderCategories,
-} from '../../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
 
 // Typescript
-import {
-  Bookmark,
-  Category,
-  Config,
-  GlobalState,
-  NewNotification,
-} from '../../../interfaces';
+import { Bookmark, Category } from '../../../interfaces';
 import { ContentType } from '../Bookmarks';
 
 // CSS
 import classes from './BookmarkTable.module.css';
 
 // UI
-import Table from '../../UI/Table/Table';
-import Icon from '../../UI/Icons/Icon/Icon';
+import { Table, Icon } from '../../UI';
 
-interface ComponentProps {
+interface Props {
   contentType: ContentType;
   categories: Category[];
-  config: Config;
-  pinCategory: (category: Category) => void;
-  deleteCategory: (id: number) => void;
   updateHandler: (data: Category | Bookmark) => void;
-  deleteBookmark: (bookmarkId: number, categoryId: number) => void;
-  createNotification: (notification: NewNotification) => void;
-  reorderCategories: (categories: Category[]) => void;
 }
 
-const BookmarkTable = (props: ComponentProps): JSX.Element => {
+export const BookmarkTable = (props: Props): JSX.Element => {
+  const { config } = useSelector((state: State) => state.config);
+
+  const dispatch = useDispatch();
+  const {
+    pinCategory,
+    deleteCategory,
+    deleteBookmark,
+    createNotification,
+    reorderCategories,
+  } = bindActionCreators(actionCreators, dispatch);
+
   const [localCategories, setLocalCategories] = useState<Category[]>([]);
   const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
 
@@ -57,7 +51,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
 
   // Check ordering
   useEffect(() => {
-    const order = props.config.useOrdering;
+    const order = config.useOrdering;
 
     if (order === 'orderId') {
       setIsCustomOrder(true);
@@ -70,7 +64,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
     );
 
     if (proceed) {
-      props.deleteCategory(category.id);
+      deleteCategory(category.id);
     }
   };
 
@@ -80,7 +74,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
     );
 
     if (proceed) {
-      props.deleteBookmark(bookmark.id, bookmark.categoryId);
+      deleteBookmark(bookmark.id, bookmark.categoryId);
     }
   };
 
@@ -96,7 +90,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
 
   const dragEndHanlder = (result: DropResult): void => {
     if (!isCustomOrder) {
-      props.createNotification({
+      createNotification({
         title: 'Error',
         message: 'Custom order is disabled',
       });
@@ -112,7 +106,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
     tmpCategories.splice(result.destination.index, 0, movedApp);
 
     setLocalCategories(tmpCategories);
-    props.reorderCategories(tmpCategories);
+    reorderCategories(tmpCategories);
   };
 
   if (props.contentType === ContentType.category) {
@@ -131,7 +125,10 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
         <DragDropContext onDragEnd={dragEndHanlder}>
           <Droppable droppableId="categories">
             {(provided) => (
-              <Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
+              <Table
+                headers={['Name', 'Visibility', 'Actions']}
+                innerRef={provided.innerRef}
+              >
                 {localCategories.map(
                   (category: Category, index): JSX.Element => {
                     return (
@@ -156,7 +153,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
                               ref={provided.innerRef}
                               style={style}
                             >
-                              <td>{category.name}</td>
+                              <td style={{ width: '300px' }}>
+                                {category.name}
+                              </td>
+                              <td style={{ width: '300px' }}>
+                                {category.isPublic ? 'Visible' : 'Hidden'}
+                              </td>
                               {!snapshot.isDragging && (
                                 <td className={classes.TableActions}>
                                   <div
@@ -186,12 +188,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
                                   </div>
                                   <div
                                     className={classes.TableAction}
-                                    onClick={() => props.pinCategory(category)}
+                                    onClick={() => pinCategory(category)}
                                     onKeyDown={(e) =>
                                       keyboardActionHandler(
                                         e,
                                         category,
-                                        props.pinCategory
+                                        pinCategory
                                       )
                                     }
                                     tabIndex={0}
@@ -232,7 +234,9 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
     });
 
     return (
-      <Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
+      <Table
+        headers={['Name', 'URL', 'Icon', 'Visibility', 'Category', 'Actions']}
+      >
         {bookmarks.map(
           (bookmark: { bookmark: Bookmark; categoryName: string }) => {
             return (
@@ -240,6 +244,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
                 <td>{bookmark.bookmark.name}</td>
                 <td>{bookmark.bookmark.url}</td>
                 <td>{bookmark.bookmark.icon}</td>
+                <td>{bookmark.bookmark.isPublic ? 'Visible' : 'Hidden'}</td>
                 <td>{bookmark.categoryName}</td>
                 <td className={classes.TableActions}>
                   <div
@@ -265,19 +270,3 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
     );
   }
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    config: state.config.config,
-  };
-};
-
-const actions = {
-  pinCategory,
-  deleteCategory,
-  deleteBookmark,
-  createNotification,
-  reorderCategories,
-};
-
-export default connect(mapStateToProps, actions)(BookmarkTable);

+ 76 - 89
client/src/components/Bookmarks/Bookmarks.tsx

@@ -1,25 +1,30 @@
 import { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
-import { connect } from 'react-redux';
-import { getCategories } from '../../store/actions';
 
+// Redux
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../store';
+
+// Typescript
+import { Category, Bookmark } from '../../interfaces';
+
+// CSS
 import classes from './Bookmarks.module.css';
 
-import { Container } from '../UI/Layout/Layout';
-import Headline from '../UI/Headlines/Headline/Headline';
-import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
-
-import BookmarkGrid from './BookmarkGrid/BookmarkGrid';
-import { Category, GlobalState, Bookmark } from '../../interfaces';
-import Spinner from '../UI/Spinner/Spinner';
-import Modal from '../UI/Modal/Modal';
-import BookmarkForm from './BookmarkForm/BookmarkForm';
-import BookmarkTable from './BookmarkTable/BookmarkTable';
-
-interface ComponentProps {
-  loading: boolean;
-  categories: Category[];
-  getCategories: () => void;
+// UI
+import { Container, Headline, ActionButton, Spinner, Modal } from '../UI';
+
+// Components
+import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
+import { BookmarkTable } from './BookmarkTable/BookmarkTable';
+import { Form } from './Form/Form';
+
+// Utils
+import { bookmarkTemplate, categoryTemplate } from '../../utility';
+
+interface Props {
   searching: boolean;
 }
 
@@ -28,8 +33,14 @@ export enum ContentType {
   bookmark,
 }
 
-const Bookmarks = (props: ComponentProps): JSX.Element => {
-  const { getCategories, categories, loading, searching = false } = props;
+export const Bookmarks = (props: Props): JSX.Element => {
+  const {
+    bookmarks: { loading, categories },
+    auth: { isAuthenticated },
+  } = useSelector((state: State) => state);
+
+  const dispatch = useDispatch();
+  const { getCategories } = bindActionCreators(actionCreators, dispatch);
 
   const [modalIsOpen, setModalIsOpen] = useState(false);
   const [formContentType, setFormContentType] = useState(ContentType.category);
@@ -38,30 +49,24 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
     ContentType.category
   );
   const [isInUpdate, setIsInUpdate] = useState(false);
-  const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
-    name: '',
-    id: -1,
-    isPinned: false,
-    orderId: 0,
-    bookmarks: [],
-    createdAt: new Date(),
-    updatedAt: new Date(),
-  });
-  const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
-    name: '',
-    url: '',
-    categoryId: -1,
-    icon: '',
-    id: -1,
-    createdAt: new Date(),
-    updatedAt: new Date(),
-  });
+  const [categoryInUpdate, setCategoryInUpdate] =
+    useState<Category>(categoryTemplate);
+  const [bookmarkInUpdate, setBookmarkInUpdate] =
+    useState<Bookmark>(bookmarkTemplate);
 
   useEffect(() => {
-    if (categories.length === 0) {
+    if (!categories.length) {
       getCategories();
     }
-  }, [getCategories]);
+  }, []);
+
+  // observe if user is authenticated -> set default view if not
+  useEffect(() => {
+    if (!isAuthenticated) {
+      setIsInEdit(false);
+      setModalIsOpen(false);
+    }
+  }, [isAuthenticated]);
 
   const toggleModal = (): void => {
     setModalIsOpen(!modalIsOpen);
@@ -102,55 +107,46 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
   return (
     <Container>
       <Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
-        {!isInUpdate ? (
-          <BookmarkForm
-            modalHandler={toggleModal}
-            contentType={formContentType}
-          />
-        ) : formContentType === ContentType.category ? (
-          <BookmarkForm
-            modalHandler={toggleModal}
-            contentType={formContentType}
-            category={categoryInUpdate}
-          />
-        ) : (
-          <BookmarkForm
-            modalHandler={toggleModal}
-            contentType={formContentType}
-            bookmark={bookmarkInUpdate}
-          />
-        )}
+        <Form
+          modalHandler={toggleModal}
+          contentType={formContentType}
+          inUpdate={isInUpdate}
+          category={categoryInUpdate}
+          bookmark={bookmarkInUpdate}
+        />
       </Modal>
 
       <Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
 
-      <div className={classes.ActionsContainer}>
-        <ActionButton
-          name="Add Category"
-          icon="mdiPlusBox"
-          handler={() => addActionHandler(ContentType.category)}
-        />
-        <ActionButton
-          name="Add Bookmark"
-          icon="mdiPlusBox"
-          handler={() => addActionHandler(ContentType.bookmark)}
-        />
-        <ActionButton
-          name="Edit Categories"
-          icon="mdiPencil"
-          handler={() => editActionHandler(ContentType.category)}
-        />
-        <ActionButton
-          name="Edit Bookmarks"
-          icon="mdiPencil"
-          handler={() => editActionHandler(ContentType.bookmark)}
-        />
-      </div>
+      {isAuthenticated && (
+        <div className={classes.ActionsContainer}>
+          <ActionButton
+            name="Add Category"
+            icon="mdiPlusBox"
+            handler={() => addActionHandler(ContentType.category)}
+          />
+          <ActionButton
+            name="Add Bookmark"
+            icon="mdiPlusBox"
+            handler={() => addActionHandler(ContentType.bookmark)}
+          />
+          <ActionButton
+            name="Edit Categories"
+            icon="mdiPencil"
+            handler={() => editActionHandler(ContentType.category)}
+          />
+          <ActionButton
+            name="Edit Bookmarks"
+            icon="mdiPencil"
+            handler={() => editActionHandler(ContentType.bookmark)}
+          />
+        </div>
+      )}
 
       {loading ? (
         <Spinner />
       ) : !isInEdit ? (
-        <BookmarkGrid categories={categories} searching />
+        <BookmarkGrid categories={categories} searching={props.searching} />
       ) : (
         <BookmarkTable
           contentType={tableContentType}
@@ -161,12 +157,3 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
     </Container>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    loading: state.bookmark.loading,
-    categories: state.bookmark.categories,
-  };
-};
-
-export default connect(mapStateToProps, { getCategories })(Bookmarks);

+ 260 - 0
client/src/components/Bookmarks/Form/BookmarksForm.tsx

@@ -0,0 +1,260 @@
+import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
+
+// Redux
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
+
+// Typescript
+import { Bookmark, Category, NewBookmark } from '../../../interfaces';
+
+// UI
+import { ModalForm, InputGroup, Button } from '../../UI';
+
+// CSS
+import classes from './Form.module.css';
+
+// Utils
+import { inputHandler, newBookmarkTemplate } from '../../../utility';
+
+interface Props {
+  modalHandler: () => void;
+  bookmark?: Bookmark;
+}
+
+export const BookmarksForm = ({
+  bookmark,
+  modalHandler,
+}: Props): JSX.Element => {
+  const { categories } = useSelector((state: State) => state.bookmarks);
+
+  const dispatch = useDispatch();
+  const { addBookmark, updateBookmark, createNotification } =
+    bindActionCreators(actionCreators, dispatch);
+
+  const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
+  const [customIcon, setCustomIcon] = useState<File | null>(null);
+
+  const [formData, setFormData] = useState<NewBookmark>(newBookmarkTemplate);
+
+  // Load bookmark data if provided for editing
+  useEffect(() => {
+    if (bookmark) {
+      setFormData({ ...bookmark });
+    } else {
+      setFormData(newBookmarkTemplate);
+    }
+  }, [bookmark]);
+
+  const inputChangeHandler = (
+    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+    options?: { isNumber?: boolean; isBool?: boolean }
+  ) => {
+    inputHandler<NewBookmark>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
+    });
+  };
+
+  const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
+    if (e.target.files) {
+      setCustomIcon(e.target.files[0]);
+    }
+  };
+
+  // Bookmarks form handler
+  const formSubmitHandler = (e: FormEvent): void => {
+    e.preventDefault();
+
+    const createFormData = (): FormData => {
+      const data = new FormData();
+      if (customIcon) {
+        data.append('icon', customIcon);
+      }
+      data.append('name', formData.name);
+      data.append('url', formData.url);
+      data.append('categoryId', `${formData.categoryId}`);
+      data.append('isPublic', `${formData.isPublic}`);
+
+      return data;
+    };
+
+    const checkCategory = (): boolean => {
+      if (formData.categoryId < 0) {
+        createNotification({
+          title: 'Error',
+          message: 'Please select category',
+        });
+
+        return false;
+      }
+
+      return true;
+    };
+
+    if (!bookmark) {
+      // add new bookmark
+      if (!checkCategory()) return;
+
+      if (formData.categoryId < 0) {
+        createNotification({
+          title: 'Error',
+          message: 'Please select category',
+        });
+        return;
+      }
+
+      if (customIcon) {
+        const data = createFormData();
+        addBookmark(data);
+      } else {
+        addBookmark(formData);
+      }
+
+      setFormData({
+        ...newBookmarkTemplate,
+        categoryId: formData.categoryId,
+        isPublic: formData.isPublic,
+      });
+    } else {
+      // update
+      if (!checkCategory()) return;
+
+      if (customIcon) {
+        const data = createFormData();
+        updateBookmark(bookmark.id, data, {
+          prev: bookmark.categoryId,
+          curr: formData.categoryId,
+        });
+      } else {
+        updateBookmark(bookmark.id, formData, {
+          prev: bookmark.categoryId,
+          curr: formData.categoryId,
+        });
+      }
+
+      modalHandler();
+
+      setFormData(newBookmarkTemplate);
+
+      setCustomIcon(null);
+    }
+  };
+
+  return (
+    <ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
+      <InputGroup>
+        <label htmlFor="name">Bookmark Name</label>
+        <input
+          type="text"
+          name="name"
+          id="name"
+          placeholder="Reddit"
+          required
+          value={formData.name}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+
+      <InputGroup>
+        <label htmlFor="url">Bookmark URL</label>
+        <input
+          type="text"
+          name="url"
+          id="url"
+          placeholder="reddit.com"
+          required
+          value={formData.url}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+
+      <InputGroup>
+        <label htmlFor="categoryId">Bookmark Category</label>
+        <select
+          name="categoryId"
+          id="categoryId"
+          required
+          onChange={(e) => inputChangeHandler(e, { isNumber: true })}
+          value={formData.categoryId}
+        >
+          <option value={-1}>Select category</option>
+          {categories.map((category: Category): JSX.Element => {
+            return (
+              <option key={category.id} value={category.id}>
+                {category.name}
+              </option>
+            );
+          })}
+        </select>
+      </InputGroup>
+
+      {!useCustomIcon ? (
+        // mdi
+        <InputGroup>
+          <label htmlFor="icon">Bookmark Icon (optional)</label>
+          <input
+            type="text"
+            name="icon"
+            id="icon"
+            placeholder="book-open-outline"
+            value={formData.icon}
+            onChange={(e) => inputChangeHandler(e)}
+          />
+          <span>
+            Use icon name from MDI or pass a valid URL.
+            <a href="https://materialdesignicons.com/" target="blank">
+              {' '}
+              Click here for reference
+            </a>
+          </span>
+          <span
+            onClick={() => toggleUseCustomIcon(!useCustomIcon)}
+            className={classes.Switch}
+          >
+            Switch to custom icon upload
+          </span>
+        </InputGroup>
+      ) : (
+        // custom
+        <InputGroup>
+          <label htmlFor="icon">Bookmark Icon (optional)</label>
+          <input
+            type="file"
+            name="icon"
+            id="icon"
+            onChange={(e) => fileChangeHandler(e)}
+            accept=".jpg,.jpeg,.png,.svg"
+          />
+          <span
+            onClick={() => {
+              setCustomIcon(null);
+              toggleUseCustomIcon(!useCustomIcon);
+            }}
+            className={classes.Switch}
+          >
+            Switch to MDI
+          </span>
+        </InputGroup>
+      )}
+
+      <InputGroup>
+        <label htmlFor="isPublic">Bookmark visibility</label>
+        <select
+          id="isPublic"
+          name="isPublic"
+          value={formData.isPublic ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>Visible (anyone can access it)</option>
+          <option value={0}>Hidden (authentication required)</option>
+        </select>
+      </InputGroup>
+
+      <Button>{bookmark ? 'Update bookmark' : 'Add new bookmark'}</Button>
+    </ModalForm>
+  );
+};

+ 100 - 0
client/src/components/Bookmarks/Form/CategoryForm.tsx

@@ -0,0 +1,100 @@
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
+
+// Redux
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
+
+// Typescript
+import { Category, NewCategory } from '../../../interfaces';
+
+// UI
+import { ModalForm, InputGroup, Button } from '../../UI';
+
+// Utils
+import { inputHandler, newCategoryTemplate } from '../../../utility';
+
+interface Props {
+  modalHandler: () => void;
+  category?: Category;
+}
+
+export const CategoryForm = ({
+  category,
+  modalHandler,
+}: Props): JSX.Element => {
+  const dispatch = useDispatch();
+  const { addCategory, updateCategory } = bindActionCreators(
+    actionCreators,
+    dispatch
+  );
+
+  const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate);
+
+  // Load category data if provided for editing
+  useEffect(() => {
+    if (category) {
+      setFormData({ ...category });
+    } else {
+      setFormData(newCategoryTemplate);
+    }
+  }, [category]);
+
+  const inputChangeHandler = (
+    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+    options?: { isNumber?: boolean; isBool?: boolean }
+  ) => {
+    inputHandler<NewCategory>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
+    });
+  };
+
+  // Category form handler
+  const formSubmitHandler = (e: FormEvent): void => {
+    e.preventDefault();
+
+    if (!category) {
+      addCategory(formData);
+    } else {
+      updateCategory(category.id, formData);
+    }
+
+    setFormData(newCategoryTemplate);
+    modalHandler();
+  };
+
+  return (
+    <ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
+      <InputGroup>
+        <label htmlFor="name">Category Name</label>
+        <input
+          type="text"
+          name="name"
+          id="name"
+          placeholder="Social Media"
+          required
+          value={formData.name}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+
+      <InputGroup>
+        <label htmlFor="isPublic">Category visibility</label>
+        <select
+          id="isPublic"
+          name="isPublic"
+          value={formData.isPublic ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>Visible (anyone can access it)</option>
+          <option value={0}>Hidden (authentication required)</option>
+        </select>
+      </InputGroup>
+
+      <Button>{category ? 'Update category' : 'Add new category'}</Button>
+    </ModalForm>
+  );
+};

+ 0 - 0
client/src/components/Bookmarks/BookmarkForm/BookmarkForm.module.css → client/src/components/Bookmarks/Form/Form.module.css


+ 44 - 0
client/src/components/Bookmarks/Form/Form.tsx

@@ -0,0 +1,44 @@
+// Typescript
+import { Bookmark, Category } from '../../../interfaces';
+import { ContentType } from '../Bookmarks';
+
+// Utils
+import { CategoryForm } from './CategoryForm';
+import { BookmarksForm } from './BookmarksForm';
+import { Fragment } from 'react';
+
+interface Props {
+  modalHandler: () => void;
+  contentType: ContentType;
+  inUpdate?: boolean;
+  category?: Category;
+  bookmark?: Bookmark;
+}
+
+export const Form = (props: Props): JSX.Element => {
+  const { modalHandler, contentType, inUpdate, category, bookmark } = props;
+
+  return (
+    <Fragment>
+      {!inUpdate ? (
+        // form: add new
+        <Fragment>
+          {contentType === ContentType.category ? (
+            <CategoryForm modalHandler={modalHandler} />
+          ) : (
+            <BookmarksForm modalHandler={modalHandler} />
+          )}
+        </Fragment>
+      ) : (
+        // form: update
+        <Fragment>
+          {contentType === ContentType.category ? (
+            <CategoryForm modalHandler={modalHandler} category={category} />
+          ) : (
+            <BookmarksForm modalHandler={modalHandler} bookmark={bookmark} />
+          )}
+        </Fragment>
+      )}
+    </Fragment>
+  );
+};

+ 9 - 17
client/src/components/Home/Header/Header.tsx

@@ -1,17 +1,17 @@
 import { useEffect, useState } from 'react';
-import { connect } from 'react-redux';
 import { Link } from 'react-router-dom';
-import { Config, GlobalState } from '../../../interfaces';
-import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget';
-import { getDateTime } from './functions/getDateTime';
-import { greeter } from './functions/greeter';
+
+// CSS
 import classes from './Header.module.css';
 
-interface Props {
-  config: Config;
-}
+// Components
+import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
 
-const Header = (props: Props): JSX.Element => {
+// Utils
+import { getDateTime } from './functions/getDateTime';
+import { greeter } from './functions/greeter';
+
+export const Header = (): JSX.Element => {
   const [dateTime, setDateTime] = useState<string>(getDateTime());
   const [greeting, setGreeting] = useState<string>(greeter());
 
@@ -39,11 +39,3 @@ const Header = (props: Props): JSX.Element => {
     </header>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps)(Header);

+ 13 - 2
client/src/components/Home/Header/functions/getDateTime.ts

@@ -1,3 +1,5 @@
+import { parseTime } from '../../../../utility';
+
 export const getDateTime = (): string => {
   const days = localStorage.getItem('daySchema')?.split(';') || [
     'Sunday',
@@ -27,14 +29,23 @@ export const getDateTime = (): string => {
   const now = new Date();
 
   const useAmericanDate = localStorage.useAmericanDate === 'true';
+  const showTime = localStorage.showTime === 'true';
+
+  const p = parseTime;
+
+  const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
+    now.getSeconds()
+  )}`;
+
+  const timeEl = showTime ? ` - ${time}` : '';
 
   if (!useAmericanDate) {
     return `${days[now.getDay()]}, ${now.getDate()} ${
       months[now.getMonth()]
-    } ${now.getFullYear()}`;
+    } ${now.getFullYear()}${timeEl}`;
   } else {
     return `${days[now.getDay()]}, ${
       months[now.getMonth()]
-    } ${now.getDate()} ${now.getFullYear()}`;
+    } ${now.getDate()} ${now.getFullYear()}${timeEl}`;
   }
 };

+ 41 - 53
client/src/components/Home/Home.tsx

@@ -2,47 +2,41 @@ import { useState, useEffect, Fragment } from 'react';
 import { Link } from 'react-router-dom';
 
 // Redux
-import { connect } from 'react-redux';
-import { getApps, getCategories } from '../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../store';
 
 // Typescript
-import { GlobalState } from '../../interfaces/GlobalState';
-import { App, Category, Config } from '../../interfaces';
+import { App, Category } from '../../interfaces';
 
 // UI
-import Icon from '../UI/Icons/Icon/Icon';
-import { Container } from '../UI/Layout/Layout';
-import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline';
-import Spinner from '../UI/Spinner/Spinner';
+import { Icon, Container, SectionHeadline, Spinner } from '../UI';
 
 // CSS
 import classes from './Home.module.css';
 
 // Components
-import AppGrid from '../Apps/AppGrid/AppGrid';
-import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
-import SearchBar from '../SearchBar/SearchBar';
-import Header from './Header/Header';
-
-interface ComponentProps {
-  getApps: Function;
-  getCategories: Function;
-  appsLoading: boolean;
-  apps: App[];
-  categoriesLoading: boolean;
-  categories: Category[];
-  config: Config;
-}
-
-const Home = (props: ComponentProps): JSX.Element => {
+import { AppGrid } from '../Apps/AppGrid/AppGrid';
+import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
+import { SearchBar } from '../SearchBar/SearchBar';
+import { Header } from './Header/Header';
+
+// Utils
+import { escapeRegex } from '../../utility';
+
+export const Home = (): JSX.Element => {
   const {
-    getApps,
-    apps,
-    appsLoading,
-    getCategories,
-    categories,
-    categoriesLoading,
-  } = props;
+    apps: { apps, loading: appsLoading },
+    bookmarks: { categories, loading: bookmarksLoading },
+    config: { config },
+  } = useSelector((state: State) => state);
+
+  const dispatch = useDispatch();
+  const { getApps, getCategories } = bindActionCreators(
+    actionCreators,
+    dispatch
+  );
 
   // Local search query
   const [localSearch, setLocalSearch] = useState<null | string>(null);
@@ -56,20 +50,22 @@ const Home = (props: ComponentProps): JSX.Element => {
     if (!apps.length) {
       getApps();
     }
-  }, [getApps]);
+  }, []);
 
   // Load bookmark categories
   useEffect(() => {
     if (!categories.length) {
       getCategories();
     }
-  }, [getCategories]);
+  }, []);
 
   useEffect(() => {
     if (localSearch) {
       // Search through apps
       setAppSearchResult([
-        ...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)),
+        ...apps.filter(({ name }) =>
+          new RegExp(escapeRegex(localSearch), 'i').test(name)
+        ),
       ]);
 
       // Search through bookmarks
@@ -79,7 +75,9 @@ const Home = (props: ComponentProps): JSX.Element => {
       category.bookmarks = categories
         .map(({ bookmarks }) => bookmarks)
         .flat()
-        .filter(({ name }) => new RegExp(localSearch, 'i').test(name));
+        .filter(({ name }) =>
+          new RegExp(escapeRegex(localSearch), 'i').test(name)
+        );
 
       setBookmarkSearchResult([category]);
     } else {
@@ -90,7 +88,7 @@ const Home = (props: ComponentProps): JSX.Element => {
 
   return (
     <Container>
-      {!props.config.hideSearch ? (
+      {!config.hideSearch ? (
         <SearchBar
           setLocalSearch={setLocalSearch}
           appSearchResult={appSearchResult}
@@ -100,9 +98,9 @@ const Home = (props: ComponentProps): JSX.Element => {
         <div></div>
       )}
 
-      {!props.config.hideHeader ? <Header /> : <div></div>}
+      {!config.hideHeader ? <Header /> : <div></div>}
 
-      {!props.config.hideApps ? (
+      {!config.hideApps ? (
         <Fragment>
           <SectionHeadline title="Applications" link="/applications" />
           {appsLoading ? (
@@ -124,16 +122,18 @@ const Home = (props: ComponentProps): JSX.Element => {
         <div></div>
       )}
 
-      {!props.config.hideCategories ? (
+      {!config.hideCategories ? (
         <Fragment>
           <SectionHeadline title="Bookmarks" link="/bookmarks" />
-          {categoriesLoading ? (
+          {bookmarksLoading ? (
             <Spinner />
           ) : (
             <BookmarkGrid
               categories={
                 !bookmarkSearchResult
-                  ? categories.filter(({ isPinned }) => isPinned)
+                  ? categories.filter(
+                      ({ isPinned, bookmarks }) => isPinned && bookmarks.length
+                    )
                   : bookmarkSearchResult
               }
               totalCategories={categories.length}
@@ -151,15 +151,3 @@ const Home = (props: ComponentProps): JSX.Element => {
     </Container>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    appsLoading: state.app.loading,
-    apps: state.app.apps,
-    categoriesLoading: state.bookmark.loading,
-    categories: state.bookmark.categories,
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps, { getApps, getCategories })(Home);

+ 8 - 17
client/src/components/NotificationCenter/NotificationCenter.tsx

@@ -1,21 +1,20 @@
-import { connect } from 'react-redux';
-import { GlobalState, Notification as _Notification } from '../../interfaces';
+import { useSelector } from 'react-redux';
+import { Notification as NotificationInterface } from '../../interfaces';
 
 import classes from './NotificationCenter.module.css';
 
-import Notification from '../UI/Notification/Notification';
+import { Notification } from '../UI';
+import { State } from '../../store/reducers';
 
-interface ComponentProps {
-  notifications: _Notification[];
-}
+export const NotificationCenter = (): JSX.Element => {
+  const { notifications } = useSelector((state: State) => state.notification);
 
-const NotificationCenter = (props: ComponentProps): JSX.Element => {
   return (
     <div
       className={classes.NotificationCenter}
-      style={{ height: `${props.notifications.length * 75}px` }}
+      style={{ height: `${notifications.length * 75}px` }}
     >
-      {props.notifications.map((notification: _Notification) => {
+      {notifications.map((notification: NotificationInterface) => {
         return (
           <Notification
             title={notification.title}
@@ -29,11 +28,3 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
     </div>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    notifications: state.notification.notifications,
-  };
-};
-
-export default connect(mapStateToProps)(NotificationCenter);

+ 13 - 0
client/src/components/Routing/ProtectedRoute.tsx

@@ -0,0 +1,13 @@
+import { useSelector } from 'react-redux';
+import { Redirect, Route, RouteProps } from 'react-router';
+import { State } from '../../store/reducers';
+
+export const ProtectedRoute = ({ ...rest }: RouteProps) => {
+  const { isAuthenticated } = useSelector((state: State) => state.auth);
+
+  if (isAuthenticated) {
+    return <Route {...rest} />;
+  } else {
+    return <Redirect to="/settings/app" />;
+  }
+};

+ 20 - 33
client/src/components/SearchBar/SearchBar.tsx

@@ -1,42 +1,33 @@
 import { useRef, useEffect, KeyboardEvent } from 'react';
 
 // Redux
-import { connect } from 'react-redux';
-import { createNotification } from '../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
 
 // Typescript
-import {
-  App,
-  Category,
-  Config,
-  GlobalState,
-  NewNotification,
-} from '../../interfaces';
+import { App, Category } from '../../interfaces';
 
 // CSS
 import classes from './SearchBar.module.css';
 
 // Utils
 import { searchParser, urlParser, redirectUrl } from '../../utility';
+import { State } from '../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../store';
 
-interface ComponentProps {
-  createNotification: (notification: NewNotification) => void;
+interface Props {
   setLocalSearch: (query: string) => void;
   appSearchResult: App[] | null;
   bookmarkSearchResult: Category[] | null;
-  config: Config;
-  loading: boolean;
 }
 
-const SearchBar = (props: ComponentProps): JSX.Element => {
-  const {
-    setLocalSearch,
-    createNotification,
-    config,
-    loading,
-    appSearchResult,
-    bookmarkSearchResult,
-  } = props;
+export const SearchBar = (props: Props): JSX.Element => {
+  const { config, loading } = useSelector((state: State) => state.config);
+
+  const dispatch = useDispatch();
+  const { createNotification } = bindActionCreators(actionCreators, dispatch);
+
+  const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
 
   const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
 
@@ -54,12 +45,17 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
 
       if (key === 'Escape') {
         clearSearch();
+      } else if (document.activeElement !== inputRef.current) {
+        if (key === '`') {
+          inputRef.current.focus();
+          clearSearch();
+        }
       }
     };
 
-    window.addEventListener('keydown', keyOutsideFocus);
+    window.addEventListener('keyup', keyOutsideFocus);
 
-    return () => window.removeEventListener('keydown', keyOutsideFocus);
+    return () => window.removeEventListener('keyup', keyOutsideFocus);
   }, []);
 
   const clearSearch = () => {
@@ -126,12 +122,3 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
     </div>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    config: state.config.config,
-    loading: state.config.loading,
-  };
-};
-
-export default connect(mapStateToProps, { createNotification })(SearchBar);

+ 9 - 3
client/src/components/Settings/AppDetails/AppDetails.module.css

@@ -1,8 +1,14 @@
-.AppVersion {
+.text {
   color: var(--color-primary);
   margin-bottom: 15px;
 }
 
-.AppVersion a {
+.text a,
+.text span {
   color: var(--color-accent);
-}
+}
+
+.separator {
+  margin: 30px 0;
+  border: 1px solid var(--color-primary);
+}

+ 36 - 27
client/src/components/Settings/AppDetails/AppDetails.tsx

@@ -1,34 +1,43 @@
 import { Fragment } from 'react';
-
+import { Button, SettingsHeadline } from '../../UI';
 import classes from './AppDetails.module.css';
-import Button from '../../UI/Buttons/Button/Button';
 import { checkVersion } from '../../../utility';
+import { AuthForm } from './AuthForm/AuthForm';
 
-const AppDetails = (): JSX.Element => {
+export const AppDetails = (): JSX.Element => {
   return (
     <Fragment>
-      <p className={classes.AppVersion}>
-        <a
-          href='https://github.com/pawelmalak/flame'
-          target='_blank'
-          rel='noreferrer'>
-          Flame
-        </a>
-        {' '}
-        version {process.env.REACT_APP_VERSION}
-      </p>
-      <p className={classes.AppVersion}>
-        See changelog {' '}
-        <a
-          href='https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md'
-          target='_blank'
-          rel='noreferrer'>
-          here
-        </a>
-      </p>
-      <Button click={() => checkVersion(true)}>Check for updates</Button>
-    </Fragment>
-  )
-}
+      <SettingsHeadline text="Authentication" />
+      <AuthForm />
+
+      <hr className={classes.separator} />
 
-export default AppDetails;
+      <div>
+        <SettingsHeadline text="App version" />
+        <p className={classes.text}>
+          <a
+            href="https://github.com/pawelmalak/flame"
+            target="_blank"
+            rel="noreferrer"
+          >
+            Flame
+          </a>{' '}
+          version {process.env.REACT_APP_VERSION}
+        </p>
+
+        <p className={classes.text}>
+          See changelog{' '}
+          <a
+            href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md"
+            target="_blank"
+            rel="noreferrer"
+          >
+            here
+          </a>
+        </p>
+
+        <Button click={() => checkVersion(true)}>Check for updates</Button>
+      </div>
+    </Fragment>
+  );
+};

+ 103 - 0
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx

@@ -0,0 +1,103 @@
+import { FormEvent, Fragment, useEffect, useState } from 'react';
+
+// Redux
+import { useSelector, useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../../store';
+import { State } from '../../../../store/reducers';
+import { decodeToken, parseTokenExpire } from '../../../../utility';
+
+// Other
+import { InputGroup, Button } from '../../../UI';
+import classes from '../AppDetails.module.css';
+
+export const AuthForm = (): JSX.Element => {
+  const { isAuthenticated, token } = useSelector((state: State) => state.auth);
+
+  const dispatch = useDispatch();
+  const { login, logout } = bindActionCreators(actionCreators, dispatch);
+
+  const [tokenExpires, setTokenExpires] = useState('');
+  const [formData, setFormData] = useState({
+    password: '',
+    duration: '14d',
+  });
+
+  useEffect(() => {
+    if (token) {
+      const decoded = decodeToken(token);
+      const expiresIn = parseTokenExpire(decoded.exp);
+      setTokenExpires(expiresIn);
+    }
+  }, [token]);
+
+  const formHandler = (e: FormEvent) => {
+    e.preventDefault();
+    login(formData);
+    setFormData({
+      password: '',
+      duration: '14d',
+    });
+  };
+
+  return (
+    <Fragment>
+      {!isAuthenticated ? (
+        <form onSubmit={formHandler}>
+          <InputGroup>
+            <label htmlFor="password">Password</label>
+            <input
+              type="password"
+              id="password"
+              name="password"
+              placeholder="••••••"
+              autoComplete="current-password"
+              value={formData.password}
+              onChange={(e) =>
+                setFormData({ ...formData, password: e.target.value })
+              }
+            />
+            <span>
+              See
+              <a
+                href="https://github.com/pawelmalak/flame/wiki/Authentication"
+                target="blank"
+              >
+                {` project wiki `}
+              </a>
+              to read more about authentication
+            </span>
+          </InputGroup>
+
+          <InputGroup>
+            <label htmlFor="duration">Session duration</label>
+            <select
+              id="duration"
+              name="duration"
+              value={formData.duration}
+              onChange={(e) =>
+                setFormData({ ...formData, duration: e.target.value })
+              }
+            >
+              <option value="1h">1 hour</option>
+              <option value="1d">1 day</option>
+              <option value="14d">2 weeks</option>
+              <option value="30d">1 month</option>
+              <option value="1y">1 year</option>
+            </select>
+          </InputGroup>
+
+          <Button>Login</Button>
+        </form>
+      ) : (
+        <div>
+          <p className={classes.text}>
+            You are logged in. Your session will expire{' '}
+            <span>{tokenExpires}</span>
+          </p>
+          <Button click={logout}>Logout</Button>
+        </div>
+      )}
+    </Fragment>
+  );
+};

+ 122 - 0
client/src/components/Settings/DockerSettings/DockerSettings.tsx

@@ -0,0 +1,122 @@
+import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
+
+// Redux
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
+
+// Typescript
+import { DockerSettingsForm } from '../../../interfaces';
+
+// UI
+import { InputGroup, Button, SettingsHeadline } from '../../UI';
+
+// Utils
+import { inputHandler, dockerSettingsTemplate } from '../../../utility';
+
+export const DockerSettings = (): JSX.Element => {
+  const { loading, config } = useSelector((state: State) => state.config);
+
+  const dispatch = useDispatch();
+  const { updateConfig } = bindActionCreators(actionCreators, dispatch);
+
+  // Initial state
+  const [formData, setFormData] = useState<DockerSettingsForm>(
+    dockerSettingsTemplate
+  );
+
+  // Get config
+  useEffect(() => {
+    setFormData({
+      ...config,
+    });
+  }, [loading]);
+
+  // Form handler
+  const formSubmitHandler = async (e: FormEvent) => {
+    e.preventDefault();
+
+    // Save settings
+    await updateConfig(formData);
+  };
+
+  // Input handler
+  const inputChangeHandler = (
+    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+    options?: { isNumber?: boolean; isBool?: boolean }
+  ) => {
+    inputHandler<DockerSettingsForm>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
+    });
+  };
+
+  return (
+    <form onSubmit={(e) => formSubmitHandler(e)}>
+      <SettingsHeadline text="Docker" />
+      {/* CUSTOM DOCKER SOCKET HOST */}
+      <InputGroup>
+        <label htmlFor="dockerHost">Docker Host</label>
+        <input
+          type="text"
+          id="dockerHost"
+          name="dockerHost"
+          placeholder="dockerHost:port"
+          value={formData.dockerHost}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+
+      {/* USE DOCKER API */}
+      <InputGroup>
+        <label htmlFor="dockerApps">Use Docker API</label>
+        <select
+          id="dockerApps"
+          name="dockerApps"
+          value={formData.dockerApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>True</option>
+          <option value={0}>False</option>
+        </select>
+      </InputGroup>
+
+      {/* UNPIN DOCKER APPS */}
+      <InputGroup>
+        <label htmlFor="unpinStoppedApps">
+          Unpin stopped containers / other apps
+        </label>
+        <select
+          id="unpinStoppedApps"
+          name="unpinStoppedApps"
+          value={formData.unpinStoppedApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>True</option>
+          <option value={0}>False</option>
+        </select>
+      </InputGroup>
+
+      {/* KUBERNETES SETTINGS */}
+      <SettingsHeadline text="Kubernetes" />
+      {/* USE KUBERNETES */}
+      <InputGroup>
+        <label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
+        <select
+          id="kubernetesApps"
+          name="kubernetesApps"
+          value={formData.kubernetesApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>True</option>
+          <option value={0}>False</option>
+        </select>
+      </InputGroup>
+
+      <Button>Save changes</Button>
+    </form>
+  );
+};

+ 25 - 34
client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx

@@ -1,29 +1,31 @@
 import { Fragment, useState } from 'react';
-import { connect } from 'react-redux';
 
+// Redux
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../../store';
+
+// Typescript
+import { Query } from '../../../../interfaces';
+
+// CSS
 import classes from './CustomQueries.module.css';
 
-import Modal from '../../../UI/Modal/Modal';
-import Icon from '../../../UI/Icons/Icon/Icon';
-import {
-  Config,
-  GlobalState,
-  NewNotification,
-  Query,
-} from '../../../../interfaces';
-import QueriesForm from './QueriesForm';
-import { deleteQuery, createNotification } from '../../../../store/actions';
-import Button from '../../../UI/Buttons/Button/Button';
-
-interface Props {
-  customQueries: Query[];
-  deleteQuery: (prefix: string) => {};
-  createNotification: (notification: NewNotification) => void;
-  config: Config;
-}
-
-const CustomQueries = (props: Props): JSX.Element => {
-  const { customQueries, deleteQuery, createNotification } = props;
+// UI
+import { Modal, Icon, Button } from '../../../UI';
+
+// Components
+import { QueriesForm } from './QueriesForm';
+
+export const CustomQueries = (): JSX.Element => {
+  const { customQueries, config } = useSelector((state: State) => state.config);
+
+  const dispatch = useDispatch();
+  const { deleteQuery, createNotification } = bindActionCreators(
+    actionCreators,
+    dispatch
+  );
 
   const [modalIsOpen, setModalIsOpen] = useState(false);
   const [editableQuery, setEditableQuery] = useState<Query | null>(null);
@@ -34,7 +36,7 @@ const CustomQueries = (props: Props): JSX.Element => {
   };
 
   const deleteHandler = (query: Query) => {
-    const currentProvider = props.config.defaultSearchProvider;
+    const currentProvider = config.defaultSearchProvider;
     const isCurrent = currentProvider === query.prefix;
 
     if (isCurrent) {
@@ -105,14 +107,3 @@ const CustomQueries = (props: Props): JSX.Element => {
     </Fragment>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    customQueries: state.config.customQueries,
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps, { deleteQuery, createNotification })(
-  CustomQueries
-);

+ 18 - 11
client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx

@@ -1,20 +1,26 @@
 import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
+
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../../store';
+
 import { Query } from '../../../../interfaces';
-import Button from '../../../UI/Buttons/Button/Button';
-import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
-import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
-import { connect } from 'react-redux';
-import { addQuery, updateQuery } from '../../../../store/actions';
+
+import { Button, InputGroup, ModalForm } from '../../../UI';
 
 interface Props {
   modalHandler: () => void;
-  addQuery: (query: Query) => {};
-  updateQuery: (query: Query, Oldprefix: string) => {};
   query?: Query;
 }
 
-const QueriesForm = (props: Props): JSX.Element => {
-  const { modalHandler, addQuery, updateQuery, query } = props;
+export const QueriesForm = (props: Props): JSX.Element => {
+  const dispatch = useDispatch();
+  const { addQuery, updateQuery } = bindActionCreators(
+    actionCreators,
+    dispatch
+  );
+
+  const { modalHandler, query } = props;
 
   const [formData, setFormData] = useState<Query>({
     name: '',
@@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => {
           onChange={(e) => inputChangeHandler(e)}
         />
       </InputGroup>
+
       <InputGroup>
         <label htmlFor="name">Prefix</label>
         <input
@@ -89,6 +96,7 @@ const QueriesForm = (props: Props): JSX.Element => {
           onChange={(e) => inputChangeHandler(e)}
         />
       </InputGroup>
+
       <InputGroup>
         <label htmlFor="name">Query Template</label>
         <input
@@ -101,9 +109,8 @@ const QueriesForm = (props: Props): JSX.Element => {
           onChange={(e) => inputChangeHandler(e)}
         />
       </InputGroup>
+
       {query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
     </ModalForm>
   );
 };
-
-export default connect(null, { addQuery, updateQuery })(QueriesForm);

+ 24 - 42
client/src/components/Settings/SearchSettings/SearchSettings.tsx

@@ -1,26 +1,15 @@
 // React
 import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
-import { connect } from 'react-redux';
-
-// State
-import { createNotification, updateConfig } from '../../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
 
 // Typescript
-import {
-  Config,
-  GlobalState,
-  NewNotification,
-  Query,
-  SearchForm,
-} from '../../../interfaces';
+import { Query, SearchForm } from '../../../interfaces';
 
 // Components
-import CustomQueries from './CustomQueries/CustomQueries';
+import { CustomQueries } from './CustomQueries/CustomQueries';
 
 // UI
-import Button from '../../UI/Buttons/Button/Button';
-import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
+import { Button, SettingsHeadline, InputGroup } from '../../UI';
 
 // Utils
 import { inputHandler, searchSettingsTemplate } from '../../../utility';
@@ -28,31 +17,35 @@ import { inputHandler, searchSettingsTemplate } from '../../../utility';
 // Data
 import { queries } from '../../../utility/searchQueries.json';
 
-interface Props {
-  createNotification: (notification: NewNotification) => void;
-  updateConfig: (formData: SearchForm) => void;
-  loading: boolean;
-  customQueries: Query[];
-  config: Config;
-}
+// Redux
+import { State } from '../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
+
+export const SearchSettings = (): JSX.Element => {
+  const { loading, customQueries, config } = useSelector(
+    (state: State) => state.config
+  );
+
+  const dispatch = useDispatch();
+  const { updateConfig } = bindActionCreators(actionCreators, dispatch);
 
-const SearchSettings = (props: Props): JSX.Element => {
   // Initial state
   const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
 
   // Get config
   useEffect(() => {
     setFormData({
-      ...props.config,
+      ...config,
     });
-  }, [props.loading]);
+  }, [loading]);
 
   // Form handler
   const formSubmitHandler = async (e: FormEvent) => {
     e.preventDefault();
 
     // Save settings
-    await props.updateConfig(formData);
+    await updateConfig(formData);
   };
 
   // Input handler
@@ -84,7 +77,7 @@ const SearchSettings = (props: Props): JSX.Element => {
             value={formData.defaultSearchProvider}
             onChange={(e) => inputChangeHandler(e)}
           >
-            {[...queries, ...props.customQueries].map((query: Query, idx) => {
+            {[...queries, ...customQueries].map((query: Query, idx) => {
               const isCustom = idx >= queries.length;
 
               return (
@@ -95,6 +88,7 @@ const SearchSettings = (props: Props): JSX.Element => {
             })}
           </select>
         </InputGroup>
+
         <InputGroup>
           <label htmlFor="searchSameTab">
             Open search results in the same tab
@@ -109,6 +103,7 @@ const SearchSettings = (props: Props): JSX.Element => {
             <option value={0}>False</option>
           </select>
         </InputGroup>
+
         <InputGroup>
           <label htmlFor="hideSearch">Hide search bar</label>
           <select
@@ -121,6 +116,7 @@ const SearchSettings = (props: Props): JSX.Element => {
             <option value={0}>False</option>
           </select>
         </InputGroup>
+
         <InputGroup>
           <label htmlFor="disableAutofocus">Disable search bar autofocus</label>
           <select
@@ -133,6 +129,7 @@ const SearchSettings = (props: Props): JSX.Element => {
             <option value={0}>False</option>
           </select>
         </InputGroup>
+
         <Button>Save changes</Button>
       </form>
 
@@ -142,18 +139,3 @@ const SearchSettings = (props: Props): JSX.Element => {
     </Fragment>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    loading: state.config.loading,
-    customQueries: state.config.customQueries,
-    config: state.config.config,
-  };
-};
-
-const actions = {
-  createNotification,
-  updateConfig,
-};
-
-export default connect(mapStateToProps, actions)(SearchSettings);

+ 33 - 17
client/src/components/Settings/Settings.tsx

@@ -1,6 +1,9 @@
-//
 import { NavLink, Link, Switch, Route } from 'react-router-dom';
 
+// Redux
+import { useSelector } from 'react-redux';
+import { State } from '../../store/reducers';
+
 // Typescript
 import { Route as SettingsRoute } from '../../interfaces';
 
@@ -8,28 +11,33 @@ import { Route as SettingsRoute } from '../../interfaces';
 import classes from './Settings.module.css';
 
 // Components
-import Themer from '../Themer/Themer';
-import WeatherSettings from './WeatherSettings/WeatherSettings';
-import OtherSettings from './OtherSettings/OtherSettings';
-import AppDetails from './AppDetails/AppDetails';
-import StyleSettings from './StyleSettings/StyleSettings';
-import SearchSettings from './SearchSettings/SearchSettings';
+import { Themer } from '../Themer/Themer';
+import { WeatherSettings } from './WeatherSettings/WeatherSettings';
+import { UISettings } from './UISettings/UISettings';
+import { AppDetails } from './AppDetails/AppDetails';
+import { StyleSettings } from './StyleSettings/StyleSettings';
+import { SearchSettings } from './SearchSettings/SearchSettings';
+import { DockerSettings } from './DockerSettings/DockerSettings';
+import { ProtectedRoute } from '../Routing/ProtectedRoute';
 
 // UI
-import { Container } from '../UI/Layout/Layout';
-import Headline from '../UI/Headlines/Headline/Headline';
+import { Container, Headline } from '../UI';
 
 // Data
 import { routes } from './settings.json';
 
-const Settings = (): JSX.Element => {
+export const Settings = (): JSX.Element => {
+  const { isAuthenticated } = useSelector((state: State) => state.auth);
+
+  const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
+
   return (
     <Container>
       <Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
       <div className={classes.Settings}>
         {/* NAVIGATION MENU */}
         <nav className={classes.SettingsNav}>
-          {routes.map(({ name, dest }: SettingsRoute, idx) => (
+          {tabs.map(({ name, dest }: SettingsRoute, idx) => (
             <NavLink
               className={classes.SettingsNavLink}
               activeClassName={classes.SettingsNavLinkActive}
@@ -46,10 +54,20 @@ const Settings = (): JSX.Element => {
         <section className={classes.SettingsContent}>
           <Switch>
             <Route exact path="/settings" component={Themer} />
-            <Route path="/settings/weather" component={WeatherSettings} />
-            <Route path="/settings/search" component={SearchSettings} />
-            <Route path="/settings/other" component={OtherSettings} />
-            <Route path="/settings/css" component={StyleSettings} />
+            <ProtectedRoute
+              path="/settings/weather"
+              component={WeatherSettings}
+            />
+            <ProtectedRoute
+              path="/settings/search"
+              component={SearchSettings}
+            />
+            <ProtectedRoute path="/settings/interface" component={UISettings} />
+            <ProtectedRoute
+              path="/settings/docker"
+              component={DockerSettings}
+            />
+            <ProtectedRoute path="/settings/css" component={StyleSettings} />
             <Route path="/settings/app" component={AppDetails} />
           </Switch>
         </section>
@@ -57,5 +75,3 @@ const Settings = (): JSX.Element => {
     </Container>
   );
 };
-
-export default Settings;

+ 32 - 28
client/src/components/Settings/StyleSettings/StyleSettings.tsx

@@ -2,54 +2,60 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
 import axios from 'axios';
 
 // Redux
-import { connect } from 'react-redux';
-import { createNotification } from '../../../store/actions';
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
 
 // Typescript
-import { ApiResponse, NewNotification } from '../../../interfaces';
+import { ApiResponse } from '../../../interfaces';
 
-// UI
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
-import Button from '../../UI/Buttons/Button/Button';
+// Other
+import { InputGroup, Button } from '../../UI';
+import { applyAuth } from '../../../utility';
 
-interface ComponentProps {
-  createNotification: (notification: NewNotification) => void;
-}
+export const StyleSettings = (): JSX.Element => {
+  const dispatch = useDispatch();
+  const { createNotification } = bindActionCreators(actionCreators, dispatch);
 
-const StyleSettings = (props: ComponentProps): JSX.Element => {
   const [customStyles, setCustomStyles] = useState<string>('');
 
   useEffect(() => {
-    axios.get<ApiResponse<string>>('/api/config/0/css')
-      .then(data => setCustomStyles(data.data.data))
-      .catch(err => console.log(err.response));
-  }, [])
+    axios
+      .get<ApiResponse<string>>('/api/config/0/css')
+      .then((data) => setCustomStyles(data.data.data))
+      .catch((err) => console.log(err.response));
+  }, []);
 
   const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
     e.preventDefault();
     setCustomStyles(e.target.value);
-  }
+  };
 
   const formSubmitHandler = (e: FormEvent) => {
     e.preventDefault();
 
-    axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles })
+    axios
+      .put<ApiResponse<{}>>(
+        '/api/config/0/css',
+        { styles: customStyles },
+        { headers: applyAuth() }
+      )
       .then(() => {
-        props.createNotification({
+        createNotification({
           title: 'Success',
-          message: 'CSS saved. Reload page to see changes'
-        })
+          message: 'CSS saved. Reload page to see changes',
+        });
       })
-      .catch(err => console.log(err.response));
-  }
+      .catch((err) => console.log(err.response));
+  };
 
   return (
     <form onSubmit={(e) => formSubmitHandler(e)}>
       <InputGroup>
-        <label htmlFor='customStyles'>Custom CSS</label>
+        <label htmlFor="customStyles">Custom CSS</label>
         <textarea
-          id='customStyles'
-          name='customStyles'
+          id="customStyles"
+          name="customStyles"
           value={customStyles}
           onChange={(e) => inputChangeHandler(e)}
           spellCheck={false}
@@ -57,7 +63,5 @@ const StyleSettings = (props: ComponentProps): JSX.Element => {
       </InputGroup>
       <Button>Save CSS</Button>
     </form>
-  )
-}
-
-export default connect(null, { createNotification })(StyleSettings);
+  );
+};

+ 35 - 108
client/src/components/Settings/OtherSettings/OtherSettings.tsx → client/src/components/Settings/UISettings/UISettings.tsx

@@ -1,41 +1,28 @@
 import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
 
 // Redux
-import { connect } from 'react-redux';
-import {
-  createNotification,
-  updateConfig,
-  sortApps,
-  sortCategories,
-} from '../../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
+import { State } from '../../../store/reducers';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
 
 // Typescript
-import {
-  Config,
-  GlobalState,
-  NewNotification,
-  OtherSettingsForm,
-} from '../../../interfaces';
+import { OtherSettingsForm } from '../../../interfaces';
 
 // UI
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
-import Button from '../../UI/Buttons/Button/Button';
-import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
+import { InputGroup, Button, SettingsHeadline } from '../../UI';
 
 // Utils
 import { otherSettingsTemplate, inputHandler } from '../../../utility';
 
-interface ComponentProps {
-  createNotification: (notification: NewNotification) => void;
-  updateConfig: (formData: OtherSettingsForm) => void;
-  sortApps: () => void;
-  sortCategories: () => void;
-  loading: boolean;
-  config: Config;
-}
+export const UISettings = (): JSX.Element => {
+  const { loading, config } = useSelector((state: State) => state.config);
 
-const OtherSettings = (props: ComponentProps): JSX.Element => {
-  const { config } = props;
+  const dispatch = useDispatch();
+  const { updateConfig, sortApps, sortCategories } = bindActionCreators(
+    actionCreators,
+    dispatch
+  );
 
   // Initial state
   const [formData, setFormData] = useState<OtherSettingsForm>(
@@ -47,21 +34,21 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
     setFormData({
       ...config,
     });
-  }, [props.loading]);
+  }, [loading]);
 
   // Form handler
   const formSubmitHandler = async (e: FormEvent) => {
     e.preventDefault();
 
     // Save settings
-    await props.updateConfig(formData);
+    await updateConfig(formData);
 
     // Update local page title
     document.title = formData.customTitle;
 
     // Sort apps and categories with new settings
-    props.sortApps();
-    props.sortCategories();
+    sortApps();
+    sortCategories();
   };
 
   // Input handler
@@ -185,8 +172,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         </select>
       </InputGroup>
 
-      {/* MODULES OPTIONS */}
-      <SettingsHeadline text="Modules" />
+      {/* HEADER OPTIONS */}
+      <SettingsHeadline text="Header" />
       {/* HIDE HEADER */}
       <InputGroup>
         <label htmlFor="hideHeader">Hide greeting and date</label>
@@ -246,6 +233,22 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <span>Names must be separated with semicolon</span>
       </InputGroup>
 
+      {/* SHOW TIME */}
+      <InputGroup>
+        <label htmlFor="showTime">Show time</label>
+        <select
+          id="showTime"
+          name="showTime"
+          value={formData.showTime ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>True</option>
+          <option value={0}>False</option>
+        </select>
+      </InputGroup>
+
+      {/* MODULES OPTIONS */}
+      <SettingsHeadline text="Modules" />
       {/* HIDE APPS */}
       <InputGroup>
         <label htmlFor="hideApps">Hide applications</label>
@@ -274,83 +277,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         </select>
       </InputGroup>
 
-      {/* DOCKER SETTINGS */}
-      <SettingsHeadline text="Docker" />
-      {/* CUSTOM DOCKER SOCKET HOST */}
-      <InputGroup>
-        <label htmlFor="dockerHost">Docker Host</label>
-        <input
-          type="text"
-          id="dockerHost"
-          name="dockerHost"
-          placeholder="dockerHost:port"
-          value={formData.dockerHost}
-          onChange={(e) => inputChangeHandler(e)}
-        />
-      </InputGroup>
-
-      {/* USE DOCKER API */}
-      <InputGroup>
-        <label htmlFor="dockerApps">Use Docker API</label>
-        <select
-          id="dockerApps"
-          name="dockerApps"
-          value={formData.dockerApps ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
-      </InputGroup>
-
-      {/* UNPIN DOCKER APPS */}
-      <InputGroup>
-        <label htmlFor="unpinStoppedApps">
-          Unpin stopped containers / other apps
-        </label>
-        <select
-          id="unpinStoppedApps"
-          name="unpinStoppedApps"
-          value={formData.unpinStoppedApps ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
-      </InputGroup>
-
-      {/* KUBERNETES SETTINGS */}
-      <SettingsHeadline text="Kubernetes" />
-      {/* USE KUBERNETES */}
-      <InputGroup>
-        <label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
-        <select
-          id="kubernetesApps"
-          name="kubernetesApps"
-          value={formData.kubernetesApps ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
-      </InputGroup>
       <Button>Save changes</Button>
     </form>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    loading: state.config.loading,
-    config: state.config.config,
-  };
-};
-
-const actions = {
-  createNotification,
-  updateConfig,
-  sortApps,
-  sortCategories,
-};
-
-export default connect(mapStateToProps, actions)(OtherSettings);

+ 24 - 36
client/src/components/Settings/WeatherSettings/WeatherSettings.tsx

@@ -2,34 +2,29 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
 import axios from 'axios';
 
 // Redux
-import { connect } from 'react-redux';
-import { createNotification, updateConfig } from '../../../store/actions';
+import { useDispatch, useSelector } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
+import { State } from '../../../store/reducers';
 
 // Typescript
-import {
-  ApiResponse,
-  Config,
-  GlobalState,
-  NewNotification,
-  Weather,
-  WeatherForm,
-} from '../../../interfaces';
+import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
 
 // UI
-import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
-import Button from '../../UI/Buttons/Button/Button';
+import { InputGroup, Button } from '../../UI';
 
 // Utils
 import { inputHandler, weatherSettingsTemplate } from '../../../utility';
 
-interface ComponentProps {
-  createNotification: (notification: NewNotification) => void;
-  updateConfig: (formData: WeatherForm) => void;
-  loading: boolean;
-  config: Config;
-}
+export const WeatherSettings = (): JSX.Element => {
+  const { loading, config } = useSelector((state: State) => state.config);
+
+  const dispatch = useDispatch();
+  const { createNotification, updateConfig } = bindActionCreators(
+    actionCreators,
+    dispatch
+  );
 
-const WeatherSettings = (props: ComponentProps): JSX.Element => {
   // Initial state
   const [formData, setFormData] = useState<WeatherForm>(
     weatherSettingsTemplate
@@ -38,9 +33,9 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
   // Get config
   useEffect(() => {
     setFormData({
-      ...props.config,
+      ...config,
     });
-  }, [props.loading]);
+  }, [loading]);
 
   // Form handler
   const formSubmitHandler = async (e: FormEvent) => {
@@ -48,26 +43,26 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
 
     // Check for api key input
     if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
-      props.createNotification({
+      createNotification({
         title: 'Warning',
         message: 'API key is missing. Weather Module will NOT work',
       });
     }
 
     // Save settings
-    await props.updateConfig(formData);
+    await updateConfig(formData);
 
     // Update weather
     axios
       .get<ApiResponse<Weather>>('/api/weather/update')
       .then(() => {
-        props.createNotification({
+        createNotification({
           title: 'Success',
           message: 'Weather updated',
         });
       })
       .catch((err) => {
-        props.createNotification({
+        createNotification({
           title: 'Error',
           message: err.response.data.error,
         });
@@ -108,6 +103,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
           . Key is required for weather module to work.
         </span>
       </InputGroup>
+
       <InputGroup>
         <label htmlFor="lat">Location latitude</label>
         <input
@@ -131,6 +127,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
           </a>
         </span>
       </InputGroup>
+
       <InputGroup>
         <label htmlFor="long">Location longitude</label>
         <input
@@ -144,6 +141,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
           lang="en-150"
         />
       </InputGroup>
+
       <InputGroup>
         <label htmlFor="isCelsius">Temperature unit</label>
         <select
@@ -156,18 +154,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
           <option value={0}>Fahrenheit</option>
         </select>
       </InputGroup>
+
       <Button>Save changes</Button>
     </form>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    loading: state.config.loading,
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps, { createNotification, updateConfig })(
-  WeatherSettings
-);

+ 18 - 7
client/src/components/Settings/settings.json

@@ -2,27 +2,38 @@
   "routes": [
     {
       "name": "Theme",
-      "dest": "/settings"
+      "dest": "/settings",
+      "authRequired": false
     },
     {
       "name": "Weather",
-      "dest": "/settings/weather"
+      "dest": "/settings/weather",
+      "authRequired": true
     },
     {
       "name": "Search",
-      "dest": "/settings/search"
+      "dest": "/settings/search",
+      "authRequired": true
     },
     {
-      "name": "Other",
-      "dest": "/settings/other"
+      "name": "Interface",
+      "dest": "/settings/interface",
+      "authRequired": true
+    },
+    {
+      "name": "Docker",
+      "dest": "/settings/docker",
+      "authRequired": true
     },
     {
       "name": "CSS",
-      "dest": "/settings/css"
+      "dest": "/settings/css",
+      "authRequired": true
     },
     {
       "name": "App",
-      "dest": "/settings/app"
+      "dest": "/settings/app",
+      "authRequired": false
     }
   ]
 }

+ 8 - 7
client/src/components/Themer/ThemePreview.tsx

@@ -1,14 +1,17 @@
 import { Theme } from '../../interfaces/Theme';
 import classes from './ThemePreview.module.css';
 
-interface ComponentProps {
+interface Props {
   theme: Theme;
   applyTheme: Function;
 }
 
-const ThemePreview = (props: ComponentProps): JSX.Element => {
+export const ThemePreview = (props: Props): JSX.Element => {
   return (
-    <div className={classes.ThemePreview} onClick={() => props.applyTheme(props.theme.name)}>
+    <div
+      className={classes.ThemePreview}
+      onClick={() => props.applyTheme(props.theme.name)}
+    >
       <div className={classes.ColorsPreview}>
         <div
           className={classes.ColorPreview}
@@ -25,7 +28,5 @@ const ThemePreview = (props: ComponentProps): JSX.Element => {
       </div>
       <p>{props.theme.name}</p>
     </div>
-  )
-}
-
-export default ThemePreview;
+  );
+};

+ 14 - 22
client/src/components/Themer/Themer.tsx

@@ -1,37 +1,29 @@
 import { Fragment } from 'react';
-import { connect } from 'react-redux';
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../store';
 
 import classes from './Themer.module.css';
 
 import { themes } from './themes.json';
 import { Theme } from '../../interfaces/Theme';
-import ThemePreview from './ThemePreview';
+import { ThemePreview } from './ThemePreview';
 
-import { setTheme } from '../../store/actions';
-
-interface ComponentProps {
-  setTheme: Function;
-}
-
-const Themer = (props: ComponentProps): JSX.Element => {
+export const Themer = (): JSX.Element => {
+  const dispatch = useDispatch();
+  const { setTheme } = bindActionCreators(actionCreators, dispatch);
 
   return (
     <Fragment>
       <div>
         <div className={classes.ThemerGrid}>
-          {themes.map((theme: Theme, idx: number): JSX.Element => (
-            <ThemePreview
-              key={idx}
-              theme={theme}
-              applyTheme={props.setTheme}
-            />
-          ))}
+          {themes.map(
+            (theme: Theme, idx: number): JSX.Element => (
+              <ThemePreview key={idx} theme={theme} applyTheme={setTheme} />
+            )
+          )}
         </div>
       </div>
     </Fragment>
-
-  )
-}
-
-
-export default connect(null, { setTheme })(Themer);
+  );
+};

+ 12 - 22
client/src/components/UI/Buttons/ActionButton/ActionButton.tsx

@@ -2,55 +2,45 @@ import { Fragment } from 'react';
 import { Link } from 'react-router-dom';
 
 import classes from './ActionButton.module.css';
-import Icon from '../../Icons/Icon/Icon';
+import { Icon } from '../..';
 
-interface ComponentProps {
+interface Props {
   name: string;
   icon: string;
   link?: string;
   handler?: () => void;
 }
 
-const ActionButton = (props: ComponentProps): JSX.Element => {
+export const ActionButton = (props: Props): JSX.Element => {
   const body = (
     <Fragment>
       <div className={classes.ActionButtonIcon}>
         <Icon icon={props.icon} />
       </div>
-      <div className={classes.ActionButtonName}>
-        {props.name}
-      </div>
+      <div className={classes.ActionButtonName}>{props.name}</div>
     </Fragment>
   );
 
   if (props.link) {
     return (
-      <Link
-        to={props.link}
-        tabIndex={0}>
+      <Link to={props.link} tabIndex={0}>
         {body}
       </Link>
-    )
+    );
   } else if (props.handler) {
     return (
       <div
         className={classes.ActionButton}
         onClick={props.handler}
         onKeyPress={(e) => {
-          if (e.key === 'Enter' && props.handler) props.handler()
+          if (e.key === 'Enter' && props.handler) props.handler();
         }}
         tabIndex={0}
-        >{body}
-      </div>
-    )
-  } else {
-    return (
-      <div
-        className={classes.ActionButton}>
+      >
         {body}
       </div>
-    )
+    );
+  } else {
+    return <div className={classes.ActionButton}>{body}</div>;
   }
-}
-
-export default ActionButton;
+};

+ 8 - 12
client/src/components/UI/Buttons/Button/Button.tsx

@@ -1,21 +1,17 @@
+import { ReactNode } from 'react';
 import classes from './Button.module.css';
 
-interface ComponentProps {
-  children: string;
+interface Props {
+  children: ReactNode;
   click?: any;
 }
 
-const Button = (props: ComponentProps): JSX.Element => {
-  const {
-    children,
-    click
-  } = props;
+export const Button = (props: Props): JSX.Element => {
+  const { children, click } = props;
 
   return (
-    <button className={classes.Button} onClick={click ? click : () => {}} >
+    <button className={classes.Button} onClick={click ? click : () => {}}>
       {children}
     </button>
-  )
-}
-
-export default Button;
+  );
+};

+ 6 - 11
client/src/components/UI/Forms/InputGroup/InputGroup.tsx

@@ -1,15 +1,10 @@
+import { ReactNode } from 'react';
 import classes from './InputGroup.module.css';
 
-interface ComponentProps {
-  children: JSX.Element | JSX.Element[];
+interface Props {
+  children: ReactNode;
 }
 
-const InputGroup = (props: ComponentProps): JSX.Element => {
-  return (
-    <div className={classes.InputGroup}>
-      {props.children}
-    </div>
-  )
-}
-
-export default InputGroup;
+export const InputGroup = (props: Props): JSX.Element => {
+  return <div className={classes.InputGroup}>{props.children}</div>;
+};

+ 9 - 13
client/src/components/UI/Forms/ModalForm/ModalForm.tsx

@@ -1,31 +1,27 @@
-import { SyntheticEvent } from 'react';
+import { ReactNode, SyntheticEvent } from 'react';
 
 import classes from './ModalForm.module.css';
-import Icon from '../../Icons/Icon/Icon';
+import { Icon } from '../..';
 
 interface ComponentProps {
-  children: JSX.Element | JSX.Element[];
+  children: ReactNode;
   modalHandler?: () => void;
   formHandler: (e: SyntheticEvent<HTMLFormElement>) => void;
 }
 
-const ModalForm = (props: ComponentProps): JSX.Element => {
+export const ModalForm = (props: ComponentProps): JSX.Element => {
   const _modalHandler = (): void => {
     if (props.modalHandler) {
       props.modalHandler();
     }
-  }
+  };
 
   return (
     <div className={classes.ModalForm}>
       <div className={classes.ModalFormIcon} onClick={_modalHandler}>
-        <Icon icon='mdiClose' />
+        <Icon icon="mdiClose" />
       </div>
-      <form onSubmit={(e) => props.formHandler(e)}>
-        {props.children}
-      </form>
+      <form onSubmit={(e) => props.formHandler(e)}>{props.children}</form>
     </div>
-  )
-}
-
-export default ModalForm;
+  );
+};

+ 9 - 9
client/src/components/UI/Headlines/Headline/Headline.tsx

@@ -1,18 +1,18 @@
-import { Fragment } from 'react';
+import { Fragment, ReactNode } from 'react';
 import classes from './Headline.module.css';
 
-interface ComponentProps {
+interface Props {
   title: string;
-  subtitle?: string | JSX.Element;
+  subtitle?: ReactNode;
 }
 
-const Headline = (props: ComponentProps): JSX.Element => {
+export const Headline = (props: Props): JSX.Element => {
   return (
     <Fragment>
       <h1 className={classes.HeadlineTitle}>{props.title}</h1>
-      {props.subtitle && <p className={classes.HeadlineSubtitle}>{props.subtitle}</p>}
+      {props.subtitle && (
+        <p className={classes.HeadlineSubtitle}>{props.subtitle}</p>
+      )}
     </Fragment>
-  )
-}
-
-export default Headline;
+  );
+};

+ 5 - 7
client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.tsx

@@ -2,17 +2,15 @@ import { Link } from 'react-router-dom';
 
 import classes from './SectionHeadline.module.css';
 
-interface ComponentProps {
+interface Props {
   title: string;
-  link: string
+  link: string;
 }
 
-const SectionHeadline = (props: ComponentProps): JSX.Element => {
+export const SectionHeadline = (props: Props): JSX.Element => {
   return (
     <Link to={props.link}>
       <h2 className={classes.SectionHeadline}>{props.title}</h2>
     </Link>
-  )
-}
-
-export default SectionHeadline;
+  );
+};

+ 1 - 3
client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.tsx

@@ -4,8 +4,6 @@ interface Props {
   text: string;
 }
 
-const SettingsHeadline = (props: Props): JSX.Element => {
+export const SettingsHeadline = (props: Props): JSX.Element => {
   return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
 };
-
-export default SettingsHeadline;

+ 1 - 3
client/src/components/UI/Icons/Icon/Icon.module.css

@@ -1,6 +1,4 @@
 .Icon {
   color: var(--color-primary);
-  /* for settings */
-  /* color: var(--color-background); */
   width: 90%;
-}
+}

+ 4 - 6
client/src/components/UI/Icons/Icon/Icon.tsx

@@ -2,12 +2,12 @@ import classes from './Icon.module.css';
 
 import { Icon as MDIcon } from '@mdi/react';
 
-interface ComponentProps {
+interface Props {
   icon: string;
   color?: string;
 }
 
-const Icon = (props: ComponentProps): JSX.Element => {
+export const Icon = (props: Props): JSX.Element => {
   const MDIcons = require('@mdi/js');
   let iconPath = MDIcons[props.icon];
 
@@ -22,7 +22,5 @@ const Icon = (props: ComponentProps): JSX.Element => {
       path={iconPath}
       color={props.color ? props.color : 'var(--color-primary)'}
     />
-  )
-}
-
-export default Icon;
+  );
+};

+ 11 - 18
client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx

@@ -1,39 +1,32 @@
 import { useEffect } from 'react';
-import { connect } from 'react-redux';
+import { useSelector } from 'react-redux';
 import { Skycons } from 'skycons-ts';
-import { GlobalState, Theme } from '../../../../interfaces';
+import { State } from '../../../../store/reducers';
 import { IconMapping, TimeOfDay } from './IconMapping';
 
-interface ComponentProps {
-  theme: Theme;
+interface Props {
   weatherStatusCode: number;
   isDay: number;
 }
 
-const WeatherIcon = (props: ComponentProps): JSX.Element => {
+export const WeatherIcon = (props: Props): JSX.Element => {
+  const { theme } = useSelector((state: State) => state.theme);
+
   const icon = props.isDay
     ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
     : new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night);
 
   useEffect(() => {
     const delay = setTimeout(() => {
-      const skycons = new Skycons({'color': props.theme.colors.accent});
+      const skycons = new Skycons({ color: theme.colors.accent });
       skycons.add(`weather-icon`, icon);
       skycons.play();
     }, 1);
 
     return () => {
       clearTimeout(delay);
-    }
-  }, [props.weatherStatusCode, icon, props.theme.colors.accent]);
-
-  return <canvas id={`weather-icon`} width='50' height='50'></canvas>
-}
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    theme: state.theme.theme
-  }
-}
+    };
+  }, [props.weatherStatusCode, icon, theme.colors.accent]);
 
-export default connect(mapStateToProps)(WeatherIcon);
+  return <canvas id={`weather-icon`} width="50" height="50"></canvas>;
+};

+ 4 - 7
client/src/components/UI/Layout/Layout.tsx

@@ -1,13 +1,10 @@
+import { ReactNode } from 'react';
 import classes from './Layout.module.css';
 
 interface ComponentProps {
-  children: JSX.Element | JSX.Element[];
+  children: ReactNode;
 }
 
 export const Container = (props: ComponentProps): JSX.Element => {
-  return (
-    <div className={classes.Container}>
-      {props.children}
-    </div>
-  )
-}
+  return <div className={classes.Container}>{props.children}</div>;
+};

+ 11 - 10
client/src/components/UI/Modal/Modal.tsx

@@ -1,28 +1,29 @@
-import { MouseEvent, useRef } from 'react';
+import { MouseEvent, ReactNode, useRef } from 'react';
 
 import classes from './Modal.module.css';
 
-interface ComponentProps {
+interface Props {
   isOpen: boolean;
   setIsOpen: Function;
-  children: JSX.Element;
+  children: ReactNode;
 }
 
-const Modal = (props: ComponentProps): JSX.Element => {
+export const Modal = (props: Props): JSX.Element => {
   const modalRef = useRef(null);
-  const modalClasses = [classes.Modal, props.isOpen ? classes.ModalOpen : classes.ModalClose].join(' ');
+  const modalClasses = [
+    classes.Modal,
+    props.isOpen ? classes.ModalOpen : classes.ModalClose,
+  ].join(' ');
 
   const clickHandler = (e: MouseEvent) => {
     if (e.target === modalRef.current) {
       props.setIsOpen(false);
     }
-  }
+  };
 
   return (
     <div className={modalClasses} onClick={clickHandler} ref={modalRef}>
       {props.children}
     </div>
-  )
-}
-
-export default Modal;
+  );
+};

+ 11 - 10
client/src/components/UI/Notification/Notification.tsx

@@ -1,18 +1,21 @@
 import { useEffect, useState } from 'react';
-import { connect } from 'react-redux';
-import { clearNotification } from '../../../store/actions';
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../store';
 
 import classes from './Notification.module.css';
 
-interface ComponentProps {
+interface Props {
   title: string;
   message: string;
   id: number;
   url: string | null;
-  clearNotification: (id: number) => void;
 }
 
-const Notification = (props: ComponentProps): JSX.Element => {
+export const Notification = (props: Props): JSX.Element => {
+  const dispatch = useDispatch();
+  const { clearNotification } = bindActionCreators(actionCreators, dispatch);
+
   const [isOpen, setIsOpen] = useState(true);
   const elementClasses = [
     classes.Notification,
@@ -24,13 +27,13 @@ const Notification = (props: ComponentProps): JSX.Element => {
       setIsOpen(false);
     }, 3500);
 
-    const clearNotification = setTimeout(() => {
-      props.clearNotification(props.id);
+    const clearNotificationTimeout = setTimeout(() => {
+      clearNotification(props.id);
     }, 3600);
 
     return () => {
       window.clearTimeout(closeNotification);
-      window.clearTimeout(clearNotification);
+      window.clearTimeout(clearNotificationTimeout);
     };
   }, []);
 
@@ -48,5 +51,3 @@ const Notification = (props: ComponentProps): JSX.Element => {
     </div>
   );
 };
-
-export default connect(null, { clearNotification })(Notification);

+ 3 - 5
client/src/components/UI/Spinner/Spinner.tsx

@@ -1,11 +1,9 @@
 import classes from './Spinner.module.css';
 
-const Spinner = (): JSX.Element => {
+export const Spinner = (): JSX.Element => {
   return (
     <div className={classes.SpinnerWrapper}>
       <div className={classes.Spinner}>Loading...</div>
     </div>
-  )
-}
-
-export default Spinner;
+  );
+};

+ 11 - 11
client/src/components/UI/Table/Table.tsx

@@ -1,26 +1,26 @@
 import classes from './Table.module.css';
 
-interface ComponentProps {
-  children: JSX.Element | JSX.Element[];
+interface Props {
+  children: React.ReactNode;
   headers: string[];
   innerRef?: any;
 }
 
-const Table = (props: ComponentProps): JSX.Element => {
+export const Table = (props: Props): JSX.Element => {
   return (
     <div className={classes.TableContainer} ref={props.innerRef}>
       <table className={classes.Table}>
         <thead className={classes.TableHead}>
           <tr>
-            {props.headers.map((header: string, index: number): JSX.Element => (<th key={index}>{header}</th>))}
+            {props.headers.map(
+              (header: string, index: number): JSX.Element => (
+                <th key={index}>{header}</th>
+              )
+            )}
           </tr>
         </thead>
-        <tbody className={classes.TableBody}>
-          {props.children}
-        </tbody>
+        <tbody className={classes.TableBody}>{props.children}</tbody>
       </table>
     </div>
-  )
-}
-
-export default Table;
+  );
+};

+ 14 - 0
client/src/components/UI/index.ts

@@ -0,0 +1,14 @@
+export * from './Table/Table';
+export * from './Spinner/Spinner';
+export * from './Notification/Notification';
+export * from './Modal/Modal';
+export * from './Layout/Layout';
+export * from './Icons/Icon/Icon';
+export * from './Icons/WeatherIcon/WeatherIcon';
+export * from './Headlines/Headline/Headline';
+export * from './Headlines/SectionHeadline/SectionHeadline';
+export * from './Headlines/SettingsHeadline/SettingsHeadline';
+export * from './Forms/InputGroup/InputGroup';
+export * from './Forms/ModalForm/ModalForm';
+export * from './Buttons/ActionButton/ActionButton';
+export * from './Buttons/Button/Button';

+ 9 - 20
client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx

@@ -2,23 +2,21 @@ import { useState, useEffect, Fragment } from 'react';
 import axios from 'axios';
 
 // Redux
-import { connect } from 'react-redux';
+import { useSelector } from 'react-redux';
 
 // Typescript
-import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces';
+import { Weather, ApiResponse } from '../../../interfaces';
 
 // CSS
 import classes from './WeatherWidget.module.css';
 
 // UI
-import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
+import { WeatherIcon } from '../../UI';
+import { State } from '../../../store/reducers';
 
-interface ComponentProps {
-  configLoading: boolean;
-  config: Config;
-}
+export const WeatherWidget = (): JSX.Element => {
+  const { loading, config } = useSelector((state: State) => state.config);
 
-const WeatherWidget = (props: ComponentProps): JSX.Element => {
   const [weather, setWeather] = useState<Weather>({
     externalLastUpdate: '',
     tempC: 0,
@@ -68,8 +66,8 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
   return (
     <div className={classes.WeatherWidget}>
       {isLoading ||
-        props.configLoading ||
-        (props.config.WEATHER_API_KEY && weather.id > 0 && (
+        loading ||
+        (config.WEATHER_API_KEY && weather.id > 0 && (
           <Fragment>
             <div className={classes.WeatherIcon}>
               <WeatherIcon
@@ -78,7 +76,7 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
               />
             </div>
             <div className={classes.WeatherDetails}>
-              {props.config.isCelsius ? (
+              {config.isCelsius ? (
                 <span>{weather.tempC}°C</span>
               ) : (
                 <span>{weather.tempF}°F</span>
@@ -90,12 +88,3 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
     </div>
   );
 };
-
-const mapStateToProps = (state: GlobalState) => {
-  return {
-    configLoading: state.config.loading,
-    config: state.config.config,
-  };
-};
-
-export default connect(mapStateToProps)(WeatherWidget);

+ 9 - 3
client/src/index.tsx

@@ -1,11 +1,17 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import './index.css';
-import App from './App';
+
+import { Provider } from 'react-redux';
+import { store } from './store/store';
+
+import { App } from './App';
 
 ReactDOM.render(
   <React.StrictMode>
-    <App />
+    <Provider store={store}>
+      <App />
+    </Provider>
   </React.StrictMode>,
   document.getElementById('root')
-);
+);

+ 7 - 1
client/src/interfaces/Api.ts

@@ -7,4 +7,10 @@ export interface Model {
 export interface ApiResponse<T> {
   success: boolean;
   data: T;
-}
+}
+
+export interface Token {
+  app: string;
+  exp: number;
+  iat: number;
+}

+ 6 - 8
client/src/interfaces/App.ts

@@ -1,15 +1,13 @@
 import { Model } from '.';
 
-export interface App extends Model {
+export interface NewApp {
   name: string;
   url: string;
   icon: string;
-  isPinned: boolean;
-  orderId: number;
+  isPublic: boolean;
 }
 
-export interface NewApp {
-  name: string;
-  url: string;
-  icon: string;
-}
+export interface App extends Model, NewApp {
+  orderId: number;
+  isPinned: boolean;
+}

+ 3 - 7
client/src/interfaces/Bookmark.ts

@@ -1,15 +1,11 @@
 import { Model } from '.';
 
-export interface Bookmark extends Model {
+export interface NewBookmark {
   name: string;
   url: string;
   categoryId: number;
   icon: string;
+  isPublic: boolean;
 }
 
-export interface NewBookmark {
-  name: string;
-  url: string;
-  categoryId: number;
-  icon: string;
-}
+export interface Bookmark extends Model, NewBookmark {}

+ 5 - 5
client/src/interfaces/Category.ts

@@ -1,12 +1,12 @@
 import { Model, Bookmark } from '.';
 
-export interface Category extends Model {
+export interface NewCategory {
   name: string;
+  isPublic: boolean;
+}
+
+export interface Category extends Model, NewCategory {
   isPinned: boolean;
   orderId: number;
   bookmarks: Bookmark[];
 }
-
-export interface NewCategory {
-  name: string;
-}

+ 1 - 0
client/src/interfaces/Config.ts

@@ -24,4 +24,5 @@ export interface Config {
   greetingsSchema: string;
   daySchema: string;
   monthSchema: string;
+  showTime: boolean;
 }

+ 8 - 4
client/src/interfaces/Forms.ts

@@ -22,12 +22,16 @@ export interface OtherSettingsForm {
   useOrdering: string;
   appsSameTab: boolean;
   bookmarksSameTab: boolean;
-  dockerApps: boolean;
-  dockerHost: string;
-  kubernetesApps: boolean;
-  unpinStoppedApps: boolean;
   useAmericanDate: boolean;
   greetingsSchema: string;
   daySchema: string;
   monthSchema: string;
+  showTime: boolean;
+}
+
+export interface DockerSettingsForm {
+  dockerApps: boolean;
+  dockerHost: string;
+  kubernetesApps: boolean;
+  unpinStoppedApps: boolean;
 }

+ 0 - 13
client/src/interfaces/GlobalState.ts

@@ -1,13 +0,0 @@
-import { State as AppState } from '../store/reducers/app';
-import { State as ThemeState } from '../store/reducers/theme';
-import { State as BookmarkState } from '../store/reducers/bookmark';
-import { State as NotificationState } from '../store/reducers/notification';
-import { State as ConfigState } from '../store/reducers/config';
-
-export interface GlobalState {
-  theme: ThemeState;
-  app: AppState;
-  bookmark: BookmarkState;
-  notification: NotificationState;
-  config: ConfigState;
-}

+ 1 - 0
client/src/interfaces/Route.ts

@@ -1,4 +1,5 @@
 export interface Route {
   name: string;
   dest: string;
+  authRequired: boolean;
 }

+ 0 - 1
client/src/interfaces/index.ts

@@ -1,6 +1,5 @@
 export * from './App';
 export * from './Theme';
-export * from './GlobalState';
 export * from './Api';
 export * from './Weather';
 export * from './Bookmark';

+ 198 - 0
client/src/store/action-creators/app.ts

@@ -0,0 +1,198 @@
+import { ActionType } from '../action-types';
+import { Dispatch } from 'redux';
+import { ApiResponse, App, Config, NewApp } from '../../interfaces';
+import {
+  AddAppAction,
+  DeleteAppAction,
+  GetAppsAction,
+  PinAppAction,
+  ReorderAppsAction,
+  SortAppsAction,
+  UpdateAppAction,
+} from '../actions/app';
+import axios from 'axios';
+import { applyAuth } from '../../utility';
+
+export const getApps =
+  () => async (dispatch: Dispatch<GetAppsAction<undefined | App[]>>) => {
+    dispatch({
+      type: ActionType.getApps,
+      payload: undefined,
+    });
+
+    try {
+      const res = await axios.get<ApiResponse<App[]>>('/api/apps', {
+        headers: applyAuth(),
+      });
+
+      dispatch({
+        type: ActionType.getAppsSuccess,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const pinApp =
+  (app: App) => async (dispatch: Dispatch<PinAppAction>) => {
+    try {
+      const { id, isPinned, name } = app;
+      const res = await axios.put<ApiResponse<App>>(
+        `/api/apps/${id}`,
+        {
+          isPinned: !isPinned,
+        },
+        {
+          headers: applyAuth(),
+        }
+      );
+
+      const status = isPinned
+        ? 'unpinned from Homescreen'
+        : 'pinned to Homescreen';
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `App ${name} ${status}`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.pinApp,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const addApp =
+  (formData: NewApp | FormData) => async (dispatch: Dispatch<AddAppAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<App>>('/api/apps', formData, {
+        headers: applyAuth(),
+      });
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `App added`,
+        },
+      });
+
+      await dispatch({
+        type: ActionType.addAppSuccess,
+        payload: res.data.data,
+      });
+
+      // Sort apps
+      dispatch<any>(sortApps());
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const deleteApp =
+  (id: number) => async (dispatch: Dispatch<DeleteAppAction>) => {
+    try {
+      await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`, {
+        headers: applyAuth(),
+      });
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: 'App deleted',
+        },
+      });
+
+      dispatch({
+        type: ActionType.deleteApp,
+        payload: id,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const updateApp =
+  (id: number, formData: NewApp | FormData) =>
+  async (dispatch: Dispatch<UpdateAppAction>) => {
+    try {
+      const res = await axios.put<ApiResponse<App>>(
+        `/api/apps/${id}`,
+        formData,
+        {
+          headers: applyAuth(),
+        }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `App updated`,
+        },
+      });
+
+      await dispatch({
+        type: ActionType.updateApp,
+        payload: res.data.data,
+      });
+
+      // Sort apps
+      dispatch<any>(sortApps());
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const reorderApps =
+  (apps: App[]) => async (dispatch: Dispatch<ReorderAppsAction>) => {
+    interface ReorderQuery {
+      apps: {
+        id: number;
+        orderId: number;
+      }[];
+    }
+
+    try {
+      const updateQuery: ReorderQuery = { apps: [] };
+
+      apps.forEach((app, index) =>
+        updateQuery.apps.push({
+          id: app.id,
+          orderId: index + 1,
+        })
+      );
+
+      await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery, {
+        headers: applyAuth(),
+      });
+
+      dispatch({
+        type: ActionType.reorderApps,
+        payload: apps,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const sortApps = () => async (dispatch: Dispatch<SortAppsAction>) => {
+  try {
+    const res = await axios.get<ApiResponse<Config>>('/api/config');
+
+    dispatch({
+      type: ActionType.sortApps,
+      payload: res.data.data.useOrdering,
+    });
+  } catch (err) {
+    console.log(err);
+  }
+};

+ 85 - 0
client/src/store/action-creators/auth.ts

@@ -0,0 +1,85 @@
+import { Dispatch } from 'redux';
+import { ApiResponse } from '../../interfaces';
+import { ActionType } from '../action-types';
+import {
+  AuthErrorAction,
+  AutoLoginAction,
+  LoginAction,
+  LogoutAction,
+} from '../actions/auth';
+import axios, { AxiosError } from 'axios';
+import { getApps, getCategories } from '.';
+
+export const login =
+  (formData: { password: string; duration: string }) =>
+  async (dispatch: Dispatch<LoginAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<{ token: string }>>(
+        '/api/auth',
+        formData
+      );
+
+      localStorage.setItem('token', res.data.data.token);
+
+      dispatch({
+        type: ActionType.login,
+        payload: res.data.data.token,
+      });
+
+      dispatch<any>(getApps());
+      dispatch<any>(getCategories());
+    } catch (err) {
+      dispatch<any>(authError(err, true));
+    }
+  };
+
+export const logout = () => (dispatch: Dispatch<LogoutAction>) => {
+  localStorage.removeItem('token');
+
+  dispatch({
+    type: ActionType.logout,
+  });
+
+  dispatch<any>(getApps());
+  dispatch<any>(getCategories());
+};
+
+export const autoLogin = () => async (dispatch: Dispatch<AutoLoginAction>) => {
+  const token: string = localStorage.token;
+
+  try {
+    await axios.post<ApiResponse<{ token: { isValid: boolean } }>>(
+      '/api/auth/validate',
+      { token }
+    );
+
+    dispatch({
+      type: ActionType.autoLogin,
+      payload: token,
+    });
+
+    dispatch<any>(getApps());
+    dispatch<any>(getCategories());
+  } catch (err) {
+    dispatch<any>(authError(err, false));
+  }
+};
+
+export const authError =
+  (error: unknown, showNotification: boolean) =>
+  (dispatch: Dispatch<AuthErrorAction>) => {
+    const apiError = error as AxiosError;
+
+    if (showNotification) {
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Error',
+          message: apiError.response?.data.error,
+        },
+      });
+    }
+
+    dispatch<any>(getApps());
+    dispatch<any>(getCategories());
+  };

+ 321 - 0
client/src/store/action-creators/bookmark.ts

@@ -0,0 +1,321 @@
+import axios from 'axios';
+import { Dispatch } from 'redux';
+import {
+  ApiResponse,
+  Bookmark,
+  Category,
+  Config,
+  NewBookmark,
+  NewCategory,
+} from '../../interfaces';
+import { applyAuth } from '../../utility';
+import { ActionType } from '../action-types';
+import {
+  AddBookmarkAction,
+  AddCategoryAction,
+  DeleteBookmarkAction,
+  DeleteCategoryAction,
+  GetCategoriesAction,
+  PinCategoryAction,
+  ReorderCategoriesAction,
+  SortCategoriesAction,
+  UpdateBookmarkAction,
+  UpdateCategoryAction,
+} from '../actions/bookmark';
+
+export const getCategories =
+  () =>
+  async (dispatch: Dispatch<GetCategoriesAction<undefined | Category[]>>) => {
+    dispatch({
+      type: ActionType.getCategories,
+      payload: undefined,
+    });
+
+    try {
+      const res = await axios.get<ApiResponse<Category[]>>('/api/categories', {
+        headers: applyAuth(),
+      });
+
+      dispatch({
+        type: ActionType.getCategoriesSuccess,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const addCategory =
+  (formData: NewCategory) => async (dispatch: Dispatch<AddCategoryAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<Category>>(
+        '/api/categories',
+        formData,
+        { headers: applyAuth() }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Category ${formData.name} created`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.addCategory,
+        payload: res.data.data,
+      });
+
+      dispatch<any>(sortCategories());
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const addBookmark =
+  (formData: NewBookmark | FormData) =>
+  async (dispatch: Dispatch<AddBookmarkAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<Bookmark>>(
+        '/api/bookmarks',
+        formData,
+        { headers: applyAuth() }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Bookmark created`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.addBookmark,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const pinCategory =
+  (category: Category) => async (dispatch: Dispatch<PinCategoryAction>) => {
+    try {
+      const { id, isPinned, name } = category;
+      const res = await axios.put<ApiResponse<Category>>(
+        `/api/categories/${id}`,
+        { isPinned: !isPinned },
+        { headers: applyAuth() }
+      );
+
+      const status = isPinned
+        ? 'unpinned from Homescreen'
+        : 'pinned to Homescreen';
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Category ${name} ${status}`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.pinCategory,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const deleteCategory =
+  (id: number) => async (dispatch: Dispatch<DeleteCategoryAction>) => {
+    try {
+      await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`, {
+        headers: applyAuth(),
+      });
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Category deleted`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.deleteCategory,
+        payload: id,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const updateCategory =
+  (id: number, formData: NewCategory) =>
+  async (dispatch: Dispatch<UpdateCategoryAction>) => {
+    try {
+      const res = await axios.put<ApiResponse<Category>>(
+        `/api/categories/${id}`,
+        formData,
+        { headers: applyAuth() }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Category ${formData.name} updated`,
+        },
+      });
+
+      dispatch({
+        type: ActionType.updateCategory,
+        payload: res.data.data,
+      });
+
+      dispatch<any>(sortCategories());
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const deleteBookmark =
+  (bookmarkId: number, categoryId: number) =>
+  async (dispatch: Dispatch<DeleteBookmarkAction>) => {
+    try {
+      await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`, {
+        headers: applyAuth(),
+      });
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: 'Bookmark deleted',
+        },
+      });
+
+      dispatch({
+        type: ActionType.deleteBookmark,
+        payload: {
+          bookmarkId,
+          categoryId,
+        },
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const updateBookmark =
+  (
+    bookmarkId: number,
+    formData: NewBookmark | FormData,
+    category: {
+      prev: number;
+      curr: number;
+    }
+  ) =>
+  async (
+    dispatch: Dispatch<
+      DeleteBookmarkAction | AddBookmarkAction | UpdateBookmarkAction
+    >
+  ) => {
+    try {
+      const res = await axios.put<ApiResponse<Bookmark>>(
+        `/api/bookmarks/${bookmarkId}`,
+        formData,
+        { headers: applyAuth() }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: `Bookmark updated`,
+        },
+      });
+
+      // Check if category was changed
+      const categoryWasChanged = category.curr !== category.prev;
+
+      if (categoryWasChanged) {
+        // Delete bookmark from old category
+        dispatch({
+          type: ActionType.deleteBookmark,
+          payload: {
+            bookmarkId,
+            categoryId: category.prev,
+          },
+        });
+
+        // Add bookmark to the new category
+        dispatch({
+          type: ActionType.addBookmark,
+          payload: res.data.data,
+        });
+      } else {
+        // Else update only name/url/icon
+        dispatch({
+          type: ActionType.updateBookmark,
+          payload: res.data.data,
+        });
+      }
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const sortCategories =
+  () => async (dispatch: Dispatch<SortCategoriesAction>) => {
+    try {
+      const res = await axios.get<ApiResponse<Config>>('/api/config');
+
+      dispatch({
+        type: ActionType.sortCategories,
+        payload: res.data.data.useOrdering,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const reorderCategories =
+  (categories: Category[]) =>
+  async (dispatch: Dispatch<ReorderCategoriesAction>) => {
+    interface ReorderQuery {
+      categories: {
+        id: number;
+        orderId: number;
+      }[];
+    }
+
+    try {
+      const updateQuery: ReorderQuery = { categories: [] };
+
+      categories.forEach((category, index) =>
+        updateQuery.categories.push({
+          id: category.id,
+          orderId: index + 1,
+        })
+      );
+
+      await axios.put<ApiResponse<{}>>(
+        '/api/categories/0/reorder',
+        updateQuery,
+        { headers: applyAuth() }
+      );
+
+      dispatch({
+        type: ActionType.reorderCategories,
+        payload: categories,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };

+ 156 - 0
client/src/store/action-creators/config.ts

@@ -0,0 +1,156 @@
+import { Dispatch } from 'redux';
+import {
+  AddQueryAction,
+  DeleteQueryAction,
+  FetchQueriesAction,
+  GetConfigAction,
+  UpdateConfigAction,
+  UpdateQueryAction,
+} from '../actions/config';
+import axios from 'axios';
+import {
+  ApiResponse,
+  Config,
+  DockerSettingsForm,
+  OtherSettingsForm,
+  Query,
+  SearchForm,
+  WeatherForm,
+} from '../../interfaces';
+import { ActionType } from '../action-types';
+import { storeUIConfig, applyAuth } from '../../utility';
+
+const keys: (keyof Config)[] = [
+  'useAmericanDate',
+  'greetingsSchema',
+  'daySchema',
+  'monthSchema',
+  'showTime',
+];
+
+export const getConfig = () => async (dispatch: Dispatch<GetConfigAction>) => {
+  try {
+    const res = await axios.get<ApiResponse<Config>>('/api/config');
+
+    dispatch({
+      type: ActionType.getConfig,
+      payload: res.data.data,
+    });
+
+    // Set custom page title if set
+    document.title = res.data.data.customTitle;
+
+    // Store settings for priority UI elements
+    for (let key of keys) {
+      storeUIConfig(key, res.data.data);
+    }
+  } catch (err) {
+    console.log(err);
+  }
+};
+
+export const updateConfig =
+  (
+    formData: WeatherForm | OtherSettingsForm | SearchForm | DockerSettingsForm
+  ) =>
+  async (dispatch: Dispatch<UpdateConfigAction>) => {
+    try {
+      const res = await axios.put<ApiResponse<Config>>(
+        '/api/config',
+        formData,
+        {
+          headers: applyAuth(),
+        }
+      );
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: 'Settings updated',
+        },
+      });
+
+      dispatch({
+        type: ActionType.updateConfig,
+        payload: res.data.data,
+      });
+
+      // Store settings for priority UI elements
+      for (let key of keys) {
+        storeUIConfig(key, res.data.data);
+      }
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const fetchQueries =
+  () => async (dispatch: Dispatch<FetchQueriesAction>) => {
+    try {
+      const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
+
+      dispatch({
+        type: ActionType.fetchQueries,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const addQuery =
+  (query: Query) => async (dispatch: Dispatch<AddQueryAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<Query>>('/api/queries', query, {
+        headers: applyAuth(),
+      });
+
+      dispatch({
+        type: ActionType.addQuery,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const deleteQuery =
+  (prefix: string) => async (dispatch: Dispatch<DeleteQueryAction>) => {
+    try {
+      const res = await axios.delete<ApiResponse<Query[]>>(
+        `/api/queries/${prefix}`,
+        {
+          headers: applyAuth(),
+        }
+      );
+
+      dispatch({
+        type: ActionType.deleteQuery,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const updateQuery =
+  (query: Query, oldPrefix: string) =>
+  async (dispatch: Dispatch<UpdateQueryAction>) => {
+    try {
+      const res = await axios.put<ApiResponse<Query[]>>(
+        `/api/queries/${oldPrefix}`,
+        query,
+        {
+          headers: applyAuth(),
+        }
+      );
+
+      dispatch({
+        type: ActionType.updateQuery,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };

+ 6 - 0
client/src/store/action-creators/index.ts

@@ -0,0 +1,6 @@
+export * from './theme';
+export * from './config';
+export * from './notification';
+export * from './app';
+export * from './bookmark';
+export * from './auth';

+ 24 - 0
client/src/store/action-creators/notification.ts

@@ -0,0 +1,24 @@
+import { Dispatch } from 'redux';
+import { NewNotification } from '../../interfaces';
+import { ActionType } from '../action-types';
+import {
+  CreateNotificationAction,
+  ClearNotificationAction,
+} from '../actions/notification';
+
+export const createNotification =
+  (notification: NewNotification) =>
+  (dispatch: Dispatch<CreateNotificationAction>) => {
+    dispatch({
+      type: ActionType.createNotification,
+      payload: notification,
+    });
+  };
+
+export const clearNotification =
+  (id: number) => (dispatch: Dispatch<ClearNotificationAction>) => {
+    dispatch({
+      type: ActionType.clearNotification,
+      payload: id,
+    });
+  };

+ 26 - 0
client/src/store/action-creators/theme.ts

@@ -0,0 +1,26 @@
+import { Dispatch } from 'redux';
+import { SetThemeAction } from '../actions/theme';
+import { ActionType } from '../action-types';
+import { Theme } from '../../interfaces/Theme';
+import { themes } from '../../components/Themer/themes.json';
+
+export const setTheme =
+  (name: string) => (dispatch: Dispatch<SetThemeAction>) => {
+    const theme = themes.find((theme) => theme.name === name);
+
+    if (theme) {
+      localStorage.setItem('theme', name);
+      loadTheme(theme);
+
+      dispatch({
+        type: ActionType.setTheme,
+        payload: theme,
+      });
+    }
+  };
+
+export const loadTheme = (theme: Theme): void => {
+  for (const [key, value] of Object.entries(theme.colors)) {
+    document.body.style.setProperty(`--color-${key}`, value);
+  }
+};

+ 45 - 0
client/src/store/action-types/index.ts

@@ -0,0 +1,45 @@
+export enum ActionType {
+  // THEME
+  setTheme = 'SET_THEME',
+  // CONFIG
+  getConfig = 'GET_CONFIG',
+  updateConfig = 'UPDATE_CONFIG',
+  // QUERIES
+  addQuery = 'ADD_QUERY',
+  deleteQuery = 'DELETE_QUERY',
+  fetchQueries = 'FETCH_QUERIES',
+  updateQuery = 'UPDATE_QUERY',
+  // NOTIFICATIONS
+  createNotification = 'CREATE_NOTIFICATION',
+  clearNotification = 'CLEAR_NOTIFICATION',
+  // APPS
+  getApps = 'GET_APPS',
+  getAppsSuccess = 'GET_APPS_SUCCESS',
+  getAppsError = 'GET_APPS_ERROR',
+  pinApp = 'PIN_APP',
+  addApp = 'ADD_APP',
+  addAppSuccess = 'ADD_APP_SUCCESS',
+  deleteApp = 'DELETE_APP',
+  updateApp = 'UPDATE_APP',
+  reorderApps = 'REORDER_APPS',
+  sortApps = 'SORT_APPS',
+  // CATEGORES
+  getCategories = 'GET_CATEGORIES',
+  getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
+  getCategoriesError = 'GET_CATEGORIES_ERROR',
+  addCategory = 'ADD_CATEGORY',
+  pinCategory = 'PIN_CATEGORY',
+  deleteCategory = 'DELETE_CATEGORY',
+  updateCategory = 'UPDATE_CATEGORY',
+  sortCategories = 'SORT_CATEGORIES',
+  reorderCategories = 'REORDER_CATEGORIES',
+  // BOOKMARKS
+  addBookmark = 'ADD_BOOKMARK',
+  deleteBookmark = 'DELETE_BOOKMARK',
+  updateBookmark = 'UPDATE_BOOKMARK',
+  // AUTH
+  login = 'LOGIN',
+  logout = 'LOGOUT',
+  autoLogin = 'AUTO_LOGIN',
+  authError = 'AUTH_ERROR',
+}

+ 0 - 110
client/src/store/actions/actionTypes.ts

@@ -1,110 +0,0 @@
-import {
-  // Theme
-  SetThemeAction,
-  // Apps
-  GetAppsAction,
-  PinAppAction,
-  AddAppAction,
-  DeleteAppAction,
-  UpdateAppAction,
-  ReorderAppsAction,
-  SortAppsAction,
-  // Categories
-  GetCategoriesAction,
-  AddCategoryAction,
-  PinCategoryAction,
-  DeleteCategoryAction,
-  UpdateCategoryAction,
-  SortCategoriesAction,
-  ReorderCategoriesAction,
-  // Bookmarks
-  AddBookmarkAction,
-  DeleteBookmarkAction,
-  UpdateBookmarkAction,
-  // Notifications
-  CreateNotificationAction,
-  ClearNotificationAction,
-  // Config
-  GetConfigAction,
-  UpdateConfigAction,
-} from './';
-import {
-  AddQueryAction,
-  DeleteQueryAction,
-  FetchQueriesAction,
-  UpdateQueryAction,
-} from './config';
-
-export enum ActionTypes {
-  // Theme
-  setTheme = 'SET_THEME',
-  // Apps
-  getApps = 'GET_APPS',
-  getAppsSuccess = 'GET_APPS_SUCCESS',
-  getAppsError = 'GET_APPS_ERROR',
-  pinApp = 'PIN_APP',
-  addApp = 'ADD_APP',
-  addAppSuccess = 'ADD_APP_SUCCESS',
-  deleteApp = 'DELETE_APP',
-  updateApp = 'UPDATE_APP',
-  reorderApps = 'REORDER_APPS',
-  sortApps = 'SORT_APPS',
-  // Categories
-  getCategories = 'GET_CATEGORIES',
-  getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
-  getCategoriesError = 'GET_CATEGORIES_ERROR',
-  addCategory = 'ADD_CATEGORY',
-  pinCategory = 'PIN_CATEGORY',
-  deleteCategory = 'DELETE_CATEGORY',
-  updateCategory = 'UPDATE_CATEGORY',
-  sortCategories = 'SORT_CATEGORIES',
-  reorderCategories = 'REORDER_CATEGORIES',
-  // Bookmarks
-  addBookmark = 'ADD_BOOKMARK',
-  deleteBookmark = 'DELETE_BOOKMARK',
-  updateBookmark = 'UPDATE_BOOKMARK',
-  // Notifications
-  createNotification = 'CREATE_NOTIFICATION',
-  clearNotification = 'CLEAR_NOTIFICATION',
-  // Config
-  getConfig = 'GET_CONFIG',
-  updateConfig = 'UPDATE_CONFIG',
-  fetchQueries = 'FETCH_QUERIES',
-  addQuery = 'ADD_QUERY',
-  deleteQuery = 'DELETE_QUERY',
-  updateQuery = 'UPDATE_QUERY',
-}
-
-export type Action =
-  // Theme
-  | SetThemeAction
-  // Apps
-  | GetAppsAction<any>
-  | PinAppAction
-  | AddAppAction
-  | DeleteAppAction
-  | UpdateAppAction
-  | ReorderAppsAction
-  | SortAppsAction
-  // Categories
-  | GetCategoriesAction<any>
-  | AddCategoryAction
-  | PinCategoryAction
-  | DeleteCategoryAction
-  | UpdateCategoryAction
-  | SortCategoriesAction
-  | ReorderCategoriesAction
-  // Bookmarks
-  | AddBookmarkAction
-  | DeleteBookmarkAction
-  | UpdateBookmarkAction
-  // Notifications
-  | CreateNotificationAction
-  | ClearNotificationAction
-  // Config
-  | GetConfigAction
-  | UpdateConfigAction
-  | FetchQueriesAction
-  | AddQueryAction
-  | DeleteQueryAction
-  | UpdateQueryAction;

+ 11 - 178
client/src/store/actions/app.ts

@@ -1,205 +1,38 @@
-import axios from 'axios';
-import { Dispatch } from 'redux';
-import { ActionTypes } from './actionTypes';
-import { App, ApiResponse, NewApp, Config } from '../../interfaces';
-import { CreateNotificationAction } from './notification';
+import { ActionType } from '../action-types';
+import { App } from '../../interfaces';
 
 export interface GetAppsAction<T> {
   type:
-    | ActionTypes.getApps
-    | ActionTypes.getAppsSuccess
-    | ActionTypes.getAppsError;
+    | ActionType.getApps
+    | ActionType.getAppsSuccess
+    | ActionType.getAppsError;
   payload: T;
 }
-
-export const getApps = () => async (dispatch: Dispatch) => {
-  dispatch<GetAppsAction<undefined>>({
-    type: ActionTypes.getApps,
-    payload: undefined,
-  });
-
-  try {
-    const res = await axios.get<ApiResponse<App[]>>('/api/apps');
-
-    dispatch<GetAppsAction<App[]>>({
-      type: ActionTypes.getAppsSuccess,
-      payload: res.data.data,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
 export interface PinAppAction {
-  type: ActionTypes.pinApp;
+  type: ActionType.pinApp;
   payload: App;
 }
 
-export const pinApp = (app: App) => async (dispatch: Dispatch) => {
-  try {
-    const { id, isPinned, name } = app;
-    const res = await axios.put<ApiResponse<App>>(`/api/apps/${id}`, {
-      isPinned: !isPinned,
-    });
-
-    const status = isPinned
-      ? 'unpinned from Homescreen'
-      : 'pinned to Homescreen';
-
-    dispatch<CreateNotificationAction>({
-      type: ActionTypes.createNotification,
-      payload: {
-        title: 'Success',
-        message: `App ${name} ${status}`,
-      },
-    });
-
-    dispatch<PinAppAction>({
-      type: ActionTypes.pinApp,
-      payload: res.data.data,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
 export interface AddAppAction {
-  type: ActionTypes.addAppSuccess;
+  type: ActionType.addAppSuccess;
   payload: App;
 }
-
-export const addApp =
-  (formData: NewApp | FormData) => async (dispatch: Dispatch) => {
-    try {
-      const res = await axios.post<ApiResponse<App>>('/api/apps', formData);
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `App added`,
-        },
-      });
-
-      await dispatch<AddAppAction>({
-        type: ActionTypes.addAppSuccess,
-        payload: res.data.data,
-      });
-
-      // Sort apps
-      dispatch<any>(sortApps());
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
 export interface DeleteAppAction {
-  type: ActionTypes.deleteApp;
+  type: ActionType.deleteApp;
   payload: number;
 }
 
-export const deleteApp = (id: number) => async (dispatch: Dispatch) => {
-  try {
-    await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`);
-
-    dispatch<CreateNotificationAction>({
-      type: ActionTypes.createNotification,
-      payload: {
-        title: 'Success',
-        message: 'App deleted',
-      },
-    });
-
-    dispatch<DeleteAppAction>({
-      type: ActionTypes.deleteApp,
-      payload: id,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
 export interface UpdateAppAction {
-  type: ActionTypes.updateApp;
+  type: ActionType.updateApp;
   payload: App;
 }
 
-export const updateApp =
-  (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => {
-    try {
-      const res = await axios.put<ApiResponse<App>>(
-        `/api/apps/${id}`,
-        formData
-      );
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `App updated`,
-        },
-      });
-
-      await dispatch<UpdateAppAction>({
-        type: ActionTypes.updateApp,
-        payload: res.data.data,
-      });
-
-      // Sort apps
-      dispatch<any>(sortApps());
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
 export interface ReorderAppsAction {
-  type: ActionTypes.reorderApps;
+  type: ActionType.reorderApps;
   payload: App[];
 }
 
-interface ReorderQuery {
-  apps: {
-    id: number;
-    orderId: number;
-  }[];
-}
-
-export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => {
-  try {
-    const updateQuery: ReorderQuery = { apps: [] };
-
-    apps.forEach((app, index) =>
-      updateQuery.apps.push({
-        id: app.id,
-        orderId: index + 1,
-      })
-    );
-
-    await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery);
-
-    dispatch<ReorderAppsAction>({
-      type: ActionTypes.reorderApps,
-      payload: apps,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
 export interface SortAppsAction {
-  type: ActionTypes.sortApps;
+  type: ActionType.sortApps;
   payload: string;
 }
-
-export const sortApps = () => async (dispatch: Dispatch) => {
-  try {
-    const res = await axios.get<ApiResponse<Config>>('/api/config');
-
-    dispatch<SortAppsAction>({
-      type: ActionTypes.sortApps,
-      payload: res.data.data.useOrdering,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};

+ 19 - 0
client/src/store/actions/auth.ts

@@ -0,0 +1,19 @@
+import { ActionType } from '../action-types';
+
+export interface LoginAction {
+  type: ActionType.login;
+  payload: string;
+}
+
+export interface LogoutAction {
+  type: ActionType.logout;
+}
+
+export interface AutoLoginAction {
+  type: ActionType.autoLogin;
+  payload: string;
+}
+
+export interface AuthErrorAction {
+  type: ActionType.authError;
+}

+ 14 - 327
client/src/store/actions/bookmark.ts

@@ -1,371 +1,58 @@
-import axios from 'axios';
-import { Dispatch } from 'redux';
-import { ActionTypes } from './actionTypes';
-import {
-  Category,
-  ApiResponse,
-  NewCategory,
-  Bookmark,
-  NewBookmark,
-  Config,
-} from '../../interfaces';
-import { CreateNotificationAction } from './notification';
+import { Bookmark, Category } from '../../interfaces';
+import { ActionType } from '../action-types';
 
-/**
- * GET CATEGORIES
- */
 export interface GetCategoriesAction<T> {
   type:
-    | ActionTypes.getCategories
-    | ActionTypes.getCategoriesSuccess
-    | ActionTypes.getCategoriesError;
+    | ActionType.getCategories
+    | ActionType.getCategoriesSuccess
+    | ActionType.getCategoriesError;
   payload: T;
 }
 
-export const getCategories = () => async (dispatch: Dispatch) => {
-  dispatch<GetCategoriesAction<undefined>>({
-    type: ActionTypes.getCategories,
-    payload: undefined,
-  });
-
-  try {
-    const res = await axios.get<ApiResponse<Category[]>>('/api/categories');
-
-    dispatch<GetCategoriesAction<Category[]>>({
-      type: ActionTypes.getCategoriesSuccess,
-      payload: res.data.data,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
-/**
- * ADD CATEGORY
- */
 export interface AddCategoryAction {
-  type: ActionTypes.addCategory;
+  type: ActionType.addCategory;
   payload: Category;
 }
 
-export const addCategory =
-  (formData: NewCategory) => async (dispatch: Dispatch) => {
-    try {
-      const res = await axios.post<ApiResponse<Category>>(
-        '/api/categories',
-        formData
-      );
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `Category ${formData.name} created`,
-        },
-      });
-
-      dispatch<AddCategoryAction>({
-        type: ActionTypes.addCategory,
-        payload: res.data.data,
-      });
-
-      dispatch<any>(sortCategories());
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-/**
- * ADD BOOKMARK
- */
 export interface AddBookmarkAction {
-  type: ActionTypes.addBookmark;
+  type: ActionType.addBookmark;
   payload: Bookmark;
 }
 
-export const addBookmark =
-  (formData: NewBookmark | FormData) => async (dispatch: Dispatch) => {
-    try {
-      const res = await axios.post<ApiResponse<Bookmark>>(
-        '/api/bookmarks',
-        formData
-      );
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `Bookmark created`,
-        },
-      });
-
-      dispatch<AddBookmarkAction>({
-        type: ActionTypes.addBookmark,
-        payload: res.data.data,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-/**
- * PIN CATEGORY
- */
 export interface PinCategoryAction {
-  type: ActionTypes.pinCategory;
+  type: ActionType.pinCategory;
   payload: Category;
 }
 
-export const pinCategory =
-  (category: Category) => async (dispatch: Dispatch) => {
-    try {
-      const { id, isPinned, name } = category;
-      const res = await axios.put<ApiResponse<Category>>(
-        `/api/categories/${id}`,
-        { isPinned: !isPinned }
-      );
-
-      const status = isPinned
-        ? 'unpinned from Homescreen'
-        : 'pinned to Homescreen';
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `Category ${name} ${status}`,
-        },
-      });
-
-      dispatch<PinCategoryAction>({
-        type: ActionTypes.pinCategory,
-        payload: res.data.data,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-/**
- * DELETE CATEGORY
- */
 export interface DeleteCategoryAction {
-  type: ActionTypes.deleteCategory;
+  type: ActionType.deleteCategory;
   payload: number;
 }
 
-export const deleteCategory = (id: number) => async (dispatch: Dispatch) => {
-  try {
-    await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`);
-
-    dispatch<CreateNotificationAction>({
-      type: ActionTypes.createNotification,
-      payload: {
-        title: 'Success',
-        message: `Category deleted`,
-      },
-    });
-
-    dispatch<DeleteCategoryAction>({
-      type: ActionTypes.deleteCategory,
-      payload: id,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
-/**
- * UPDATE CATEGORY
- */
 export interface UpdateCategoryAction {
-  type: ActionTypes.updateCategory;
+  type: ActionType.updateCategory;
   payload: Category;
 }
 
-export const updateCategory =
-  (id: number, formData: NewCategory) => async (dispatch: Dispatch) => {
-    try {
-      const res = await axios.put<ApiResponse<Category>>(
-        `/api/categories/${id}`,
-        formData
-      );
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `Category ${formData.name} updated`,
-        },
-      });
-
-      dispatch<UpdateCategoryAction>({
-        type: ActionTypes.updateCategory,
-        payload: res.data.data,
-      });
-
-      dispatch<any>(sortCategories());
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-/**
- * DELETE BOOKMARK
- */
 export interface DeleteBookmarkAction {
-  type: ActionTypes.deleteBookmark;
+  type: ActionType.deleteBookmark;
   payload: {
     bookmarkId: number;
     categoryId: number;
   };
 }
 
-export const deleteBookmark =
-  (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => {
-    try {
-      await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: 'Bookmark deleted',
-        },
-      });
-
-      dispatch<DeleteBookmarkAction>({
-        type: ActionTypes.deleteBookmark,
-        payload: {
-          bookmarkId,
-          categoryId,
-        },
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-/**
- * UPDATE BOOKMARK
- */
 export interface UpdateBookmarkAction {
-  type: ActionTypes.updateBookmark;
+  type: ActionType.updateBookmark;
   payload: Bookmark;
 }
 
-export const updateBookmark =
-  (
-    bookmarkId: number,
-    formData: NewBookmark | FormData,
-    category: {
-      prev: number;
-      curr: number;
-    }
-  ) =>
-  async (dispatch: Dispatch) => {
-    try {
-      const res = await axios.put<ApiResponse<Bookmark>>(
-        `/api/bookmarks/${bookmarkId}`,
-        formData
-      );
-
-      dispatch<CreateNotificationAction>({
-        type: ActionTypes.createNotification,
-        payload: {
-          title: 'Success',
-          message: `Bookmark updated`,
-        },
-      });
-
-      // Check if category was changed
-      const categoryWasChanged = category.curr !== category.prev;
-
-      if (categoryWasChanged) {
-        // Delete bookmark from old category
-        dispatch<DeleteBookmarkAction>({
-          type: ActionTypes.deleteBookmark,
-          payload: {
-            bookmarkId,
-            categoryId: category.prev,
-          },
-        });
-
-        // Add bookmark to the new category
-        dispatch<AddBookmarkAction>({
-          type: ActionTypes.addBookmark,
-          payload: res.data.data,
-        });
-      } else {
-        // Else update only name/url/icon
-        dispatch<UpdateBookmarkAction>({
-          type: ActionTypes.updateBookmark,
-          payload: res.data.data,
-        });
-      }
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-/**
- * SORT CATEGORIES
- */
 export interface SortCategoriesAction {
-  type: ActionTypes.sortCategories;
+  type: ActionType.sortCategories;
   payload: string;
 }
 
-export const sortCategories = () => async (dispatch: Dispatch) => {
-  try {
-    const res = await axios.get<ApiResponse<Config>>('/api/config');
-
-    dispatch<SortCategoriesAction>({
-      type: ActionTypes.sortCategories,
-      payload: res.data.data.useOrdering,
-    });
-  } catch (err) {
-    console.log(err);
-  }
-};
-
-/**
- * REORDER CATEGORIES
- */
 export interface ReorderCategoriesAction {
-  type: ActionTypes.reorderCategories;
+  type: ActionType.reorderCategories;
   payload: Category[];
 }
-
-interface ReorderQuery {
-  categories: {
-    id: number;
-    orderId: number;
-  }[];
-}
-
-export const reorderCategories =
-  (categories: Category[]) => async (dispatch: Dispatch) => {
-    try {
-      const updateQuery: ReorderQuery = { categories: [] };
-
-      categories.forEach((category, index) =>
-        updateQuery.categories.push({
-          id: category.id,
-          orderId: index + 1,
-        })
-      );
-
-      await axios.put<ApiResponse<{}>>(
-        '/api/categories/0/reorder',
-        updateQuery
-      );
-
-      dispatch<ReorderCategoriesAction>({
-        type: ActionTypes.reorderCategories,
-        payload: categories,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };

+ 8 - 133
client/src/store/actions/config.ts

@@ -1,157 +1,32 @@
-import axios from 'axios';
-import { Dispatch } from 'redux';
-import { ActionTypes } from './actionTypes';
-import { Config, ApiResponse, Query } from '../../interfaces';
-import { CreateNotificationAction } from './notification';
-import { storeUIConfig } from '../../utility';
+import { ActionType } from '../action-types';
+import { Config, Query } from '../../interfaces';
 
 export interface GetConfigAction {
-  type: ActionTypes.getConfig;
+  type: ActionType.getConfig;
   payload: Config;
 }
 
-export const getConfig = () => async (dispatch: Dispatch) => {
-  try {
-    const res = await axios.get<ApiResponse<Config>>('/api/config');
-
-    dispatch<GetConfigAction>({
-      type: ActionTypes.getConfig,
-      payload: res.data.data,
-    });
-
-    // Set custom page title if set
-    document.title = res.data.data.customTitle;
-
-    // Store settings for priority UI elements
-    const keys: (keyof Config)[] = [
-      'useAmericanDate',
-      'greetingsSchema',
-      'daySchema',
-      'monthSchema',
-    ];
-    for (let key of keys) {
-      storeUIConfig(key, res.data.data);
-    }
-  } catch (err) {
-    console.log(err);
-  }
-};
-
 export interface UpdateConfigAction {
-  type: ActionTypes.updateConfig;
+  type: ActionType.updateConfig;
   payload: Config;
 }
 
-export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
-  try {
-    const res = await axios.put<ApiResponse<Config>>('/api/config', formData);
-
-    dispatch<CreateNotificationAction>({
-      type: ActionTypes.createNotification,
-      payload: {
-        title: 'Success',
-        message: 'Settings updated',
-      },
-    });
-
-    dispatch<UpdateConfigAction>({
-      type: ActionTypes.updateConfig,
-      payload: res.data.data,
-    });
-
-    // Store settings for priority UI elements
-    const keys: (keyof Config)[] = [
-      'useAmericanDate',
-      'greetingsSchema',
-      'daySchema',
-      'monthSchema',
-    ];
-    for (let key of keys) {
-      storeUIConfig(key, res.data.data);
-    }
-  } catch (err) {
-    console.log(err);
-  }
-};
-
 export interface FetchQueriesAction {
-  type: ActionTypes.fetchQueries;
+  type: ActionType.fetchQueries;
   payload: Query[];
 }
 
-export const fetchQueries =
-  () => async (dispatch: Dispatch<FetchQueriesAction>) => {
-    try {
-      const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
-
-      dispatch<FetchQueriesAction>({
-        type: ActionTypes.fetchQueries,
-        payload: res.data.data,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
 export interface AddQueryAction {
-  type: ActionTypes.addQuery;
+  type: ActionType.addQuery;
   payload: Query;
 }
 
-export const addQuery =
-  (query: Query) => async (dispatch: Dispatch<AddQueryAction>) => {
-    try {
-      const res = await axios.post<ApiResponse<Query>>('/api/queries', query);
-
-      dispatch<AddQueryAction>({
-        type: ActionTypes.addQuery,
-        payload: res.data.data,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
 export interface DeleteQueryAction {
-  type: ActionTypes.deleteQuery;
+  type: ActionType.deleteQuery;
   payload: Query[];
 }
 
-export const deleteQuery =
-  (prefix: string) => async (dispatch: Dispatch<DeleteQueryAction>) => {
-    try {
-      const res = await axios.delete<ApiResponse<Query[]>>(
-        `/api/queries/${prefix}`
-      );
-
-      dispatch<DeleteQueryAction>({
-        type: ActionTypes.deleteQuery,
-        payload: res.data.data,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
 export interface UpdateQueryAction {
-  type: ActionTypes.updateQuery;
+  type: ActionType.updateQuery;
   payload: Query[];
 }
-
-export const updateQuery =
-  (query: Query, oldPrefix: string) =>
-  async (dispatch: Dispatch<UpdateQueryAction>) => {
-    try {
-      const res = await axios.put<ApiResponse<Query[]>>(
-        `/api/queries/${oldPrefix}`,
-        query
-      );
-
-      dispatch<UpdateQueryAction>({
-        type: ActionTypes.updateQuery,
-        payload: res.data.data,
-      });
-    } catch (err) {
-      console.log(err);
-    }
-  };

+ 86 - 6
client/src/store/actions/index.ts

@@ -1,6 +1,86 @@
-export * from './theme';
-export * from './app';
-export * from './actionTypes';
-export * from './bookmark';
-export * from './notification';
-export * from './config';
+import { App } from '../../interfaces';
+
+import { SetThemeAction } from './theme';
+
+import {
+  AddQueryAction,
+  DeleteQueryAction,
+  FetchQueriesAction,
+  GetConfigAction,
+  UpdateConfigAction,
+  UpdateQueryAction,
+} from './config';
+
+import {
+  ClearNotificationAction,
+  CreateNotificationAction,
+} from './notification';
+
+import {
+  GetAppsAction,
+  PinAppAction,
+  AddAppAction,
+  DeleteAppAction,
+  UpdateAppAction,
+  ReorderAppsAction,
+  SortAppsAction,
+} from './app';
+
+import {
+  GetCategoriesAction,
+  AddCategoryAction,
+  PinCategoryAction,
+  DeleteCategoryAction,
+  UpdateCategoryAction,
+  SortCategoriesAction,
+  ReorderCategoriesAction,
+  AddBookmarkAction,
+  DeleteBookmarkAction,
+  UpdateBookmarkAction,
+} from './bookmark';
+
+import {
+  AuthErrorAction,
+  AutoLoginAction,
+  LoginAction,
+  LogoutAction,
+} from './auth';
+
+export type Action =
+  // Theme
+  | SetThemeAction
+  // Config
+  | GetConfigAction
+  | UpdateConfigAction
+  | AddQueryAction
+  | DeleteQueryAction
+  | FetchQueriesAction
+  | UpdateQueryAction
+  // Notifications
+  | CreateNotificationAction
+  | ClearNotificationAction
+  // Apps
+  | GetAppsAction<undefined | App[]>
+  | PinAppAction
+  | AddAppAction
+  | DeleteAppAction
+  | UpdateAppAction
+  | ReorderAppsAction
+  | SortAppsAction
+  // Categories
+  | GetCategoriesAction<any>
+  | AddCategoryAction
+  | PinCategoryAction
+  | DeleteCategoryAction
+  | UpdateCategoryAction
+  | SortCategoriesAction
+  | ReorderCategoriesAction
+  // Bookmarks
+  | AddBookmarkAction
+  | DeleteBookmarkAction
+  | UpdateBookmarkAction
+  // Auth
+  | LoginAction
+  | LogoutAction
+  | AutoLoginAction
+  | AuthErrorAction;

+ 5 - 20
client/src/store/actions/notification.ts

@@ -1,27 +1,12 @@
-import { Dispatch } from 'redux';
-import { ActionTypes } from '.';
+import { ActionType } from '../action-types';
 import { NewNotification } from '../../interfaces';
 
 export interface CreateNotificationAction {
-  type: ActionTypes.createNotification,
-  payload: NewNotification
-}
-
-export const createNotification = (notification: NewNotification) => (dispatch: Dispatch) => {
-  dispatch<CreateNotificationAction>({
-    type: ActionTypes.createNotification,
-    payload: notification
-  })
+  type: ActionType.createNotification;
+  payload: NewNotification;
 }
 
 export interface ClearNotificationAction {
-  type: ActionTypes.clearNotification,
-  payload: number
+  type: ActionType.clearNotification;
+  payload: number;
 }
-
-export const clearNotification = (id: number) => (dispatch: Dispatch) => {
-  dispatch<ClearNotificationAction>({
-    type: ActionTypes.clearNotification,
-    payload: id
-  })
-}

+ 4 - 26
client/src/store/actions/theme.ts

@@ -1,29 +1,7 @@
-import { Dispatch } from 'redux';
-import { themes } from '../../components/Themer/themes.json';
-import { Theme } from '../../interfaces/Theme';
-import { ActionTypes } from './actionTypes';
+import { ActionType } from '../action-types';
+import { Theme } from '../../interfaces';
 
 export interface SetThemeAction {
-  type: ActionTypes.setTheme,
-  payload: Theme
+  type: ActionType.setTheme;
+  payload: Theme;
 }
-
-export const setTheme = (themeName: string) => (dispatch: Dispatch) => {
-  const theme = themes.find((theme: Theme) => theme.name === themeName);
-
-  if (theme) {
-    localStorage.setItem('theme', themeName);
-    loadTheme(theme);
-
-    dispatch<SetThemeAction>({
-      type: ActionTypes.setTheme,
-      payload: theme
-    })
-  }
-}
-
-export const loadTheme = (theme: Theme): void => {
-  for (const [key, value] of Object.entries(theme.colors)) {
-    document.body.style.setProperty(`--color-${key}`, value);
-  }
-}

+ 2 - 0
client/src/store/index.ts

@@ -0,0 +1,2 @@
+export * from './store';
+export * as actionCreators from './action-creators';

+ 80 - 106
client/src/store/reducers/app.ts

@@ -1,118 +1,92 @@
-import { ActionTypes, Action } from '../actions';
-import { App } from '../../interfaces/App';
+import { ActionType } from '../action-types';
+import { Action } from '../actions/index';
+import { App } from '../../interfaces';
 import { sortData } from '../../utility';
 
-export interface State {
+interface AppsState {
   loading: boolean;
   apps: App[];
   errors: string | undefined;
 }
 
-const initialState: State = {
+const initialState: AppsState = {
   loading: true,
   apps: [],
-  errors: undefined
-}
-
-const getApps = (state: State, action: Action): State => {
-  return {
-    ...state,
-    loading: true,
-    errors: undefined
-  }
-}
-
-const getAppsSuccess = (state: State, action: Action): State => {
-  return {
-    ...state,
-    loading: false,
-    apps: action.payload
-  }
-}
-
-const getAppsError = (state: State, action: Action): State => {
-  return {
-    ...state,
-    loading: false,
-    errors: action.payload
-  }
-}
-
-const pinApp = (state: State, action: Action): State => {
-  const tmpApps = [...state.apps];
-  const changedApp = tmpApps.find((app: App) => app.id === action.payload.id);
-  
-  if (changedApp) {
-    changedApp.isPinned = action.payload.isPinned;
-  }
-  
-  return {
-    ...state,
-    apps: tmpApps
-  }
-}
-
-const addAppSuccess = (state: State, action: Action): State => {
-  return {
-    ...state,
-    apps: [...state.apps, action.payload]
-  }
-}
-
-const deleteApp = (state: State, action: Action): State => {
-  const tmpApps = [...state.apps].filter((app: App) => app.id !== action.payload);
+  errors: undefined,
+};
 
-  return {
-    ...state,
-    apps: tmpApps
-  }
-}
-
-const updateApp = (state: State, action: Action): State => {
-  const tmpApps = [...state.apps];
-  const appInUpdate = tmpApps.find((app: App) => app.id === action.payload.id);
-
-  if (appInUpdate) {
-    appInUpdate.name = action.payload.name;
-    appInUpdate.url = action.payload.url;
-    appInUpdate.icon = action.payload.icon;
-  }
-
-  return {
-    ...state,
-    apps: tmpApps
-  }
-}
-
-const reorderApps = (state: State, action: Action): State => {
-  return {
-    ...state,
-    apps: action.payload
-  }
-}
-
-const sortApps = (state: State, action: Action): State => {
-  const sortedApps = sortData<App>(state.apps, action.payload);
-
-  return {
-    ...state,
-    apps: sortedApps
-  }
-}
-
-const appReducer = (state = initialState, action: Action) => {
+export const appsReducer = (
+  state: AppsState = initialState,
+  action: Action
+): AppsState => {
   switch (action.type) {
-    case ActionTypes.getApps: return getApps(state, action);
-    case ActionTypes.getAppsSuccess: return getAppsSuccess(state, action);
-    case ActionTypes.getAppsError: return getAppsError(state, action);
-    case ActionTypes.pinApp: return pinApp(state, action);
-    case ActionTypes.addAppSuccess: return addAppSuccess(state, action);
-    case ActionTypes.deleteApp: return deleteApp(state, action);
-    case ActionTypes.updateApp: return updateApp(state, action);
-    case ActionTypes.reorderApps: return reorderApps(state, action);
-    case ActionTypes.sortApps: return sortApps(state, action);
-    default: return state;
+    case ActionType.getApps:
+      return {
+        ...state,
+        loading: true,
+        errors: undefined,
+      };
+
+    case ActionType.getAppsSuccess:
+      return {
+        ...state,
+        loading: false,
+        apps: action.payload || [],
+      };
+
+    case ActionType.pinApp:
+      const pinnedAppIdx = state.apps.findIndex(
+        (app) => app.id === action.payload.id
+      );
+
+      return {
+        ...state,
+        apps: [
+          ...state.apps.slice(0, pinnedAppIdx),
+          action.payload,
+          ...state.apps.slice(pinnedAppIdx + 1),
+        ],
+      };
+
+    case ActionType.addAppSuccess:
+      return {
+        ...state,
+        apps: [...state.apps, action.payload],
+      };
+
+    case ActionType.deleteApp:
+      return {
+        ...state,
+        apps: [...state.apps].filter((app) => app.id !== action.payload),
+      };
+
+    case ActionType.updateApp:
+      const updatedAppIdx = state.apps.findIndex(
+        (app) => app.id === action.payload.id
+      );
+
+      return {
+        ...state,
+        apps: [
+          ...state.apps.slice(0, updatedAppIdx),
+          action.payload,
+          ...state.apps.slice(updatedAppIdx + 1),
+        ],
+      };
+
+    case ActionType.reorderApps:
+      return {
+        ...state,
+        apps: action.payload,
+      };
+
+    case ActionType.sortApps:
+      return {
+        ...state,
+        apps: sortData<App>(state.apps, action.payload),
+      };
+
+    default:
+      return state;
   }
-}
-
-export default appReducer;
+};

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff