Преглед изворни кода

import from up to date repo

Matthew Horwood пре 2 година
родитељ
комит
12baf72567
86 измењених фајлова са 2701 додато и 1927 уклоњено
  1. 16 0
      CHANGELOG.md
  2. 21 11
      README.md
  3. 1 1
      client/.env
  4. 519 275
      client/package-lock.json
  5. 16 19
      client/package.json
  6. 38 30
      client/src/App.tsx
  7. 14 12
      client/src/components/Apps/AppCard/AppCard.module.css
  8. 7 8
      client/src/components/Apps/AppCard/AppCard.tsx
  9. 22 17
      client/src/components/Apps/AppForm/AppForm.tsx
  10. 22 19
      client/src/components/Apps/AppTable/AppTable.tsx
  11. 17 28
      client/src/components/Apps/Apps.tsx
  12. 11 20
      client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx
  13. 28 35
      client/src/components/Bookmarks/Bookmarks.tsx
  14. 26 21
      client/src/components/Bookmarks/Form/BookmarksForm.tsx
  15. 4 17
      client/src/components/Bookmarks/Form/CategoryForm.tsx
  16. 15 17
      client/src/components/Bookmarks/Form/Form.tsx
  17. 23 28
      client/src/components/Bookmarks/Table/BookmarksTable.tsx
  18. 23 28
      client/src/components/Bookmarks/Table/CategoryTable.tsx
  19. 3 1
      client/src/components/Home/Header/Header.module.css
  20. 6 16
      client/src/components/Home/Header/Header.tsx
  21. 4 11
      client/src/components/Home/Header/functions/getDateTime.ts
  22. 33 51
      client/src/components/Home/Home.tsx
  23. 4 6
      client/src/components/NotificationCenter/NotificationCenter.tsx
  24. 3 3
      client/src/components/Routing/ProtectedRoute.tsx
  25. 7 3
      client/src/components/SearchBar/SearchBar.module.css
  26. 21 17
      client/src/components/SearchBar/SearchBar.tsx
  27. 13 19
      client/src/components/Settings/AppDetails/AppDetails.tsx
  28. 9 16
      client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
  29. 34 49
      client/src/components/Settings/DockerSettings/DockerSettings.tsx
  30. 12 25
      client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx
  31. 4 12
      client/src/components/Settings/GeneralSettings/CustomQueries/QueriesForm.tsx
  32. 67 83
      client/src/components/Settings/GeneralSettings/GeneralSettings.tsx
  33. 2 1
      client/src/components/Settings/Settings.module.css
  34. 12 24
      client/src/components/Settings/Settings.tsx
  35. 4 13
      client/src/components/Settings/StyleSettings/StyleSettings.tsx
  36. 23 38
      client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.tsx
  37. 18 24
      client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx
  38. 13 20
      client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx
  39. 1 4
      client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx
  40. 2 7
      client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx
  41. 24 32
      client/src/components/Settings/Themer/Themer.tsx
  42. 78 89
      client/src/components/Settings/UISettings/UISettings.tsx
  43. 16 23
      client/src/components/Settings/WeatherSettings/WeatherSettings.tsx
  44. 3 4
      client/src/components/UI/Buttons/ActionButton/ActionButton.tsx
  45. 26 0
      client/src/components/UI/Checkbox/Checkbox.module.css
  46. 25 0
      client/src/components/UI/Checkbox/Checkbox.tsx
  47. 10 0
      client/src/components/UI/Forms/InputGroup/InputGroup.module.css
  48. 15 2
      client/src/components/UI/Forms/InputGroup/InputGroup.tsx
  49. 1 2
      client/src/components/UI/Forms/ModalForm/ModalForm.tsx
  50. 2 2
      client/src/components/UI/Headlines/Headline/Headline.tsx
  51. 6 1
      client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.module.css
  52. 1 2
      client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.tsx
  53. 1 1
      client/src/components/UI/Headlines/SettingsHeadline/SettingsHeadline.tsx
  54. 11 0
      client/src/components/UI/Icons/Icon/Icon.module.css
  55. 23 15
      client/src/components/UI/Icons/Icon/Icon.tsx
  56. 3 338
      client/src/components/UI/Icons/WeatherIcon/IconMapping.ts
  57. 3 3
      client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx
  58. 160 118
      client/src/components/UI/Icons/WeatherIcon/WeatherMapping.json
  59. 1 1
      client/src/components/UI/Modal/Modal.tsx
  60. 2 6
      client/src/components/UI/Notification/Notification.tsx
  61. 0 1
      client/src/components/UI/Text/Message/Message.tsx
  62. 14 22
      client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx
  63. 1 6
      client/src/index.tsx
  64. 2 0
      client/src/interfaces/Config.ts
  65. 2 0
      client/src/interfaces/Forms.ts
  66. 155 0
      client/src/state/app.ts
  67. 99 0
      client/src/state/auth.ts
  68. 378 0
      client/src/state/bookmark.ts
  69. 69 0
      client/src/state/config.ts
  70. 49 0
      client/src/state/notification.ts
  71. 81 0
      client/src/state/queries.ts
  72. 127 0
      client/src/state/theme.ts
  73. 17 0
      client/src/utility/array.ts
  74. 20 22
      client/src/utility/checkVersion.ts
  75. 5 1
      client/src/utility/iconParser.ts
  76. 1 1
      client/src/utility/index.ts
  77. 55 51
      client/src/utility/searchParser.ts
  78. 2 0
      client/src/utility/templateObjects/configTemplate.ts
  79. 2 0
      client/src/utility/templateObjects/settingsTemplate.ts
  80. 62 129
      controllers/apps/docker/useDocker.js
  81. 6 6
      controllers/apps/docker/useKubernetes.js
  82. 4 4
      k8s/overlays/shokohsc/ingress.yaml
  83. 2 2
      package-lock.json
  84. 1 1
      package.json
  85. 17 13
      utils/getExternalWeather.js
  86. 1 0
      utils/init/initialConfig.json

+ 16 - 0
CHANGELOG.md

@@ -1,3 +1,19 @@
+### v2.4.0 (2022-11-27)
+First release under mhzawadi/flame.
+
+- **Major change - replaced `redux` with `jotai` for client state management.**
+- Enabled experimental support for app icons from [dashboard-icons](https://github.com/walkxcode/Dashboard-Icons)
+- Added hover effects for Section Headlines
+- Replaced dropdowns with checkboxes for True/False settings
+- Tweak UI margins to look closer to SUI
+
+Also incorporates:
+- Automatically clear search bar ([pawelmalak/flame#265](https://github.com/pawelmalak/flame/pull/265) by @IDevJoe)
+- Enable non-root container build ([pawelmalak/flame#309](https://github.com/pawelmalak/flame/pull/309) by @luckyf)
+- bugfix: sameTab does not work if prefix is localSearch ([pawelmalak/flame#284](https://github.com/pawelmalak/flame/pull/284) by @pmjklemm)
+- Allow the image to run as non-root ([pawelmalak/flame#356](https://github.com/pawelmalak/flame/pull/356) by @glitchcrab)
+- Enforce no border-radius on search bar ([pawelmalak/flame#395](https://github.com/pawelmalak/flame/pull/395) by @davidchalifoux)
+
 ### v2.3.0 (2022-03-25)
 - Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246))
 - Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295))

+ 21 - 11
README.md

@@ -2,6 +2,16 @@
 
 ![Homescreen screenshot](.github/home.png)
 
+## mhzawadi/flame
+
+This is a hard fork of https://github.com/pawelmalak/flame.
+
+I forked because I wanted to try using Flame, but it seems it's abandoned with 100 issues and 25 open PRs.
+I decided to merge the changes from some of the open PRs in my `master` and go from there 🙂.
+
+Note: I was not an active Flame contributor. I have my own set of features I want to build on top of that.
+PRs are welcome.
+
 ## 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.
@@ -19,23 +29,23 @@ Flame is self-hosted startpage for your server. Its design is inspired (heavily)
 
 ### With Docker (recommended)
 
-[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
+[Docker Hub link](https://hub.docker.com/r/mhzawadi/flame)
 
 ```sh
-docker pull pawelmalak/flame
+docker pull mhzawadi/flame
 
 # for ARM architecture (e.g. RaspberryPi)
-docker pull pawelmalak/flame:multiarch
+docker pull mhzawadi/flame:multiarch
 
 # installing specific version
-docker pull pawelmalak/flame:2.0.0
+docker pull mhzawadi/flame:2.0.0
 ```
 
 #### Deployment
 
 ```sh
 # run container
-docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password pawelmalak/flame
+docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password mhzawadi/flame
 ```
 
 #### Building images
@@ -59,7 +69,7 @@ version: '3.6'
 
 services:
   flame:
-    image: pawelmalak/flame
+    image: mhzawadi/flame
     container_name: flame
     volumes:
       - /path/to/host/data:/app/data
@@ -123,7 +133,7 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/
 
 ```sh
 # clone repository
-git clone https://github.com/pawelmalak/flame
+git clone https://github.com/mhzawadi/flame
 cd flame
 
 # run only once
@@ -219,10 +229,10 @@ In order to use the Kubernetes integration, each ingress must have the following
 ```yml
 metadata:
   annotations:
-  - flame.pawelmalak/type=application # "app" works too
-  - flame.pawelmalak/name=My container
-  - flame.pawelmalak/url=https://example.com
-  - flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
+  - flame.georgesg/type=application # "app" works too
+  - flame.georgesg/name=My container
+  - flame.georgesg/url=https://example.com
+  - flame.georgesg/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 > Docker

+ 1 - 1
client/.env

@@ -1 +1 @@
-REACT_APP_VERSION=2.3.0
+REACT_APP_VERSION=2.4.0

Разлика између датотеке није приказан због своје велике величине
+ 519 - 275
client/package-lock.json


+ 16 - 19
client/package.json

@@ -1,37 +1,37 @@
 {
   "name": "client",
-  "version": "0.1.0",
+  "version": "2.4.0",
   "private": true,
   "dependencies": {
     "@mdi/js": "^6.4.95",
     "@mdi/react": "^1.5.0",
-    "@testing-library/jest-dom": "^5.15.0",
-    "@testing-library/react": "^12.1.2",
-    "@testing-library/user-event": "^13.5.0",
-    "@types/jest": "^27.0.2",
-    "@types/node": "^16.11.6",
-    "@types/react": "^17.0.34",
-    "@types/react-beautiful-dnd": "^13.1.2",
-    "@types/react-dom": "^17.0.11",
-    "@types/react-redux": "^7.1.20",
-    "@types/react-router-dom": "^5.1.7",
     "axios": "^0.24.0",
+    "classnames": "^2.3.2",
     "external-svg-loader": "^1.3.4",
     "http-proxy-middleware": "^2.0.1",
+    "jotai": "^1.10.0",
     "jwt-decode": "^3.1.2",
     "react": "^17.0.2",
     "react-beautiful-dnd": "^13.1.0",
     "react-dom": "^17.0.2",
-    "react-redux": "^7.2.6",
     "react-router-dom": "^5.2.0",
     "react-scripts": "4.0.3",
-    "redux": "^4.1.2",
-    "redux-devtools-extension": "^2.13.9",
-    "redux-thunk": "^2.4.0",
     "skycons-ts": "^0.2.0",
-    "typescript": "^4.4.4",
     "web-vitals": "^2.1.2"
   },
+  "devDependencies": {
+    "@testing-library/jest-dom": "^5.15.0",
+    "@testing-library/react": "^12.1.2",
+    "@testing-library/user-event": "^13.5.0",
+    "@types/jest": "^27.0.2",
+    "@types/node": "^16.11.6",
+    "@types/react": "^17.0.34",
+    "@types/react-beautiful-dnd": "^13.1.2",
+    "@types/react-dom": "^17.0.11",
+    "@types/react-router-dom": "^5.1.7",
+    "prettier": "^2.4.1",
+    "typescript": "^4.4.4"
+  },
   "scripts": {
     "start": "react-scripts start",
     "build": "react-scripts build",
@@ -55,8 +55,5 @@
       "last 1 firefox version",
       "last 1 safari version"
     ]
-  },
-  "devDependencies": {
-    "prettier": "^2.4.1"
   }
 }

+ 38 - 30
client/src/App.tsx

@@ -1,38 +1,43 @@
+import 'external-svg-loader';
+import { useAtomValue } from 'jotai';
 import { useEffect } from 'react';
 import { BrowserRouter, Route, Switch } from 'react-router-dom';
-import 'external-svg-loader';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { autoLogin, getConfig } from './store/action-creators';
-import { actionCreators, store } from './store';
-import { State } from './store/reducers';
-
-// Utils
-import { checkVersion, decodeToken, parsePABToTheme } 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 { Home } from './components/Home/Home';
 import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
+import { Settings } from './components/Settings/Settings';
+import { Spinner } from './components/UI';
+import { useAutoLogin, useLogout } from './state/auth';
+import { configAtom, configLoadingAtom, useFetchConfig } from './state/config';
+import { infoMessage, useCreateNotification } from './state/notification';
+import { useFetchQueries } from './state/queries';
+import { useFetchThemes, useSetTheme } from './state/theme';
+import { decodeToken, parsePABToTheme, useCheckVersion } from './utility';
+
+export const App = (): JSX.Element => {
+  const autoLogin = useAutoLogin();
 
-// Get config
-store.dispatch<any>(getConfig());
+  // Validate token
+  if (localStorage.token) {
+    autoLogin();
+  }
 
-// Validate token
-if (localStorage.token) {
-  store.dispatch<any>(autoLogin());
-}
+  const getConfig = useFetchConfig();
+  const config = useAtomValue(configAtom);
+  const loading = useAtomValue(configLoadingAtom);
 
-export const App = (): JSX.Element => {
-  const { config, loading } = useSelector((state: State) => state.config);
+  useEffect(() => {
+    getConfig();
+  }, []);
 
-  const dispath = useDispatch();
-  const { fetchQueries, setTheme, logout, createNotification, fetchThemes } =
-    bindActionCreators(actionCreators, dispath);
+  const createNotification = useCreateNotification();
+  const setTheme = useSetTheme();
+  const fetchThemes = useFetchThemes();
+  const fetchQueries = useFetchQueries();
+  const checkVersion = useCheckVersion();
+
+  const logout = useLogout();
 
   useEffect(() => {
     // check if token is valid
@@ -43,10 +48,9 @@ export const App = (): JSX.Element => {
 
         if (now > expiresIn) {
           logout();
-          createNotification({
-            title: 'Info',
-            message: 'Session expired. You have been logged out',
-          });
+          createNotification(
+            infoMessage('Session expired. You have been logged out')
+          );
         }
       }
     }, 1000);
@@ -75,6 +79,10 @@ export const App = (): JSX.Element => {
     }
   }, [loading]);
 
+  if (loading) {
+    return <Spinner />;
+  }
+
   return (
     <>
       <BrowserRouter>

+ 14 - 12
client/src/components/Apps/AppCard/AppCard.module.css

@@ -2,7 +2,15 @@
   width: 100%;
   display: flex;
   align-items: center;
-  margin-bottom: 20px;
+  margin-bottom: 10px;
+  padding: 2px 4px;
+  border-radius: 4px;
+
+  transition: background-color 0.2s;
+}
+
+.AppCard:hover {
+  background-color: rgba(0, 0, 0, 0.2);
 }
 
 .AppCardIcon {
@@ -11,15 +19,12 @@
   margin-right: 0.5em;
 }
 
-.AppCardDetails {
-  text-transform: uppercase;
-}
-
 .AppCardDetails h5 {
   font-size: 1em;
   font-weight: 500;
   color: var(--color-primary);
-  margin-bottom: -4px;
+  margin-bottom: -2px;
+  text-transform: uppercase;
 }
 
 .AppCardDetails span {
@@ -31,13 +36,10 @@
 
 @media (min-width: 500px) {
   .AppCard {
-    padding: 2px;
-    border-radius: 4px;
+    padding: 5px 10px;
+    border-radius: 8px;
     transition: all 0.1s;
-  }
-
-  .AppCard:hover {
-    background-color: rgba(0, 0, 0, 0.2);
+    margin-bottom: 20px;
   }
 }
 

+ 7 - 8
client/src/components/Apps/AppCard/AppCard.tsx

@@ -1,17 +1,16 @@
-import classes from './AppCard.module.css';
-import { Icon } from '../../UI';
-import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
-
+import { useAtomValue } from 'jotai';
 import { App } from '../../../interfaces';
-import { useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
+import { configAtom } from '../../../state/config';
+import { isImage, isSvg, isUrl, urlParser } from '../../../utility';
+import { Icon } from '../../UI';
+import classes from './AppCard.module.css';
 
 interface Props {
   app: App;
 }
 
 export const AppCard = ({ app }: Props): JSX.Element => {
-  const { config } = useSelector((state: State) => state.config);
+  const config = useAtomValue(configAtom);
 
   const [displayUrl, redirectUrl] = urlParser(app.url);
 
@@ -41,7 +40,7 @@ export const AppCard = ({ app }: Props): JSX.Element => {
       </div>
     );
   } else {
-    iconEl = <Icon icon={iconParser(icon)} />;
+    iconEl = <Icon icon={icon} />;
   }
 
   return (

+ 22 - 17
client/src/components/Apps/AppForm/AppForm.tsx

@@ -1,25 +1,22 @@
-import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAtom } from 'jotai';
+import { ChangeEvent, SyntheticEvent, useEffect, useState } from 'react';
 import { NewApp } from '../../../interfaces';
-
-import classes from './AppForm.module.css';
-
-import { ModalForm, InputGroup, Button } from '../../UI';
+import { appInUpdateAtom, useAddApp, useUpdateApp } from '../../../state/app';
+import { useCreateNotification } from '../../../state/notification';
 import { inputHandler, newAppTemplate } from '../../../utility';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-import { State } from '../../../store/reducers';
+import { Button, InputGroup, ModalForm } from '../../UI';
+import classes from './AppForm.module.css';
 
 interface Props {
   modalHandler: () => void;
 }
 
 export const AppForm = ({ modalHandler }: Props): JSX.Element => {
-  const { appInUpdate } = useSelector((state: State) => state.apps);
+  const createNotification = useCreateNotification();
 
-  const dispatch = useDispatch();
-  const { addApp, updateApp, setEditApp, createNotification } =
-    bindActionCreators(actionCreators, dispatch);
+  const [appInUpdate, setEditApp] = useAtom(appInUpdateAtom);
+  const addApp = useAddApp();
+  const updateApp = useUpdateApp();
 
   const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
   const [customIcon, setCustomIcon] = useState<File | null>(null);
@@ -164,11 +161,19 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
             onChange={(e) => inputChangeHandler(e)}
           />
           <span>
-            Use icon name from MDI or pass a valid URL.
+            Use icon name from{' '}
             <a href="https://materialdesignicons.com/" target="blank">
-              {' '}
-              Click here for reference
+              MDI
+            </a>
+            , icon name from{' '}
+            <a
+              href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/"
+              target="_blank"
+              rel="noreferrer"
+            >
+              dashboard-icons
             </a>
+            , or pass a valid URL.
           </span>
           <span
             onClick={() => toggleUseCustomIcon(!useCustomIcon)}
@@ -196,7 +201,7 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
             }}
             className={classes.Switch}
           >
-            Switch to MDI
+            Switch to icon name
           </span>
         </InputGroup>
       )}

+ 22 - 19
client/src/components/Apps/AppTable/AppTable.tsx

@@ -1,38 +1,41 @@
-import { Fragment, useState, useEffect } from 'react';
+import { Fragment, useEffect, useState } from 'react';
 import {
   DragDropContext,
-  Droppable,
   Draggable,
+  Droppable,
   DropResult,
 } from 'react-beautiful-dnd';
 import { Link } from 'react-router-dom';
 
 // Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
 
-// Typescript
+import { useAtomValue } from 'jotai';
 import { App } from '../../../interfaces';
-
-// Other
-import { Message, Table } from '../../UI';
+import {
+  appsAtom,
+  useDeleteApp,
+  usePinApp,
+  useReorderApps,
+  useUpdateApp,
+} from '../../../state/app';
+import { configAtom } from '../../../state/config';
+import { useCreateNotification } from '../../../state/notification';
 import { TableActions } from '../../Actions/TableActions';
+import { Message, Table } from '../../UI';
 
 interface Props {
   openFormForUpdating: (app: App) => void;
 }
 
 export const AppTable = (props: Props): JSX.Element => {
-  const {
-    apps: { apps },
-    config: { config },
-  } = useSelector((state: State) => state);
+  const config = useAtomValue(configAtom);
 
-  const dispatch = useDispatch();
-  const { pinApp, deleteApp, reorderApps, createNotification, updateApp } =
-    bindActionCreators(actionCreators, dispatch);
+  const apps = useAtomValue(appsAtom);
+  const pinApp = usePinApp();
+  const deleteApp = useDeleteApp();
+  const reorderApps = useReorderApps();
+  const updateApp = useUpdateApp();
+  const createNotification = useCreateNotification();
 
   const [localApps, setLocalApps] = useState<App[]>([]);
 
@@ -87,7 +90,7 @@ export const AppTable = (props: Props): JSX.Element => {
   };
 
   return (
-    <Fragment>
+    <>
       <Message isPrimary={false}>
         {config.useOrdering === 'orderId' ? (
           <p>You can drag and drop single rows to reorder application</p>
@@ -155,6 +158,6 @@ export const AppTable = (props: Props): JSX.Element => {
           )}
         </Droppable>
       </DragDropContext>
-    </Fragment>
+    </>
   );
 };

+ 17 - 28
client/src/components/Apps/Apps.tsx

@@ -1,47 +1,36 @@
+import { useAtomValue, useSetAtom } from 'jotai';
 import { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-
-// Typescript
 import { App } from '../../interfaces';
-
-// CSS
-import classes from './Apps.module.css';
-
-// UI
-import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
-
-// Subcomponents
-import { AppGrid } from './AppGrid/AppGrid';
+import {
+  appInUpdateAtom,
+  appsAtom,
+  appsLoadingAtom,
+  useFetchApps,
+} from '../../state/app';
+import { authAtom } from '../../state/auth';
+import { ActionButton, Container, Headline, Modal, Spinner } from '../UI';
 import { AppForm } from './AppForm/AppForm';
+import { AppGrid } from './AppGrid/AppGrid';
+import classes from './Apps.module.css';
 import { AppTable } from './AppTable/AppTable';
 
-// Utils
-import { State } from '../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../store';
-
 interface Props {
   searching: boolean;
 }
 
 export const Apps = (props: Props): JSX.Element => {
-  // Get Redux state
-  const {
-    apps: { apps, loading },
-    auth: { isAuthenticated },
-  } = useSelector((state: State) => state);
+  const { isAuthenticated } = useAtomValue(authAtom);
 
-  // Get Redux action creators
-  const dispatch = useDispatch();
-  const { getApps, setEditApp } = bindActionCreators(actionCreators, dispatch);
+  const apps = useAtomValue(appsAtom);
+  const setEditApp = useSetAtom(appInUpdateAtom);
+  const loading = useAtomValue(appsLoadingAtom);
+  const fetchApps = useFetchApps();
 
   // Load apps if array is empty
   useEffect(() => {
     if (!apps.length) {
-      getApps();
+      fetchApps();
     }
   }, []);
 

+ 11 - 20
client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx

@@ -1,18 +1,12 @@
+import { useAtomValue, useSetAtom } from 'jotai';
 import { Fragment } 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 } from '../../../interfaces';
-
-// Other
-import classes from './BookmarkCard.module.css';
+import { authAtom } from '../../../state/auth';
+import { categoryInEditAtom } from '../../../state/bookmark';
+import { configAtom } from '../../../state/config';
+import { isImage, isSvg, isUrl, urlParser } from '../../../utility';
 import { Icon } from '../../UI';
-import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
+import classes from './BookmarkCard.module.css';
 
 interface Props {
   category: Category;
@@ -22,13 +16,10 @@ interface Props {
 export const BookmarkCard = (props: Props): JSX.Element => {
   const { category, fromHomepage = false } = props;
 
-  const {
-    config: { config },
-    auth: { isAuthenticated },
-  } = useSelector((state: State) => state);
+  const config = useAtomValue(configAtom);
+  const { isAuthenticated } = useAtomValue(authAtom);
 
-  const dispatch = useDispatch();
-  const { setEditCategory } = bindActionCreators(actionCreators, dispatch);
+  const setCategoryInEdit = useSetAtom(categoryInEditAtom);
 
   return (
     <div className={classes.BookmarkCard}>
@@ -38,7 +29,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
         }
         onClick={() => {
           if (!fromHomepage && isAuthenticated) {
-            setEditCategory(category);
+            setCategoryInEdit(category);
           }
         }}
       >
@@ -81,7 +72,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
             } else {
               iconEl = (
                 <div className={classes.BookmarkIcon}>
-                  <Icon icon={iconParser(icon)} />
+                  <Icon icon={icon} />
                 </div>
               );
             }

+ 28 - 35
client/src/components/Bookmarks/Bookmarks.tsx

@@ -1,29 +1,26 @@
 import { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
-
-// 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';
-
-// UI
+import { Bookmark, Category } from '../../interfaces';
 import {
+  ActionButton,
   Container,
   Headline,
-  ActionButton,
-  Spinner,
-  Modal,
   Message,
+  Modal,
+  Spinner,
 } from '../UI';
+import classes from './Bookmarks.module.css';
 
 // Components
+import { useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { authAtom } from '../../state/auth';
+import {
+  bookmarkInEditAtom,
+  bookmarksLoadingAtom,
+  categoriesAtom,
+  categoryInEditAtom,
+  useFetchCategories,
+} from '../../state/bookmark';
 import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
 import { Form } from './Form/Form';
 import { Table } from './Table/Table';
@@ -38,21 +35,19 @@ export enum ContentType {
 }
 
 export const Bookmarks = (props: Props): JSX.Element => {
-  // Get Redux state
-  const {
-    bookmarks: { loading, categories, categoryInEdit },
-    auth: { isAuthenticated },
-  } = useSelector((state: State) => state);
+  const { isAuthenticated } = useAtomValue(authAtom);
+
+  const loading = useAtomValue(bookmarksLoadingAtom);
+  const categories = useAtomValue(categoriesAtom);
+  const [categoryInEdit, setCategoryInEdit] = useAtom(categoryInEditAtom);
+  const setBookmarkInEdit = useSetAtom(bookmarkInEditAtom);
 
-  // Get Redux action creators
-  const dispatch = useDispatch();
-  const { getCategories, setEditCategory, setEditBookmark } =
-    bindActionCreators(actionCreators, dispatch);
+  const fetchCategories = useFetchCategories();
 
   // Load categories if array is empty
   useEffect(() => {
     if (!categories.length) {
-      getCategories();
+      fetchCategories();
     }
   }, []);
 
@@ -84,7 +79,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
 
   useEffect(() => {
     setShowTable(false);
-    setEditCategory(null);
+    setCategoryInEdit(null);
   }, []);
 
   // Form actions
@@ -107,10 +102,10 @@ export const Bookmarks = (props: Props): JSX.Element => {
 
     if (instanceOfCategory(data)) {
       setFormContentType(ContentType.category);
-      setEditCategory(data);
+      setCategoryInEdit(data);
     } else {
       setFormContentType(ContentType.bookmark);
-      setEditBookmark(data);
+      setBookmarkInEdit(data);
     }
 
     toggleModal();
@@ -120,7 +115,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
   const showTableForEditing = (contentType: ContentType) => {
     // We're in the edit mode and the same button was clicked - go back to list
     if (showTable && contentType === tableContentType) {
-      setEditCategory(null);
+      setBookmarkInEdit(null);
       setShowTable(false);
     } else {
       setShowTable(true);
@@ -130,7 +125,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
 
   const finishEditing = () => {
     setShowTable(false);
-    setEditCategory(null);
+    setBookmarkInEdit(null);
   };
 
   return (
@@ -176,9 +171,7 @@ export const Bookmarks = (props: Props): JSX.Element => {
         <Message isPrimary={false}>
           Click on category name to edit its bookmarks
         </Message>
-      ) : (
-        <></>
-      )}
+      ) : null}
 
       {loading ? (
         <Spinner />

+ 26 - 21
client/src/components/Bookmarks/Form/BookmarksForm.tsx

@@ -1,22 +1,19 @@
-import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 
 // Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
 
 // Typescript
+import { useAtomValue } from 'jotai';
 import { Bookmark, Category, NewBookmark } from '../../../interfaces';
-
-// UI
-import { ModalForm, InputGroup, Button } from '../../UI';
-
-// CSS
-import classes from './Form.module.css';
-
-// Utils
+import {
+  categoriesAtom,
+  useAddBookmark,
+  useUpdateBookmark,
+} from '../../../state/bookmark';
+import { useCreateNotification } from '../../../state/notification';
 import { inputHandler, newBookmarkTemplate } from '../../../utility';
+import { Button, InputGroup, ModalForm } from '../../UI';
+import classes from './Form.module.css';
 
 interface Props {
   modalHandler: () => void;
@@ -27,11 +24,11 @@ export const BookmarksForm = ({
   bookmark,
   modalHandler,
 }: Props): JSX.Element => {
-  const { categories } = useSelector((state: State) => state.bookmarks);
+  const createNotification = useCreateNotification();
 
-  const dispatch = useDispatch();
-  const { addBookmark, updateBookmark, createNotification } =
-    bindActionCreators(actionCreators, dispatch);
+  const categories = useAtomValue(categoriesAtom);
+  const addBookmark = useAddBookmark();
+  const updateBookmark = useUpdateBookmark();
 
   const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
   const [customIcon, setCustomIcon] = useState<File | null>(null);
@@ -219,11 +216,19 @@ export const BookmarksForm = ({
             onChange={(e) => inputChangeHandler(e)}
           />
           <span>
-            Use icon name from MDI or pass a valid URL.
+            Use icon name from{' '}
             <a href="https://materialdesignicons.com/" target="blank">
-              {' '}
-              Click here for reference
+              MDI
+            </a>
+            , icon name from{' '}
+            <a
+              href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/"
+              target="_blank"
+              rel="noreferrer"
+            >
+              dashboard-icons
             </a>
+            , or pass a valid URL.
           </span>
           <span
             onClick={() => toggleUseCustomIcon(!useCustomIcon)}
@@ -250,7 +255,7 @@ export const BookmarksForm = ({
             }}
             className={classes.Switch}
           >
-            Switch to MDI
+            Switch to icon name
           </span>
         </InputGroup>
       )}

+ 4 - 17
client/src/components/Bookmarks/Form/CategoryForm.tsx

@@ -1,18 +1,8 @@
 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 { useAddCategory, useUpdateCategory } from '../../../state/bookmark';
 import { inputHandler, newCategoryTemplate } from '../../../utility';
+import { Button, InputGroup, ModalForm } from '../../UI';
 
 interface Props {
   modalHandler: () => void;
@@ -23,11 +13,8 @@ export const CategoryForm = ({
   category,
   modalHandler,
 }: Props): JSX.Element => {
-  const dispatch = useDispatch();
-  const { addCategory, updateCategory } = bindActionCreators(
-    actionCreators,
-    dispatch
-  );
+  const addCategory = useAddCategory();
+  const updateCategory = useUpdateCategory();
 
   const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate);
 

+ 15 - 17
client/src/components/Bookmarks/Form/Form.tsx

@@ -1,13 +1,12 @@
-// Typescript
+import { useAtomValue } from 'jotai';
+import {
+  bookmarkInEditAtom,
+  categoryInEditAtom,
+} from '../../../state/bookmark';
+import { bookmarkTemplate, categoryTemplate } from '../../../utility';
 import { ContentType } from '../Bookmarks';
-
-// Utils
-import { CategoryForm } from './CategoryForm';
 import { BookmarksForm } from './BookmarksForm';
-import { Fragment } from 'react';
-import { useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-import { bookmarkTemplate, categoryTemplate } from '../../../utility';
+import { CategoryForm } from './CategoryForm';
 
 interface Props {
   modalHandler: () => void;
@@ -16,26 +15,25 @@ interface Props {
 }
 
 export const Form = (props: Props): JSX.Element => {
-  const { categoryInEdit, bookmarkInEdit } = useSelector(
-    (state: State) => state.bookmarks
-  );
+  const categoryInEdit = useAtomValue(categoryInEditAtom);
+  const bookmarkInEdit = useAtomValue(bookmarkInEditAtom);
 
   const { modalHandler, contentType, inUpdate } = 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}
@@ -47,8 +45,8 @@ export const Form = (props: Props): JSX.Element => {
               bookmark={bookmarkInEdit || bookmarkTemplate}
             />
           )}
-        </Fragment>
+        </>
       )}
-    </Fragment>
+    </>
   );
 };

+ 23 - 28
client/src/components/Bookmarks/Table/BookmarksTable.tsx

@@ -1,42 +1,37 @@
-import { useState, useEffect, Fragment } from 'react';
+import { useAtomValue } from 'jotai';
+import { useEffect, useState } from 'react';
 import {
   DragDropContext,
-  Droppable,
   Draggable,
+  Droppable,
   DropResult,
 } from 'react-beautiful-dnd';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-
-// Typescript
 import { Bookmark, Category } from '../../../interfaces';
-
-// UI
-import { Message, Table } from '../../UI';
-import { TableActions } from '../../Actions/TableActions';
+import {
+  categoryInEditAtom,
+  useDeleteBookmark,
+  useReorderBookmarks,
+  useUpdateBookmark,
+} from '../../../state/bookmark';
+import { configAtom } from '../../../state/config';
+import { useCreateNotification } from '../../../state/notification';
 import { bookmarkTemplate } from '../../../utility';
+import { TableActions } from '../../Actions/TableActions';
+import { Message, Table } from '../../UI';
 
 interface Props {
   openFormForUpdating: (data: Category | Bookmark) => void;
 }
 
 export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
-  const {
-    bookmarks: { categoryInEdit },
-    config: { config },
-  } = useSelector((state: State) => state);
-
-  const dispatch = useDispatch();
-  const {
-    deleteBookmark,
-    updateBookmark,
-    createNotification,
-    reorderBookmarks,
-  } = bindActionCreators(actionCreators, dispatch);
+  const config = useAtomValue(configAtom);
+
+  const categoryInEdit = useAtomValue(categoryInEditAtom);
+  const deleteBookmark = useDeleteBookmark();
+  const updateBookmark = useUpdateBookmark();
+  const reorderBookmarks = useReorderBookmarks();
+
+  const createNotification = useCreateNotification();
 
   const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]);
 
@@ -103,7 +98,7 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
   };
 
   return (
-    <Fragment>
+    <>
       {!categoryInEdit ? (
         <Message isPrimary={false}>
           Switch to grid view and click on the name of category you want to edit
@@ -183,6 +178,6 @@ export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
           </Droppable>
         </DragDropContext>
       )}
-    </Fragment>
+    </>
   );
 };

+ 23 - 28
client/src/components/Bookmarks/Table/CategoryTable.tsx

@@ -1,43 +1,38 @@
-import { useState, useEffect, Fragment } from 'react';
+import { useAtomValue } from 'jotai';
+import { useEffect, useState } from 'react';
 import {
   DragDropContext,
-  Droppable,
   Draggable,
+  Droppable,
   DropResult,
 } from 'react-beautiful-dnd';
 import { Link } from 'react-router-dom';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-
-// Typescript
 import { Bookmark, Category } from '../../../interfaces';
-
-// UI
-import { Message, Table } from '../../UI';
+import {
+  categoriesAtom,
+  useDeleteCategory,
+  usePinCategory,
+  useReorderCategories,
+  useUpdateCategory,
+} from '../../../state/bookmark';
+import { configAtom } from '../../../state/config';
+import { useCreateNotification } from '../../../state/notification';
 import { TableActions } from '../../Actions/TableActions';
+import { Message, Table } from '../../UI';
 
 interface Props {
   openFormForUpdating: (data: Category | Bookmark) => void;
 }
 
 export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
-  const {
-    config: { config },
-    bookmarks: { categories },
-  } = useSelector((state: State) => state);
-
-  const dispatch = useDispatch();
-  const {
-    pinCategory,
-    deleteCategory,
-    createNotification,
-    reorderCategories,
-    updateCategory,
-  } = bindActionCreators(actionCreators, dispatch);
+  const config = useAtomValue(configAtom);
+
+  const categories = useAtomValue(categoriesAtom);
+  const pinCategory = usePinCategory();
+  const deleteCategory = useDeleteCategory();
+  const reorderCategories = useReorderCategories();
+  const updateCategory = useUpdateCategory();
+  const createNotification = useCreateNotification();
 
   const [localCategories, setLocalCategories] = useState<Category[]>([]);
 
@@ -95,7 +90,7 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
   };
 
   return (
-    <Fragment>
+    <>
       <Message isPrimary={false}>
         {config.useOrdering === 'orderId' ? (
           <p>You can drag and drop single rows to reorder categories</p>
@@ -161,6 +156,6 @@ export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
           )}
         </Droppable>
       </DragDropContext>
-    </Fragment>
+    </>
   );
 };

+ 3 - 1
client/src/components/Home/Header/Header.module.css

@@ -16,12 +16,14 @@
   display: flex;
   justify-content: space-between;
   align-items: center;
-  margin-bottom: 2.5rem;
+  margin-bottom: 60px;
 }
 
 .SettingsLink {
+  display: inline-block;
   visibility: visible;
   color: var(--color-accent);
+  margin-bottom: 10px;
 }
 
 @media (min-width: 769px) {

+ 6 - 16
client/src/components/Home/Header/Header.tsx

@@ -1,24 +1,14 @@
+import { useAtomValue } from 'jotai';
 import { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
-
-// Redux
-import { useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-
-// CSS
-import classes from './Header.module.css';
-
-// Components
+import { configAtom } from '../../../state/config';
 import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
-
-// Utils
 import { getDateTime } from './functions/getDateTime';
 import { greeter } from './functions/greeter';
+import classes from './Header.module.css';
 
 export const Header = (): JSX.Element => {
-  const { hideHeader, hideDate, showTime } = useSelector(
-    (state: State) => state.config.config
-  );
+  const { hideHeader, hideDate, showTime } = useAtomValue(configAtom);
 
   const [dateTime, setDateTime] = useState<string>(getDateTime());
   const [greeting, setGreeting] = useState<string>(greeter());
@@ -36,12 +26,12 @@ export const Header = (): JSX.Element => {
 
   return (
     <header className={classes.Header}>
-      {(!hideDate || showTime) && <p>{dateTime}</p>}
-
       <Link to="/settings" className={classes.SettingsLink}>
         Go to Settings
       </Link>
 
+      {(!hideDate || showTime) && <p>{dateTime}</p>}
+
       {!hideHeader && (
         <span className={classes.HeaderMain}>
           <h1>{greeting}</h1>

+ 4 - 11
client/src/components/Home/Header/functions/getDateTime.ts

@@ -48,23 +48,16 @@ export const getDateTime = (): string => {
   }
 
   // Time
-  const p = parseTime;
   let timeEl = '';
 
   if (showTime) {
-    const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
-      now.getSeconds()
-    )}`;
-
-    timeEl = time;
+    timeEl = `${parseTime(now.getHours())}:${parseTime(
+      now.getMinutes()
+    )}:${parseTime(now.getSeconds())}`;
   }
 
   // Separator
-  let separator = '';
-
-  if (!hideDate && showTime) {
-    separator = ' - ';
-  }
+  const separator = !hideDate && showTime ? ' · ' : '';
 
   // Output
   return `${dateEl}${separator}${timeEl}`;

+ 33 - 51
client/src/components/Home/Home.tsx

@@ -1,43 +1,35 @@
-import { useState, useEffect, Fragment } from 'react';
+import { useAtomValue } from 'jotai';
+import { useEffect, useState } from 'react';
 import { Link } from 'react-router-dom';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { State } from '../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../store';
-
-// Typescript
 import { App, Category } from '../../interfaces';
-
-// UI
-import { Icon, Container, SectionHeadline, Spinner, Message } from '../UI';
-
-// CSS
-import classes from './Home.module.css';
-
-// Components
+import { appsAtom, appsLoadingAtom, useFetchApps } from '../../state/app';
+import { authAtom } from '../../state/auth';
+import {
+  bookmarksLoadingAtom,
+  categoriesAtom,
+  useFetchCategories,
+} from '../../state/bookmark';
+import { configAtom } from '../../state/config';
+import { escapeRegex } from '../../utility';
 import { AppGrid } from '../Apps/AppGrid/AppGrid';
 import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
 import { SearchBar } from '../SearchBar/SearchBar';
+import { Container, Icon, Message, SectionHeadline, Spinner } from '../UI';
 import { Header } from './Header/Header';
-
-// Utils
-import { escapeRegex } from '../../utility';
+import classes from './Home.module.css';
 
 export const Home = (): JSX.Element => {
-  const {
-    apps: { apps, loading: appsLoading },
-    bookmarks: { categories, loading: bookmarksLoading },
-    config: { config },
-    auth: { isAuthenticated },
-  } = useSelector((state: State) => state);
-
-  const dispatch = useDispatch();
-  const { getApps, getCategories } = bindActionCreators(
-    actionCreators,
-    dispatch
-  );
+  const config = useAtomValue(configAtom);
+
+  const { isAuthenticated } = useAtomValue(authAtom);
+
+  const apps = useAtomValue(appsAtom);
+  const appsLoading = useAtomValue(appsLoadingAtom);
+  const fetchApps = useFetchApps();
+
+  const categories = useAtomValue(categoriesAtom);
+  const bookmarksLoading = useAtomValue(bookmarksLoadingAtom);
+  const fetchCategories = useFetchCategories();
 
   // Local search query
   const [localSearch, setLocalSearch] = useState<null | string>(null);
@@ -46,17 +38,13 @@ export const Home = (): JSX.Element => {
     null | Category[]
   >(null);
 
-  // Load applications
   useEffect(() => {
     if (!apps.length) {
-      getApps();
+      fetchApps();
     }
-  }, []);
 
-  // Load bookmark categories
-  useEffect(() => {
     if (!categories.length) {
-      getCategories();
+      fetchCategories();
     }
   }, []);
 
@@ -110,12 +98,10 @@ export const Home = (): JSX.Element => {
           Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
           login and start customizing your new homepage
         </Message>
-      ) : (
-        <></>
-      )}
+      ) : null}
 
       {!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
-        <Fragment>
+        <>
           <SectionHeadline title="Applications" link="/applications" />
           {appsLoading ? (
             <Spinner />
@@ -131,14 +117,12 @@ export const Home = (): JSX.Element => {
             />
           )}
           <div className={classes.HomeSpace}></div>
-        </Fragment>
-      ) : (
-        <></>
-      )}
+        </>
+      ) : null}
 
       {!config.hideCategories &&
       (isAuthenticated || categories.some((c) => c.isPinned)) ? (
-        <Fragment>
+        <>
           <SectionHeadline title="Bookmarks" link="/bookmarks" />
           {bookmarksLoading ? (
             <Spinner />
@@ -156,10 +140,8 @@ export const Home = (): JSX.Element => {
               fromHomepage={true}
             />
           )}
-        </Fragment>
-      ) : (
-        <></>
-      )}
+        </>
+      ) : null}
 
       <Link to="/settings" className={classes.SettingsButton}>
         <Icon icon="mdiCog" color="var(--color-background)" />

+ 4 - 6
client/src/components/NotificationCenter/NotificationCenter.tsx

@@ -1,13 +1,11 @@
-import { useSelector } from 'react-redux';
+import { useAtomValue } from 'jotai';
 import { Notification as NotificationInterface } from '../../interfaces';
-
-import classes from './NotificationCenter.module.css';
-
+import { notificationsAtom } from '../../state/notification';
 import { Notification } from '../UI';
-import { State } from '../../store/reducers';
+import classes from './NotificationCenter.module.css';
 
 export const NotificationCenter = (): JSX.Element => {
-  const { notifications } = useSelector((state: State) => state.notification);
+  const { notifications } = useAtomValue(notificationsAtom);
 
   return (
     <div

+ 3 - 3
client/src/components/Routing/ProtectedRoute.tsx

@@ -1,9 +1,9 @@
-import { useSelector } from 'react-redux';
+import { useAtomValue } from 'jotai';
 import { Redirect, Route, RouteProps } from 'react-router';
-import { State } from '../../store/reducers';
+import { authAtom } from '../../state/auth';
 
 export const ProtectedRoute = ({ ...rest }: RouteProps) => {
-  const { isAuthenticated } = useSelector((state: State) => state.auth);
+  const { isAuthenticated } = useAtomValue(authAtom);
 
   if (isAuthenticated) {
     return <Route {...rest} />;

+ 7 - 3
client/src/components/SearchBar/SearchBar.module.css

@@ -1,17 +1,21 @@
+.SearchProvider {
+  color: var(--color-accent);
+}
+
 .SearchBar {
   width: 100%;
   padding: 10px 0;
   color: var(--color-primary);
-  /* font-size: 20px; */
-  margin-bottom: 20px;
+  margin-bottom: 80px;
   background-color: transparent;
   border: none;
   border-bottom: 2px solid var(--color-accent);
   opacity: 0.5;
   transition: all 0.2s;
+  border-radius: 0px;
 }
 
 .SearchBar:focus {
   opacity: 1;
   outline: none;
-}
+}

+ 21 - 17
client/src/components/SearchBar/SearchBar.tsx

@@ -1,20 +1,11 @@
-import { useRef, useEffect, KeyboardEvent } from 'react';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-
-// Typescript
+import { useAtomValue } from 'jotai';
+import { KeyboardEvent, useEffect, useRef, useState } from 'react';
 import { App, Category } from '../../interfaces';
-
-// CSS
+import { configAtom, configLoadingAtom } from '../../state/config';
+import { useCreateNotification } from '../../state/notification';
+import { redirectUrl, urlParser, useSearchParser } from '../../utility';
 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 Props {
   setLocalSearch: (query: string) => void;
   appSearchResult: App[] | null;
@@ -22,15 +13,20 @@ interface Props {
 }
 
 export const SearchBar = (props: Props): JSX.Element => {
-  const { config, loading } = useSelector((state: State) => state.config);
+  const config = useAtomValue(configAtom);
+  const loading = useAtomValue(configLoadingAtom);
+  const searchParser = useSearchParser();
 
-  const dispatch = useDispatch();
-  const { createNotification } = bindActionCreators(actionCreators, dispatch);
+  const createNotification = useCreateNotification();
 
   const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
 
   const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
 
+  const [searchProvider, setSearchProvider] = useState(
+    searchParser('').primarySearch.name
+  );
+
   // Search bar autofocus
   useEffect(() => {
     if (!loading && !config.disableAutofocus) {
@@ -78,6 +74,10 @@ export const SearchBar = (props: Props): JSX.Element => {
       setLocalSearch(encodedURL);
     }
 
+    if (primarySearch.name) {
+      setSearchProvider(primarySearch.name);
+    }
+
     if (e.code === 'Enter' || e.code === 'NumpadEnter') {
       if (!primarySearch.prefix) {
         // Prefix not found -> emit notification
@@ -113,6 +113,7 @@ export const SearchBar = (props: Props): JSX.Element => {
         const url = `${primarySearch.template}${encodedURL}`;
         redirectUrl(url, sameTab);
       }
+      if (config.autoClearSearch) clearSearch();
     } else if (e.code === 'Escape') {
       clearSearch();
     }
@@ -120,6 +121,9 @@ export const SearchBar = (props: Props): JSX.Element => {
 
   return (
     <div className={classes.SearchContainer}>
+      {!config.hideSearchProvider && (
+        <span className={classes.SearchProvider}>{searchProvider}</span>
+      )}
       <input
         ref={inputRef}
         type="text"

+ 13 - 19
client/src/components/Settings/AppDetails/AppDetails.tsx

@@ -1,34 +1,28 @@
-import { Fragment } from 'react';
-
-// UI
+import { useAtomValue } from 'jotai';
+import { authAtom } from '../../../state/auth';
+import { useCheckVersion } from '../../../utility';
 import { Button, SettingsHeadline } from '../../UI';
-import { AuthForm } from './AuthForm/AuthForm';
 import classes from './AppDetails.module.css';
-
-// Store
-import { useSelector } from 'react-redux';
-import { State } from '../../../store/reducers';
-
-// Other
-import { checkVersion } from '../../../utility';
+import { AuthForm } from './AuthForm/AuthForm';
 
 export const AppDetails = (): JSX.Element => {
-  const { isAuthenticated } = useSelector((state: State) => state.auth);
+  const { isAuthenticated } = useAtomValue(authAtom);
+  const checkVersion = useCheckVersion(true);
 
   return (
-    <Fragment>
+    <>
       <SettingsHeadline text="Authentication" />
       <AuthForm />
 
       {isAuthenticated && (
-        <Fragment>
+        <>
           <hr className={classes.separator} />
 
           <div>
             <SettingsHeadline text="App version" />
             <p className={classes.text}>
               <a
-                href="https://github.com/pawelmalak/flame"
+                href="https://github.com/GeorgeSG/flame"
                 target="_blank"
                 rel="noreferrer"
               >
@@ -40,7 +34,7 @@ export const AppDetails = (): JSX.Element => {
             <p className={classes.text}>
               See changelog{' '}
               <a
-                href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md"
+                href="https://github.com/GeorgeSG/flame/blob/master/CHANGELOG.md"
                 target="_blank"
                 rel="noreferrer"
               >
@@ -48,10 +42,10 @@ export const AppDetails = (): JSX.Element => {
               </a>
             </p>
 
-            <Button click={() => checkVersion(true)}>Check for updates</Button>
+            <Button click={checkVersion}>Check for updates</Button>
           </div>
-        </Fragment>
+        </>
       )}
-    </Fragment>
+    </>
   );
 };

+ 9 - 16
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx

@@ -1,21 +1,14 @@
-import { FormEvent, Fragment, useEffect, useState, useRef } from 'react';
-
-// Redux
-import { useSelector, useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../../store';
-import { State } from '../../../../store/reducers';
+import { useAtomValue } from 'jotai';
+import { FormEvent, useEffect, useRef, useState } from 'react';
+import { authAtom, useLogin, useLogout } from '../../../../state/auth';
 import { decodeToken, parseTokenExpire } from '../../../../utility';
-
-// Other
-import { InputGroup, Button } from '../../../UI';
+import { Button, InputGroup } 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 { isAuthenticated, token } = useAtomValue(authAtom);
+  const login = useLogin();
+  const logout = useLogout();
 
   const [tokenExpires, setTokenExpires] = useState('');
   const [formData, setFormData] = useState({
@@ -47,7 +40,7 @@ export const AuthForm = (): JSX.Element => {
   };
 
   return (
-    <Fragment>
+    <>
       {!isAuthenticated ? (
         <form onSubmit={formHandler}>
           <InputGroup>
@@ -105,6 +98,6 @@ export const AuthForm = (): JSX.Element => {
           <Button click={logout}>Logout</Button>
         </div>
       )}
-    </Fragment>
+    </>
   );
 };

+ 34 - 49
client/src/components/Settings/DockerSettings/DockerSettings.tsx

@@ -1,25 +1,19 @@
-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 { useAtomValue } from 'jotai';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 import { DockerSettingsForm } from '../../../interfaces';
-
-// UI
-import { InputGroup, Button, SettingsHeadline } from '../../UI';
-
-// Utils
-import { inputHandler, dockerSettingsTemplate } from '../../../utility';
+import {
+  configAtom,
+  configLoadingAtom,
+  useUpdateConfig,
+} from '../../../state/config';
+import { dockerSettingsTemplate, inputHandler } from '../../../utility';
+import { Button, InputGroup, SettingsHeadline } from '../../UI';
+import { Checkbox } from '../../UI/Checkbox/Checkbox';
 
 export const DockerSettings = (): JSX.Element => {
-  const { loading, config } = useSelector((state: State) => state.config);
-
-  const dispatch = useDispatch();
-  const { updateConfig } = bindActionCreators(actionCreators, dispatch);
+  const loading = useAtomValue(configLoadingAtom);
+  const config = useAtomValue(configAtom);
+  const updateConfig = useUpdateConfig();
 
   // Initial state
   const [formData, setFormData] = useState<DockerSettingsForm>(
@@ -54,6 +48,9 @@ export const DockerSettings = (): JSX.Element => {
     });
   };
 
+  const onBooleanToggle = (prop: keyof DockerSettingsForm) =>
+    setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
+
   return (
     <form onSubmit={(e) => formSubmitHandler(e)}>
       <SettingsHeadline text="Docker" />
@@ -71,49 +68,37 @@ export const DockerSettings = (): JSX.Element => {
       </InputGroup>
 
       {/* USE DOCKER API */}
-      <InputGroup>
-        <label htmlFor="dockerApps">Use Docker API</label>
-        <select
+      <InputGroup type="horizontal">
+        <Checkbox
           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>
+          checked={formData.dockerApps}
+          onClick={() => onBooleanToggle('dockerApps')}
+        />
+        <label htmlFor="dockerApps">Use Docker API</label>
       </InputGroup>
 
       {/* UNPIN DOCKER APPS */}
-      <InputGroup>
+      <InputGroup type="horizontal">
+        <Checkbox
+          id="unpinStoppedApps"
+          checked={formData.unpinStoppedApps}
+          onClick={() => onBooleanToggle('unpinStoppedApps')}
+        />
         <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
+      <InputGroup type="horizontal">
+        <Checkbox
           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>
+          checked={formData.kubernetesApps}
+          onClick={() => onBooleanToggle('kubernetesApps')}
+        />
+        <label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
       </InputGroup>
 
       <Button>Save changes</Button>

+ 12 - 25
client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx

@@ -1,28 +1,17 @@
+import { useAtomValue } from 'jotai';
 import { Fragment, useState } from 'react';
-
-// 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';
-
-// UI
-import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI';
-
-// Components
+import { configAtom } from '../../../../state/config';
+import { useCreateNotification } from '../../../../state/notification';
+import { customQueriesAtom, useDeleteQuery } from '../../../../state/queries';
+import { ActionIcons, Button, CompactTable, Icon, Modal } from '../../../UI';
 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 customQueries = useAtomValue(customQueriesAtom);
+  const deleteQuery = useDeleteQuery();
+  const config = useAtomValue(configAtom);
+  const createNotification = useCreateNotification();
 
   const [modalIsOpen, setModalIsOpen] = useState(false);
   const [editableQuery, setEditableQuery] = useState<Query | null>(null);
@@ -49,7 +38,7 @@ export const CustomQueries = (): JSX.Element => {
   };
 
   return (
-    <Fragment>
+    <>
       <Modal
         isOpen={modalIsOpen}
         setIsOpen={() => setModalIsOpen(!modalIsOpen)}
@@ -82,9 +71,7 @@ export const CustomQueries = (): JSX.Element => {
               </Fragment>
             ))}
           </CompactTable>
-        ) : (
-          <></>
-        )}
+        ) : null}
 
         <Button
           click={() => {
@@ -95,6 +82,6 @@ export const CustomQueries = (): JSX.Element => {
           Add new search provider
         </Button>
       </section>
-    </Fragment>
+    </>
   );
 };

+ 4 - 12
client/src/components/Settings/GeneralSettings/CustomQueries/QueriesForm.tsx

@@ -1,11 +1,6 @@
-import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
-
-import { useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../../store';
-
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 import { Query } from '../../../../interfaces';
-
+import { useAddQuery, useUpdateQuery } from '../../../../state/queries';
 import { Button, InputGroup, ModalForm } from '../../../UI';
 
 interface Props {
@@ -14,11 +9,8 @@ interface Props {
 }
 
 export const QueriesForm = (props: Props): JSX.Element => {
-  const dispatch = useDispatch();
-  const { addQuery, updateQuery } = bindActionCreators(
-    actionCreators,
-    dispatch
-  );
+  const addQuery = useAddQuery();
+  const updateQuery = useUpdateQuery();
 
   const { modalHandler, query } = props;
 

+ 67 - 83
client/src/components/Settings/GeneralSettings/GeneralSettings.tsx

@@ -1,36 +1,36 @@
-// React
-import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
-
-// Typescript
-import { Query, GeneralForm } from '../../../interfaces';
-
-// Components
+import { useAtomValue } from 'jotai';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
+import { GeneralForm, Query } from '../../../interfaces';
+import { useSetSortedApps } from '../../../state/app';
+import {
+  categoriesAtom,
+  useSetSortedCategories,
+  useSetSortedBookmarks,
+} from '../../../state/bookmark';
+import {
+  configAtom,
+  configLoadingAtom,
+  useUpdateConfig,
+} from '../../../state/config';
+import { customQueriesAtom } from '../../../state/queries';
+import { generalSettingsTemplate, inputHandler } from '../../../utility';
+import { queries } from '../../../utility/searchQueries.json';
+import { Button, InputGroup, SettingsHeadline } from '../../UI';
+import { Checkbox } from '../../UI/Checkbox/Checkbox';
 import { CustomQueries } from './CustomQueries/CustomQueries';
 
-// UI
-import { Button, SettingsHeadline, InputGroup } from '../../UI';
-
-// Utils
-import { inputHandler, generalSettingsTemplate } from '../../../utility';
-
-// Data
-import { queries } from '../../../utility/searchQueries.json';
+export const GeneralSettings = (): JSX.Element => {
+  const config = useAtomValue(configAtom);
+  const loading = useAtomValue(configLoadingAtom);
 
-// Redux
-import { State } from '../../../store/reducers';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
+  const updateConfig = useUpdateConfig();
+  const customQueries = useAtomValue(customQueriesAtom);
 
-export const GeneralSettings = (): JSX.Element => {
-  const {
-    config: { loading, customQueries, config },
-    bookmarks: { categories },
-  } = useSelector((state: State) => state);
+  const setSortedApps = useSetSortedApps();
 
-  const dispatch = useDispatch();
-  const { updateConfig, sortApps, sortCategories, sortBookmarks } =
-    bindActionCreators(actionCreators, dispatch);
+  const categories = useAtomValue(categoriesAtom);
+  const setSortedCategories = useSetSortedCategories();
+  const setSortedBookmarks = useSetSortedBookmarks();
 
   // Initial state
   const [formData, setFormData] = useState<GeneralForm>(
@@ -53,15 +53,18 @@ export const GeneralSettings = (): JSX.Element => {
 
     // Sort entities with new settings
     if (formData.useOrdering !== config.useOrdering) {
-      sortApps();
-      sortCategories();
+      setSortedApps();
+      setSortedCategories();
 
       for (let { id } of categories) {
-        sortBookmarks(id);
+        setSortedBookmarks(id);
       }
     }
   };
 
+  const onBooleanToggle = (prop: keyof GeneralForm) =>
+    setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
+
   // Input handler
   const inputChangeHandler = (
     e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
@@ -76,7 +79,7 @@ export const GeneralSettings = (): JSX.Element => {
   };
 
   return (
-    <Fragment>
+    <>
       <form
         onSubmit={(e) => formSubmitHandler(e)}
         style={{ marginBottom: '30px' }}
@@ -101,67 +104,52 @@ export const GeneralSettings = (): JSX.Element => {
         {/* === APPS OPTIONS === */}
         <SettingsHeadline text="Apps" />
         {/* PIN APPS */}
-        <InputGroup>
+        <InputGroup type="horizontal">
+          <Checkbox
+            id="pinAppsByDefault"
+            name="pinAppsByDefault"
+            checked={formData.pinAppsByDefault}
+            onClick={() => onBooleanToggle('pinAppsByDefault')}
+          />
           <label htmlFor="pinAppsByDefault">
             Pin new applications by default
           </label>
-          <select
-            id="pinAppsByDefault"
-            name="pinAppsByDefault"
-            value={formData.pinAppsByDefault ? 1 : 0}
-            onChange={(e) => inputChangeHandler(e, { isBool: true })}
-          >
-            <option value={1}>True</option>
-            <option value={0}>False</option>
-          </select>
         </InputGroup>
 
         {/* APPS OPPENING */}
-        <InputGroup>
-          <label htmlFor="appsSameTab">Open applications in the same tab</label>
-          <select
+        <InputGroup type="horizontal">
+          <Checkbox
             id="appsSameTab"
-            name="appsSameTab"
-            value={formData.appsSameTab ? 1 : 0}
-            onChange={(e) => inputChangeHandler(e, { isBool: true })}
-          >
-            <option value={1}>True</option>
-            <option value={0}>False</option>
-          </select>
+            checked={formData.appsSameTab}
+            onClick={() => onBooleanToggle('appsSameTab')}
+          />
+          <label htmlFor="appsSameTab">Open applications in the same tab</label>
         </InputGroup>
 
         {/* === BOOKMARKS OPTIONS === */}
         <SettingsHeadline text="Bookmarks" />
         {/* PIN CATEGORIES */}
-        <InputGroup>
+        <InputGroup type="horizontal">
+          <Checkbox
+            id="pinCategoriesByDefault"
+            checked={formData.pinCategoriesByDefault}
+            onClick={() => onBooleanToggle('pinCategoriesByDefault')}
+          />
           <label htmlFor="pinCategoriesByDefault">
             Pin new categories by default
           </label>
-          <select
-            id="pinCategoriesByDefault"
-            name="pinCategoriesByDefault"
-            value={formData.pinCategoriesByDefault ? 1 : 0}
-            onChange={(e) => inputChangeHandler(e, { isBool: true })}
-          >
-            <option value={1}>True</option>
-            <option value={0}>False</option>
-          </select>
         </InputGroup>
 
         {/* BOOKMARKS OPPENING */}
-        <InputGroup>
+        <InputGroup type="horizontal">
+          <Checkbox
+            id="bookmarksSameTab"
+            checked={formData.bookmarksSameTab}
+            onClick={() => onBooleanToggle('bookmarksSameTab')}
+          />
           <label htmlFor="bookmarksSameTab">
             Open bookmarks in the same tab
           </label>
-          <select
-            id="bookmarksSameTab"
-            name="bookmarksSameTab"
-            value={formData.bookmarksSameTab ? 1 : 0}
-            onChange={(e) => inputChangeHandler(e, { isBool: true })}
-          >
-            <option value={1}>True</option>
-            <option value={0}>False</option>
-          </select>
         </InputGroup>
 
         {/* === SEARCH OPTIONS === */}
@@ -214,19 +202,15 @@ export const GeneralSettings = (): JSX.Element => {
           </InputGroup>
         )}
 
-        <InputGroup>
+        <InputGroup type="horizontal">
+          <Checkbox
+            id="searchSameTab"
+            checked={formData.searchSameTab}
+            onClick={() => onBooleanToggle('searchSameTab')}
+          />
           <label htmlFor="searchSameTab">
             Open search results in the same tab
           </label>
-          <select
-            id="searchSameTab"
-            name="searchSameTab"
-            value={formData.searchSameTab ? 1 : 0}
-            onChange={(e) => inputChangeHandler(e, { isBool: true })}
-          >
-            <option value={1}>True</option>
-            <option value={0}>False</option>
-          </select>
         </InputGroup>
 
         <Button>Save changes</Button>
@@ -235,6 +219,6 @@ export const GeneralSettings = (): JSX.Element => {
       {/* CUSTOM QUERIES */}
       <SettingsHeadline text="Custom search providers" />
       <CustomQueries />
-    </Fragment>
+    </>
   );
 };

+ 2 - 1
client/src/components/Settings/Settings.module.css

@@ -16,6 +16,7 @@
   align-items: center;
   height: 40px;
   transition: all 0.3s;
+  cursor: pointer;
 }
 
 .SettingsNavLink:hover,
@@ -37,4 +38,4 @@
   .Settings {
     grid-template-columns: 1fr 3fr;
   }
-}
+}

+ 12 - 24
client/src/components/Settings/Settings.tsx

@@ -1,33 +1,21 @@
-import { NavLink, Link, Switch, Route } from 'react-router-dom';
-
-// Redux
-import { useSelector } from 'react-redux';
-import { State } from '../../store/reducers';
-
-// Typescript
+import { useAtomValue } from 'jotai';
+import { Link, NavLink, Route, Switch } from 'react-router-dom';
 import { Route as SettingsRoute } from '../../interfaces';
-
-// CSS
-import classes from './Settings.module.css';
-
-// Components
-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 { GeneralSettings } from './GeneralSettings/GeneralSettings';
-import { DockerSettings } from './DockerSettings/DockerSettings';
+import { authAtom } from '../../state/auth';
 import { ProtectedRoute } from '../Routing/ProtectedRoute';
-
-// UI
 import { Container, Headline } from '../UI';
-
-// Data
+import { AppDetails } from './AppDetails/AppDetails';
+import { DockerSettings } from './DockerSettings/DockerSettings';
+import { GeneralSettings } from './GeneralSettings/GeneralSettings';
 import { routes } from './settings.json';
+import classes from './Settings.module.css';
+import { StyleSettings } from './StyleSettings/StyleSettings';
+import { Themer } from './Themer/Themer';
+import { UISettings } from './UISettings/UISettings';
+import { WeatherSettings } from './WeatherSettings/WeatherSettings';
 
 export const Settings = (): JSX.Element => {
-  const { isAuthenticated } = useSelector((state: State) => state.auth);
+  const { isAuthenticated } = useAtomValue(authAtom);
 
   const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
 

+ 4 - 13
client/src/components/Settings/StyleSettings/StyleSettings.tsx

@@ -1,21 +1,12 @@
-import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
 import axios from 'axios';
-
-// Redux
-import { useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-
-// Typescript
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 import { ApiResponse } from '../../../interfaces';
-
-// Other
-import { InputGroup, Button } from '../../UI';
+import { useCreateNotification } from '../../../state/notification';
 import { applyAuth } from '../../../utility';
+import { Button, InputGroup } from '../../UI';
 
 export const StyleSettings = (): JSX.Element => {
-  const dispatch = useDispatch();
-  const { createNotification } = bindActionCreators(actionCreators, dispatch);
+  const createNotification = useCreateNotification();
 
   const [customStyles, setCustomStyles] = useState<string>('');
 

+ 23 - 38
client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.tsx

@@ -1,15 +1,8 @@
-import { useState, useEffect } from 'react';
-
-// Redux
-import { useSelector, useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../../store';
-import { State } from '../../../../store/reducers';
-
-// Other
+import { useAtom, useAtomValue } from 'jotai';
+import { useEffect, useState } from 'react';
 import { Theme } from '../../../../interfaces';
-
-// UI
+import { authAtom } from '../../../../state/auth';
+import { themeInEditAtom, userThemesAtom } from '../../../../state/theme';
 import { Button, Modal } from '../../../UI';
 import { ThemeGrid } from '../ThemeGrid/ThemeGrid';
 import classes from './ThemeBuilder.module.css';
@@ -21,16 +14,26 @@ interface Props {
 }
 
 export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
-  const {
-    auth: { isAuthenticated },
-    theme: { themeInEdit, userThemes },
-  } = useSelector((state: State) => state);
+  const { isAuthenticated } = useAtomValue(authAtom);
 
-  const { editTheme } = bindActionCreators(actionCreators, useDispatch());
+  const [themeInEdit, setThemeInEdit] = useAtom(themeInEditAtom);
+  const userThemes = useAtomValue(userThemesAtom);
 
   const [showModal, toggleShowModal] = useState(false);
   const [isInEdit, toggleIsInEdit] = useState(false);
 
+  const showEdit = () => {
+    toggleIsInEdit(true);
+    toggleShowModal(true);
+  };
+
+  const showCreate = () => {
+    setThemeInEdit(null);
+    toggleIsInEdit(false);
+    toggleShowModal(true);
+  };
+
+  // TODO: Refactor all useEffects to simplify
   useEffect(() => {
     if (themeInEdit) {
       toggleIsInEdit(false);
@@ -51,7 +54,7 @@ export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
       <Modal
         isOpen={showModal}
         setIsOpen={() => toggleShowModal(!showModal)}
-        cb={() => editTheme(null)}
+        cb={() => setThemeInEdit(null)}
       >
         {isInEdit ? (
           <ThemeEditor modalHandler={() => toggleShowModal(!showModal)} />
@@ -66,28 +69,10 @@ export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
       {/* BUTTONS */}
       {isAuthenticated && (
         <div className={classes.Buttons}>
-          <Button
-            click={() => {
-              editTheme(null);
-              toggleIsInEdit(false);
-              toggleShowModal(!showModal);
-            }}
-          >
-            Create new theme
-          </Button>
-
+          <Button click={showCreate}>Create new theme</Button>
           {themes.length ? (
-            <Button
-              click={() => {
-                toggleIsInEdit(true);
-                toggleShowModal(!showModal);
-              }}
-            >
-              Edit user themes
-            </Button>
-          ) : (
-            <></>
-          )}
+            <Button click={showEdit}>Edit user themes</Button>
+          ) : null}
         </div>
       )}
     </div>

+ 18 - 24
client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx

@@ -1,31 +1,24 @@
-import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../../store';
-import { State } from '../../../../store/reducers';
-
-// UI
+import { useAtom, useAtomValue } from 'jotai';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
+import { Theme } from '../../../../interfaces';
+import {
+  activeThemeAtom,
+  themeInEditAtom,
+  useAddTheme,
+  useUpdateTheme,
+} from '../../../../state/theme';
 import { Button, InputGroup, ModalForm } from '../../../UI';
 import classes from './ThemeCreator.module.css';
 
-// Other
-import { Theme } from '../../../../interfaces';
-
 interface Props {
   modalHandler: () => void;
 }
 
 export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
-  const {
-    theme: { activeTheme, themeInEdit },
-  } = useSelector((state: State) => state);
-
-  const { addTheme, updateTheme, editTheme } = bindActionCreators(
-    actionCreators,
-    useDispatch()
-  );
+  const activeTheme = useAtomValue(activeThemeAtom);
+  const [themeInEdit, setThemeInEdit] = useAtom(themeInEditAtom);
+  const addTheme = useAddTheme();
+  const updateTheme = useUpdateTheme();
 
   const [formData, setFormData] = useState<Theme>({
     name: '',
@@ -37,9 +30,10 @@ export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
     },
   });
 
-  useEffect(() => {
-    setFormData({ ...formData, colors: activeTheme.colors });
-  }, [activeTheme]);
+  useEffect(
+    () => setFormData({ ...formData, colors: activeTheme.colors }),
+    [activeTheme]
+  );
 
   useEffect(() => {
     if (themeInEdit) {
@@ -69,7 +63,7 @@ export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
   };
 
   const closeModal = () => {
-    editTheme(null);
+    setThemeInEdit(null);
     modalHandler();
   };
 

+ 13 - 20
client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx

@@ -1,32 +1,25 @@
+import { useAtomValue, useSetAtom } from 'jotai';
 import { Fragment } from 'react';
-
-// Redux
-import { useSelector, useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
 import { Theme } from '../../../../interfaces';
-import { actionCreators } from '../../../../store';
-import { State } from '../../../../store/reducers';
-
-// Other
+import {
+  themeInEditAtom,
+  useDeleteTheme,
+  userThemesAtom,
+} from '../../../../state/theme';
 import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI';
 
 interface Props {
   modalHandler: () => void;
 }
 
-export const ThemeEditor = (props: Props): JSX.Element => {
-  const {
-    theme: { userThemes },
-  } = useSelector((state: State) => state);
-
-  const { deleteTheme, editTheme } = bindActionCreators(
-    actionCreators,
-    useDispatch()
-  );
+export const ThemeEditor = ({ modalHandler }: Props): JSX.Element => {
+  const userThemes = useAtomValue(userThemesAtom);
+  const setThemeInEdit = useSetAtom(themeInEditAtom);
+  const deleteTheme = useDeleteTheme();
 
   const updateHandler = (theme: Theme) => {
-    props.modalHandler();
-    editTheme(theme);
+    modalHandler();
+    setThemeInEdit(theme);
   };
 
   const deleteHandler = (theme: Theme) => {
@@ -36,7 +29,7 @@ export const ThemeEditor = (props: Props): JSX.Element => {
   };
 
   return (
-    <ModalForm formHandler={() => {}} modalHandler={props.modalHandler}>
+    <ModalForm formHandler={() => {}} modalHandler={modalHandler}>
       <CompactTable headers={['Name', 'Actions']}>
         {userThemes.map((t, idx) => (
           <Fragment key={idx}>

+ 1 - 4
client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx

@@ -1,8 +1,5 @@
-// Components
-import { ThemePreview } from '../ThemePreview/ThemePreview';
-
-// Other
 import { Theme } from '../../../../interfaces';
+import { ThemePreview } from '../ThemePreview/ThemePreview';
 import classes from './ThemeGrid.module.css';
 
 interface Props {

+ 2 - 7
client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx

@@ -1,10 +1,5 @@
-// Redux
-import { useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../../store';
-
-// Other
 import { Theme } from '../../../../interfaces/Theme';
+import { useSetTheme } from '../../../../state/theme';
 import classes from './ThemePreview.module.css';
 
 interface Props {
@@ -14,7 +9,7 @@ interface Props {
 export const ThemePreview = ({
   theme: { colors, name },
 }: Props): JSX.Element => {
-  const { setTheme } = bindActionCreators(actionCreators, useDispatch());
+  const setTheme = useSetTheme();
 
   return (
     <div className={classes.ThemePreview} onClick={() => setTheme(colors)}>

+ 24 - 32
client/src/components/Settings/Themer/Themer.tsx

@@ -1,35 +1,31 @@
-import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-import { State } from '../../../store/reducers';
-
-// Typescript
+import { useAtomValue } from 'jotai';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 import { Theme, ThemeSettingsForm } from '../../../interfaces';
-
-// Components
-import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
-import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
-import { ThemeGrid } from './ThemeGrid/ThemeGrid';
-
-// Other
+import { authAtom } from '../../../state/auth';
+import {
+  configAtom,
+  configLoadingAtom,
+  useUpdateConfig,
+} from '../../../state/config';
+import { themesAtom, userThemesAtom } from '../../../state/theme';
 import {
   inputHandler,
   parseThemeToPAB,
   themeSettingsTemplate,
 } from '../../../utility';
+import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
+import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
+import { ThemeGrid } from './ThemeGrid/ThemeGrid';
 
 export const Themer = (): JSX.Element => {
-  const {
-    auth: { isAuthenticated },
-    config: { loading, config },
-    theme: { themes, userThemes },
-  } = useSelector((state: State) => state);
+  const { isAuthenticated } = useAtomValue(authAtom);
+
+  const themes = useAtomValue(themesAtom);
+  const userThemes = useAtomValue(userThemesAtom);
 
-  const dispatch = useDispatch();
-  const { updateConfig } = bindActionCreators(actionCreators, dispatch);
+  const loading = useAtomValue(configLoadingAtom);
+  const config = useAtomValue(configAtom);
+  const updateConfig = useUpdateConfig();
 
   // Initial state
   const [formData, setFormData] = useState<ThemeSettingsForm>(
@@ -37,11 +33,7 @@ export const Themer = (): JSX.Element => {
   );
 
   // Get config
-  useEffect(() => {
-    setFormData({
-      ...config,
-    });
-  }, [loading]);
+  useEffect(() => setFormData({ ...config }), [loading]);
 
   // Form handler
   const formSubmitHandler = async (e: FormEvent) => {
@@ -65,14 +57,14 @@ export const Themer = (): JSX.Element => {
   };
 
   const customThemesEl = (
-    <Fragment>
+    <>
       <SettingsHeadline text="User themes" />
       <ThemeBuilder themes={userThemes} />
-    </Fragment>
+    </>
   );
 
   return (
-    <Fragment>
+    <>
       <SettingsHeadline text="App themes" />
       {!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />}
 
@@ -100,6 +92,6 @@ export const Themer = (): JSX.Element => {
           <Button>Save changes</Button>
         </form>
       )}
-    </Fragment>
+    </>
   );
 };

+ 78 - 89
client/src/components/Settings/UISettings/UISettings.tsx

@@ -1,25 +1,19 @@
-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 { useAtomValue } from 'jotai';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 import { UISettingsForm } from '../../../interfaces';
-
-// UI
-import { InputGroup, Button, SettingsHeadline } from '../../UI';
-
-// Utils
-import { uiSettingsTemplate, inputHandler } from '../../../utility';
+import {
+  configAtom,
+  configLoadingAtom,
+  useUpdateConfig,
+} from '../../../state/config';
+import { inputHandler, uiSettingsTemplate } from '../../../utility';
+import { Button, InputGroup, SettingsHeadline } from '../../UI';
+import { Checkbox } from '../../UI/Checkbox/Checkbox';
 
 export const UISettings = (): JSX.Element => {
-  const { loading, config } = useSelector((state: State) => state.config);
-
-  const dispatch = useDispatch();
-  const { updateConfig } = bindActionCreators(actionCreators, dispatch);
+  const loading = useAtomValue(configLoadingAtom);
+  const config = useAtomValue(configAtom);
+  const updateConfig = useUpdateConfig();
 
   // Initial state
   const [formData, setFormData] = useState<UISettingsForm>(uiSettingsTemplate);
@@ -55,6 +49,9 @@ export const UISettings = (): JSX.Element => {
     });
   };
 
+  const onBooleanToggle = (prop: keyof UISettingsForm) =>
+    setFormData((prev) => ({ ...prev, [prop]: !prev[prop] }));
+
   return (
     <form onSubmit={(e) => formSubmitHandler(e)}>
       {/* === OTHER OPTIONS === */}
@@ -75,77 +72,77 @@ export const UISettings = (): JSX.Element => {
       {/* === SEARCH OPTIONS === */}
       <SettingsHeadline text="Search" />
       {/* HIDE SEARCHBAR */}
-      <InputGroup>
-        <label htmlFor="hideSearch">Hide search bar</label>
-        <select
+      <InputGroup type="horizontal">
+        <Checkbox
           id="hideSearch"
-          name="hideSearch"
-          value={formData.hideSearch ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
+          checked={formData.hideSearch}
+          onClick={() => onBooleanToggle('hideSearch')}
+        />
+        <label htmlFor="hideSearch">Hide search bar</label>
+      </InputGroup>
+      {/* HIDE SEARCH PROVIDER*/}
+      <InputGroup type="horizontal">
+        <Checkbox
+          id="hideSearchProvider"
+          checked={formData.hideSearchProvider}
+          onClick={() => onBooleanToggle('hideSearchProvider')}
+        />
+        <label htmlFor="hideSearchProvider">Hide search provider label</label>
       </InputGroup>
 
       {/* AUTOFOCUS SEARCHBAR */}
-      <InputGroup>
+      <InputGroup type="horizontal">
+        <Checkbox
+          id="appsSameTab"
+          checked={formData.disableAutofocus}
+          onClick={() => onBooleanToggle('disableAutofocus')}
+        />
         <label htmlFor="disableAutofocus">Disable search bar autofocus</label>
-        <select
-          id="disableAutofocus"
-          name="disableAutofocus"
-          value={formData.disableAutofocus ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
+      </InputGroup>
+
+      <InputGroup type="horizontal">
+        <Checkbox
+          id="autoClearSearch"
+          checked={formData.autoClearSearch}
+          onClick={() => onBooleanToggle('autoClearSearch')}
+        />
+        <label htmlFor="autoClearSearch">
+          Automatically clear the search bar
+        </label>
       </InputGroup>
 
       {/* === HEADER OPTIONS === */}
       <SettingsHeadline text="Header" />
       {/* HIDE HEADER */}
-      <InputGroup>
+      <InputGroup type="horizontal">
+        <Checkbox
+          id="hideHeader"
+          checked={formData.hideHeader}
+          onClick={() => onBooleanToggle('hideHeader')}
+        />
         <label htmlFor="hideHeader">
           Hide headline (greetings and weather)
         </label>
-        <select
-          id="hideHeader"
-          name="hideHeader"
-          value={formData.hideHeader ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
       </InputGroup>
 
       {/* HIDE DATE */}
-      <InputGroup>
-        <label htmlFor="hideDate">Hide date</label>
-        <select
+      <InputGroup type="horizontal">
+        <Checkbox
           id="hideDate"
-          name="hideDate"
-          value={formData.hideDate ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
+          checked={formData.hideDate}
+          onClick={() => onBooleanToggle('hideDate')}
+        />
+        <label htmlFor="hideDate">Hide date</label>
       </InputGroup>
 
       {/* HIDE TIME */}
-      <InputGroup>
-        <label htmlFor="showTime">Hide time</label>
-        <select
+      <InputGroup type="horizontal">
+        <Checkbox
           id="showTime"
-          name="showTime"
-          value={formData.showTime ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={0}>True</option>
-          <option value={1}>False</option>
-        </select>
+          checked={!formData.showTime}
+          onClick={() => onBooleanToggle('showTime')}
+        />
+        <label htmlFor="showTime">Hide time</label>
       </InputGroup>
 
       {/* DATE FORMAT */}
@@ -210,31 +207,23 @@ export const UISettings = (): JSX.Element => {
       {/* === SECTIONS OPTIONS === */}
       <SettingsHeadline text="Sections" />
       {/* HIDE APPS */}
-      <InputGroup>
-        <label htmlFor="hideApps">Hide applications</label>
-        <select
+      <InputGroup type="horizontal">
+        <Checkbox
           id="hideApps"
-          name="hideApps"
-          value={formData.hideApps ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
+          checked={formData.hideApps}
+          onClick={() => onBooleanToggle('hideApps')}
+        />
+        <label htmlFor="hideApps">Hide applications</label>
       </InputGroup>
 
       {/* HIDE CATEGORIES */}
-      <InputGroup>
-        <label htmlFor="hideCategories">Hide categories</label>
-        <select
+      <InputGroup type="horizontal">
+        <Checkbox
           id="hideCategories"
-          name="hideCategories"
-          value={formData.hideCategories ? 1 : 0}
-          onChange={(e) => inputChangeHandler(e, { isBool: true })}
-        >
-          <option value={1}>True</option>
-          <option value={0}>False</option>
-        </select>
+          checked={formData.hideCategories}
+          onClick={() => onBooleanToggle('hideCategories')}
+        />
+        <label htmlFor="hideCategories">Hide categories</label>
       </InputGroup>
 
       <Button>Save changes</Button>

+ 16 - 23
client/src/components/Settings/WeatherSettings/WeatherSettings.tsx

@@ -1,29 +1,22 @@
-import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
 import axios from 'axios';
-
-// Redux
-import { useDispatch, useSelector } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-import { State } from '../../../store/reducers';
-
-// Typescript
+import { useAtomValue } from 'jotai';
+import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
 import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
-
-// UI
-import { InputGroup, Button, SettingsHeadline } from '../../UI';
-
-// Utils
+import {
+  configAtom,
+  configLoadingAtom,
+  useUpdateConfig,
+} from '../../../state/config';
+import { useCreateNotification } from '../../../state/notification';
 import { inputHandler, weatherSettingsTemplate } from '../../../utility';
+import { Button, InputGroup, SettingsHeadline } from '../../UI';
 
 export const WeatherSettings = (): JSX.Element => {
-  const { loading, config } = useSelector((state: State) => state.config);
+  const config = useAtomValue(configAtom);
+  const loading = useAtomValue(configLoadingAtom);
+  const updateConfig = useUpdateConfig();
 
-  const dispatch = useDispatch();
-  const { createNotification, updateConfig } = bindActionCreators(
-    actionCreators,
-    dispatch
-  );
+  const createNotification = useCreateNotification();
 
   // Initial state
   const [formData, setFormData] = useState<WeatherForm>(
@@ -110,10 +103,10 @@ export const WeatherSettings = (): JSX.Element => {
           onChange={(e) => inputChangeHandler(e)}
         />
         <span>
-          Using
-          <a href="https://www.weatherapi.com/pricing.aspx" target="blank">
+          Now using
+          <a href="https://openweathermap.org/api" target="blank">
             {' '}
-            Weather API
+            OpenWeatherMap
           </a>
           . Key is required for weather module to work.
         </span>

+ 3 - 4
client/src/components/UI/Buttons/ActionButton/ActionButton.tsx

@@ -1,8 +1,7 @@
 import { Fragment } from 'react';
 import { Link } from 'react-router-dom';
-
-import classes from './ActionButton.module.css';
 import { Icon } from '../..';
+import classes from './ActionButton.module.css';
 
 interface Props {
   name: string;
@@ -13,12 +12,12 @@ interface Props {
 
 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>
-    </Fragment>
+    </>
   );
 
   if (props.link) {

+ 26 - 0
client/src/components/UI/Checkbox/Checkbox.module.css

@@ -0,0 +1,26 @@
+.Checkbox {
+  position: relative;
+  display: inline-block;
+  width: 20px;
+  height: 20px;
+  background-color: var(--color-primary);
+  border-radius: 4px;
+  margin: 8px 0;
+  cursor: pointer;
+}
+
+.Checkbox.Checked::after {
+  content: '';
+  display: block;
+  position: absolute;
+  top: 3px;
+  left: 3px;
+  width: 14px;
+  height: 14px;
+  background-color: var(--color-background);
+  border-radius: 3px;
+}
+
+.Checkbox > input {
+  visibility: hidden;
+}

+ 25 - 0
client/src/components/UI/Checkbox/Checkbox.tsx

@@ -0,0 +1,25 @@
+import C from 'classnames';
+import classes from './Checkbox.module.css';
+
+interface Props {
+  checked: boolean;
+  onClick(): void;
+  id?: string;
+  name?: string;
+}
+
+export const Checkbox = ({
+  id,
+  name,
+  checked,
+  onClick,
+}: Props): JSX.Element => {
+  return (
+    <div
+      className={C(classes.Checkbox, { [classes.Checked]: checked })}
+      onClick={onClick}
+    >
+      <input type="checkbox" {...{ id, name }} />
+    </div>
+  );
+};

+ 10 - 0
client/src/components/UI/Forms/InputGroup/InputGroup.module.css

@@ -2,6 +2,16 @@
   margin-bottom: 15px;
 }
 
+.InputGroup.Horizontal {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.InputGroup label {
+  cursor: pointer;
+}
+
 .InputGroup label,
 .InputGroup span,
 .InputGroup input,

+ 15 - 2
client/src/components/UI/Forms/InputGroup/InputGroup.tsx

@@ -1,10 +1,23 @@
+import C from 'classnames';
 import { ReactNode } from 'react';
 import classes from './InputGroup.module.css';
 
 interface Props {
+  type?: 'vertical' | 'horizontal';
   children: ReactNode;
 }
 
-export const InputGroup = (props: Props): JSX.Element => {
-  return <div className={classes.InputGroup}>{props.children}</div>;
+export const InputGroup = ({
+  type = 'vertical',
+  children,
+}: Props): JSX.Element => {
+  return (
+    <div
+      className={C(classes.InputGroup, {
+        [classes.Horizontal]: type === 'horizontal',
+      })}
+    >
+      {children}
+    </div>
+  );
 };

+ 1 - 2
client/src/components/UI/Forms/ModalForm/ModalForm.tsx

@@ -1,7 +1,6 @@
 import { ReactNode, SyntheticEvent } from 'react';
-
-import classes from './ModalForm.module.css';
 import { Icon } from '../..';
+import classes from './ModalForm.module.css';
 
 interface ComponentProps {
   children: ReactNode;

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

@@ -8,11 +8,11 @@ interface Props {
 
 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>
       )}
-    </Fragment>
+    </>
   );
 };

+ 6 - 1
client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.module.css

@@ -4,4 +4,9 @@
   font-weight: 900;
   font-size: 20px;
   margin-bottom: 16px;
-}
+  transition: color 0.2s;
+}
+
+.SectionHeadlineLink:hover .SectionHeadline {
+  color: var(--color-accent);
+}

+ 1 - 2
client/src/components/UI/Headlines/SectionHeadline/SectionHeadline.tsx

@@ -1,5 +1,4 @@
 import { Link } from 'react-router-dom';
-
 import classes from './SectionHeadline.module.css';
 
 interface Props {
@@ -9,7 +8,7 @@ interface Props {
 
 export const SectionHeadline = (props: Props): JSX.Element => {
   return (
-    <Link to={props.link}>
+    <Link to={props.link} className={classes.SectionHeadlineLink}>
       <h2 className={classes.SectionHeadline}>{props.title}</h2>
     </Link>
   );

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

@@ -1,4 +1,4 @@
-const classes = require('./SettingsHeadline.module.css');
+import classes from './SettingsHeadline.module.css';
 
 interface Props {
   text: string;

+ 11 - 0
client/src/components/UI/Icons/Icon/Icon.module.css

@@ -2,3 +2,14 @@
   color: var(--color-primary);
   width: 90%;
 }
+
+.AppIconWrapper {
+  display: flex;
+  align-items: center;
+  height: 35px;
+  width: 35px;
+}
+
+.AppIcon {
+  height: 31px;
+}

+ 23 - 15
client/src/components/UI/Icons/Icon/Icon.tsx

@@ -1,26 +1,34 @@
-import classes from './Icon.module.css';
-
 import { Icon as MDIcon } from '@mdi/react';
+import { iconParser } from '../../../../utility';
+import classes from './Icon.module.css';
 
 interface Props {
   icon: string;
   color?: string;
 }
 
-export const Icon = (props: Props): JSX.Element => {
+export const Icon = ({ icon, color }: Props): JSX.Element => {
   const MDIcons = require('@mdi/js');
-  let iconPath = MDIcons[props.icon];
+  const mdiIcon = iconParser(icon);
+  let mdiIconPath = MDIcons[mdiIcon];
 
-  if (!iconPath) {
-    console.log(`Icon ${props.icon} not found`);
-    iconPath = MDIcons.mdiCancel;
+  if (!mdiIconPath) {
+    return (
+      <div className={classes.AppIconWrapper}>
+        <img
+          src={`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${icon}.png`}
+          className={classes.AppIcon}
+          alt={icon}
+        />
+      </div>
+    );
+  } else {
+    return (
+      <MDIcon
+        className={classes.Icon}
+        path={mdiIconPath}
+        color={color ? color : 'var(--color-primary)'}
+      />
+    );
   }
-
-  return (
-    <MDIcon
-      className={classes.Icon}
-      path={iconPath}
-      color={props.color ? props.color : 'var(--color-primary)'}
-    />
-  );
 };

+ 3 - 338
client/src/components/UI/Icons/WeatherIcon/IconMapping.ts

@@ -13,345 +13,10 @@ export enum TimeOfDay {
   night
 }
 
+const mapFromJson = require('./WeatherMapping.json')
+
 export class IconMapping {
-  private conditions: WeatherCondition[] = [
-    {
-      code: 1000,
-      icon: {
-        day: 'clear-day',
-        night: 'clear-night'
-      }
-    },
-    {
-      code: 1003,
-      icon: {
-        day: 'partly-cloudy-day',
-        night: 'partly-cloudy-night'
-      }
-    },
-    {
-      code: 1006,
-      icon: {
-        day: 'cloudy',
-        night: 'cloudy'
-      }
-    },
-    {
-      code: 1009,
-      icon: {
-        day: 'cloudy',
-        night: 'cloudy'
-      }
-    },
-    {
-      code: 1030,
-      icon: {
-        day: 'fog',
-        night: 'fog'
-      }
-    },
-    {
-      code: 1063,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1066,
-      icon: {
-        day: 'snow-day',
-        night: 'snow-night'
-      }
-    },
-    {
-      code: 1069,
-      icon: {
-        day: 'rain-snow-day',
-        night: 'rain-snow-night'
-      }
-    },
-    {
-      code: 1072,
-      icon: {
-        day: 'sleet',
-        night: 'sleet'
-      }
-    },
-    {
-      code: 1087,
-      icon: {
-        day: 'thunder-day',
-        night: 'thunder-night'
-      }
-    },
-    {
-      code: 1114,
-      icon: {
-        day: 'snow',
-        night: 'snow'
-      }
-    },
-    {
-      code: 1117,
-      icon: {
-        day: 'snow',
-        night: 'snow'
-      }
-    },
-    {
-      code: 1135,
-      icon: {
-        day: 'fog',
-        night: 'fog'
-      }
-    },
-    {
-      code: 1147,
-      icon: {
-        day: 'fog',
-        night: 'fog'
-      }
-    },
-    {
-      code: 1150,
-      icon: {
-        day: 'rain',
-        night: 'rain'
-      }
-    },
-    {
-      code: 1153,
-      icon: {
-        day: 'rain',
-        night: 'rain'
-      }
-    },
-    {
-      code: 1168,
-      icon: {
-        day: 'sleet',
-        night: 'sleet'
-      }
-    },
-    {
-      code: 1171,
-      icon: {
-        day: 'sleet',
-        night: 'sleet'
-      }
-    },
-    {
-      code: 1180,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1183,
-      icon: {
-        day: 'rain',
-        night: 'rain'
-      }
-    },
-    {
-      code: 1186,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1189,
-      icon: {
-        day: 'rain',
-        night: 'rain'
-      }
-    },
-    {
-      code: 1192,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1195,
-      icon: {
-        day: 'rain',
-        night: 'rain'
-      }
-    },
-    {
-      code: 1198,
-      icon: {
-        day: 'sleet',
-        night: 'sleet'
-      }
-    },
-    {
-      code: 1201,
-      icon: {
-        day: 'sleet',
-        night: 'sleet'
-      }
-    },
-    {
-      code: 1204,
-      icon: {
-        day: 'rain-snow',
-        night: 'rain-snow'
-      }
-    },
-    {
-      code: 1207,
-      icon: {
-        day: 'rain-snow',
-        night: 'rain-snow'
-      }
-    },
-    {
-      code: 1210,
-      icon: {
-        day: 'snow-day',
-        night: 'snow-night'
-      }
-    },
-    {
-      code: 1213,
-      icon: {
-        day: 'snow',
-        night: 'snow'
-      }
-    },
-    {
-      code: 1216,
-      icon: {
-        day: 'snow-day',
-        night: 'snow-night'
-      }
-    },
-    {
-      code: 1219,
-      icon: {
-        day: 'snow',
-        night: 'snow'
-      }
-    },
-    {
-      code: 1222,
-      icon: {
-        day: 'snow-day',
-        night: 'snow-night'
-      }
-    },
-    {
-      code: 1225,
-      icon: {
-        day: 'snow',
-        night: 'snow'
-      }
-    },
-    {
-      code: 1237,
-      icon: {
-        day: 'hail',
-        night: 'hail'
-      }
-    },
-    {
-      code: 1240,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1243,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1246,
-      icon: {
-        day: 'rain-day',
-        night: 'rain-night'
-      }
-    },
-    {
-      code: 1249,
-      icon: {
-        day: 'rain-snow-day',
-        night: 'rain-snow-night'
-      }
-    },
-    {
-      code: 1252,
-      icon: {
-        day: 'rain-snow-day',
-        night: 'rain-snow-night'
-      }
-    },
-    {
-      code: 1255,
-      icon: {
-        day: 'snow-day',
-        night: 'snow-night'
-      }
-    },
-    {
-      code: 1258,
-      icon: {
-        day: 'snow-day',
-        night: 'snow-night'
-      }
-    },
-    {
-      code: 1261,
-      icon: {
-        day: 'hail',
-        night: 'hail'
-      }
-    },
-    {
-      code: 1264,
-      icon: {
-        day: 'hail',
-        night: 'hail'
-      }
-    },
-    {
-      code: 1273,
-      icon: {
-        day: 'thunder-rain-day',
-        night: 'thunder-rain-night'
-      }
-    },
-    {
-      code: 1276,
-      icon: {
-        day: 'thunder-rain',
-        night: 'thunder-rain'
-      }
-    },
-    {
-      code: 1279,
-      icon: {
-        day: 'thunder-day',
-        night: 'thunder-night'
-      }
-    },
-    {
-      code: 1282,
-      icon: {
-        day: 'thunder',
-        night: 'thunder'
-      }
-    }
-  ];
+  private conditions: WeatherCondition[] = mapFromJson.mapping
 
   mapIcon(weatherStatusCode: number, timeOfDay: TimeOfDay): IconKey {
     const mapping = this.conditions.find((condition: WeatherCondition) => condition.code === weatherStatusCode);

+ 3 - 3
client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx

@@ -1,7 +1,7 @@
+import { useAtomValue } from 'jotai';
 import { useEffect } from 'react';
-import { useSelector } from 'react-redux';
 import { Skycons } from 'skycons-ts';
-import { State } from '../../../../store/reducers';
+import { activeThemeAtom } from '../../../../state/theme';
 import { IconMapping, TimeOfDay } from './IconMapping';
 
 interface Props {
@@ -10,7 +10,7 @@ interface Props {
 }
 
 export const WeatherIcon = (props: Props): JSX.Element => {
-  const { activeTheme } = useSelector((state: State) => state.theme);
+  const activeTheme = useAtomValue(activeThemeAtom);
 
   const icon = props.isDay
     ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)

+ 160 - 118
client/src/components/UI/Icons/WeatherIcon/WeatherMapping.json

@@ -1,340 +1,382 @@
 {
   "mapping": [
     {
-      "code": 1000,
+      "code": 800,
       "icon": {
         "day": "clear-day",
         "night": "clear-night"
       }
     },
     {
-      "code": 1003,
+      "code": 801,
       "icon": {
         "day": "partly-cloudy-day",
         "night": "partly-cloudy-night"
       }
     },
     {
-      "code": 1006,
+      "code": 802,
+      "icon": {
+        "day": "partly-cloudy-day",
+        "night": "partly-cloudy-night"
+      }
+    },
+    {
+      "code": 803,
       "icon": {
         "day": "cloudy",
         "night": "cloudy"
       }
     },
     {
-      "code": 1009,
+      "code": 804,
       "icon": {
         "day": "cloudy",
         "night": "cloudy"
       }
     },
     {
-      "code": 1030,
+      "code": 701,
       "icon": {
         "day": "fog",
         "night": "fog"
       }
     },
     {
-      "code": 1063,
+      "code": 711,
       "icon": {
-        "day": "rain-day",
-        "night": "rain-night"
+        "day": "fog",
+        "night": "fog"
       }
     },
     {
-      "code": 1066,
+      "code": 721,
       "icon": {
-        "day": "snow-day",
-        "night": "snow-night"
+        "day": "fog",
+        "night": "fog"
       }
     },
     {
-      "code": 1069,
+      "code": 731,
       "icon": {
-        "day": "rain-snow-day",
-        "night": "rain-snow-night"
+        "day": "fog",
+        "night": "fog"
       }
     },
     {
-      "code": 1072,
+      "code": 741,
       "icon": {
-        "day": "sleet",
-        "night": "sleet"
+        "day": "fog",
+        "night": "fog"
       }
     },
     {
-      "code": 1087,
+      "code": 751,
       "icon": {
-        "day": "thunder-day",
-        "night": "thunder-night"
+        "day": "fog",
+        "night": "fog"
       }
     },
     {
-      "code": 1114,
+      "code": 761,
       "icon": {
-        "day": "snow",
-        "night": "snow"
+        "day": "fog",
+        "night": "fog"
       }
     },
     {
-      "code": 1117,
+      "code": 771,
       "icon": {
-        "day": "snow",
-        "night": "snow"
+        "day": "wind",
+        "night": "wind"
       }
     },
     {
-      "code": 1135,
+      "code": 781,
       "icon": {
-        "day": "fog",
-        "night": "fog"
+        "day": "wind",
+        "night": "wind"
       }
     },
     {
-      "code": 1147,
+      "code": 600,
       "icon": {
-        "day": "fog",
-        "night": "fog"
+        "day": "snow-day",
+        "night": "snow-night"
       }
     },
     {
-      "code": 1150,
+      "code": 601,
       "icon": {
-        "day": "rain",
-        "night": "rain"
+        "day": "snow-day",
+        "night": "snow-night"
       }
     },
     {
-      "code": 1153,
+      "code": 602,
       "icon": {
-        "day": "rain",
-        "night": "rain"
+        "day": "snow",
+        "night": "snow"
       }
     },
     {
-      "code": 1168,
+      "code": 611,
       "icon": {
         "day": "sleet",
         "night": "sleet"
       }
     },
     {
-      "code": 1171,
+      "code": 612,
       "icon": {
         "day": "sleet",
         "night": "sleet"
       }
     },
     {
-      "code": 1180,
+      "code": 613,
       "icon": {
-        "day": "rain-day",
-        "night": "rain-night"
+        "day": "sleet",
+        "night": "sleet"
       }
     },
     {
-      "code": 1183,
+      "code": 615,
       "icon": {
-        "day": "rain",
-        "night": "rain"
+        "day": "rain-snow-day",
+        "night": "rain-snow-night"
       }
     },
     {
-      "code": 1186,
+      "code": 616,
       "icon": {
-        "day": "rain-day",
-        "night": "rain-night"
+        "day": "rain-snow-day",
+        "night": "rain-snow-night"
       }
     },
     {
-      "code": 1189,
+      "code": 620,
       "icon": {
-        "day": "rain",
-        "night": "rain"
+        "day": "rain-snow",
+        "night": "rain-snow"
       }
     },
     {
-      "code": 1192,
+      "code": 621,
       "icon": {
-        "day": "rain-day",
-        "night": "rain-night"
+        "day": "rain-snow",
+        "night": "rain-snow"
       }
     },
     {
-      "code": 1195,
+      "code": 622,
       "icon": {
-        "day": "rain",
-        "night": "rain"
+        "day": "rain-snow",
+        "night": "rain-snow"
       }
     },
     {
-      "code": 1198,
+      "code": 500,
       "icon": {
-        "day": "sleet",
-        "night": "sleet"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1201,
+      "code": 501,
       "icon": {
-        "day": "sleet",
-        "night": "sleet"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1204,
+      "code": 502,
       "icon": {
-        "day": "rain-snow",
-        "night": "rain-snow"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1207,
+      "code": 503,
       "icon": {
-        "day": "rain-snow",
-        "night": "rain-snow"
+        "day": "rain",
+        "night": "rain"
       }
     },
     {
-      "code": 1210,
+      "code": 504,
       "icon": {
-        "day": "snow-day",
-        "night": "snow-night"
+        "day": "rain",
+        "night": "rain"
       }
     },
     {
-      "code": 1213,
+      "code": 511,
       "icon": {
-        "day": "snow",
-        "night": "snow"
+        "day": "hail",
+        "night": "hail"
       }
     },
     {
-      "code": 1216,
+      "code": 520,
       "icon": {
-        "day": "snow-day",
-        "night": "snow-night"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1219,
+      "code": 521,
       "icon": {
-        "day": "snow",
-        "night": "snow"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1222,
+      "code": 522,
       "icon": {
-        "day": "snow-day",
-        "night": "snow-night"
+        "day": "rain",
+        "night": "rain"
       }
     },
     {
-      "code": 1225,
+      "code": 531,
       "icon": {
-        "day": "snow",
-        "night": "snow"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1237,
+      "code": 300,
       "icon": {
-        "day": "hail",
-        "night": "hail"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1240,
+      "code": 301,
       "icon": {
         "day": "rain-day",
         "night": "rain-night"
       }
     },
     {
-      "code": 1243,
+      "code": 302,
       "icon": {
         "day": "rain-day",
         "night": "rain-night"
       }
     },
     {
-      "code": 1246,
+      "code": 310,
       "icon": {
         "day": "rain-day",
         "night": "rain-night"
       }
     },
     {
-      "code": 1249,
+      "code": 311,
       "icon": {
-        "day": "rain-snow-day",
-        "night": "rain-snow-night"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1252,
+      "code": 312,
       "icon": {
-        "day": "rain-snow-day",
-        "night": "rain-snow-night"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1255,
+      "code": 313,
       "icon": {
-        "day": "snow-day",
-        "night": "snow-night"
+        "day": "rain-day",
+        "night": "rain-night"
       }
     },
     {
-      "code": 1258,
+      "code": 314,
       "icon": {
-        "day": "snow-day",
-        "night": "snow-night"
+        "day": "rain",
+        "night": "rain"
       }
     },
     {
-      "code": 1261,
+      "code": 321,
       "icon": {
-        "day": "hail",
-        "night": "hail"
+        "day": "rain",
+        "night": "rain"
       }
     },
     {
-      "code": 1264,
+      "code": 200,
       "icon": {
-        "day": "hail",
-        "night": "hail"
+        "day": "thunder-rain-day",
+        "night": "thunder-rain-night"
       }
     },
     {
-      "code": 1273,
+      "code": 201,
       "icon": {
         "day": "thunder-rain-day",
         "night": "thunder-rain-night"
       }
     },
     {
-      "code": 1276,
+      "code": 202,
       "icon": {
-        "day": "thunder-rain",
-        "night": "thunder-rain"
+        "day": "thunder-rain-day",
+        "night": "thunder-rain-night"
+      }
+    },
+    {
+      "code": 210,
+      "icon": {
+        "day": "thunder-day",
+        "night": "thunder-night"
       }
     },
     {
-      "code": 1279,
+      "code": 211,
       "icon": {
         "day": "thunder-day",
         "night": "thunder-night"
       }
     },
     {
-      "code": 1282,
+      "code": 212,
       "icon": {
         "day": "thunder",
         "night": "thunder"
       }
+    },
+    {
+      "code": 221,
+      "icon": {
+        "day": "thunder-day",
+        "night": "thunder-night"
+      }
+    },
+    {
+      "code": 230,
+      "icon": {
+        "day": "thunder-rain-day",
+        "night": "thunder-rain-night"
+      }
+    },
+    {
+      "code": 231,
+      "icon": {
+        "day": "thunder-rain-day",
+        "night": "thunder-rain-night"
+      }
+    },
+    {
+      "code": 232,
+      "icon": {
+        "day": "thunder-rain-day",
+        "night": "thunder-rain-night"
+      }
     }
   ]
 }

+ 1 - 1
client/src/components/UI/Modal/Modal.tsx

@@ -1,5 +1,4 @@
 import { MouseEvent, ReactNode, useRef } from 'react';
-
 import classes from './Modal.module.css';
 
 interface Props {
@@ -9,6 +8,7 @@ interface Props {
   cb?: Function;
 }
 
+// TODO: refactor cb + setIsOpen into onClose()
 export const Modal = ({
   isOpen,
   setIsOpen,

+ 2 - 6
client/src/components/UI/Notification/Notification.tsx

@@ -1,8 +1,5 @@
 import { useEffect, useState } from 'react';
-import { useDispatch } from 'react-redux';
-import { bindActionCreators } from 'redux';
-import { actionCreators } from '../../../store';
-
+import { useClearNotification } from '../../../state/notification';
 import classes from './Notification.module.css';
 
 interface Props {
@@ -13,8 +10,7 @@ interface Props {
 }
 
 export const Notification = (props: Props): JSX.Element => {
-  const dispatch = useDispatch();
-  const { clearNotification } = bindActionCreators(actionCreators, dispatch);
+  const clearNotification = useClearNotification();
 
   const [isOpen, setIsOpen] = useState(true);
   const elementClasses = [

+ 0 - 1
client/src/components/UI/Text/Message/Message.tsx

@@ -1,5 +1,4 @@
 import { ReactNode } from 'react';
-
 import classes from './Message.module.css';
 
 interface Props {

+ 14 - 22
client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx

@@ -1,27 +1,17 @@
-import { useState, useEffect, Fragment } from 'react';
 import axios from 'axios';
-
-// Redux
-import { useSelector } from 'react-redux';
-
-// Typescript
-import { Weather, ApiResponse } from '../../../interfaces';
-
-// CSS
-import classes from './WeatherWidget.module.css';
-
-// UI
-import { WeatherIcon } from '../../UI';
-import { State } from '../../../store/reducers';
+import { useAtomValue } from 'jotai';
+import { Fragment, useEffect, useState } from 'react';
+import { ApiResponse, Weather } from '../../../interfaces';
+import { configAtom, configLoadingAtom } from '../../../state/config';
 import { weatherTemplate } from '../../../utility/templateObjects/weatherTemplate';
+import { WeatherIcon } from '../../UI';
+import classes from './WeatherWidget.module.css';
 
 export const WeatherWidget = (): JSX.Element => {
-  const { loading: configLoading, config } = useSelector(
-    (state: State) => state.config
-  );
+  const configLoading = useAtomValue(configLoadingAtom);
+  const config = useAtomValue(configAtom);
 
   const [weather, setWeather] = useState<Weather>(weatherTemplate);
-  const [isLoading, setIsLoading] = useState(true);
 
   // Initial request to get data
   useEffect(() => {
@@ -32,7 +22,6 @@ export const WeatherWidget = (): JSX.Element => {
         if (weatherData) {
           setWeather(weatherData);
         }
-        setIsLoading(false);
       })
       .catch((err) => console.log(err));
   }, []);
@@ -59,7 +48,7 @@ export const WeatherWidget = (): JSX.Element => {
     <div className={classes.WeatherWidget}>
       {configLoading ||
         (config.WEATHER_API_KEY && weather.id > 0 && (
-          <Fragment>
+          <>
             <div className={classes.WeatherIcon}>
               <WeatherIcon
                 weatherStatusCode={weather.conditionCode}
@@ -75,9 +64,12 @@ export const WeatherWidget = (): JSX.Element => {
               )}
 
               {/* ADDITIONAL DATA */}
-              <span>{weather[config.weatherData]}%</span>
+              <span>
+                {weather.conditionText} · 
+                {weather[config.weatherData]}%
+              </span>
             </div>
-          </Fragment>
+          </>
         ))}
     </div>
   );

+ 1 - 6
client/src/index.tsx

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

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

@@ -16,8 +16,10 @@ export interface Config {
   hideApps: boolean;
   hideCategories: boolean;
   hideSearch: boolean;
+  hideSearchProvider: boolean;
   defaultSearchProvider: string;
   secondarySearchProvider: string;
+  autoClearSearch: boolean;
   dockerApps: boolean;
   dockerHost: string;
   kubernetesApps: boolean;

+ 2 - 0
client/src/interfaces/Forms.ts

@@ -31,7 +31,9 @@ export interface UISettingsForm {
   showTime: boolean;
   hideDate: boolean;
   hideSearch: boolean;
+  hideSearchProvider: boolean;
   disableAutofocus: boolean;
+  autoClearSearch: boolean;
 }
 
 export interface DockerSettingsForm {

+ 155 - 0
client/src/state/app.ts

@@ -0,0 +1,155 @@
+import axios from 'axios';
+import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
+import { ApiResponse, App, Config, NewApp } from '../interfaces';
+import { applyAuth, insertAt, sortData } from '../utility';
+import { configAtom } from './config';
+import { successMessage, useCreateNotification } from './notification';
+
+export const appsLoadingAtom = atom(true);
+export const appsAtom = atom<App[]>([]);
+export const appInUpdateAtom = atom<App | null>(null);
+
+export const useFetchApps = () => {
+  const setAppsLoading = useSetAtom(appsLoadingAtom);
+  const setApps = useSetAtom(appsAtom);
+
+  return async () => {
+    setAppsLoading(true);
+
+    try {
+      const res = await axios.get<ApiResponse<App[]>>('/api/apps', {
+        headers: applyAuth(),
+      });
+
+      setApps(res.data.data);
+    } catch (err) {
+      console.log(err);
+    } finally {
+      setAppsLoading(false);
+    }
+  };
+};
+
+export const usePinApp = () => {
+  const apps = useAtomValue(appsAtom);
+  const setSortedApps = useSetSortedApps();
+  const createNotification = useCreateNotification();
+
+  return async (app: App) => {
+    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';
+
+      createNotification(successMessage(`App ${name} ${status}`));
+      const appIdx = apps.findIndex(({ id }) => id === res.data.data.id);
+      setSortedApps(insertAt(apps, appIdx, res.data.data));
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useAddApp = () => {
+  const apps = useAtomValue(appsAtom);
+  const setSortApps = useSetSortedApps();
+  const createNotification = useCreateNotification();
+
+  return async (formData: NewApp | FormData) => {
+    try {
+      const res = await axios.post<ApiResponse<App>>('/api/apps', formData, {
+        headers: applyAuth(),
+      });
+
+      createNotification(successMessage('App added'));
+
+      setSortApps([...apps, res.data.data]);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useDeleteApp = () => {
+  const setApps = useSetAtom(appsAtom);
+  const createNotification = useCreateNotification();
+
+  return async (deleteId: App['id']) => {
+    try {
+      await axios.delete<ApiResponse<{}>>(`/api/apps/${deleteId}`, {
+        headers: applyAuth(),
+      });
+
+      createNotification(successMessage('App deleted'));
+      setApps((prev) => prev.filter(({ id }) => id !== deleteId));
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useUpdateApp = () => {
+  const apps = useAtomValue(appsAtom);
+  const setSortedApps = useSetSortedApps();
+  const createNotification = useCreateNotification();
+
+  return async (updateId: App['id'], formData: NewApp | FormData) => {
+    try {
+      const res = await axios.put<ApiResponse<App>>(
+        `/api/apps/${updateId}`,
+        formData,
+        { headers: applyAuth() }
+      );
+
+      createNotification(successMessage('App updated'));
+      const appIdx = apps.findIndex(({ id }) => id === res.data.data.id);
+      setSortedApps(insertAt(apps, appIdx, res.data.data));
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+export const useReorderApps = () => {
+  const setApps = useSetAtom(appsAtom);
+  return async (apps: App[]) => {
+    interface ReorderQuery {
+      apps: {
+        id: App['id'];
+        orderId: number;
+      }[];
+    }
+
+    try {
+      const updateQuery: ReorderQuery = {
+        apps: apps.map((app, index) => ({
+          id: app.id,
+          orderId: index + 1,
+        })),
+      };
+
+      await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery, {
+        headers: applyAuth(),
+      });
+
+      setApps(apps);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useSetSortedApps = () => {
+  const { useOrdering } = useAtomValue(configAtom);
+  const [apps, setApps] = useAtom(appsAtom);
+
+  return (localApps?: App[]) => {
+    setApps(sortData<App>(localApps || apps, useOrdering));
+  };
+};

+ 99 - 0
client/src/state/auth.ts

@@ -0,0 +1,99 @@
+import axios, { AxiosError } from 'axios';
+import { atom, useSetAtom } from 'jotai';
+import { ApiResponse } from '../interfaces';
+import { useFetchApps } from './app';
+import { useFetchCategories } from './bookmark';
+import { errorMessage, useCreateNotification } from './notification';
+
+interface AuthState {
+  isAuthenticated: boolean;
+  token: string | null;
+}
+
+const loggedOutState: AuthState = {
+  isAuthenticated: false,
+  token: null,
+};
+
+export const authAtom = atom<AuthState>(loggedOutState);
+
+export const useLogin = () => {
+  const setAuth = useSetAtom(authAtom);
+  const fetchApps = useFetchApps();
+  const fetchCategories = useFetchCategories();
+  const authError = useAuthError();
+
+  return async (formData: { password: string; duration: string }) => {
+    try {
+      const res = await axios.post<ApiResponse<{ token: string }>>(
+        '/api/auth',
+        formData
+      );
+      const token = res.data.data.token;
+
+      localStorage.setItem('token', token);
+      setAuth({ token, isAuthenticated: true });
+
+      fetchApps();
+      fetchCategories();
+    } catch (err) {
+      authError(err, true);
+    }
+  };
+};
+
+export const useLogout = () => {
+  const setAuth = useSetAtom(authAtom);
+  const fetchApps = useFetchApps();
+  const fetchCategories = useFetchCategories();
+
+  return () => {
+    localStorage.removeItem('token');
+    setAuth(loggedOutState);
+
+    fetchApps();
+    fetchCategories();
+  };
+};
+
+export const useAutoLogin = () => {
+  const setAuth = useSetAtom(authAtom);
+  const fetchApps = useFetchApps();
+  const fetchCategories = useFetchCategories();
+  const authError = useAuthError();
+
+  return async () => {
+    const token: string = localStorage.token;
+
+    try {
+      await axios.post<ApiResponse<{ token: { isValid: boolean } }>>(
+        '/api/auth/validate',
+        { token }
+      );
+
+      setAuth({ token, isAuthenticated: true });
+
+      fetchApps();
+      fetchCategories();
+    } catch (err) {
+      authError(err);
+    }
+  };
+};
+
+export const useAuthError = () => {
+  const createNotification = useCreateNotification();
+  const fetchApps = useFetchApps();
+
+  return (error: unknown, showNotification: boolean = false) => {
+    const apiError = error as AxiosError;
+
+    if (showNotification) {
+      createNotification(
+        errorMessage(apiError.response?.data.error ?? 'Authenticaton error')
+      );
+    }
+
+    fetchApps();
+  };
+};

+ 378 - 0
client/src/state/bookmark.ts

@@ -0,0 +1,378 @@
+import axios from 'axios';
+import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
+import {
+  ApiResponse,
+  Bookmark,
+  Category,
+  NewBookmark,
+  NewCategory,
+} from '../interfaces';
+import { applyAuth, insertAt, sortData } from '../utility';
+import { configAtom } from './config';
+import { successMessage, useCreateNotification } from './notification';
+
+export const bookmarksLoadingAtom = atom(true);
+export const categoriesAtom = atom<Category[]>([]);
+export const categoryInEditAtom = atom<Category | null>(null);
+export const bookmarkInEditAtom = atom<Bookmark | null>(null);
+
+export const useFetchCategories = () => {
+  const setLoading = useSetAtom(bookmarksLoadingAtom);
+  const setCategories = useSetAtom(categoriesAtom);
+
+  return async () => {
+    setLoading(true);
+
+    try {
+      const res = await axios.get<ApiResponse<Category[]>>('/api/categories', {
+        headers: applyAuth(),
+      });
+      setCategories(res.data.data);
+      setLoading(false);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useAddCategory = () => {
+  const createNotification = useCreateNotification();
+  const categories = useAtomValue(categoriesAtom);
+  const setSortedCategories = useSetSortedCategories();
+
+  return async (formData: NewCategory) => {
+    try {
+      const res = await axios.post<ApiResponse<Category>>(
+        '/api/categories',
+        formData,
+        { headers: applyAuth() }
+      );
+      createNotification(successMessage(`Category ${formData.name} created`));
+      const category = res.data.data;
+
+      const newCategories = [...categories, { ...category, bookmarks: [] }];
+      setSortedCategories(newCategories);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const usePinCategory = () => {
+  const createNotification = useCreateNotification();
+  const [categories, setCategories] = useAtom(categoriesAtom);
+
+  return async ({ id, isPinned, name }: Category) => {
+    try {
+      const res = await axios.put<ApiResponse<Category>>(
+        `/api/categories/${id}`,
+        { isPinned: !isPinned },
+        { headers: applyAuth() }
+      );
+
+      const status = isPinned
+        ? 'unpinned from Homescreen'
+        : 'pinned to Homescreen';
+
+      createNotification(successMessage(`Category ${name} ${status}`));
+
+      const category = res.data.data;
+      const categoryIdx = categories.findIndex(({ id }) => id === category.id);
+
+      setCategories(
+        insertAt(categories, categoryIdx, {
+          ...category,
+          bookmarks: [...categories[categoryIdx].bookmarks],
+        })
+      );
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useDeleteCategory = () => {
+  const createNotification = useCreateNotification();
+  const setCategories = useSetAtom(categoriesAtom);
+
+  return async (id: number) => {
+    try {
+      await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`, {
+        headers: applyAuth(),
+      });
+
+      createNotification(successMessage('Category deleted'));
+      setCategories((prev) => prev.filter((category) => category.id !== id));
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useUpdateCategory = () => {
+  const createNotification = useCreateNotification();
+  const categories = useAtomValue(categoriesAtom);
+  const setSortCategories = useSetSortedCategories();
+
+  return async (id: number, formData: NewCategory) => {
+    try {
+      const res = await axios.put<ApiResponse<Category>>(
+        `/api/categories/${id}`,
+        formData,
+        { headers: applyAuth() }
+      );
+
+      createNotification(successMessage(`Category ${formData.name} updated`));
+
+      const category = res.data.data;
+
+      const categoryIdx = categories.findIndex(({ id }) => id === category.id);
+      const newCategories = insertAt(categories, categoryIdx, {
+        ...category,
+        bookmarks: [...categories[categoryIdx].bookmarks],
+      });
+      setSortCategories(newCategories);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useAddBookmark = () => {
+  const createNotification = useCreateNotification();
+  const setSortedBookmarks = useSetSortedBookmarks();
+  const setCategoryInEdit = useSetAtom(categoryInEditAtom);
+  const categories = useAtomValue(categoriesAtom);
+
+  return async (formData: NewBookmark | FormData) => {
+    try {
+      const res = await axios.post<ApiResponse<Bookmark>>(
+        '/api/bookmarks',
+        formData,
+        { headers: applyAuth() }
+      );
+
+      createNotification(successMessage(`Bookmark created`));
+
+      const newBookmark = res.data.data;
+
+      const categoryIdx = categories.findIndex(
+        ({ id }) => id === newBookmark.categoryId
+      );
+
+      const targetCategory = {
+        ...categories[categoryIdx],
+        bookmarks: [...categories[categoryIdx].bookmarks, newBookmark],
+      };
+
+      const newCategories = insertAt(categories, categoryIdx, targetCategory);
+      setSortedBookmarks(res.data.data.categoryId, newCategories);
+      setCategoryInEdit(targetCategory);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useDeleteBookmark = () => {
+  const createNotification = useCreateNotification();
+  const categories = useAtomValue(categoriesAtom);
+  const setSortedBookmarks = useSetSortedBookmarks();
+  const setCategoryInEdit = useSetAtom(categoryInEditAtom);
+
+  return async (bookmarkId: number, categoryId: number) => {
+    try {
+      await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`, {
+        headers: applyAuth(),
+      });
+
+      createNotification(successMessage('Bookmark deleted'));
+
+      const categoryIdx = categories.findIndex(({ id }) => id === categoryId);
+      const targetCategory = {
+        ...categories[categoryIdx],
+        bookmarks: categories[categoryIdx].bookmarks.filter(
+          (bookmark) => bookmark.id !== bookmarkId
+        ),
+      };
+
+      const newCategories = insertAt(categories, categoryIdx, targetCategory);
+      setSortedBookmarks(categoryId, newCategories);
+      setCategoryInEdit(targetCategory);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useUpdateBookmark = () => {
+  const createNotification = useCreateNotification();
+  const deleteBookmark = useDeleteBookmark();
+  const addBookmark = useAddBookmark();
+  const categories = useAtomValue(categoriesAtom);
+  const setCategoryInEdit = useSetAtom(categoryInEditAtom);
+  const setSortedBookmarks = useSetSortedBookmarks();
+
+  return async (
+    bookmarkId: number,
+    formData: NewBookmark | FormData,
+    category: {
+      prev: number;
+      curr: number;
+    }
+  ) => {
+    try {
+      const res = await axios.put<ApiResponse<Bookmark>>(
+        `/api/bookmarks/${bookmarkId}`,
+        formData,
+        { headers: applyAuth() }
+      );
+      const newBookmark = res.data.data;
+
+      createNotification(successMessage('Bookmark updated'));
+
+      // Check if category was changed
+      const categoryWasChanged = category.curr !== category.prev;
+
+      if (categoryWasChanged) {
+        // Delete bookmark from old category
+        deleteBookmark(bookmarkId, category.prev);
+
+        // Add bookmark to the new category
+        addBookmark(newBookmark);
+      } else {
+        // Else replace in current category
+        const categoryIdx = categories.findIndex(
+          ({ id }) => id === newBookmark.categoryId
+        );
+
+        const prevBookmarks = categories[categoryIdx].bookmarks;
+
+        const bookmarkIdx = prevBookmarks.findIndex(
+          ({ id }) => id === newBookmark.id
+        );
+
+        const targetCategory = {
+          ...categories[categoryIdx],
+          bookmarks: insertAt(prevBookmarks, bookmarkIdx, newBookmark),
+        };
+
+        setCategoryInEdit(targetCategory);
+        setSortedBookmarks(
+          res.data.data.categoryId,
+          insertAt(categories, categoryIdx, targetCategory)
+        );
+      }
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useSetSortedCategories = () => {
+  const { useOrdering } = useAtomValue(configAtom);
+  const setCategories = useSetAtom(categoriesAtom);
+  const categories = useAtomValue(categoriesAtom);
+
+  return (localCategories?: Category[]) => {
+    setCategories(
+      sortData<Category>(localCategories || categories, useOrdering)
+    );
+  };
+};
+
+export const useReorderCategories = () => {
+  const setCategories = useSetAtom(categoriesAtom);
+
+  return async (categories: Category[]) => {
+    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() }
+      );
+      setCategories(categories);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useReorderBookmarks = () => {
+  const [categories, setCategories] = useAtom(categoriesAtom);
+
+  return async (bookmarks: Bookmark[], categoryId: number) => {
+    interface ReorderQuery {
+      bookmarks: {
+        id: number;
+        orderId: number;
+      }[];
+    }
+
+    try {
+      const updateQuery: ReorderQuery = { bookmarks: [] };
+
+      bookmarks.forEach((bookmark, index) =>
+        updateQuery.bookmarks.push({
+          id: bookmark.id,
+          orderId: index + 1,
+        })
+      );
+
+      await axios.put<ApiResponse<{}>>(
+        '/api/bookmarks/0/reorder',
+        updateQuery,
+        { headers: applyAuth() }
+      );
+
+      const categoryIdx = categories.findIndex(({ id }) => id === categoryId);
+      const newCategories = insertAt(categories, categoryIdx, {
+        ...categories[categoryIdx],
+        bookmarks,
+      });
+
+      setCategories(newCategories);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useSetSortedBookmarks = () => {
+  const { useOrdering } = useAtomValue(configAtom);
+  const [categories, setCategories] = useAtom(categoriesAtom);
+
+  return (categoryId: number, localCategories?: Category[]) => {
+    const targetCategories = localCategories || categories;
+
+    const categoryIdx = targetCategories.findIndex(
+      ({ id }) => id === categoryId
+    );
+
+    const category = targetCategories[categoryIdx];
+    const sortedBookmarks = sortData<Bookmark>(category.bookmarks, useOrdering);
+
+    const newCategories = insertAt(targetCategories, categoryIdx, {
+      ...category,
+      bookmarks: sortedBookmarks,
+    });
+
+    setCategories(newCategories);
+  };
+};

+ 69 - 0
client/src/state/config.ts

@@ -0,0 +1,69 @@
+import axios from 'axios';
+import { atom, useSetAtom } from 'jotai';
+import { ApiResponse, Config } from '../interfaces';
+import { ConfigFormData } from '../types';
+import { applyAuth, configTemplate, storeUIConfig } from '../utility';
+import { successMessage, useCreateNotification } from './notification';
+
+export const configLoadingAtom = atom(true);
+export const configAtom = atom<Config>(configTemplate);
+
+const persistedConfigKeys: (keyof Config)[] = [
+  'useAmericanDate',
+  'greetingsSchema',
+  'daySchema',
+  'monthSchema',
+  'showTime',
+  'hideDate',
+];
+
+const useReplaceConfig = () => {
+  const setConfig = useSetAtom(configAtom);
+
+  return (config: Config) => {
+    setConfig(config);
+    persistedConfigKeys.forEach((key) => storeUIConfig(key, config));
+    document.title = config.customTitle;
+  };
+};
+
+export const useFetchConfig = () => {
+  const setConfigLoading = useSetAtom(configLoadingAtom);
+  const setConfig = useSetAtom(configAtom);
+
+  return async () => {
+    setConfigLoading(true);
+
+    try {
+      const res = await axios.get<ApiResponse<Config>>('/api/config');
+      const config = res.data.data;
+
+      setConfig(config);
+      persistedConfigKeys.forEach((key) => storeUIConfig(key, config));
+      document.title = config.customTitle;
+      setConfigLoading(false);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useUpdateConfig = () => {
+  const createNotification = useCreateNotification();
+  const replaceConfig = useReplaceConfig();
+
+  return async (formData: ConfigFormData) => {
+    try {
+      const res = await axios.put<ApiResponse<Config>>(
+        '/api/config',
+        formData,
+        { headers: applyAuth() }
+      );
+      const config = res.data.data;
+      replaceConfig(config);
+      createNotification(successMessage('Settings updated'));
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};

+ 49 - 0
client/src/state/notification.ts

@@ -0,0 +1,49 @@
+import { atom, useSetAtom } from 'jotai';
+import { NewNotification, Notification } from '../interfaces';
+
+export interface NotificationState {
+  notifications: Notification[];
+  idCounter: number;
+}
+
+export const notificationsAtom = atom<NotificationState>({
+  notifications: [],
+  idCounter: 0,
+});
+
+export const successMessage = (message: string): NewNotification => ({
+  title: 'Success',
+  message,
+});
+
+export const errorMessage = (message: string): NewNotification => ({
+  title: 'Error',
+  message,
+});
+
+export const infoMessage = (message: string): NewNotification => ({
+  title: 'Info',
+  message,
+});
+
+export const useCreateNotification = () => {
+  const setNotifications = useSetAtom(notificationsAtom);
+
+  return (newNotification: NewNotification) => {
+    setNotifications(({ notifications, idCounter }) => ({
+      notifications: [...notifications, { ...newNotification, id: idCounter }],
+      idCounter: idCounter + 1,
+    }));
+  };
+};
+
+export const useClearNotification = () => {
+  const setNotifications = useSetAtom(notificationsAtom);
+
+  return (removeId: Notification['id']) => {
+    setNotifications((prev) => ({
+      ...prev,
+      notifications: prev.notifications.filter(({ id }) => id !== removeId),
+    }));
+  };
+};

+ 81 - 0
client/src/state/queries.ts

@@ -0,0 +1,81 @@
+import axios, { AxiosError } from 'axios';
+import { atom, useSetAtom } from 'jotai';
+import { ApiResponse, Query } from '../interfaces';
+import { applyAuth } from '../utility';
+import { errorMessage, useCreateNotification } from './notification';
+
+export const customQueriesAtom = atom<Query[]>([]);
+
+export const useFetchQueries = () => {
+  const setQueries = useSetAtom(customQueriesAtom);
+
+  return async () => {
+    try {
+      const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
+      setQueries(res.data.data);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useAddQuery = () => {
+  const createNotification = useCreateNotification();
+  const setQueries = useSetAtom(customQueriesAtom);
+
+  return async (query: Query) => {
+    try {
+      const res = await axios.post<ApiResponse<Query>>('/api/queries', query, {
+        headers: applyAuth(),
+      });
+      setQueries((prev) => [...prev, res.data.data]);
+    } catch (err) {
+      const error = err as AxiosError<{ error: string }>;
+      createNotification(
+        errorMessage(error.response?.data.error ?? 'Unable to add query')
+      );
+    }
+  };
+};
+
+export const useDeleteQuery = () => {
+  const createNotification = useCreateNotification();
+  const setQueries = useSetAtom(customQueriesAtom);
+
+  return async (prefix: string) => {
+    try {
+      const res = await axios.delete<ApiResponse<Query[]>>(
+        `/api/queries/${prefix}`,
+        { headers: applyAuth() }
+      );
+      setQueries(res.data.data);
+    } catch (err) {
+      const error = err as AxiosError<{ error: string }>;
+      createNotification(
+        errorMessage(error.response?.data.error ?? 'Unable to delete query')
+      );
+    }
+  };
+};
+
+export const useUpdateQuery = () => {
+  const createNotification = useCreateNotification();
+  const setQueries = useSetAtom(customQueriesAtom);
+
+  return async (query: Query, oldPrefix: string) => {
+    try {
+      const res = await axios.put<ApiResponse<Query[]>>(
+        `/api/queries/${oldPrefix}`,
+        query,
+        { headers: applyAuth() }
+      );
+      setQueries(res.data.data);
+    } catch (err) {
+      const error = err as AxiosError<{ error: string }>;
+      createNotification(
+        errorMessage(error.response?.data.error ?? 'Unable to update query')
+      );
+      console.log(err);
+    }
+  };
+};

+ 127 - 0
client/src/state/theme.ts

@@ -0,0 +1,127 @@
+import axios, { AxiosError } from 'axios';
+import { atom, useSetAtom } from 'jotai';
+import { ApiResponse, Theme, ThemeColors } from '../interfaces';
+import {
+  applyAuth,
+  arrayPartition,
+  parsePABToTheme,
+  parseThemeToPAB,
+} from '../utility';
+import {
+  errorMessage,
+  successMessage,
+  useCreateNotification,
+} from './notification';
+
+const savedTheme = localStorage.theme
+  ? parsePABToTheme(localStorage.theme)
+  : parsePABToTheme('#effbff;#6ee2ff;#242b33');
+
+export const activeThemeAtom = atom<Theme>({
+  name: 'main',
+  isCustom: false,
+  colors: {
+    ...savedTheme,
+  },
+});
+
+export const themesAtom = atom<Theme[]>([]);
+export const userThemesAtom = atom<Theme[]>([]);
+export const themeInEditAtom = atom<Theme | null>(null);
+
+export const useFetchThemes = () => {
+  const setThemes = useSetAtom(themesAtom);
+  const setUserThemes = useSetAtom(userThemesAtom);
+
+  return async () => {
+    try {
+      const res = await axios.get<ApiResponse<Theme[]>>('/api/themes');
+      const [themes, userThemes] = arrayPartition<Theme>(
+        res.data.data,
+        (e) => !e.isCustom
+      );
+
+      setThemes(themes);
+      setUserThemes(userThemes);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useSetTheme = () => {
+  const setTheme = useSetAtom(activeThemeAtom);
+
+  return (colors: ThemeColors, remember: boolean = true) => {
+    if (remember) {
+      localStorage.setItem('theme', parseThemeToPAB(colors));
+    }
+
+    for (const [key, value] of Object.entries(colors)) {
+      document.body.style.setProperty(`--color-${key}`, value);
+    }
+
+    setTheme((prev) => ({ ...prev, colors }));
+  };
+};
+
+export const useAddTheme = () => {
+  const setUserThemes = useSetAtom(userThemesAtom);
+  const createNotification = useCreateNotification();
+
+  return async (theme: Theme) => {
+    try {
+      const res = await axios.post<ApiResponse<Theme>>('/api/themes', theme, {
+        headers: applyAuth(),
+      });
+
+      setUserThemes((prev) => [...prev, res.data.data]);
+      createNotification(successMessage('Theme added'));
+    } catch (err) {
+      const error = err as AxiosError<{ error: string }>;
+      createNotification(
+        errorMessage(error.response?.data.error ?? 'Unable to add theme')
+      );
+    }
+  };
+};
+
+export const useUpdateTheme = () => {
+  const setUserThemes = useSetAtom(userThemesAtom);
+
+  return async (theme: Theme, originalName: string) => {
+    try {
+      const res = await axios.put<ApiResponse<Theme[]>>(
+        `/api/themes/${originalName}`,
+        theme,
+        { headers: applyAuth() }
+      );
+
+      setUserThemes(res.data.data);
+    } catch (err) {
+      console.log(err);
+    }
+  };
+};
+
+export const useDeleteTheme = () => {
+  const setUserThemes = useSetAtom(userThemesAtom);
+  const createNotification = useCreateNotification();
+
+  return async (name: string) => {
+    try {
+      const res = await axios.delete<ApiResponse<Theme[]>>(
+        `/api/themes/${name}`,
+        { headers: applyAuth() }
+      );
+
+      setUserThemes(res.data.data);
+      createNotification(successMessage('Theme deleted'));
+    } catch (err) {
+      const error = err as AxiosError<{ error: string }>;
+      createNotification(
+        errorMessage(error.response?.data.error ?? 'Unable to delete theme')
+      );
+    }
+  };
+};

+ 17 - 0
client/src/utility/array.ts

@@ -0,0 +1,17 @@
+export const arrayPartition = <T>(
+  arr: T[],
+  isValid: (e: T) => boolean
+): T[][] => {
+  let pass: T[] = [];
+  let fail: T[] = [];
+
+  arr.forEach((e) => (isValid(e) ? pass : fail).push(e));
+
+  return [pass, fail];
+};
+
+export const insertAt = <T>(arr: T[], index: number, element: T): T[] => [
+  ...arr.slice(0, index),
+  element,
+  ...arr.slice(index + 1),
+];

+ 20 - 22
client/src/utility/checkVersion.ts

@@ -1,34 +1,32 @@
 import axios from 'axios';
-import { store } from '../store/store';
-import { createNotification } from '../store/action-creators';
+import { useCreateNotification } from '../state/notification';
 
-export const checkVersion = async (isForced: boolean = false) => {
-  try {
-    const res = await axios.get<string>(
-      'https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env'
-    );
+export const useCheckVersion = (isForced: boolean = false) => {
+  const createNotification = useCreateNotification();
+  return async () => {
+    try {
+      const res = await axios.get<string>(
+        'https://raw.githubusercontent.com/GeorgeSG/flame/master/client/.env'
+      );
 
-    const githubVersion = res.data
-      .split('\n')
-      .map((pair) => pair.split('='))[0][1];
+      const githubVersion = res.data
+        .split('\n')
+        .map((pair) => pair.split('='))[0][1];
 
-    if (githubVersion !== process.env.REACT_APP_VERSION) {
-      store.dispatch<any>(
+      if (githubVersion !== process.env.REACT_APP_VERSION) {
         createNotification({
           title: 'Info',
           message: 'New version is available!',
-          url: 'https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md',
-        })
-      );
-    } else if (isForced) {
-      store.dispatch<any>(
+          url: 'https://github.com/GeorgeSG/flame/blob/master/CHANGELOG.md',
+        });
+      } else if (isForced) {
         createNotification({
           title: 'Info',
           message: 'You are using the latest version!',
-        })
-      );
+        });
+      }
+    } catch (err) {
+      console.log(err);
     }
-  } catch (err) {
-    console.log(err);
-  }
+  };
 };

+ 5 - 1
client/src/utility/iconParser.ts

@@ -4,6 +4,10 @@
  * @returns Parsed icon name to be used with mdi/js, e.g mdiAlertBoxOutline
  */
 export const iconParser = (mdiName: string): string => {
+  if (mdiName.startsWith('mdi')) {
+    return mdiName;
+  }
+
   let parsedName = mdiName
     .split('-')
     .map((word: string) => `${word[0].toUpperCase()}${word.slice(1)}`)
@@ -11,4 +15,4 @@ export const iconParser = (mdiName: string): string => {
   parsedName = `mdi${parsedName}`;
 
   return parsedName;
-}
+};

+ 1 - 1
client/src/utility/index.ts

@@ -13,4 +13,4 @@ export * from './decodeToken';
 export * from './applyAuth';
 export * from './escapeRegex';
 export * from './parseTheme';
-export * from './arrayPartition';
+export * from './array';

+ 55 - 51
client/src/utility/searchParser.ts

@@ -1,68 +1,72 @@
-import { queries } from './searchQueries.json';
-import { SearchResult } from '../interfaces';
-import { store } from '../store/store';
+import { useAtomValue } from 'jotai';
 import { isUrlOrIp } from '.';
+import { SearchResult } from '../interfaces';
+import { configAtom } from '../state/config';
+import { customQueriesAtom } from '../state/queries';
+import { queries } from './searchQueries.json';
 
-export const searchParser = (searchQuery: string): SearchResult => {
-  const result: SearchResult = {
-    isLocal: false,
-    isURL: false,
-    sameTab: false,
-    encodedURL: '',
-    primarySearch: {
-      name: '',
-      prefix: '',
-      template: '',
-    },
-    secondarySearch: {
-      name: '',
-      prefix: '',
-      template: '',
-    },
-    rawQuery: searchQuery,
-  };
+export const useSearchParser = () => {
+  const customQueries = useAtomValue(customQueriesAtom);
+  const config = useAtomValue(configAtom);
 
-  const { customQueries, config } = store.getState().config;
+  return (searchQuery: string): SearchResult => {
+    const result: SearchResult = {
+      isLocal: false,
+      isURL: false,
+      sameTab: false,
+      encodedURL: '',
+      primarySearch: {
+        name: '',
+        prefix: '',
+        template: '',
+      },
+      secondarySearch: {
+        name: '',
+        prefix: '',
+        template: '',
+      },
+      rawQuery: searchQuery,
+    };
 
-  // Check if url or ip was passed
-  result.isURL = isUrlOrIp(searchQuery);
+    // Check if url or ip was passed
+    result.isURL = isUrlOrIp(searchQuery);
 
-  // Match prefix and query
-  const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
+    // Match prefix and query
+    const splitQuery = searchQuery.match(/^\/([a-z]+)(.+)$/i);
 
-  // Extract prefix
-  const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
+    // Extract prefix
+    const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
 
-  // Encode url
-  const encodedURL = splitQuery
-    ? encodeURIComponent(splitQuery[2])
-    : encodeURIComponent(searchQuery);
+    // Encode url
+    const encodedURL = splitQuery
+      ? encodeURIComponent(splitQuery[2])
+      : encodeURIComponent(searchQuery);
 
-  // Find primary search engine template
-  const findProvider = (prefix: string) => {
-    return [...queries, ...customQueries].find((q) => q.prefix === prefix);
-  };
+    // Find primary search engine template
+    const findProvider = (prefix: string) => {
+      return [...queries, ...customQueries].find((q) => q.prefix === prefix);
+    };
 
-  const primarySearch = findProvider(prefix);
-  const secondarySearch = findProvider(config.secondarySearchProvider);
+    const primarySearch = findProvider(prefix);
+    const secondarySearch = findProvider(config.secondarySearchProvider);
 
-  // If search providers were found
-  if (primarySearch) {
-    result.primarySearch = primarySearch;
-    result.encodedURL = encodedURL;
+    // If search providers were found
+    if (primarySearch) {
+      result.primarySearch = primarySearch;
+      result.encodedURL = encodedURL;
 
-    if (prefix === 'l') {
-      result.isLocal = true;
-    } else {
+      if (prefix === 'l') {
+        result.isLocal = true;
+      }
       result.sameTab = config.searchSameTab;
-    }
 
-    if (secondarySearch) {
-      result.secondarySearch = secondarySearch;
+      if (secondarySearch) {
+        result.secondarySearch = secondarySearch;
+      }
+
+      return result;
     }
 
     return result;
-  }
-
-  return result;
+  };
 };

+ 2 - 0
client/src/utility/templateObjects/configTemplate.ts

@@ -16,8 +16,10 @@ export const configTemplate: Config = {
   hideApps: false,
   hideCategories: false,
   hideSearch: false,
+  hideSearchProvider: false,
   defaultSearchProvider: 'l',
   secondarySearchProvider: 'd',
+  autoClearSearch: false,
   dockerApps: false,
   dockerHost: 'localhost',
   kubernetesApps: false,

+ 2 - 0
client/src/utility/templateObjects/settingsTemplate.ts

@@ -19,7 +19,9 @@ export const uiSettingsTemplate: UISettingsForm = {
   showTime: false,
   hideDate: false,
   hideSearch: false,
+  hideSearchProvider: false,
   disableAutofocus: false,
+  autoClearSearch: false,
 };
 
 export const weatherSettingsTemplate: WeatherForm = {

+ 62 - 129
controllers/apps/docker/useDocker.js

@@ -11,62 +11,20 @@ const useDocker = async (apps) => {
     dockerHost: host,
   } = await loadConfig();
 
-  const dockerApps = [];
-
   let containers = null;
-  let containers_swarm = null;
-
-  function addApp(dockerApps, labels){
-    // add each container as flame formatted app
-    if (
-      'flame.name' in labels &&
-      'flame.url' in labels &&
-      /^app/.test(labels['flame.type'])
-    ) {
-      for (let i = 0; i < labels['flame.name'].split(';').length; i++) {
-        const names = labels['flame.name'].split(';');
-        const urls = labels['flame.url'].split(';');
-        let icons = '';
-
-        if ('flame.icon' in labels) {
-          icons = labels['flame.icon'].split(';');
-        }
-
-        dockerApps.push({
-          name: names[i] || names[0],
-          url: urls[i] || urls[0],
-          icon: icons[i] || 'docker',
-        });
-      }
-    }
-  }
 
   // Get list of containers
   try {
     if (host.includes('localhost')) {
       // Use default host
-      function getDocker(){
-        return axios.get(
-          `http://${host}/containers/json?{"status":["running"]}`,
-          {
-            socketPath: '/var/run/docker.sock',
-          }
-        );
-      }
-      function getSwarm(){
-        return axios.get(
-          `http://${host}/services`,
-          {
-            socketPath: '/var/run/docker.sock',
-          }
-        );
-      }
-
-      [ containers, containers_swarm ] = await Promise.all(
-        [getDocker(),
-          getSwarm()
-        ]);
+      let { data } = await axios.get(
+        `http://${host}/containers/json?{"status":["running"]}`,
+        {
+          socketPath: '/var/run/docker.sock',
+        }
+      );
 
+      containers = data;
     } else {
       // Use custom host
       let { data } = await axios.get(
@@ -79,17 +37,20 @@ const useDocker = async (apps) => {
     logger.log(`Can't connect to the Docker API on ${host}`, 'ERROR');
   }
 
-  if (containers_swarm) {
+  if (containers) {
     apps = await App.findAll({
       order: [[orderType, 'ASC']],
     });
 
-    services = containers_swarm.data;
-    for (const service of services) {
-      let labels = service.Spec.Labels;
+    // Filter out containers without any annotations
+    containers = containers.filter((e) => Object.keys(e.Labels).length !== 0);
 
-      labels['flame.name'] = service.Spec.Name;
-      labels['flame.type'] = 'application';
+    const dockerApps = [];
+
+    for (const container of containers) {
+      let labels = container.Labels;
+
+      // Traefik labels for URL configuration
       if (!('flame.url' in labels)) {
         for (const label of Object.keys(labels)) {
           if (/^traefik.*.frontend.rule/.test(label)) {
@@ -120,96 +81,68 @@ const useDocker = async (apps) => {
           }
         }
       }
-      addApp(dockerApps, labels);
-    }
-  }
 
-  if (containers) {
-    apps = await App.findAll({
-      order: [[orderType, 'ASC']],
-    });
-
-    // Filter out containers without any annotations
-    containers = containers.data.filter((e) => Object.keys(e.Labels).length !== 0);
-
-    for (const container of containers) {
-      let labels = container.Labels;
-      if(!('com.docker.stack.namespace' in labels)){
-        console.log(container)
-        labels['flame.name'] = container.Names[0];
-        labels['flame.type'] = 'application';
-      // Traefik labels for URL configuration
-        if (!('flame.url' in labels)) {
-          for (const label of Object.keys(labels)) {
-            if (/^traefik.*.frontend.rule/.test(label)) {
-              // Traefik 1.x
-              let value = labels[label];
-
-              if (value.indexOf('Host') !== -1) {
-                value = value.split('Host:')[1];
-                labels['flame.url'] =
-                  'https://' + value.split(',').join(';https://');
-              }
-            } else if (/^traefik.*?\.rule/.test(label)) {
-              // Traefik 2.x
-              const value = labels[label];
-
-              if (value.indexOf('Host') !== -1) {
-                const regex = /\`([a-zA-Z0-9\.\-]+)\`/g;
-                const domains = [];
-
-                while ((match = regex.exec(value)) != null) {
-                  domains.push('http://' + match[1]);
-                }
+      // add each container as flame formatted app
+      if (
+        'flame.name' in labels &&
+        'flame.url' in labels &&
+        /^app/.test(labels['flame.type'])
+      ) {
+        for (let i = 0; i < labels['flame.name'].split(';').length; i++) {
+          const names = labels['flame.name'].split(';');
+          const urls = labels['flame.url'].split(';');
+          let icons = '';
 
-                if (domains.length > 0) {
-                  labels['flame.url'] = domains.join(';');
-                }
-              }
-            }
+          if ('flame.icon' in labels) {
+            icons = labels['flame.icon'].split(';');
           }
+
+          dockerApps.push({
+            name: names[i] || names[0],
+            url: urls[i] || urls[0],
+            icon: icons[i] || 'docker',
+          });
         }
       }
-      addApp(dockerApps, labels);
     }
-  }
 
-  if (unpinStoppedApps) {
-    for (const app of apps) {
-      await app.update({ isPinned: false });
+    if (unpinStoppedApps) {
+      for (const app of apps) {
+        await app.update({ isPinned: false });
+      }
     }
-  }
-  for (const item of dockerApps) {
-    // If app already exists, update it
-    if (apps.some((app) => app.name === item.name)) {
-      const app = apps.find((a) => a.name === item.name);
 
-      if (
-        item.icon === 'custom' ||
-        (item.icon === 'docker' && app.icon != 'docker')
-      ) {
-        // update without overriding icon
-        await app.update({
-          name: item.name,
-          url: item.url,
-          isPinned: true,
-        });
+    for (const item of dockerApps) {
+      // If app already exists, update it
+      if (apps.some((app) => app.name === item.name)) {
+        const app = apps.find((a) => a.name === item.name);
+
+        if (
+          item.icon === 'custom' ||
+          (item.icon === 'docker' && app.icon != 'docker')
+        ) {
+          // update without overriding icon
+          await app.update({
+            name: item.name,
+            url: item.url,
+            isPinned: true,
+          });
+        } else {
+          await app.update({
+            ...item,
+            isPinned: true,
+          });
+        }
       } else {
-        await app.update({
+        // else create new app
+        await App.create({
           ...item,
+          icon: item.icon === 'custom' ? 'docker' : item.icon,
           isPinned: true,
         });
       }
-    } else {
-      // else create new app
-      await App.create({
-        ...item,
-        icon: item.icon === 'custom' ? 'docker' : item.icon,
-        isPinned: true,
-      });
     }
   }
-
 };
 
 module.exports = useDocker;

+ 6 - 6
controllers/apps/docker/useKubernetes.js

@@ -35,14 +35,14 @@ const useKubernetes = async (apps) => {
       const annotations = ingress.metadata.annotations;
 
       if (
-        'flame.pawelmalak/name' in annotations &&
-        'flame.pawelmalak/url' in annotations &&
-        /^app/.test(annotations['flame.pawelmalak/type'])
+        'flame.georgesg/name' in annotations &&
+        'flame.georgesg/url' in annotations &&
+        /^app/.test(annotations['flame.georgesg/type'])
       ) {
         kubernetesApps.push({
-          name: annotations['flame.pawelmalak/name'],
-          url: annotations['flame.pawelmalak/url'],
-          icon: annotations['flame.pawelmalak/icon'] || 'kubernetes',
+          name: annotations['flame.georgesg/name'],
+          url: annotations['flame.georgesg/url'],
+          icon: annotations['flame.georgesg/icon'] || 'kubernetes',
         });
       }
     }

+ 4 - 4
k8s/overlays/shokohsc/ingress.yaml

@@ -6,10 +6,10 @@ metadata:
   annotations:
     kubernetes.io/ingress.class: nginx
     cert-manager.io/cluster-issuer: ca-cluster-issuer
-    flame.pawelmalak/name: flame
-    flame.pawelmalak/url: dev.flame.shokohsc.home
-    flame.pawelmalak/type: app
-    flame.pawelmalak/icon: fire
+    flame.georgesg/name: flame
+    flame.georgesg/url: dev.flame.shokohsc.home
+    flame.georgesg/type: app
+    flame.georgesg/icon: fire
 spec:
   rules:
   - host: dev.flame.shokohsc.home

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "flame",
-  "version": "0.1.0",
+  "version": "2.4.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "flame",
-      "version": "0.1.0",
+      "version": "2.4.0",
       "license": "ISC",
       "dependencies": {
         "@kubernetes/client-node": "^0.15.1",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "flame",
-  "version": "0.1.0",
+  "version": "2.4.0",
   "description": "Self-hosted start page",
   "main": "index.js",
   "scripts": {

+ 17 - 13
utils/getExternalWeather.js

@@ -3,27 +3,31 @@ const axios = require('axios');
 const loadConfig = require('./loadConfig');
 
 const getExternalWeather = async () => {
-  const { WEATHER_API_KEY: secret, lat, long } = await loadConfig();
+  const { WEATHER_API_KEY: secret, lat, long, isCelsius } = await loadConfig();
+  
+  //units = standard, metric, imperial
+  const units = isCelsius?'metric':'imperial'
 
   // Fetch data from external API
   try {
     const res = await axios.get(
-      `http://api.weatherapi.com/v1/current.json?key=${secret}&q=${lat},${long}`
+      `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${long}&appid=${secret}&units=${units}`
     );
 
     // Save weather data
-    const cursor = res.data.current;
+    const cursor = res.data;
+    const isDay = (Math.floor(Date.now()/1000) < cursor.sys.sunset) | 0
     const weatherData = await Weather.create({
-      externalLastUpdate: cursor.last_updated,
-      tempC: cursor.temp_c,
-      tempF: cursor.temp_f,
-      isDay: cursor.is_day,
-      cloud: cursor.cloud,
-      conditionText: cursor.condition.text,
-      conditionCode: cursor.condition.code,
-      humidity: cursor.humidity,
-      windK: cursor.wind_kph,
-      windM: cursor.wind_mph,
+      externalLastUpdate: cursor.dt,
+      tempC: cursor.main.temp,
+      tempF: cursor.main.temp,
+      isDay: isDay,
+      cloud: cursor.clouds.all,
+      conditionText: cursor.weather[0].main,
+      conditionCode: cursor.weather[0].id,
+      humidity: cursor.main.humidity,
+      windK: cursor.wind.speed,
+      windM: 0,
     });
     return weatherData;
   } catch (err) {

+ 1 - 0
utils/init/initialConfig.json

@@ -14,6 +14,7 @@
   "hideApps": false,
   "hideCategories": false,
   "hideSearch": false,
+  "hideSearchProvider": false,
   "defaultSearchProvider": "l",
   "secondarySearchProvider": "d",
   "dockerApps": false,

Неке датотеке нису приказане због велике количине промена