Browse Source

Merge pull request #334 from pawelmalak/feature

Version 2.3.0
pawelmalak 3 years ago
parent
commit
446b4095f6
66 changed files with 1309 additions and 222 deletions
  1. 1 0
      .dev/build_dev.sh
  2. 1 1
      .dev/build_latest.sh
  3. 1 1
      .dev/build_multiarch.sh
  4. 1 1
      .env
  5. 7 0
      CHANGELOG.md
  6. 1 1
      README.md
  7. 1 0
      api.js
  8. 1 1
      client/.env
  9. 7 4
      client/src/App.tsx
  10. 13 4
      client/src/components/Apps/AppForm/AppForm.tsx
  11. 11 0
      client/src/components/Bookmarks/Form/BookmarksForm.tsx
  12. 21 13
      client/src/components/SearchBar/SearchBar.tsx
  13. 0 30
      client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.module.css
  14. 23 32
      client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx
  15. 30 2
      client/src/components/Settings/GeneralSettings/GeneralSettings.tsx
  16. 7 0
      client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.module.css
  17. 95 0
      client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.tsx
  18. 6 0
      client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.module.css
  19. 152 0
      client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx
  20. 57 0
      client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx
  21. 1 1
      client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.module.css
  22. 22 0
      client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx
  23. 0 32
      client/src/components/Settings/Themer/ThemePreview.tsx
  24. 0 0
      client/src/components/Settings/Themer/ThemePreview/ThemePreview.module.css
  25. 38 0
      client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx
  26. 27 23
      client/src/components/Settings/Themer/Themer.tsx
  27. 4 0
      client/src/components/UI/Forms/InputGroup/InputGroup.module.css
  28. 11 0
      client/src/components/UI/Icons/ActionIcons/ActionIcons.module.css
  29. 10 0
      client/src/components/UI/Icons/ActionIcons/ActionIcons.tsx
  30. 3 3
      client/src/components/UI/Icons/WeatherIcon/WeatherIcon.tsx
  31. 12 4
      client/src/components/UI/Modal/Modal.tsx
  32. 16 0
      client/src/components/UI/Tables/CompactTable/CompactTable.module.css
  33. 27 0
      client/src/components/UI/Tables/CompactTable/CompactTable.tsx
  34. 0 0
      client/src/components/UI/Tables/Table/Table.module.css
  35. 0 0
      client/src/components/UI/Tables/Table/Table.tsx
  36. 3 1
      client/src/components/UI/index.ts
  37. 1 0
      client/src/interfaces/Config.ts
  38. 1 0
      client/src/interfaces/Forms.ts
  39. 4 2
      client/src/interfaces/SearchResult.ts
  40. 9 6
      client/src/interfaces/Theme.ts
  41. 10 2
      client/src/store/action-creators/config.ts
  42. 115 17
      client/src/store/action-creators/theme.ts
  43. 5 0
      client/src/store/action-types/index.ts
  44. 13 1
      client/src/store/actions/index.ts
  45. 26 1
      client/src/store/actions/theme.ts
  46. 66 8
      client/src/store/reducers/theme.ts
  47. 11 0
      client/src/utility/arrayPartition.ts
  48. 2 0
      client/src/utility/index.ts
  49. 20 0
      client/src/utility/parseTheme.ts
  50. 27 11
      client/src/utility/searchParser.ts
  51. 1 0
      client/src/utility/templateObjects/configTemplate.ts
  52. 1 0
      client/src/utility/templateObjects/settingsTemplate.ts
  53. 7 0
      controllers/queries/addQuery.js
  54. 28 0
      controllers/themes/addTheme.js
  55. 22 0
      controllers/themes/deleteTheme.js
  56. 17 0
      controllers/themes/getThemes.js
  57. 6 0
      controllers/themes/index.js
  58. 32 0
      controllers/themes/updateTheme.js
  59. 11 2
      routes/queries.js
  60. 29 0
      routes/themes.js
  61. 5 3
      utils/Logger.js
  62. 2 0
      utils/init/index.js
  63. 1 0
      utils/init/initialConfig.json
  64. 160 0
      utils/init/initialFiles.json
  65. 28 0
      utils/init/normalizeTheme.js
  66. 39 15
      utils/init/themes.json

+ 1 - 0
.dev/build_dev.sh

@@ -0,0 +1 @@
+docker build -t flame:dev -f .docker/Dockerfile .

+ 1 - 1
.dev/build_latest.sh

@@ -1,2 +1,2 @@
-docker build -t pawelmalak/flame -t "pawelmalak/flame:$1" -f .docker/Dockerfile "$2" \
+docker build -t pawelmalak/flame -t "pawelmalak/flame:$1" -f .docker/Dockerfile . \
   && docker push pawelmalak/flame && docker push "pawelmalak/flame:$1"

+ 1 - 1
.dev/build_multiarch.sh

@@ -3,4 +3,4 @@ docker buildx build \
   -f .docker/Dockerfile.multiarch \
   -t pawelmalak/flame:multiarch \
   -t "pawelmalak/flame:multiarch$1" \
-  --push "$2"
+  --push .

+ 1 - 1
.env

@@ -1,5 +1,5 @@
 PORT=5005
 NODE_ENV=development
-VERSION=2.2.2
+VERSION=2.3.0
 PASSWORD=flame_password
 SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b

+ 7 - 0
CHANGELOG.md

@@ -1,3 +1,10 @@
+### 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))
+- Fixed bug where pressing Enter with empty search bar would redirect to search results ([#325](https://github.com/pawelmalak/flame/issues/325))
+- Fixed bug where user could create empty app or bookmark which was causing page to go blank ([#332](https://github.com/pawelmalak/flame/issues/332))
+- Added new theme: Mint
+
 ### v2.2.2 (2022-03-21)
 - Added option to get user location directly from the app ([#287](https://github.com/pawelmalak/flame/issues/287))
 - Fixed bug with local search not working when using prefix ([#289](https://github.com/pawelmalak/flame/issues/289))

+ 1 - 1
README.md

@@ -11,7 +11,7 @@ Flame is self-hosted startpage for your server. Its design is inspired (heavily)
 - 📌 Pin your favourite items to the homescreen for quick and easy access
 - 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
 - 🔑 Authentication system to protect your settings, apps and bookmarks
-- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes
+- 🔨 Dozens of options to customize Flame interface to your needs, including support for custom CSS, 15 built-in color themes and custom theme builder
 - ☀️ Weather widget with current temperature, cloud coverage and animated weather status
 - 🐳 Docker integration to automatically pick and add apps based on their labels
 

+ 1 - 0
api.js

@@ -22,6 +22,7 @@ api.use('/api/categories', require('./routes/category'));
 api.use('/api/bookmarks', require('./routes/bookmark'));
 api.use('/api/queries', require('./routes/queries'));
 api.use('/api/auth', require('./routes/auth'));
+api.use('/api/themes', require('./routes/themes'));
 
 // Custom error handler
 api.use(errorHandler);

+ 1 - 1
client/.env

@@ -1 +1 @@
-REACT_APP_VERSION=2.2.2
+REACT_APP_VERSION=2.3.0

+ 7 - 4
client/src/App.tsx

@@ -10,7 +10,7 @@ import { actionCreators, store } from './store';
 import { State } from './store/reducers';
 
 // Utils
-import { checkVersion, decodeToken } from './utility';
+import { checkVersion, decodeToken, parsePABToTheme } from './utility';
 
 // Routes
 import { Home } from './components/Home/Home';
@@ -31,7 +31,7 @@ export const App = (): JSX.Element => {
   const { config, loading } = useSelector((state: State) => state.config);
 
   const dispath = useDispatch();
-  const { fetchQueries, setTheme, logout, createNotification } =
+  const { fetchQueries, setTheme, logout, createNotification, fetchThemes } =
     bindActionCreators(actionCreators, dispath);
 
   useEffect(() => {
@@ -51,9 +51,12 @@ export const App = (): JSX.Element => {
       }
     }, 1000);
 
+    // load themes
+    fetchThemes();
+
     // set user theme if present
     if (localStorage.theme) {
-      setTheme(localStorage.theme);
+      setTheme(parsePABToTheme(localStorage.theme));
     }
 
     // check for updated
@@ -68,7 +71,7 @@ export const App = (): JSX.Element => {
   // If there is no user theme, set the default one
   useEffect(() => {
     if (!loading && !localStorage.theme) {
-      setTheme(config.defaultTheme, false);
+      setTheme(parsePABToTheme(config.defaultTheme), false);
     }
   }, [loading]);
 

+ 13 - 4
client/src/components/Apps/AppForm/AppForm.tsx

@@ -18,10 +18,8 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
   const { appInUpdate } = useSelector((state: State) => state.apps);
 
   const dispatch = useDispatch();
-  const { addApp, updateApp, setEditApp } = bindActionCreators(
-    actionCreators,
-    dispatch
-  );
+  const { addApp, updateApp, setEditApp, createNotification } =
+    bindActionCreators(actionCreators, dispatch);
 
   const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
   const [customIcon, setCustomIcon] = useState<File | null>(null);
@@ -58,6 +56,17 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
   const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
     e.preventDefault();
 
+    for (let field of ['name', 'url', 'icon'] as const) {
+      if (/^ +$/.test(formData[field])) {
+        createNotification({
+          title: 'Error',
+          message: `Field cannot be empty: ${field}`,
+        });
+
+        return;
+      }
+    }
+
     const createFormData = (): FormData => {
       const data = new FormData();
 

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

@@ -69,6 +69,17 @@ export const BookmarksForm = ({
   const formSubmitHandler = (e: FormEvent): void => {
     e.preventDefault();
 
+    for (let field of ['name', 'url', 'icon'] as const) {
+      if (/^ +$/.test(formData[field])) {
+        createNotification({
+          title: 'Error',
+          message: `Field cannot be empty: ${field}`,
+        });
+
+        return;
+      }
+    }
+
     const createFormData = (): FormData => {
       const data = new FormData();
       if (customIcon) {

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

@@ -64,16 +64,22 @@ export const SearchBar = (props: Props): JSX.Element => {
   };
 
   const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
-    const { isLocal, search, query, isURL, sameTab } = searchParser(
-      inputRef.current.value
-    );
+    const {
+      isLocal,
+      encodedURL,
+      primarySearch,
+      secondarySearch,
+      isURL,
+      sameTab,
+      rawQuery,
+    } = searchParser(inputRef.current.value);
 
     if (isLocal) {
-      setLocalSearch(search);
+      setLocalSearch(encodedURL);
     }
 
     if (e.code === 'Enter' || e.code === 'NumpadEnter') {
-      if (!query.prefix) {
+      if (!primarySearch.prefix) {
         // Prefix not found -> emit notification
         createNotification({
           title: 'Error',
@@ -90,19 +96,21 @@ export const SearchBar = (props: Props): JSX.Element => {
         } else if (bookmarkSearchResult?.[0]?.bookmarks?.length) {
           redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab);
         } else {
-          // no local results -> search the internet with the default search provider
-          let template = query.template;
+          // no local results -> search the internet with the default search provider if query is not empty
+          if (!/^ *$/.test(rawQuery)) {
+            let template = primarySearch.template;
 
-          if (query.prefix === 'l') {
-            template = 'https://duckduckgo.com/?q=';
-          }
+            if (primarySearch.prefix === 'l') {
+              template = secondarySearch.template;
+            }
 
-          const url = `${template}${search}`;
-          redirectUrl(url, sameTab);
+            const url = `${template}${encodedURL}`;
+            redirectUrl(url, sameTab);
+          }
         }
       } else {
         // Valid query -> redirect to search results
-        const url = `${query.template}${search}`;
+        const url = `${primarySearch.template}${encodedURL}`;
         redirectUrl(url, sameTab);
       }
     } else if (e.code === 'Escape') {

+ 0 - 30
client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.module.css

@@ -1,30 +0,0 @@
-.QueriesGrid {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-}
-
-.QueriesGrid span {
-  color: var(--color-primary);
-}
-
-.QueriesGrid span:last-child {
-  margin-bottom: 10px;
-}
-
-.ActionIcons {
-  display: flex;
-}
-
-.ActionIcons svg {
-  width: 20px;
-}
-
-.ActionIcons svg:hover {
-  cursor: pointer;
-}
-
-.Separator {
-  grid-column: 1 / 4;
-  border-bottom: 1px solid var(--color-primary);
-  margin: 10px 0;
-}

+ 23 - 32
client/src/components/Settings/GeneralSettings/CustomQueries/CustomQueries.tsx

@@ -9,11 +9,8 @@ import { actionCreators } from '../../../../store';
 // Typescript
 import { Query } from '../../../../interfaces';
 
-// CSS
-import classes from './CustomQueries.module.css';
-
 // UI
-import { Modal, Icon, Button } from '../../../UI';
+import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI';
 
 // Components
 import { QueriesForm } from './QueriesForm';
@@ -67,33 +64,27 @@ export const CustomQueries = (): JSX.Element => {
         )}
       </Modal>
 
-      <div>
-        <div className={classes.QueriesGrid}>
-          {customQueries.length > 0 && (
-            <Fragment>
-              <span>Name</span>
-              <span>Prefix</span>
-              <span>Actions</span>
-
-              <div className={classes.Separator}></div>
-            </Fragment>
-          )}
-
-          {customQueries.map((q: Query, idx) => (
-            <Fragment key={idx}>
-              <span>{q.name}</span>
-              <span>{q.prefix}</span>
-              <span className={classes.ActionIcons}>
-                <span onClick={() => updateHandler(q)}>
-                  <Icon icon="mdiPencil" />
-                </span>
-                <span onClick={() => deleteHandler(q)}>
-                  <Icon icon="mdiDelete" />
-                </span>
-              </span>
-            </Fragment>
-          ))}
-        </div>
+      <section>
+        {customQueries.length ? (
+          <CompactTable headers={['Name', 'Prefix', 'Actions']}>
+            {customQueries.map((q: Query, idx) => (
+              <Fragment key={idx}>
+                <span>{q.name}</span>
+                <span>{q.prefix}</span>
+                <ActionIcons>
+                  <span onClick={() => updateHandler(q)}>
+                    <Icon icon="mdiPencil" />
+                  </span>
+                  <span onClick={() => deleteHandler(q)}>
+                    <Icon icon="mdiDelete" />
+                  </span>
+                </ActionIcons>
+              </Fragment>
+            ))}
+          </CompactTable>
+        ) : (
+          <></>
+        )}
 
         <Button
           click={() => {
@@ -103,7 +94,7 @@ export const CustomQueries = (): JSX.Element => {
         >
           Add new search provider
         </Button>
-      </div>
+      </section>
     </Fragment>
   );
 };

+ 30 - 2
client/src/components/Settings/GeneralSettings/GeneralSettings.tsx

@@ -164,10 +164,10 @@ export const GeneralSettings = (): JSX.Element => {
           </select>
         </InputGroup>
 
-        {/* SEARCH SETTINGS */}
+        {/* === SEARCH OPTIONS === */}
         <SettingsHeadline text="Search" />
         <InputGroup>
-          <label htmlFor="defaultSearchProvider">Default search provider</label>
+          <label htmlFor="defaultSearchProvider">Primary search provider</label>
           <select
             id="defaultSearchProvider"
             name="defaultSearchProvider"
@@ -186,6 +186,34 @@ export const GeneralSettings = (): JSX.Element => {
           </select>
         </InputGroup>
 
+        {formData.defaultSearchProvider === 'l' && (
+          <InputGroup>
+            <label htmlFor="secondarySearchProvider">
+              Secondary search provider
+            </label>
+            <select
+              id="secondarySearchProvider"
+              name="secondarySearchProvider"
+              value={formData.secondarySearchProvider}
+              onChange={(e) => inputChangeHandler(e)}
+            >
+              {[...queries, ...customQueries].map((query: Query, idx) => {
+                const isCustom = idx >= queries.length;
+
+                return (
+                  <option key={idx} value={query.prefix}>
+                    {isCustom && '+'} {query.name}
+                  </option>
+                );
+              })}
+            </select>
+            <span>
+              Will be used when "Local search" is primary search provider and
+              there are not any local results
+            </span>
+          </InputGroup>
+        )}
+
         <InputGroup>
           <label htmlFor="searchSameTab">
             Open search results in the same tab

+ 7 - 0
client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.module.css

@@ -0,0 +1,7 @@
+.ThemeBuilder {
+  margin-bottom: 30px;
+}
+
+.Buttons button:not(:last-child) {
+  margin-right: 10px;
+}

+ 95 - 0
client/src/components/Settings/Themer/ThemeBuilder/ThemeBuilder.tsx

@@ -0,0 +1,95 @@
+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 { Theme } from '../../../../interfaces';
+
+// UI
+import { Button, Modal } from '../../../UI';
+import { ThemeGrid } from '../ThemeGrid/ThemeGrid';
+import classes from './ThemeBuilder.module.css';
+import { ThemeCreator } from './ThemeCreator';
+import { ThemeEditor } from './ThemeEditor';
+
+interface Props {
+  themes: Theme[];
+}
+
+export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
+  const {
+    auth: { isAuthenticated },
+    theme: { themeInEdit, userThemes },
+  } = useSelector((state: State) => state);
+
+  const { editTheme } = bindActionCreators(actionCreators, useDispatch());
+
+  const [showModal, toggleShowModal] = useState(false);
+  const [isInEdit, toggleIsInEdit] = useState(false);
+
+  useEffect(() => {
+    if (themeInEdit) {
+      toggleIsInEdit(false);
+      toggleShowModal(true);
+    }
+  }, [themeInEdit]);
+
+  useEffect(() => {
+    if (isInEdit && !userThemes.length) {
+      toggleIsInEdit(false);
+      toggleShowModal(false);
+    }
+  }, [userThemes]);
+
+  return (
+    <div className={classes.ThemeBuilder}>
+      {/* MODALS */}
+      <Modal
+        isOpen={showModal}
+        setIsOpen={() => toggleShowModal(!showModal)}
+        cb={() => editTheme(null)}
+      >
+        {isInEdit ? (
+          <ThemeEditor modalHandler={() => toggleShowModal(!showModal)} />
+        ) : (
+          <ThemeCreator modalHandler={() => toggleShowModal(!showModal)} />
+        )}
+      </Modal>
+
+      {/* USER THEMES */}
+      <ThemeGrid themes={themes} />
+
+      {/* BUTTONS */}
+      {isAuthenticated && (
+        <div className={classes.Buttons}>
+          <Button
+            click={() => {
+              editTheme(null);
+              toggleIsInEdit(false);
+              toggleShowModal(!showModal);
+            }}
+          >
+            Create new theme
+          </Button>
+
+          {themes.length ? (
+            <Button
+              click={() => {
+                toggleIsInEdit(true);
+                toggleShowModal(!showModal);
+              }}
+            >
+              Edit user themes
+            </Button>
+          ) : (
+            <></>
+          )}
+        </div>
+      )}
+    </div>
+  );
+};

+ 6 - 0
client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.module.css

@@ -0,0 +1,6 @@
+.ColorsContainer {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  grid-gap: 10px;
+  margin-bottom: 20px;
+}

+ 152 - 0
client/src/components/Settings/Themer/ThemeBuilder/ThemeCreator.tsx

@@ -0,0 +1,152 @@
+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 { 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 [formData, setFormData] = useState<Theme>({
+    name: '',
+    isCustom: true,
+    colors: {
+      primary: '#ffffff',
+      accent: '#ffffff',
+      background: '#ffffff',
+    },
+  });
+
+  useEffect(() => {
+    setFormData({ ...formData, colors: activeTheme.colors });
+  }, [activeTheme]);
+
+  useEffect(() => {
+    if (themeInEdit) {
+      setFormData(themeInEdit);
+    }
+  }, [themeInEdit]);
+
+  const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
+    const { name, value } = e.target;
+
+    setFormData({
+      ...formData,
+      [name]: value,
+    });
+  };
+
+  const setColor = ({
+    target: { value, name },
+  }: ChangeEvent<HTMLInputElement>) => {
+    setFormData({
+      ...formData,
+      colors: {
+        ...formData.colors,
+        [name]: value,
+      },
+    });
+  };
+
+  const closeModal = () => {
+    editTheme(null);
+    modalHandler();
+  };
+
+  const formHandler = (e: FormEvent) => {
+    e.preventDefault();
+
+    if (!themeInEdit) {
+      addTheme(formData);
+    } else {
+      updateTheme(formData, themeInEdit.name);
+    }
+
+    // close modal
+    closeModal();
+
+    // clear theme name
+    setFormData({ ...formData, name: '' });
+  };
+
+  return (
+    <ModalForm formHandler={formHandler} modalHandler={closeModal}>
+      <InputGroup>
+        <label htmlFor="name">Theme name</label>
+        <input
+          type="text"
+          name="name"
+          id="name"
+          placeholder="my_theme"
+          required
+          value={formData.name}
+          onChange={(e) => inputChangeHandler(e)}
+        />
+      </InputGroup>
+
+      <div className={classes.ColorsContainer}>
+        <InputGroup>
+          <label htmlFor="primary">Primary color</label>
+          <input
+            type="color"
+            name="primary"
+            id="primary"
+            required
+            value={formData.colors.primary}
+            onChange={(e) => setColor(e)}
+          />
+        </InputGroup>
+
+        <InputGroup>
+          <label htmlFor="accent">Accent color</label>
+          <input
+            type="color"
+            name="accent"
+            id="accent"
+            required
+            value={formData.colors.accent}
+            onChange={(e) => setColor(e)}
+          />
+        </InputGroup>
+
+        <InputGroup>
+          <label htmlFor="background">Background color</label>
+          <input
+            type="color"
+            name="background"
+            id="background"
+            required
+            value={formData.colors.background}
+            onChange={(e) => setColor(e)}
+          />
+        </InputGroup>
+      </div>
+
+      {!themeInEdit ? (
+        <Button>Add theme</Button>
+      ) : (
+        <Button>Update theme</Button>
+      )}
+    </ModalForm>
+  );
+};

+ 57 - 0
client/src/components/Settings/Themer/ThemeBuilder/ThemeEditor.tsx

@@ -0,0 +1,57 @@
+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 { 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()
+  );
+
+  const updateHandler = (theme: Theme) => {
+    props.modalHandler();
+    editTheme(theme);
+  };
+
+  const deleteHandler = (theme: Theme) => {
+    if (window.confirm(`Are you sure you want to delete this theme?`)) {
+      deleteTheme(theme.name);
+    }
+  };
+
+  return (
+    <ModalForm formHandler={() => {}} modalHandler={props.modalHandler}>
+      <CompactTable headers={['Name', 'Actions']}>
+        {userThemes.map((t, idx) => (
+          <Fragment key={idx}>
+            <span>{t.name}</span>
+            <ActionIcons>
+              <span onClick={() => updateHandler(t)}>
+                <Icon icon="mdiPencil" />
+              </span>
+              <span onClick={() => deleteHandler(t)}>
+                <Icon icon="mdiDelete" />
+              </span>
+            </ActionIcons>
+          </Fragment>
+        ))}
+      </CompactTable>
+    </ModalForm>
+  );
+};

+ 1 - 1
client/src/components/Settings/Themer/Themer.module.css → client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.module.css

@@ -15,4 +15,4 @@
   .ThemerGrid {
     grid-template-columns: 1fr 1fr 1fr;
   }
-}
+}

+ 22 - 0
client/src/components/Settings/Themer/ThemeGrid/ThemeGrid.tsx

@@ -0,0 +1,22 @@
+// Components
+import { ThemePreview } from '../ThemePreview/ThemePreview';
+
+// Other
+import { Theme } from '../../../../interfaces';
+import classes from './ThemeGrid.module.css';
+
+interface Props {
+  themes: Theme[];
+}
+
+export const ThemeGrid = ({ themes }: Props): JSX.Element => {
+  return (
+    <div className={classes.ThemerGrid}>
+      {themes.map(
+        (theme: Theme, idx: number): JSX.Element => (
+          <ThemePreview key={idx} theme={theme} />
+        )
+      )}
+    </div>
+  );
+};

+ 0 - 32
client/src/components/Settings/Themer/ThemePreview.tsx

@@ -1,32 +0,0 @@
-import { Theme } from '../../../interfaces/Theme';
-import classes from './ThemePreview.module.css';
-
-interface Props {
-  theme: Theme;
-  applyTheme: Function;
-}
-
-export const ThemePreview = (props: Props): JSX.Element => {
-  return (
-    <div
-      className={classes.ThemePreview}
-      onClick={() => props.applyTheme(props.theme.name)}
-    >
-      <div className={classes.ColorsPreview}>
-        <div
-          className={classes.ColorPreview}
-          style={{ backgroundColor: props.theme.colors.background }}
-        ></div>
-        <div
-          className={classes.ColorPreview}
-          style={{ backgroundColor: props.theme.colors.primary }}
-        ></div>
-        <div
-          className={classes.ColorPreview}
-          style={{ backgroundColor: props.theme.colors.accent }}
-        ></div>
-      </div>
-      <p>{props.theme.name}</p>
-    </div>
-  );
-};

+ 0 - 0
client/src/components/Settings/Themer/ThemePreview.module.css → client/src/components/Settings/Themer/ThemePreview/ThemePreview.module.css


+ 38 - 0
client/src/components/Settings/Themer/ThemePreview/ThemePreview.tsx

@@ -0,0 +1,38 @@
+// Redux
+import { useDispatch } from 'react-redux';
+import { bindActionCreators } from 'redux';
+import { actionCreators } from '../../../../store';
+
+// Other
+import { Theme } from '../../../../interfaces/Theme';
+import classes from './ThemePreview.module.css';
+
+interface Props {
+  theme: Theme;
+}
+
+export const ThemePreview = ({
+  theme: { colors, name },
+}: Props): JSX.Element => {
+  const { setTheme } = bindActionCreators(actionCreators, useDispatch());
+
+  return (
+    <div className={classes.ThemePreview} onClick={() => setTheme(colors)}>
+      <div className={classes.ColorsPreview}>
+        <div
+          className={classes.ColorPreview}
+          style={{ backgroundColor: colors.background }}
+        ></div>
+        <div
+          className={classes.ColorPreview}
+          style={{ backgroundColor: colors.primary }}
+        ></div>
+        <div
+          className={classes.ColorPreview}
+          style={{ backgroundColor: colors.accent }}
+        ></div>
+      </div>
+      <p>{name}</p>
+    </div>
+  );
+};

+ 27 - 23
client/src/components/Settings/Themer/Themer.tsx

@@ -4,31 +4,32 @@ import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react';
 import { useDispatch, useSelector } from 'react-redux';
 import { bindActionCreators } from 'redux';
 import { actionCreators } from '../../../store';
+import { State } from '../../../store/reducers';
 
 // Typescript
 import { Theme, ThemeSettingsForm } from '../../../interfaces';
 
 // Components
-import { ThemePreview } from './ThemePreview';
-import { Button, InputGroup, SettingsHeadline } from '../../UI';
+import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
+import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
+import { ThemeGrid } from './ThemeGrid/ThemeGrid';
 
 // Other
-import classes from './Themer.module.css';
-import { themes } from './themes.json';
-import { State } from '../../../store/reducers';
-import { inputHandler, themeSettingsTemplate } from '../../../utility';
+import {
+  inputHandler,
+  parseThemeToPAB,
+  themeSettingsTemplate,
+} from '../../../utility';
 
 export const Themer = (): JSX.Element => {
   const {
     auth: { isAuthenticated },
     config: { loading, config },
+    theme: { themes, userThemes },
   } = useSelector((state: State) => state);
 
   const dispatch = useDispatch();
-  const { setTheme, updateConfig } = bindActionCreators(
-    actionCreators,
-    dispatch
-  );
+  const { updateConfig } = bindActionCreators(actionCreators, dispatch);
 
   // Initial state
   const [formData, setFormData] = useState<ThemeSettingsForm>(
@@ -47,7 +48,7 @@ export const Themer = (): JSX.Element => {
     e.preventDefault();
 
     // Save settings
-    await updateConfig(formData);
+    await updateConfig({ ...formData });
   };
 
   // Input handler
@@ -63,31 +64,34 @@ export const Themer = (): JSX.Element => {
     });
   };
 
+  const customThemesEl = (
+    <Fragment>
+      <SettingsHeadline text="User themes" />
+      <ThemeBuilder themes={userThemes} />
+    </Fragment>
+  );
+
   return (
     <Fragment>
-      <SettingsHeadline text="Set theme" />
-      <div className={classes.ThemerGrid}>
-        {themes.map(
-          (theme: Theme, idx: number): JSX.Element => (
-            <ThemePreview key={idx} theme={theme} applyTheme={setTheme} />
-          )
-        )}
-      </div>
+      <SettingsHeadline text="App themes" />
+      {!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />}
+
+      {!userThemes.length ? isAuthenticated && customThemesEl : customThemesEl}
 
       {isAuthenticated && (
         <form onSubmit={formSubmitHandler}>
           <SettingsHeadline text="Other settings" />
           <InputGroup>
-            <label htmlFor="defaultTheme">Default theme (for new users)</label>
+            <label htmlFor="defaultTheme">Default theme for new users</label>
             <select
               id="defaultTheme"
               name="defaultTheme"
               value={formData.defaultTheme}
               onChange={(e) => inputChangeHandler(e)}
             >
-              {themes.map((theme: Theme, idx) => (
-                <option key={idx} value={theme.name}>
-                  {theme.name}
+              {[...themes, ...userThemes].map((theme: Theme, idx) => (
+                <option key={idx} value={parseThemeToPAB(theme.colors)}>
+                  {theme.isCustom && '+'} {theme.name}
                 </option>
               ))}
             </select>

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

@@ -38,3 +38,7 @@
   resize: none;
   height: 50vh;
 }
+
+.InputGroup input[type='color'] {
+  all: unset;
+}

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

@@ -0,0 +1,11 @@
+.ActionIcons {
+  display: flex;
+}
+
+.ActionIcons svg {
+  width: 20px;
+}
+
+.ActionIcons svg:hover {
+  cursor: pointer;
+}

+ 10 - 0
client/src/components/UI/Icons/ActionIcons/ActionIcons.tsx

@@ -0,0 +1,10 @@
+import { ReactNode } from 'react';
+import styles from './ActionIcons.module.css';
+
+interface Props {
+  children: ReactNode;
+}
+
+export const ActionIcons = ({ children }: Props): JSX.Element => {
+  return <span className={styles.ActionIcons}>{children}</span>;
+};

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

@@ -10,7 +10,7 @@ interface Props {
 }
 
 export const WeatherIcon = (props: Props): JSX.Element => {
-  const { theme } = useSelector((state: State) => state.theme);
+  const { activeTheme } = useSelector((state: State) => state.theme);
 
   const icon = props.isDay
     ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
@@ -18,7 +18,7 @@ export const WeatherIcon = (props: Props): JSX.Element => {
 
   useEffect(() => {
     const delay = setTimeout(() => {
-      const skycons = new Skycons({ color: theme.colors.accent });
+      const skycons = new Skycons({ color: activeTheme.colors.accent });
       skycons.add(`weather-icon`, icon);
       skycons.play();
     }, 1);
@@ -26,7 +26,7 @@ export const WeatherIcon = (props: Props): JSX.Element => {
     return () => {
       clearTimeout(delay);
     };
-  }, [props.weatherStatusCode, icon, theme.colors.accent]);
+  }, [props.weatherStatusCode, icon, activeTheme.colors.accent]);
 
   return <canvas id={`weather-icon`} width="50" height="50"></canvas>;
 };

+ 12 - 4
client/src/components/UI/Modal/Modal.tsx

@@ -6,24 +6,32 @@ interface Props {
   isOpen: boolean;
   setIsOpen: Function;
   children: ReactNode;
+  cb?: Function;
 }
 
-export const Modal = (props: Props): JSX.Element => {
+export const Modal = ({
+  isOpen,
+  setIsOpen,
+  children,
+  cb,
+}: Props): JSX.Element => {
   const modalRef = useRef(null);
   const modalClasses = [
     classes.Modal,
-    props.isOpen ? classes.ModalOpen : classes.ModalClose,
+    isOpen ? classes.ModalOpen : classes.ModalClose,
   ].join(' ');
 
   const clickHandler = (e: MouseEvent) => {
     if (e.target === modalRef.current) {
-      props.setIsOpen(false);
+      setIsOpen(false);
+
+      if (cb) cb();
     }
   };
 
   return (
     <div className={modalClasses} onClick={clickHandler} ref={modalRef}>
-      {props.children}
+      {children}
     </div>
   );
 };

+ 16 - 0
client/src/components/UI/Tables/CompactTable/CompactTable.module.css

@@ -0,0 +1,16 @@
+.CompactTable {
+  display: grid;
+}
+
+.CompactTable span {
+  color: var(--color-primary);
+}
+
+.CompactTable span:last-child {
+  margin-bottom: 10px;
+}
+
+.Separator {
+  border-bottom: 1px solid var(--color-primary);
+  margin: 10px 0;
+}

+ 27 - 0
client/src/components/UI/Tables/CompactTable/CompactTable.tsx

@@ -0,0 +1,27 @@
+import { ReactNode } from 'react';
+import classes from './CompactTable.module.css';
+
+interface Props {
+  headers: string[];
+  children?: ReactNode;
+}
+
+export const CompactTable = ({ headers, children }: Props): JSX.Element => {
+  return (
+    <div
+      className={classes.CompactTable}
+      style={{ gridTemplateColumns: `repeat(${headers.length}, 1fr)` }}
+    >
+      {headers.map((h, idx) => (
+        <span key={idx}>{h}</span>
+      ))}
+
+      <div
+        className={classes.Separator}
+        style={{ gridColumn: `1 / ${headers.length + 1}` }}
+      ></div>
+
+      {children}
+    </div>
+  );
+};

+ 0 - 0
client/src/components/UI/Table/Table.module.css → client/src/components/UI/Tables/Table/Table.module.css


+ 0 - 0
client/src/components/UI/Table/Table.tsx → client/src/components/UI/Tables/Table/Table.tsx


+ 3 - 1
client/src/components/UI/index.ts

@@ -1,10 +1,12 @@
-export * from './Table/Table';
+export * from './Tables/Table/Table';
+export * from './Tables/CompactTable/CompactTable';
 export * from './Spinner/Spinner';
 export * from './Notification/Notification';
 export * from './Modal/Modal';
 export * from './Layout/Layout';
 export * from './Icons/Icon/Icon';
 export * from './Icons/WeatherIcon/WeatherIcon';
+export * from './Icons/ActionIcons/ActionIcons';
 export * from './Headlines/Headline/Headline';
 export * from './Headlines/SectionHeadline/SectionHeadline';
 export * from './Headlines/SettingsHeadline/SettingsHeadline';

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

@@ -17,6 +17,7 @@ export interface Config {
   hideCategories: boolean;
   hideSearch: boolean;
   defaultSearchProvider: string;
+  secondarySearchProvider: string;
   dockerApps: boolean;
   dockerHost: string;
   kubernetesApps: boolean;

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

@@ -10,6 +10,7 @@ export interface WeatherForm {
 
 export interface GeneralForm {
   defaultSearchProvider: string;
+  secondarySearchProvider: string;
   searchSameTab: boolean;
   pinAppsByDefault: boolean;
   pinCategoriesByDefault: boolean;

+ 4 - 2
client/src/interfaces/SearchResult.ts

@@ -4,6 +4,8 @@ export interface SearchResult {
   isLocal: boolean;
   isURL: boolean;
   sameTab: boolean;
-  search: string;
-  query: Query;
+  encodedURL: string;
+  primarySearch: Query;
+  secondarySearch: Query;
+  rawQuery: string;
 }

+ 9 - 6
client/src/interfaces/Theme.ts

@@ -1,8 +1,11 @@
+export interface ThemeColors {
+  background: string;
+  primary: string;
+  accent: string;
+}
+
 export interface Theme {
   name: string;
-  colors: {
-    background: string;
-    primary: string;
-    accent: string;
-  }
-}
+  colors: ThemeColors;
+  isCustom: boolean;
+}

+ 10 - 2
client/src/store/action-creators/config.ts

@@ -7,7 +7,7 @@ import {
   UpdateConfigAction,
   UpdateQueryAction,
 } from '../actions/config';
-import axios from 'axios';
+import axios, { AxiosError } from 'axios';
 import { ApiResponse, Config, Query } from '../../interfaces';
 import { ActionType } from '../action-types';
 import { storeUIConfig, applyAuth } from '../../utility';
@@ -103,7 +103,15 @@ export const addQuery =
         payload: res.data.data,
       });
     } catch (err) {
-      console.log(err);
+      const error = err as AxiosError<{ error: string }>;
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Error',
+          message: error.response?.data.error,
+        },
+      });
     }
   };
 

+ 115 - 17
client/src/store/action-creators/theme.ts

@@ -1,30 +1,128 @@
 import { Dispatch } from 'redux';
-import { SetThemeAction } from '../actions/theme';
+import {
+  AddThemeAction,
+  DeleteThemeAction,
+  EditThemeAction,
+  FetchThemesAction,
+  SetThemeAction,
+  UpdateThemeAction,
+} from '../actions/theme';
 import { ActionType } from '../action-types';
-import { Theme } from '../../interfaces/Theme';
-import { themes } from '../../components/Settings/Themer/themes.json';
+import { Theme, ApiResponse, ThemeColors } from '../../interfaces';
+import { applyAuth, parseThemeToPAB } from '../../utility';
+import axios, { AxiosError } from 'axios';
 
 export const setTheme =
-  (name: string, remeberTheme: boolean = true) =>
+  (colors: ThemeColors, remeberTheme: boolean = true) =>
   (dispatch: Dispatch<SetThemeAction>) => {
-    const theme = themes.find((theme) => theme.name === name);
+    if (remeberTheme) {
+      localStorage.setItem('theme', parseThemeToPAB(colors));
+    }
+
+    for (const [key, value] of Object.entries(colors)) {
+      document.body.style.setProperty(`--color-${key}`, value);
+    }
+
+    dispatch({
+      type: ActionType.setTheme,
+      payload: colors,
+    });
+  };
+
+export const fetchThemes =
+  () => async (dispatch: Dispatch<FetchThemesAction>) => {
+    try {
+      const res = await axios.get<ApiResponse<Theme[]>>('/api/themes');
+
+      dispatch({
+        type: ActionType.fetchThemes,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+export const addTheme =
+  (theme: Theme) => async (dispatch: Dispatch<AddThemeAction>) => {
+    try {
+      const res = await axios.post<ApiResponse<Theme>>('/api/themes', theme, {
+        headers: applyAuth(),
+      });
+
+      dispatch({
+        type: ActionType.addTheme,
+        payload: res.data.data,
+      });
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: 'Theme added',
+        },
+      });
+    } catch (err) {
+      const error = err as AxiosError<{ error: string }>;
 
-    if (theme) {
-      if (remeberTheme) {
-        localStorage.setItem('theme', name);
-      }
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Error',
+          message: error.response?.data.error,
+        },
+      });
+    }
+  };
 
-      loadTheme(theme);
+export const deleteTheme =
+  (name: string) => async (dispatch: Dispatch<DeleteThemeAction>) => {
+    try {
+      const res = await axios.delete<ApiResponse<Theme[]>>(
+        `/api/themes/${name}`,
+        { headers: applyAuth() }
+      );
 
       dispatch({
-        type: ActionType.setTheme,
-        payload: theme,
+        type: ActionType.deleteTheme,
+        payload: res.data.data,
+      });
+
+      dispatch<any>({
+        type: ActionType.createNotification,
+        payload: {
+          title: 'Success',
+          message: 'Theme deleted',
+        },
       });
+    } catch (err) {
+      console.log(err);
     }
   };
 
-export const loadTheme = (theme: Theme): void => {
-  for (const [key, value] of Object.entries(theme.colors)) {
-    document.body.style.setProperty(`--color-${key}`, value);
-  }
-};
+export const editTheme =
+  (theme: Theme | null) => (dispatch: Dispatch<EditThemeAction>) => {
+    dispatch({
+      type: ActionType.editTheme,
+      payload: theme,
+    });
+  };
+
+export const updateTheme =
+  (theme: Theme, originalName: string) =>
+  async (dispatch: Dispatch<UpdateThemeAction>) => {
+    try {
+      const res = await axios.put<ApiResponse<Theme[]>>(
+        `/api/themes/${originalName}`,
+        theme,
+        { headers: applyAuth() }
+      );
+
+      dispatch({
+        type: ActionType.updateTheme,
+        payload: res.data.data,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };

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

@@ -1,6 +1,11 @@
 export enum ActionType {
   // THEME
   setTheme = 'SET_THEME',
+  fetchThemes = 'FETCH_THEMES',
+  addTheme = 'ADD_THEME',
+  deleteTheme = 'DELETE_THEME',
+  updateTheme = 'UPDATE_THEME',
+  editTheme = 'EDIT_THEME',
   // CONFIG
   getConfig = 'GET_CONFIG',
   updateConfig = 'UPDATE_CONFIG',

+ 13 - 1
client/src/store/actions/index.ts

@@ -1,6 +1,13 @@
 import { App } from '../../interfaces';
 
-import { SetThemeAction } from './theme';
+import {
+  AddThemeAction,
+  DeleteThemeAction,
+  EditThemeAction,
+  FetchThemesAction,
+  SetThemeAction,
+  UpdateThemeAction,
+} from './theme';
 
 import {
   AddQueryAction,
@@ -54,6 +61,11 @@ import {
 export type Action =
   // Theme
   | SetThemeAction
+  | FetchThemesAction
+  | AddThemeAction
+  | DeleteThemeAction
+  | UpdateThemeAction
+  | EditThemeAction
   // Config
   | GetConfigAction
   | UpdateConfigAction

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

@@ -1,7 +1,32 @@
 import { ActionType } from '../action-types';
-import { Theme } from '../../interfaces';
+import { Theme, ThemeColors } from '../../interfaces';
 
 export interface SetThemeAction {
   type: ActionType.setTheme;
+  payload: ThemeColors;
+}
+
+export interface FetchThemesAction {
+  type: ActionType.fetchThemes;
+  payload: Theme[];
+}
+
+export interface AddThemeAction {
+  type: ActionType.addTheme;
   payload: Theme;
 }
+
+export interface DeleteThemeAction {
+  type: ActionType.deleteTheme;
+  payload: Theme[];
+}
+
+export interface UpdateThemeAction {
+  type: ActionType.updateTheme;
+  payload: Theme[];
+}
+
+export interface EditThemeAction {
+  type: ActionType.editTheme;
+  payload: Theme | null;
+}

+ 66 - 8
client/src/store/reducers/theme.ts

@@ -1,20 +1,30 @@
 import { Action } from '../actions';
 import { ActionType } from '../action-types';
 import { Theme } from '../../interfaces/Theme';
+import { arrayPartition, parsePABToTheme } from '../../utility';
 
 interface ThemeState {
-  theme: Theme;
+  activeTheme: Theme;
+  themes: Theme[];
+  userThemes: Theme[];
+  themeInEdit: Theme | null;
 }
 
+const savedTheme = localStorage.theme
+  ? parsePABToTheme(localStorage.theme)
+  : parsePABToTheme('#effbff;#6ee2ff;#242b33');
+
 const initialState: ThemeState = {
-  theme: {
-    name: 'tron',
+  activeTheme: {
+    name: 'main',
+    isCustom: false,
     colors: {
-      background: '#242B33',
-      primary: '#EFFBFF',
-      accent: '#6EE2FF',
+      ...savedTheme,
     },
   },
+  themes: [],
+  userThemes: [],
+  themeInEdit: null,
 };
 
 export const themeReducer = (
@@ -22,8 +32,56 @@ export const themeReducer = (
   action: Action
 ): ThemeState => {
   switch (action.type) {
-    case ActionType.setTheme:
-      return { theme: action.payload };
+    case ActionType.setTheme: {
+      return {
+        ...state,
+        activeTheme: {
+          ...state.activeTheme,
+          colors: action.payload,
+        },
+      };
+    }
+
+    case ActionType.fetchThemes: {
+      const [themes, userThemes] = arrayPartition<Theme>(
+        action.payload,
+        (e) => !e.isCustom
+      );
+
+      return {
+        ...state,
+        themes,
+        userThemes,
+      };
+    }
+
+    case ActionType.addTheme: {
+      return {
+        ...state,
+        userThemes: [...state.userThemes, action.payload],
+      };
+    }
+
+    case ActionType.deleteTheme: {
+      return {
+        ...state,
+        userThemes: action.payload,
+      };
+    }
+
+    case ActionType.editTheme: {
+      return {
+        ...state,
+        themeInEdit: action.payload,
+      };
+    }
+
+    case ActionType.updateTheme: {
+      return {
+        ...state,
+        userThemes: action.payload,
+      };
+    }
 
     default:
       return state;

+ 11 - 0
client/src/utility/arrayPartition.ts

@@ -0,0 +1,11 @@
+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];
+};

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

@@ -12,3 +12,5 @@ export * from './parseTime';
 export * from './decodeToken';
 export * from './applyAuth';
 export * from './escapeRegex';
+export * from './parseTheme';
+export * from './arrayPartition';

+ 20 - 0
client/src/utility/parseTheme.ts

@@ -0,0 +1,20 @@
+import { ThemeColors } from '../interfaces';
+
+// parse theme in PAB (primary;accent;background) format to theme colors object
+export const parsePABToTheme = (themeStr: string): ThemeColors => {
+  const [primary, accent, background] = themeStr.split(';');
+
+  return {
+    primary,
+    accent,
+    background,
+  };
+};
+
+export const parseThemeToPAB = ({
+  primary: p,
+  accent: a,
+  background: b,
+}: ThemeColors): string => {
+  return `${p};${a};${b}`;
+};

+ 27 - 11
client/src/utility/searchParser.ts

@@ -1,5 +1,5 @@
 import { queries } from './searchQueries.json';
-import { Query, SearchResult } from '../interfaces';
+import { SearchResult } from '../interfaces';
 import { store } from '../store/store';
 import { isUrlOrIp } from '.';
 
@@ -8,12 +8,18 @@ export const searchParser = (searchQuery: string): SearchResult => {
     isLocal: false,
     isURL: false,
     sameTab: false,
-    search: '',
-    query: {
+    encodedURL: '',
+    primarySearch: {
       name: '',
       prefix: '',
       template: '',
     },
+    secondarySearch: {
+      name: '',
+      prefix: '',
+      template: '',
+    },
+    rawQuery: searchQuery,
   };
 
   const { customQueries, config } = store.getState().config;
@@ -24,20 +30,26 @@ export const searchParser = (searchQuery: string): SearchResult => {
   // Match prefix and query
   const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
 
+  // Extract prefix
   const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
 
-  const search = splitQuery
+  // Encode url
+  const encodedURL = splitQuery
     ? encodeURIComponent(splitQuery[2])
     : encodeURIComponent(searchQuery);
 
-  const query = [...queries, ...customQueries].find(
-    (q: Query) => 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);
 
-  // If search provider was found
-  if (query) {
-    result.query = query;
-    result.search = search;
+  // If search providers were found
+  if (primarySearch) {
+    result.primarySearch = primarySearch;
+    result.encodedURL = encodedURL;
 
     if (prefix === 'l') {
       result.isLocal = true;
@@ -45,6 +57,10 @@ export const searchParser = (searchQuery: string): SearchResult => {
       result.sameTab = config.searchSameTab;
     }
 
+    if (secondarySearch) {
+      result.secondarySearch = secondarySearch;
+    }
+
     return result;
   }
 

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

@@ -17,6 +17,7 @@ export const configTemplate: Config = {
   hideCategories: false,
   hideSearch: false,
   defaultSearchProvider: 'l',
+  secondarySearchProvider: 'd',
   dockerApps: false,
   dockerHost: 'localhost',
   kubernetesApps: false,

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

@@ -33,6 +33,7 @@ export const weatherSettingsTemplate: WeatherForm = {
 export const generalSettingsTemplate: GeneralForm = {
   searchSameTab: false,
   defaultSearchProvider: 'l',
+  secondarySearchProvider: 'd',
   pinAppsByDefault: true,
   pinCategoriesByDefault: true,
   useOrdering: 'createdAt',

+ 7 - 0
controllers/queries/addQuery.js

@@ -1,4 +1,5 @@
 const asyncWrapper = require('../../middleware/asyncWrapper');
+const ErrorResponse = require('../../utils/ErrorResponse');
 const File = require('../../utils/File');
 
 // @desc      Add custom search query
@@ -8,6 +9,12 @@ const addQuery = asyncWrapper(async (req, res, next) => {
   const file = new File('data/customQueries.json');
   let content = JSON.parse(file.read());
 
+  const prefixes = content.queries.map((q) => q.prefix);
+
+  if (prefixes.includes(req.body.prefix)) {
+    return next(new ErrorResponse('Prefix must be unique', 400));
+  }
+
   // Add new query
   content.queries.push(req.body);
   file.write(content, true);

+ 28 - 0
controllers/themes/addTheme.js

@@ -0,0 +1,28 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const ErrorResponse = require('../../utils/ErrorResponse');
+const File = require('../../utils/File');
+
+// @desc      Create new theme
+// @route     POST /api/themes
+// @access    Private
+const addTheme = asyncWrapper(async (req, res, next) => {
+  const file = new File('data/themes.json');
+  let content = JSON.parse(file.read());
+
+  const themeNames = content.themes.map((t) => t.name);
+
+  if (themeNames.includes(req.body.name)) {
+    return next(new ErrorResponse('Name must be unique', 400));
+  }
+
+  // Add new theme
+  content.themes.push(req.body);
+  file.write(content, true);
+
+  res.status(201).json({
+    success: true,
+    data: req.body,
+  });
+});
+
+module.exports = addTheme;

+ 22 - 0
controllers/themes/deleteTheme.js

@@ -0,0 +1,22 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const File = require('../../utils/File');
+
+// @desc      Delete theme
+// @route     DELETE /api/themes/:name
+// @access    Public
+const deleteTheme = asyncWrapper(async (req, res, next) => {
+  const file = new File('data/themes.json');
+  let content = JSON.parse(file.read());
+
+  content.themes = content.themes.filter((t) => t.name != req.params.name);
+  file.write(content, true);
+
+  const userThemes = content.themes.filter((t) => t.isCustom);
+
+  res.status(200).json({
+    success: true,
+    data: userThemes,
+  });
+});
+
+module.exports = deleteTheme;

+ 17 - 0
controllers/themes/getThemes.js

@@ -0,0 +1,17 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const File = require('../../utils/File');
+
+// @desc      Get themes file
+// @route     GET /api/themes
+// @access    Public
+const getThemes = asyncWrapper(async (req, res, next) => {
+  const file = new File('data/themes.json');
+  const content = JSON.parse(file.read());
+
+  res.status(200).json({
+    success: true,
+    data: content.themes,
+  });
+});
+
+module.exports = getThemes;

+ 6 - 0
controllers/themes/index.js

@@ -0,0 +1,6 @@
+module.exports = {
+  getThemes: require('./getThemes'),
+  addTheme: require('./addTheme'),
+  deleteTheme: require('./deleteTheme'),
+  updateTheme: require('./updateTheme'),
+};

+ 32 - 0
controllers/themes/updateTheme.js

@@ -0,0 +1,32 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const File = require('../../utils/File');
+
+// @desc      Update theme
+// @route     PUT /api/themes/:name
+// @access    Public
+const updateTheme = asyncWrapper(async (req, res, next) => {
+  const file = new File('data/themes.json');
+  let content = JSON.parse(file.read());
+
+  let themeIdx = content.themes.findIndex((t) => t.name == req.params.name);
+
+  // theme found
+  if (themeIdx > -1) {
+    content.themes = [
+      ...content.themes.slice(0, themeIdx),
+      req.body,
+      ...content.themes.slice(themeIdx + 1),
+    ];
+  }
+
+  file.write(content, true);
+
+  const userThemes = content.themes.filter((t) => t.isCustom);
+
+  res.status(200).json({
+    success: true,
+    data: userThemes,
+  });
+});
+
+module.exports = updateTheme;

+ 11 - 2
routes/queries.js

@@ -2,7 +2,7 @@ const express = require('express');
 const router = express.Router();
 
 // middleware
-const { auth, requireAuth } = require('../middleware');
+const { auth, requireAuth, requireBody } = require('../middleware');
 
 const {
   getQueries,
@@ -11,7 +11,16 @@ const {
   updateQuery,
 } = require('../controllers/queries/');
 
-router.route('/').post(auth, requireAuth, addQuery).get(getQueries);
+router
+  .route('/')
+  .post(
+    auth,
+    requireAuth,
+    requireBody(['name', 'prefix', 'template']),
+    addQuery
+  )
+  .get(getQueries);
+
 router
   .route('/:prefix')
   .delete(auth, requireAuth, deleteQuery)

+ 29 - 0
routes/themes.js

@@ -0,0 +1,29 @@
+const express = require('express');
+const router = express.Router();
+
+// middleware
+const { auth, requireAuth, requireBody } = require('../middleware');
+
+const {
+  getThemes,
+  addTheme,
+  deleteTheme,
+  updateTheme,
+} = require('../controllers/themes/');
+
+router
+  .route('/')
+  .get(getThemes)
+  .post(
+    auth,
+    requireAuth,
+    requireBody(['name', 'colors', 'isCustom']),
+    addTheme
+  );
+
+router
+  .route('/:name')
+  .delete(auth, requireAuth, deleteTheme)
+  .put(auth, requireAuth, updateTheme);
+
+module.exports = router;

+ 5 - 3
utils/Logger.js

@@ -1,6 +1,6 @@
 class Logger {
   log(message, level = 'INFO') {
-    console.log(`[${this.generateTimestamp()}] [${level}] ${message}`)
+    console.log(`[${this.generateTimestamp()}] [${level}] ${message}`);
   }
 
   generateTimestamp() {
@@ -20,7 +20,9 @@ class Logger {
     // Timezone
     const tz = -d.getTimezoneOffset() / 60;
 
-    return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${tz >= 0 ? '+' + tz : tz}`;
+    return `${year}-${month}-${day} ${hour}:${minutes}:${seconds}.${miliseconds} UTC${
+      tz >= 0 ? '+' + tz : tz
+    }`;
   }
 
   parseDate(date, ms = false) {
@@ -36,4 +38,4 @@ class Logger {
   }
 }
 
-module.exports = Logger;
+module.exports = Logger;

+ 2 - 0
utils/init/index.js

@@ -1,11 +1,13 @@
 const initConfig = require('./initConfig');
 const initFiles = require('./initFiles');
 const initDockerSecrets = require('./initDockerSecrets');
+const normalizeTheme = require('./normalizeTheme');
 
 const initApp = async () => {
   initDockerSecrets();
   await initFiles();
   await initConfig();
+  await normalizeTheme();
 };
 
 module.exports = initApp;

+ 1 - 0
utils/init/initialConfig.json

@@ -15,6 +15,7 @@
   "hideCategories": false,
   "hideSearch": false,
   "defaultSearchProvider": "l",
+  "secondarySearchProvider": "d",
   "dockerApps": false,
   "dockerHost": "localhost",
   "kubernetesApps": false,

+ 160 - 0
utils/init/initialFiles.json

@@ -27,6 +27,166 @@
         "queries": []
       },
       "isJSON": true
+    },
+    {
+      "name": "themes.json",
+      "msg": {
+        "created": "Created default theme file",
+        "found": "Found theme file"
+      },
+      "paths": {
+        "src": "../../data",
+        "dest": "../../data"
+      },
+      "template": {
+        "themes": [
+          {
+            "name": "blackboard",
+            "colors": {
+              "background": "#1a1a1a",
+              "primary": "#FFFDEA",
+              "accent": "#5c5c5c"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "gazette",
+            "colors": {
+              "background": "#F2F7FF",
+              "primary": "#000000",
+              "accent": "#5c5c5c"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "espresso",
+            "colors": {
+              "background": "#21211F",
+              "primary": "#D1B59A",
+              "accent": "#4E4E4E"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "cab",
+            "colors": {
+              "background": "#F6D305",
+              "primary": "#1F1F1F",
+              "accent": "#424242"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "cloud",
+            "colors": {
+              "background": "#f1f2f0",
+              "primary": "#35342f",
+              "accent": "#37bbe4"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "lime",
+            "colors": {
+              "background": "#263238",
+              "primary": "#AABBC3",
+              "accent": "#aeea00"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "white",
+            "colors": {
+              "background": "#ffffff",
+              "primary": "#222222",
+              "accent": "#dddddd"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "tron",
+            "colors": {
+              "background": "#242B33",
+              "primary": "#EFFBFF",
+              "accent": "#6EE2FF"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "blues",
+            "colors": {
+              "background": "#2B2C56",
+              "primary": "#EFF1FC",
+              "accent": "#6677EB"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "passion",
+            "colors": {
+              "background": "#f5f5f5",
+              "primary": "#12005e",
+              "accent": "#8e24aa"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "chalk",
+            "colors": {
+              "background": "#263238",
+              "primary": "#AABBC3",
+              "accent": "#FF869A"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "paper",
+            "colors": {
+              "background": "#F8F6F1",
+              "primary": "#4C432E",
+              "accent": "#AA9A73"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "neon",
+            "colors": {
+              "background": "#091833",
+              "primary": "#EFFBFF",
+              "accent": "#ea00d9"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "pumpkin",
+            "colors": {
+              "background": "#2d3436",
+              "primary": "#EFFBFF",
+              "accent": "#ffa500"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "onedark",
+            "colors": {
+              "background": "#282c34",
+              "primary": "#dfd9d6",
+              "accent": "#98c379"
+            },
+            "isCustom": false
+          },
+          {
+            "name": "mint",
+            "colors": {
+              "background": "#282525",
+              "primary": "#d9d9d9",
+              "accent": "#50fbc2"
+            },
+            "isCustom": false
+          }
+        ]
+      },
+      "isJSON": true
     }
   ]
 }

+ 28 - 0
utils/init/normalizeTheme.js

@@ -0,0 +1,28 @@
+const { readFile, writeFile } = require('fs/promises');
+
+const normalizeTheme = async () => {
+  // open main config file
+  const configFile = await readFile('data/config.json', 'utf8');
+  const config = JSON.parse(configFile);
+
+  // open default themes file
+  const themesFile = await readFile('utils/init/themes.json', 'utf8');
+  const { themes } = JSON.parse(themesFile);
+
+  // find theme
+  const theme = themes.find((t) => t.name === config.defaultTheme);
+
+  if (theme) {
+    // save theme in new format
+    // PAB - primary;accent;background
+    const { primary: p, accent: a, background: b } = theme.colors;
+    const normalizedTheme = `${p};${a};${b}`;
+
+    await writeFile(
+      'data/config.json',
+      JSON.stringify({ ...config, defaultTheme: normalizedTheme })
+    );
+  }
+};
+
+module.exports = normalizeTheme;

+ 39 - 15
client/src/components/Settings/Themer/themes.json → utils/init/themes.json

@@ -6,7 +6,8 @@
         "background": "#1a1a1a",
         "primary": "#FFFDEA",
         "accent": "#5c5c5c"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "gazette",
@@ -14,7 +15,8 @@
         "background": "#F2F7FF",
         "primary": "#000000",
         "accent": "#5c5c5c"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "espresso",
@@ -22,7 +24,8 @@
         "background": "#21211F",
         "primary": "#D1B59A",
         "accent": "#4E4E4E"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "cab",
@@ -30,7 +33,8 @@
         "background": "#F6D305",
         "primary": "#1F1F1F",
         "accent": "#424242"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "cloud",
@@ -38,7 +42,8 @@
         "background": "#f1f2f0",
         "primary": "#35342f",
         "accent": "#37bbe4"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "lime",
@@ -46,7 +51,8 @@
         "background": "#263238",
         "primary": "#AABBC3",
         "accent": "#aeea00"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "white",
@@ -54,7 +60,8 @@
         "background": "#ffffff",
         "primary": "#222222",
         "accent": "#dddddd"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "tron",
@@ -62,7 +69,8 @@
         "background": "#242B33",
         "primary": "#EFFBFF",
         "accent": "#6EE2FF"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "blues",
@@ -70,7 +78,8 @@
         "background": "#2B2C56",
         "primary": "#EFF1FC",
         "accent": "#6677EB"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "passion",
@@ -78,7 +87,8 @@
         "background": "#f5f5f5",
         "primary": "#12005e",
         "accent": "#8e24aa"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "chalk",
@@ -86,7 +96,8 @@
         "background": "#263238",
         "primary": "#AABBC3",
         "accent": "#FF869A"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "paper",
@@ -94,7 +105,8 @@
         "background": "#F8F6F1",
         "primary": "#4C432E",
         "accent": "#AA9A73"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "neon",
@@ -102,7 +114,8 @@
         "background": "#091833",
         "primary": "#EFFBFF",
         "accent": "#ea00d9"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "pumpkin",
@@ -110,7 +123,8 @@
         "background": "#2d3436",
         "primary": "#EFFBFF",
         "accent": "#ffa500"
-      }
+      },
+      "isCustom": false
     },
     {
       "name": "onedark",
@@ -118,7 +132,17 @@
         "background": "#282c34",
         "primary": "#dfd9d6",
         "accent": "#98c379"
-      }
+      },
+      "isCustom": false
+    },
+    {
+      "name": "mint",
+      "colors": {
+        "background": "#282525",
+        "primary": "#d9d9d9",
+        "accent": "#50fbc2"
+      },
+      "isCustom": false
     }
   ]
 }