瀏覽代碼

Merge pull request #123 from pawelmalak/feature

Version 1.7.1
pawelmalak 3 年之前
父節點
當前提交
0ec77c33bf
共有 69 個文件被更改,包括 1870 次插入1580 次删除
  1. 1 1
      .env
  2. 8 0
      CHANGELOG.md
  3. 1 1
      client/.env
  4. 1 1
      client/src/App.tsx
  5. 13 6
      client/src/components/Apps/AppCard/AppCard.tsx
  6. 98 54
      client/src/components/Apps/AppTable/AppTable.tsx
  7. 12 4
      client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx
  8. 178 120
      client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx
  9. 8 9
      client/src/components/Home/Home.tsx
  10. 35 4
      client/src/components/Home/functions/dateTime.ts
  11. 21 7
      client/src/components/SearchBar/SearchBar.tsx
  12. 50 61
      client/src/components/Settings/OtherSettings/OtherSettings.tsx
  13. 9 3
      client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx
  14. 16 23
      client/src/components/Settings/SearchSettings/SearchSettings.tsx
  15. 81 75
      client/src/components/Settings/WeatherSettings/WeatherSettings.tsx
  16. 38 38
      client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx
  17. 23 8
      client/src/interfaces/Config.ts
  18. 15 17
      client/src/interfaces/Forms.ts
  19. 95 81
      client/src/store/actions/app.ts
  20. 243 203
      client/src/store/actions/bookmark.ts
  21. 11 6
      client/src/store/actions/config.ts
  22. 3 2
      client/src/store/reducers/config.ts
  23. 13 11
      client/src/store/reducers/theme.ts
  24. 2 1
      client/src/utility/index.ts
  25. 39 0
      client/src/utility/inputHandler.ts
  26. 0 24
      client/src/utility/searchConfig.ts
  27. 4 7
      client/src/utility/searchParser.ts
  28. 25 0
      client/src/utility/templateObjects/configTemplate.ts
  29. 2 0
      client/src/utility/templateObjects/index.ts
  30. 31 0
      client/src/utility/templateObjects/settingsTemplate.ts
  31. 0 326
      controllers/apps.js
  32. 33 0
      controllers/apps/createApp.js
  33. 18 0
      controllers/apps/deleteApp.js
  34. 4 0
      controllers/apps/docker/index.js
  35. 148 0
      controllers/apps/docker/useDocker.js
  36. 70 0
      controllers/apps/docker/useKubernetes.js
  37. 52 0
      controllers/apps/getAllApps.js
  38. 27 0
      controllers/apps/getSingleApp.js
  39. 8 0
      controllers/apps/index.js
  40. 23 0
      controllers/apps/reorderApps.js
  41. 35 0
      controllers/apps/updateApp.js
  42. 0 112
      controllers/bookmark.js
  43. 27 0
      controllers/bookmarks/createBookmark.js
  44. 18 0
      controllers/bookmarks/deleteBookmark.js
  45. 19 0
      controllers/bookmarks/getAllBookmarks.js
  46. 28 0
      controllers/bookmarks/getSingleBookmark.js
  47. 7 0
      controllers/bookmarks/index.js
  48. 39 0
      controllers/bookmarks/updateBookmark.js
  49. 9 17
      controllers/category.js
  50. 0 177
      controllers/config.js
  51. 18 0
      controllers/config/getCSS.js
  52. 16 0
      controllers/config/getConfig.js
  53. 6 0
      controllers/config/index.js
  54. 24 0
      controllers/config/updateCSS.js
  55. 24 0
      controllers/config/updateConfig.js
  56. 0 1
      db/index.js
  57. 37 0
      db/migrations/01_new-config.js
  58. 3 13
      middleware/asyncWrapper.js
  59. 8 4
      middleware/errorHandler.js
  60. 1 1
      middleware/multer.js
  61. 7 16
      routes/apps.js
  62. 7 10
      routes/bookmark.js
  63. 6 12
      routes/config.js
  64. 10 0
      utils/checkFileExists.js
  65. 8 12
      utils/getExternalWeather.js
  66. 1 1
      utils/init/index.js
  67. 15 29
      utils/init/initConfig.js
  68. 20 82
      utils/init/initialConfig.json
  69. 18 0
      utils/loadConfig.js

+ 1 - 1
.env

@@ -1,3 +1,3 @@
 PORT=5005
 NODE_ENV=development
-VERSION=1.7.0
+VERSION=1.7.1

+ 8 - 0
CHANGELOG.md

@@ -1,3 +1,11 @@
+### v1.7.1 (2021-10-22)
+- Fixed search action not being triggered by Numpad Enter
+- Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92))
+- Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100))
+- Added Traefik integration ([#102](https://github.com/pawelmalak/flame/issues/102))
+- Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118))
+- Performance improvements
+
 ### v1.7.0 (2021-10-11)
 - Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67))
 - Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71))

+ 1 - 1
client/.env

@@ -1 +1 @@
-REACT_APP_VERSION=1.7.0
+REACT_APP_VERSION=1.7.1

+ 1 - 1
client/src/App.tsx

@@ -16,7 +16,7 @@ import Settings from './components/Settings/Settings';
 import Bookmarks from './components/Bookmarks/Bookmarks';
 import NotificationCenter from './components/NotificationCenter/NotificationCenter';
 
-// Get config pairs from database
+// Load config
 store.dispatch<any>(getConfig());
 
 // Set theme

+ 13 - 6
client/src/components/Apps/AppCard/AppCard.tsx

@@ -2,12 +2,13 @@ import classes from './AppCard.module.css';
 import Icon from '../../UI/Icons/Icon/Icon';
 import { iconParser, urlParser } from '../../../utility';
 
-import { App } from '../../../interfaces';
-import { searchConfig } from '../../../utility';
+import { App, Config, GlobalState } from '../../../interfaces';
+import { connect } from 'react-redux';
 
 interface ComponentProps {
   app: App;
   pinHandler?: Function;
+  config: Config;
 }
 
 const AppCard = (props: ComponentProps): JSX.Element => {
@@ -29,7 +30,7 @@ const AppCard = (props: ComponentProps): JSX.Element => {
       <div className={classes.CustomIcon}>
         <svg
           data-src={`/uploads/${icon}`}
-          fill='var(--color-primary)'
+          fill="var(--color-primary)"
           className={classes.CustomIcon}
         ></svg>
       </div>
@@ -41,8 +42,8 @@ const AppCard = (props: ComponentProps): JSX.Element => {
   return (
     <a
       href={redirectUrl}
-      target={searchConfig('appsSameTab', false) ? '' : '_blank'}
-      rel='noreferrer'
+      target={props.config.appsSameTab ? '' : '_blank'}
+      rel="noreferrer"
       className={classes.AppCard}
     >
       <div className={classes.AppCardIcon}>{iconEl}</div>
@@ -54,4 +55,10 @@ const AppCard = (props: ComponentProps): JSX.Element => {
   );
 };
 
-export default AppCard;
+const mapStateToProps = (state: GlobalState) => {
+  return {
+    config: state.config.config,
+  };
+};
+
+export default connect(mapStateToProps)(AppCard);

+ 98 - 54
client/src/components/Apps/AppTable/AppTable.tsx

@@ -1,13 +1,24 @@
 import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
-import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
+import {
+  DragDropContext,
+  Droppable,
+  Draggable,
+  DropResult,
+} from 'react-beautiful-dnd';
 import { Link } from 'react-router-dom';
 
 // Redux
 import { connect } from 'react-redux';
-import { pinApp, deleteApp, reorderApps, updateConfig, createNotification } from '../../../store/actions';
+import {
+  pinApp,
+  deleteApp,
+  reorderApps,
+  updateConfig,
+  createNotification,
+} from '../../../store/actions';
 
 // Typescript
-import { App, GlobalState, NewNotification } from '../../../interfaces';
+import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
 
 // CSS
 import classes from './AppTable.module.css';
@@ -16,11 +27,9 @@ import classes from './AppTable.module.css';
 import Icon from '../../UI/Icons/Icon/Icon';
 import Table from '../../UI/Table/Table';
 
-// Utils
-import { searchConfig } from '../../../utility';
-
 interface ComponentProps {
   apps: App[];
+  config: Config;
   pinApp: (app: App) => void;
   deleteApp: (id: number) => void;
   updateAppHandler: (app: App) => void;
@@ -36,38 +45,44 @@ const AppTable = (props: ComponentProps): JSX.Element => {
   // Copy apps array
   useEffect(() => {
     setLocalApps([...props.apps]);
-  }, [props.apps])
+  }, [props.apps]);
 
   // Check ordering
   useEffect(() => {
-    const order = searchConfig('useOrdering', '');
+    const order = props.config.useOrdering;
 
     if (order === 'orderId') {
       setIsCustomOrder(true);
     }
-  }, [])
+  }, []);
 
   const deleteAppHandler = (app: App): void => {
-    const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
+    const proceed = window.confirm(
+      `Are you sure you want to delete ${app.name} at ${app.url} ?`
+    );
 
     if (proceed) {
       props.deleteApp(app.id);
     }
-  }
+  };
 
   // Support keyboard navigation for actions
-  const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
+  const keyboardActionHandler = (
+    e: KeyboardEvent,
+    app: App,
+    handler: Function
+  ) => {
     if (e.key === 'Enter') {
       handler(app);
     }
-  }
+  };
 
   const dragEndHanlder = (result: DropResult): void => {
     if (!isCustomOrder) {
       props.createNotification({
         title: 'Error',
-        message: 'Custom order is disabled'
-      })
+        message: 'Custom order is disabled',
+      });
       return;
     }
 
@@ -81,32 +96,39 @@ const AppTable = (props: ComponentProps): JSX.Element => {
 
     setLocalApps(tmpApps);
     props.reorderApps(tmpApps);
-  }
+  };
 
   return (
     <Fragment>
       <div className={classes.Message}>
-        {isCustomOrder
-          ? <p>You can drag and drop single rows to reorder application</p>
-          : <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
-        }
+        {isCustomOrder ? (
+          <p>You can drag and drop single rows to reorder application</p>
+        ) : (
+          <p>
+            Custom order is disabled. You can change it in{' '}
+            <Link to="/settings/other">settings</Link>
+          </p>
+        )}
       </div>
       <DragDropContext onDragEnd={dragEndHanlder}>
-        <Droppable droppableId='apps'>
+        <Droppable droppableId="apps">
           {(provided) => (
-            <Table headers={[
-              'Name',
-              'URL',
-              'Icon',
-              'Actions'
-            ]}
-            innerRef={provided.innerRef}>
+            <Table
+              headers={['Name', 'URL', 'Icon', 'Actions']}
+              innerRef={provided.innerRef}
+            >
               {localApps.map((app: App, index): JSX.Element => {
                 return (
-                  <Draggable key={app.id} draggableId={app.id.toString()} index={index}>
+                  <Draggable
+                    key={app.id}
+                    draggableId={app.id.toString()}
+                    index={index}
+                  >
                     {(provided, snapshot) => {
                       const style = {
-                        border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
+                        border: snapshot.isDragging
+                          ? '1px solid var(--color-accent)'
+                          : 'none',
                         borderRadius: '4px',
                         ...provided.draggableProps.style,
                       };
@@ -118,63 +140,85 @@ const AppTable = (props: ComponentProps): JSX.Element => {
                           ref={provided.innerRef}
                           style={style}
                         >
-                          <td style={{ width:'200px' }}>{app.name}</td>
-                          <td style={{ width:'200px' }}>{app.url}</td>
-                          <td style={{ width:'200px' }}>{app.icon}</td>
+                          <td style={{ width: '200px' }}>{app.name}</td>
+                          <td style={{ width: '200px' }}>{app.url}</td>
+                          <td style={{ width: '200px' }}>{app.icon}</td>
                           {!snapshot.isDragging && (
                             <td className={classes.TableActions}>
                               <div
                                 className={classes.TableAction}
                                 onClick={() => deleteAppHandler(app)}
-                                onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
-                                tabIndex={0}>
-                                <Icon icon='mdiDelete' />
+                                onKeyDown={(e) =>
+                                  keyboardActionHandler(
+                                    e,
+                                    app,
+                                    deleteAppHandler
+                                  )
+                                }
+                                tabIndex={0}
+                              >
+                                <Icon icon="mdiDelete" />
                               </div>
                               <div
                                 className={classes.TableAction}
                                 onClick={() => props.updateAppHandler(app)}
-                                onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
-                                tabIndex={0}>
-                                <Icon icon='mdiPencil' />
+                                onKeyDown={(e) =>
+                                  keyboardActionHandler(
+                                    e,
+                                    app,
+                                    props.updateAppHandler
+                                  )
+                                }
+                                tabIndex={0}
+                              >
+                                <Icon icon="mdiPencil" />
                               </div>
                               <div
                                 className={classes.TableAction}
                                 onClick={() => props.pinApp(app)}
-                                onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
-                                tabIndex={0}>
-                                {app.isPinned
-                                  ? <Icon icon='mdiPinOff' color='var(--color-accent)' />
-                                  : <Icon icon='mdiPin' />
+                                onKeyDown={(e) =>
+                                  keyboardActionHandler(e, app, props.pinApp)
                                 }
+                                tabIndex={0}
+                              >
+                                {app.isPinned ? (
+                                  <Icon
+                                    icon="mdiPinOff"
+                                    color="var(--color-accent)"
+                                  />
+                                ) : (
+                                  <Icon icon="mdiPin" />
+                                )}
                               </div>
                             </td>
                           )}
                         </tr>
-                      )
+                      );
                     }}
                   </Draggable>
-                )
+                );
               })}
             </Table>
           )}
         </Droppable>
       </DragDropContext>
     </Fragment>
-  )
-}
+  );
+};
 
 const mapStateToProps = (state: GlobalState) => {
   return {
-    apps: state.app.apps
-  }
-}
+    apps: state.app.apps,
+    config: state.config.config,
+  };
+};
 
 const actions = {
   pinApp,
   deleteApp,
   reorderApps,
   updateConfig,
-  createNotification
-}
+  createNotification,
+};
 
-export default connect(mapStateToProps, actions)(AppTable);
+export default connect(mapStateToProps, actions)(AppTable);

+ 12 - 4
client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx

@@ -1,12 +1,14 @@
-import { Bookmark, Category } from '../../../interfaces';
+import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
 import classes from './BookmarkCard.module.css';
 
 import Icon from '../../UI/Icons/Icon/Icon';
-import { iconParser, urlParser, searchConfig } from '../../../utility';
+import { iconParser, urlParser } from '../../../utility';
 import { Fragment } from 'react';
+import { connect } from 'react-redux';
 
 interface ComponentProps {
   category: Category;
+  config: Config;
 }
 
 const BookmarkCard = (props: ComponentProps): JSX.Element => {
@@ -54,7 +56,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
           return (
             <a
               href={redirectUrl}
-              target={searchConfig('bookmarksSameTab', false) ? '' : '_blank'}
+              target={props.config.bookmarksSameTab ? '' : '_blank'}
               rel="noreferrer"
               key={`bookmark-${bookmark.id}`}
             >
@@ -68,4 +70,10 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
   );
 };
 
-export default BookmarkCard;
+const mapStateToProps = (state: GlobalState) => {
+  return {
+    config: state.config.config,
+  };
+};
+
+export default connect(mapStateToProps)(BookmarkCard);

+ 178 - 120
client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx

@@ -1,13 +1,30 @@
 import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
-import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
+import {
+  DragDropContext,
+  Droppable,
+  Draggable,
+  DropResult,
+} from 'react-beautiful-dnd';
 import { Link } from 'react-router-dom';
 
 // Redux
 import { connect } from 'react-redux';
-import { pinCategory, deleteCategory, deleteBookmark, createNotification, reorderCategories } from '../../../store/actions';
+import {
+  pinCategory,
+  deleteCategory,
+  deleteBookmark,
+  createNotification,
+  reorderCategories,
+} from '../../../store/actions';
 
 // Typescript
-import { Bookmark, Category, NewNotification } from '../../../interfaces';
+import {
+  Bookmark,
+  Category,
+  Config,
+  GlobalState,
+  NewNotification,
+} from '../../../interfaces';
 import { ContentType } from '../Bookmarks';
 
 // CSS
@@ -17,12 +34,10 @@ import classes from './BookmarkTable.module.css';
 import Table from '../../UI/Table/Table';
 import Icon from '../../UI/Icons/Icon/Icon';
 
-// Utils
-import { searchConfig } from '../../../utility';
-
 interface ComponentProps {
   contentType: ContentType;
   categories: Category[];
+  config: Config;
   pinCategory: (category: Category) => void;
   deleteCategory: (id: number) => void;
   updateHandler: (data: Category | Bookmark) => void;
@@ -38,45 +53,53 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
   // Copy categories array
   useEffect(() => {
     setLocalCategories([...props.categories]);
-  }, [props.categories])
+  }, [props.categories]);
 
   // Check ordering
   useEffect(() => {
-    const order = searchConfig('useOrdering', '');
+    const order = props.config.useOrdering;
 
     if (order === 'orderId') {
       setIsCustomOrder(true);
     }
-  })
+  });
 
   const deleteCategoryHandler = (category: Category): void => {
-    const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
+    const proceed = window.confirm(
+      `Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`
+    );
 
     if (proceed) {
       props.deleteCategory(category.id);
     }
-  }
+  };
 
   const deleteBookmarkHandler = (bookmark: Bookmark): void => {
-    const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`);
+    const proceed = window.confirm(
+      `Are you sure you want to delete ${bookmark.name}?`
+    );
 
     if (proceed) {
       props.deleteBookmark(bookmark.id, bookmark.categoryId);
     }
-  }
+  };
 
-  const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => {
+  const keyboardActionHandler = (
+    e: KeyboardEvent,
+    category: Category,
+    handler: Function
+  ) => {
     if (e.key === 'Enter') {
       handler(category);
     }
-  }
+  };
 
   const dragEndHanlder = (result: DropResult): void => {
     if (!isCustomOrder) {
       props.createNotification({
         title: 'Error',
-        message: 'Custom order is disabled'
-      })
+        message: 'Custom order is disabled',
+      });
       return;
     }
 
@@ -90,136 +113,171 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
 
     setLocalCategories(tmpCategories);
     props.reorderCategories(tmpCategories);
-  }
+  };
 
   if (props.contentType === ContentType.category) {
     return (
       <Fragment>
         <div className={classes.Message}>
-          {isCustomOrder
-            ? <p>You can drag and drop single rows to reorder categories</p>
-            : <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
-          }
+          {isCustomOrder ? (
+            <p>You can drag and drop single rows to reorder categories</p>
+          ) : (
+            <p>
+              Custom order is disabled. You can change it in{' '}
+              <Link to="/settings/other">settings</Link>
+            </p>
+          )}
         </div>
         <DragDropContext onDragEnd={dragEndHanlder}>
-          <Droppable droppableId='categories'>
+          <Droppable droppableId="categories">
             {(provided) => (
-              <Table headers={[
-                'Name',
-                'Actions'
-              ]}
-              innerRef={provided.innerRef}>
-                {localCategories.map((category: Category, index): JSX.Element => {
-                  return (
-                    <Draggable key={category.id} draggableId={category.id.toString()} index={index}>
-                      {(provided, snapshot) => {
-                        const style = {
-                          border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
-                          borderRadius: '4px',
-                          ...provided.draggableProps.style,
-                        };
-
-                        return (
-                          <tr
-                            {...provided.draggableProps}
-                            {...provided.dragHandleProps}
-                            ref={provided.innerRef}
-                            style={style}  
-                          >
-                            <td>{category.name}</td>
-                            {!snapshot.isDragging && (
-                              <td className={classes.TableActions}>
-                                <div
-                                  className={classes.TableAction}
-                                  onClick={() => deleteCategoryHandler(category)}
-                                  onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)}
-                                  tabIndex={0}>
-                                  <Icon icon='mdiDelete' />
-                                </div>
-                                <div
-                                  className={classes.TableAction}
-                                  onClick={() => props.updateHandler(category)}
-                                  tabIndex={0}>
-                                  <Icon icon='mdiPencil' />
-                                </div>
-                                <div
-                                  className={classes.TableAction}
-                                  onClick={() => props.pinCategory(category)}
-                                  onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
-                                  tabIndex={0}>
-                                  {category.isPinned
-                                    ? <Icon icon='mdiPinOff' color='var(--color-accent)' />
-                                    : <Icon icon='mdiPin' />
-                                  }
-                                </div>
-                              </td>
-                            )}
-                          </tr>
-                        )
-                      }}
-                    </Draggable>
-                  )
-                })}
+              <Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
+                {localCategories.map(
+                  (category: Category, index): JSX.Element => {
+                    return (
+                      <Draggable
+                        key={category.id}
+                        draggableId={category.id.toString()}
+                        index={index}
+                      >
+                        {(provided, snapshot) => {
+                          const style = {
+                            border: snapshot.isDragging
+                              ? '1px solid var(--color-accent)'
+                              : 'none',
+                            borderRadius: '4px',
+                            ...provided.draggableProps.style,
+                          };
+
+                          return (
+                            <tr
+                              {...provided.draggableProps}
+                              {...provided.dragHandleProps}
+                              ref={provided.innerRef}
+                              style={style}
+                            >
+                              <td>{category.name}</td>
+                              {!snapshot.isDragging && (
+                                <td className={classes.TableActions}>
+                                  <div
+                                    className={classes.TableAction}
+                                    onClick={() =>
+                                      deleteCategoryHandler(category)
+                                    }
+                                    onKeyDown={(e) =>
+                                      keyboardActionHandler(
+                                        e,
+                                        category,
+                                        deleteCategoryHandler
+                                      )
+                                    }
+                                    tabIndex={0}
+                                  >
+                                    <Icon icon="mdiDelete" />
+                                  </div>
+                                  <div
+                                    className={classes.TableAction}
+                                    onClick={() =>
+                                      props.updateHandler(category)
+                                    }
+                                    tabIndex={0}
+                                  >
+                                    <Icon icon="mdiPencil" />
+                                  </div>
+                                  <div
+                                    className={classes.TableAction}
+                                    onClick={() => props.pinCategory(category)}
+                                    onKeyDown={(e) =>
+                                      keyboardActionHandler(
+                                        e,
+                                        category,
+                                        props.pinCategory
+                                      )
+                                    }
+                                    tabIndex={0}
+                                  >
+                                    {category.isPinned ? (
+                                      <Icon
+                                        icon="mdiPinOff"
+                                        color="var(--color-accent)"
+                                      />
+                                    ) : (
+                                      <Icon icon="mdiPin" />
+                                    )}
+                                  </div>
+                                </td>
+                              )}
+                            </tr>
+                          );
+                        }}
+                      </Draggable>
+                    );
+                  }
+                )}
               </Table>
             )}
           </Droppable>
         </DragDropContext>
       </Fragment>
-    )
+    );
   } else {
-    const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
+    const bookmarks: { bookmark: Bookmark; categoryName: string }[] = [];
     props.categories.forEach((category: Category) => {
       category.bookmarks.forEach((bookmark: Bookmark) => {
         bookmarks.push({
           bookmark,
-          categoryName: category.name
+          categoryName: category.name,
         });
-      })
-    })
+      });
+    });
 
     return (
-      <Table headers={[
-        'Name',
-        'URL',
-        'Icon',
-        'Category',
-        'Actions'
-      ]}>
-        {bookmarks.map((bookmark: {bookmark: Bookmark, categoryName: string}) => {
-          return (
-            <tr key={bookmark.bookmark.id}>
-              <td>{bookmark.bookmark.name}</td>
-              <td>{bookmark.bookmark.url}</td>
-              <td>{bookmark.bookmark.icon}</td>
-              <td>{bookmark.categoryName}</td>
-              <td className={classes.TableActions}>
-                <div
-                  className={classes.TableAction}
-                  onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
-                  tabIndex={0}>
-                  <Icon icon='mdiDelete' />
-                </div>
-                <div
-                  className={classes.TableAction}
-                  onClick={() => props.updateHandler(bookmark.bookmark)}
-                  tabIndex={0}>
-                  <Icon icon='mdiPencil' />
-                </div>
-              </td>
-            </tr>
-          )
-        })}
+      <Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
+        {bookmarks.map(
+          (bookmark: { bookmark: Bookmark; categoryName: string }) => {
+            return (
+              <tr key={bookmark.bookmark.id}>
+                <td>{bookmark.bookmark.name}</td>
+                <td>{bookmark.bookmark.url}</td>
+                <td>{bookmark.bookmark.icon}</td>
+                <td>{bookmark.categoryName}</td>
+                <td className={classes.TableActions}>
+                  <div
+                    className={classes.TableAction}
+                    onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
+                    tabIndex={0}
+                  >
+                    <Icon icon="mdiDelete" />
+                  </div>
+                  <div
+                    className={classes.TableAction}
+                    onClick={() => props.updateHandler(bookmark.bookmark)}
+                    tabIndex={0}
+                  >
+                    <Icon icon="mdiPencil" />
+                  </div>
+                </td>
+              </tr>
+            );
+          }
+        )}
       </Table>
-    )
+    );
   }
-}
+};
+
+const mapStateToProps = (state: GlobalState) => {
+  return {
+    config: state.config.config,
+  };
+};
 
 const actions = {
   pinCategory,
   deleteCategory,
   deleteBookmark,
   createNotification,
-  reorderCategories
-}
+  reorderCategories,
+};
 
-export default connect(null, actions)(BookmarkTable);
+export default connect(mapStateToProps, actions)(BookmarkTable);

+ 8 - 9
client/src/components/Home/Home.tsx

@@ -7,7 +7,7 @@ import { getApps, getCategories } from '../../store/actions';
 
 // Typescript
 import { GlobalState } from '../../interfaces/GlobalState';
-import { App, Category } from '../../interfaces';
+import { App, Category, Config } from '../../interfaces';
 
 // UI
 import Icon from '../UI/Icons/Icon/Icon';
@@ -28,9 +28,6 @@ import SearchBar from '../SearchBar/SearchBar';
 import { greeter } from './functions/greeter';
 import { dateTime } from './functions/dateTime';
 
-// Utils
-import { searchConfig } from '../../utility';
-
 interface ComponentProps {
   getApps: Function;
   getCategories: Function;
@@ -38,6 +35,7 @@ interface ComponentProps {
   apps: App[];
   categoriesLoading: boolean;
   categories: Category[];
+  config: Config;
 }
 
 const Home = (props: ComponentProps): JSX.Element => {
@@ -77,7 +75,7 @@ const Home = (props: ComponentProps): JSX.Element => {
     let interval: any;
 
     // Start interval only when hideHeader is false
-    if (searchConfig('hideHeader', 0) !== 1) {
+    if (!props.config.hideHeader) {
       interval = setInterval(() => {
         setHeader({
           dateTime: dateTime(),
@@ -103,13 +101,13 @@ const Home = (props: ComponentProps): JSX.Element => {
 
   return (
     <Container>
-      {searchConfig('hideSearch', 0) !== 1 ? (
+      {!props.config.hideSearch ? (
         <SearchBar setLocalSearch={setLocalSearch} />
       ) : (
         <div></div>
       )}
 
-      {searchConfig('hideHeader', 0) !== 1 ? (
+      {!props.config.hideHeader ? (
         <header className={classes.Header}>
           <p>{header.dateTime}</p>
           <Link to="/settings" className={classes.SettingsLink}>
@@ -124,7 +122,7 @@ const Home = (props: ComponentProps): JSX.Element => {
         <div></div>
       )}
 
-      {searchConfig('hideApps', 0) !== 1 ? (
+      {!props.config.hideApps ? (
         <Fragment>
           <SectionHeadline title="Applications" link="/applications" />
           {appsLoading ? (
@@ -148,7 +146,7 @@ const Home = (props: ComponentProps): JSX.Element => {
         <div></div>
       )}
 
-      {searchConfig('hideCategories', 0) !== 1 ? (
+      {!props.config.hideCategories ? (
         <Fragment>
           <SectionHeadline title="Bookmarks" link="/bookmarks" />
           {categoriesLoading ? (
@@ -182,6 +180,7 @@ const mapStateToProps = (state: GlobalState) => {
     apps: state.app.apps,
     categoriesLoading: state.bookmark.loading,
     categories: state.bookmark.categories,
+    config: state.config.config,
   };
 };
 

+ 35 - 4
client/src/components/Home/functions/dateTime.ts

@@ -1,8 +1,39 @@
 export const dateTime = (): string => {
-  const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
-  const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+  const days = [
+    'Sunday',
+    'Monday',
+    'Tuesday',
+    'Wednesday',
+    'Thursday',
+    'Friday',
+    'Saturday',
+  ];
+  const months = [
+    'January',
+    'February',
+    'March',
+    'April',
+    'May',
+    'June',
+    'July',
+    'August',
+    'September',
+    'October',
+    'November',
+    'December',
+  ];
 
   const now = new Date();
 
-  return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
-}
+  const useAmericanDate = localStorage.useAmericanDate === 'true';
+
+  if (!useAmericanDate) {
+    return `${days[now.getDay()]}, ${now.getDate()} ${
+      months[now.getMonth()]
+    } ${now.getFullYear()}`;
+  } else {
+    return `${days[now.getDay()]}, ${
+      months[now.getMonth()]
+    } ${now.getDate()} ${now.getFullYear()}`;
+  }
+};

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

@@ -27,6 +27,11 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
     inputRef.current.focus();
   }, []);
 
+  const clearSearch = () => {
+    inputRef.current.value = '';
+    setLocalSearch('');
+  };
+
   const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
     const { isLocal, search, query, isURL, sameTab } = searchParser(
       inputRef.current.value
@@ -36,31 +41,40 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
       setLocalSearch(search);
     }
 
-    if (e.code === 'Enter') {
+    if (e.code === 'Enter' || e.code === 'NumpadEnter') {
       if (!query.prefix) {
+        // Prefix not found -> emit notification
         createNotification({
           title: 'Error',
           message: 'Prefix not found',
         });
       } else if (isURL) {
+        // URL or IP passed -> redirect
         const url = urlParser(inputRef.current.value)[1];
         redirectUrl(url, sameTab);
       } else if (isLocal) {
+        // Local query -> filter apps and bookmarks
         setLocalSearch(search);
       } else {
+        // Valid query -> redirect to search results
         const url = `${query.template}${search}`;
         redirectUrl(url, sameTab);
       }
+    } else if (e.code === 'Escape') {
+      clearSearch();
     }
   };
 
   return (
-    <input
-      ref={inputRef}
-      type="text"
-      className={classes.SearchBar}
-      onKeyUp={(e) => searchHandler(e)}
-    />
+    <div className={classes.SearchContainer}>
+      <input
+        ref={inputRef}
+        type="text"
+        className={classes.SearchBar}
+        onKeyUp={(e) => searchHandler(e)}
+        onDoubleClick={clearSearch}
+      />
+    </div>
   );
 };
 

+ 50 - 61
client/src/components/Settings/OtherSettings/OtherSettings.tsx

@@ -11,9 +11,10 @@ import {
 
 // Typescript
 import {
+  Config,
   GlobalState,
   NewNotification,
-  SettingsForm,
+  OtherSettingsForm,
 } from '../../../interfaces';
 
 // UI
@@ -22,50 +23,29 @@ import Button from '../../UI/Buttons/Button/Button';
 import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
 
 // Utils
-import { searchConfig } from '../../../utility';
+import { otherSettingsTemplate, inputHandler } from '../../../utility';
 
 interface ComponentProps {
   createNotification: (notification: NewNotification) => void;
-  updateConfig: (formData: SettingsForm) => void;
+  updateConfig: (formData: OtherSettingsForm) => void;
   sortApps: () => void;
   sortCategories: () => void;
   loading: boolean;
+  config: Config;
 }
 
 const OtherSettings = (props: ComponentProps): JSX.Element => {
+  const { config } = props;
+
   // Initial state
-  const [formData, setFormData] = useState<SettingsForm>({
-    customTitle: document.title,
-    pinAppsByDefault: 1,
-    pinCategoriesByDefault: 1,
-    hideHeader: 0,
-    hideApps: 0,
-    hideCategories: 0,
-    useOrdering: 'createdAt',
-    appsSameTab: 0,
-    bookmarksSameTab: 0,
-    dockerApps: 1,
-    dockerHost: 'localhost',
-    kubernetesApps: 1,
-    unpinStoppedApps: 1,
-  });
+  const [formData, setFormData] = useState<OtherSettingsForm>(
+    otherSettingsTemplate
+  );
 
   // Get config
   useEffect(() => {
     setFormData({
-      customTitle: searchConfig('customTitle', 'Flame'),
-      pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
-      pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
-      hideHeader: searchConfig('hideHeader', 0),
-      hideApps: searchConfig('hideApps', 0),
-      hideCategories: searchConfig('hideCategories', 0),
-      useOrdering: searchConfig('useOrdering', 'createdAt'),
-      appsSameTab: searchConfig('appsSameTab', 0),
-      bookmarksSameTab: searchConfig('bookmarksSameTab', 0),
-      dockerApps: searchConfig('dockerApps', 0),
-      dockerHost: searchConfig('dockerHost', 'localhost'),
-      kubernetesApps: searchConfig('kubernetesApps', 0),
-      unpinStoppedApps: searchConfig('unpinStoppedApps', 0),
+      ...config,
     });
   }, [props.loading]);
 
@@ -87,17 +67,13 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
   // Input handler
   const inputChangeHandler = (
     e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
-    isNumber?: boolean
+    options?: { isNumber?: boolean; isBool?: boolean }
   ) => {
-    let value: string | number = e.target.value;
-
-    if (isNumber) {
-      value = parseFloat(value);
-    }
-
-    setFormData({
-      ...formData,
-      [e.target.name]: value,
+    inputHandler<OtherSettingsForm>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
     });
   };
 
@@ -116,6 +92,18 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
           onChange={(e) => inputChangeHandler(e)}
         />
       </InputGroup>
+      <InputGroup>
+        <label htmlFor="useAmericanDate">Date formatting</label>
+        <select
+          id="useAmericanDate"
+          name="useAmericanDate"
+          value={formData.useAmericanDate ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+        >
+          <option value={1}>Friday, October 22 2021</option>
+          <option value={0}>Friday, 22 October 2021</option>
+        </select>
+      </InputGroup>
 
       {/* BEAHVIOR OPTIONS */}
       <SettingsHeadline text="App Behavior" />
@@ -126,8 +114,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="pinAppsByDefault"
           name="pinAppsByDefault"
-          value={formData.pinAppsByDefault}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.pinAppsByDefault ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -140,8 +128,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="pinCategoriesByDefault"
           name="pinCategoriesByDefault"
-          value={formData.pinCategoriesByDefault}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.pinCategoriesByDefault ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -165,8 +153,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="appsSameTab"
           name="appsSameTab"
-          value={formData.appsSameTab}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.appsSameTab ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -177,8 +165,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="bookmarksSameTab"
           name="bookmarksSameTab"
-          value={formData.bookmarksSameTab}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.bookmarksSameTab ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -192,8 +180,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="hideHeader"
           name="hideHeader"
-          value={formData.hideHeader}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.hideHeader ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -204,8 +192,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="hideApps"
           name="hideApps"
-          value={formData.hideApps}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.hideApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -216,8 +204,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="hideCategories"
           name="hideCategories"
-          value={formData.hideCategories}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.hideCategories ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -242,8 +230,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="dockerApps"
           name="dockerApps"
-          value={formData.dockerApps}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.dockerApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -256,8 +244,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="unpinStoppedApps"
           name="unpinStoppedApps"
-          value={formData.unpinStoppedApps}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.unpinStoppedApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -271,8 +259,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
         <select
           id="kubernetesApps"
           name="kubernetesApps"
-          value={formData.kubernetesApps}
-          onChange={(e) => inputChangeHandler(e, true)}
+          value={formData.kubernetesApps ? 1 : 0}
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
         >
           <option value={1}>True</option>
           <option value={0}>False</option>
@@ -286,6 +274,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
 const mapStateToProps = (state: GlobalState) => {
   return {
     loading: state.config.loading,
+    config: state.config.config,
   };
 };
 

+ 9 - 3
client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx

@@ -5,16 +5,21 @@ import classes from './CustomQueries.module.css';
 
 import Modal from '../../../UI/Modal/Modal';
 import Icon from '../../../UI/Icons/Icon/Icon';
-import { GlobalState, NewNotification, Query } from '../../../../interfaces';
+import {
+  Config,
+  GlobalState,
+  NewNotification,
+  Query,
+} from '../../../../interfaces';
 import QueriesForm from './QueriesForm';
 import { deleteQuery, createNotification } from '../../../../store/actions';
 import Button from '../../../UI/Buttons/Button/Button';
-import { searchConfig } from '../../../../utility';
 
 interface Props {
   customQueries: Query[];
   deleteQuery: (prefix: string) => {};
   createNotification: (notification: NewNotification) => void;
+  config: Config;
 }
 
 const CustomQueries = (props: Props): JSX.Element => {
@@ -29,7 +34,7 @@ const CustomQueries = (props: Props): JSX.Element => {
   };
 
   const deleteHandler = (query: Query) => {
-    const currentProvider = searchConfig('defaultSearchProvider', 'l');
+    const currentProvider = props.config.defaultSearchProvider;
     const isCurrent = currentProvider === query.prefix;
 
     if (isCurrent) {
@@ -104,6 +109,7 @@ const CustomQueries = (props: Props): JSX.Element => {
 const mapStateToProps = (state: GlobalState) => {
   return {
     customQueries: state.config.customQueries,
+    config: state.config.config,
   };
 };
 

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

@@ -7,6 +7,7 @@ import { createNotification, updateConfig } from '../../../store/actions';
 
 // Typescript
 import {
+  Config,
   GlobalState,
   NewNotification,
   Query,
@@ -22,7 +23,7 @@ import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadli
 import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
 
 // Utils
-import { searchConfig } from '../../../utility';
+import { inputHandler, searchSettingsTemplate } from '../../../utility';
 
 // Data
 import { queries } from '../../../utility/searchQueries.json';
@@ -32,22 +33,17 @@ interface Props {
   updateConfig: (formData: SearchForm) => void;
   loading: boolean;
   customQueries: Query[];
+  config: Config;
 }
 
 const SearchSettings = (props: Props): JSX.Element => {
   // Initial state
-  const [formData, setFormData] = useState<SearchForm>({
-    hideSearch: 0,
-    defaultSearchProvider: 'l',
-    searchSameTab: 0,
-  });
+  const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
 
   // Get config
   useEffect(() => {
     setFormData({
-      hideSearch: searchConfig('hideSearch', 0),
-      defaultSearchProvider: searchConfig('defaultSearchProvider', 'l'),
-      searchSameTab: searchConfig('searchSameTab', 0),
+      ...props.config,
     });
   }, [props.loading]);
 
@@ -62,17 +58,13 @@ const SearchSettings = (props: Props): JSX.Element => {
   // Input handler
   const inputChangeHandler = (
     e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
-    isNumber?: boolean
+    options?: { isNumber?: boolean; isBool?: boolean }
   ) => {
-    let value: string | number = e.target.value;
-
-    if (isNumber) {
-      value = parseFloat(value);
-    }
-
-    setFormData({
-      ...formData,
-      [e.target.name]: value,
+    inputHandler<SearchForm>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
     });
   };
 
@@ -110,8 +102,8 @@ const SearchSettings = (props: Props): JSX.Element => {
           <select
             id="searchSameTab"
             name="searchSameTab"
-            value={formData.searchSameTab}
-            onChange={(e) => inputChangeHandler(e, true)}
+            value={formData.searchSameTab ? 1 : 0}
+            onChange={(e) => inputChangeHandler(e, { isBool: true })}
           >
             <option value={1}>True</option>
             <option value={0}>False</option>
@@ -122,8 +114,8 @@ const SearchSettings = (props: Props): JSX.Element => {
           <select
             id="hideSearch"
             name="hideSearch"
-            value={formData.hideSearch}
-            onChange={(e) => inputChangeHandler(e, true)}
+            value={formData.hideSearch ? 1 : 0}
+            onChange={(e) => inputChangeHandler(e, { isBool: true })}
           >
             <option value={1}>True</option>
             <option value={0}>False</option>
@@ -143,6 +135,7 @@ const mapStateToProps = (state: GlobalState) => {
   return {
     loading: state.config.loading,
     customQueries: state.config.customQueries,
+    config: state.config.config,
   };
 };
 

+ 81 - 75
client/src/components/Settings/WeatherSettings/WeatherSettings.tsx

@@ -6,38 +6,40 @@ import { connect } from 'react-redux';
 import { createNotification, updateConfig } from '../../../store/actions';
 
 // Typescript
-import { ApiResponse, GlobalState, NewNotification, Weather, WeatherForm } from '../../../interfaces';
+import {
+  ApiResponse,
+  Config,
+  GlobalState,
+  NewNotification,
+  Weather,
+  WeatherForm,
+} from '../../../interfaces';
 
 // UI
 import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
 import Button from '../../UI/Buttons/Button/Button';
 
 // Utils
-import { searchConfig } from '../../../utility';
+import { inputHandler, weatherSettingsTemplate } from '../../../utility';
 
 interface ComponentProps {
   createNotification: (notification: NewNotification) => void;
   updateConfig: (formData: WeatherForm) => void;
   loading: boolean;
+  config: Config;
 }
 
 const WeatherSettings = (props: ComponentProps): JSX.Element => {
   // Initial state
-  const [formData, setFormData] = useState<WeatherForm>({
-    WEATHER_API_KEY: '',
-    lat: 0,
-    long: 0,
-    isCelsius: 1
-  })
+  const [formData, setFormData] = useState<WeatherForm>(
+    weatherSettingsTemplate
+  );
 
   // Get config
   useEffect(() => {
     setFormData({
-      WEATHER_API_KEY: searchConfig('WEATHER_API_KEY', ''),
-      lat: searchConfig('lat', 0),
-      long: searchConfig('long', 0),
-      isCelsius: searchConfig('isCelsius', 1)
-    })
+      ...props.config,
+    });
   }, [props.loading]);
 
   // Form handler
@@ -48,120 +50,124 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
     if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
       props.createNotification({
         title: 'Warning',
-        message: 'API key is missing. Weather Module will NOT work'
-      })
+        message: 'API key is missing. Weather Module will NOT work',
+      });
     }
 
     // Save settings
     await props.updateConfig(formData);
-    
+
     // Update weather
-    axios.get<ApiResponse<Weather>>('/api/weather/update')
+    axios
+      .get<ApiResponse<Weather>>('/api/weather/update')
       .then(() => {
         props.createNotification({
           title: 'Success',
-          message: 'Weather updated'
-        })
+          message: 'Weather updated',
+        });
       })
       .catch((err) => {
         props.createNotification({
           title: 'Error',
-          message: err.response.data.error
-        })
+          message: err.response.data.error,
+        });
       });
-  }
+  };
 
   // Input handler
-  const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
-    let value: string | number = e.target.value;
-
-    if (isNumber) {
-      value = parseFloat(value);
-    }
-
-    setFormData({
-      ...formData,
-      [e.target.name]: value
-    })
-  }
+  const inputChangeHandler = (
+    e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
+    options?: { isNumber?: boolean; isBool?: boolean }
+  ) => {
+    inputHandler<WeatherForm>({
+      e,
+      options,
+      setStateHandler: setFormData,
+      state: formData,
+    });
+  };
 
   return (
     <form onSubmit={(e) => formSubmitHandler(e)}>
       <InputGroup>
-        <label htmlFor='WEATHER_API_KEY'>API key</label>
+        <label htmlFor="WEATHER_API_KEY">API key</label>
         <input
-          type='text'
-          id='WEATHER_API_KEY'
-          name='WEATHER_API_KEY'
-          placeholder='secret'
+          type="text"
+          id="WEATHER_API_KEY"
+          name="WEATHER_API_KEY"
+          placeholder="secret"
           value={formData.WEATHER_API_KEY}
           onChange={(e) => inputChangeHandler(e)}
         />
         <span>
           Using
-          <a
-            href='https://www.weatherapi.com/pricing.aspx'
-            target='blank'>
-            {' '}Weather API
+          <a href="https://www.weatherapi.com/pricing.aspx" target="blank">
+            {' '}
+            Weather API
           </a>
           . Key is required for weather module to work.
         </span>
       </InputGroup>
       <InputGroup>
-        <label htmlFor='lat'>Location latitude</label>
+        <label htmlFor="lat">Location latitude</label>
         <input
-          type='number'
-          id='lat'
-          name='lat'
-          placeholder='52.22'
+          type="number"
+          id="lat"
+          name="lat"
+          placeholder="52.22"
           value={formData.lat}
-          onChange={(e) => inputChangeHandler(e, true)}
-          step='any'
-          lang='en-150'
+          onChange={(e) => inputChangeHandler(e, { isNumber: true })}
+          step="any"
+          lang="en-150"
         />
         <span>
           You can use
           <a
-            href='https://www.latlong.net/convert-address-to-lat-long.html'
-            target='blank'>
-            {' '}latlong.net
+            href="https://www.latlong.net/convert-address-to-lat-long.html"
+            target="blank"
+          >
+            {' '}
+            latlong.net
           </a>
         </span>
       </InputGroup>
       <InputGroup>
-        <label htmlFor='long'>Location longitude</label>
+        <label htmlFor="long">Location longitude</label>
         <input
-          type='number'
-          id='long'
-          name='long'
-          placeholder='21.01'
+          type="number"
+          id="long"
+          name="long"
+          placeholder="21.01"
           value={formData.long}
-          onChange={(e) => inputChangeHandler(e, true)}
-          step='any'
-          lang='en-150'
+          onChange={(e) => inputChangeHandler(e, { isNumber: true })}
+          step="any"
+          lang="en-150"
         />
       </InputGroup>
       <InputGroup>
-        <label htmlFor='isCelsius'>Temperature unit</label>
+        <label htmlFor="isCelsius">Temperature unit</label>
         <select
-          id='isCelsius'
-          name='isCelsius'
-          onChange={(e) => inputChangeHandler(e, true)}
-          value={formData.isCelsius}
+          id="isCelsius"
+          name="isCelsius"
+          onChange={(e) => inputChangeHandler(e, { isBool: true })}
+          value={formData.isCelsius ? 1 : 0}
         >
           <option value={1}>Celsius</option>
           <option value={0}>Fahrenheit</option>
         </select>
       </InputGroup>
-    <Button>Save changes</Button>
+      <Button>Save changes</Button>
     </form>
-  )
-}
+  );
+};
 
 const mapStateToProps = (state: GlobalState) => {
   return {
-    loading: state.config.loading
-  }
-}
-
-export default connect(mapStateToProps, { createNotification, updateConfig })(WeatherSettings);
+    loading: state.config.loading,
+    config: state.config.config,
+  };
+};
+
+export default connect(mapStateToProps, { createNotification, updateConfig })(
+  WeatherSettings
+);

+ 38 - 38
client/src/components/Widgets/WeatherWidget/WeatherWidget.tsx

@@ -5,7 +5,7 @@ import axios from 'axios';
 import { connect } from 'react-redux';
 
 // Typescript
-import { Weather, ApiResponse, Config, GlobalState } from '../../../interfaces';
+import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces';
 
 // CSS
 import classes from './WeatherWidget.module.css';
@@ -13,12 +13,9 @@ import classes from './WeatherWidget.module.css';
 // UI
 import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
 
-// Utils
-import { searchConfig } from '../../../utility';
-
 interface ComponentProps {
   configLoading: boolean;
-  config: Config[];
+  config: Config;
 }
 
 const WeatherWidget = (props: ComponentProps): JSX.Element => {
@@ -32,26 +29,28 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
     conditionCode: 1000,
     id: -1,
     createdAt: new Date(),
-    updatedAt: new Date()
+    updatedAt: new Date(),
   });
   const [isLoading, setIsLoading] = useState(true);
 
   // Initial request to get data
   useEffect(() => {
-    axios.get<ApiResponse<Weather[]>>('/api/weather')
-      .then(data => {
+    axios
+      .get<ApiResponse<Weather[]>>('/api/weather')
+      .then((data) => {
         const weatherData = data.data.data[0];
         if (weatherData) {
           setWeather(weatherData);
         }
         setIsLoading(false);
       })
-      .catch(err => console.log(err));
+      .catch((err) => console.log(err));
   }, []);
 
   // Open socket for data updates
   useEffect(() => {
-    const socketProtocol = document.location.protocol === 'http:' ? 'ws:' : 'wss:';
+    const socketProtocol =
+      document.location.protocol === 'http:' ? 'ws:' : 'wss:';
     const socketAddress = `${socketProtocol}//${window.location.host}/socket`;
     const webSocketClient = new WebSocket(socketAddress);
 
@@ -59,43 +58,44 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
       const data = JSON.parse(e.data);
       setWeather({
         ...weather,
-        ...data
-      })
-    }
+        ...data,
+      });
+    };
 
     return () => webSocketClient.close();
   }, []);
 
   return (
     <div className={classes.WeatherWidget}>
-      {(isLoading || props.configLoading || searchConfig('WEATHER_API_KEY', '')) && 
-         (weather.id > 0 && 
-            (<Fragment>
-              <div className={classes.WeatherIcon}>
-                <WeatherIcon
-                  weatherStatusCode={weather.conditionCode}
-                  isDay={weather.isDay}
-                />
-              </div>
-              <div className={classes.WeatherDetails}>
-                {searchConfig('isCelsius', true)
-                  ? <span>{weather.tempC}°C</span>
-                  : <span>{weather.tempF}°F</span>
-                }
-                <span>{weather.cloud}%</span>
-              </div>
-            </Fragment>)
-        )
-      }
+      {isLoading ||
+        props.configLoading ||
+        (props.config.WEATHER_API_KEY && weather.id > 0 && (
+          <Fragment>
+            <div className={classes.WeatherIcon}>
+              <WeatherIcon
+                weatherStatusCode={weather.conditionCode}
+                isDay={weather.isDay}
+              />
+            </div>
+            <div className={classes.WeatherDetails}>
+              {props.config.isCelsius ? (
+                <span>{weather.tempC}°C</span>
+              ) : (
+                <span>{weather.tempF}°F</span>
+              )}
+              <span>{weather.cloud}%</span>
+            </div>
+          </Fragment>
+        ))}
     </div>
-  )
-}
+  );
+};
 
 const mapStateToProps = (state: GlobalState) => {
   return {
     configLoading: state.config.loading,
-    config: state.config.config
-  }
-}
+    config: state.config.config,
+  };
+};
 
-export default connect(mapStateToProps)(WeatherWidget);
+export default connect(mapStateToProps)(WeatherWidget);

+ 23 - 8
client/src/interfaces/Config.ts

@@ -1,8 +1,23 @@
-import { Model } from './';
-
-export interface Config extends Model {
-  key: string;
-  value: string;
-  valueType: string;
-  isLocked: boolean;
-}
+export interface Config {
+  WEATHER_API_KEY: string;
+  lat: number;
+  long: number;
+  isCelsius: boolean;
+  customTitle: string;
+  pinAppsByDefault: boolean;
+  pinCategoriesByDefault: boolean;
+  hideHeader: boolean;
+  useOrdering: string;
+  appsSameTab: boolean;
+  bookmarksSameTab: boolean;
+  searchSameTab: boolean;
+  hideApps: boolean;
+  hideCategories: boolean;
+  hideSearch: boolean;
+  defaultSearchProvider: string;
+  dockerApps: boolean;
+  dockerHost: string;
+  kubernetesApps: boolean;
+  unpinStoppedApps: boolean;
+  useAmericanDate: boolean;
+}

+ 15 - 17
client/src/interfaces/Forms.ts

@@ -2,30 +2,28 @@ export interface WeatherForm {
   WEATHER_API_KEY: string;
   lat: number;
   long: number;
-  isCelsius: number;
+  isCelsius: boolean;
 }
 
 export interface SearchForm {
-  hideSearch: number;
+  hideSearch: boolean;
   defaultSearchProvider: string;
-  searchSameTab: number;
+  searchSameTab: boolean;
 }
 
-export interface SettingsForm {
+export interface OtherSettingsForm {
   customTitle: string;
-  pinAppsByDefault: number;
-  pinCategoriesByDefault: number;
-  hideHeader: number;
-  hideApps: number;
-  hideCategories: number;
-  // hideSearch: number;
-  // defaultSearchProvider: string;
+  pinAppsByDefault: boolean;
+  pinCategoriesByDefault: boolean;
+  hideHeader: boolean;
+  hideApps: boolean;
+  hideCategories: boolean;
   useOrdering: string;
-  appsSameTab: number;
-  bookmarksSameTab: number;
-  // searchSameTab: number;
-  dockerApps: number;
+  appsSameTab: boolean;
+  bookmarksSameTab: boolean;
+  dockerApps: boolean;
   dockerHost: string;
-  kubernetesApps: number;
-  unpinStoppedApps: number;
+  kubernetesApps: boolean;
+  unpinStoppedApps: boolean;
+  useAmericanDate: boolean;
 }

+ 95 - 81
client/src/store/actions/app.ts

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

+ 243 - 203
client/src/store/actions/bookmark.ts

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

+ 11 - 6
client/src/store/actions/config.ts

@@ -3,16 +3,15 @@ import { Dispatch } from 'redux';
 import { ActionTypes } from './actionTypes';
 import { Config, ApiResponse, Query } from '../../interfaces';
 import { CreateNotificationAction } from './notification';
-import { searchConfig } from '../../utility';
 
 export interface GetConfigAction {
   type: ActionTypes.getConfig;
-  payload: Config[];
+  payload: Config;
 }
 
 export const getConfig = () => async (dispatch: Dispatch) => {
   try {
-    const res = await axios.get<ApiResponse<Config[]>>('/api/config');
+    const res = await axios.get<ApiResponse<Config>>('/api/config');
 
     dispatch<GetConfigAction>({
       type: ActionTypes.getConfig,
@@ -20,7 +19,10 @@ export const getConfig = () => async (dispatch: Dispatch) => {
     });
 
     // Set custom page title if set
-    document.title = searchConfig('customTitle', 'Flame');
+    document.title = res.data.data.customTitle;
+
+    // Store settings for priority UI elements
+    localStorage.setItem('useAmericanDate', `${res.data.data.useAmericanDate}`);
   } catch (err) {
     console.log(err);
   }
@@ -28,12 +30,12 @@ export const getConfig = () => async (dispatch: Dispatch) => {
 
 export interface UpdateConfigAction {
   type: ActionTypes.updateConfig;
-  payload: Config[];
+  payload: Config;
 }
 
 export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
   try {
-    const res = await axios.put<ApiResponse<Config[]>>('/api/config', formData);
+    const res = await axios.put<ApiResponse<Config>>('/api/config', formData);
 
     dispatch<CreateNotificationAction>({
       type: ActionTypes.createNotification,
@@ -47,6 +49,9 @@ export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
       type: ActionTypes.updateConfig,
       payload: res.data.data,
     });
+
+    // Store settings for priority UI elements
+    localStorage.setItem('useAmericanDate', `${res.data.data.useAmericanDate}`);
   } catch (err) {
     console.log(err);
   }

+ 3 - 2
client/src/store/reducers/config.ts

@@ -1,15 +1,16 @@
 import { ActionTypes, Action } from '../actions';
 import { Config, Query } from '../../interfaces';
+import { configTemplate } from '../../utility';
 
 export interface State {
   loading: boolean;
-  config: Config[];
+  config: Config;
   customQueries: Query[];
 }
 
 const initialState: State = {
   loading: true,
-  config: [],
+  config: configTemplate,
   customQueries: [],
 };
 

+ 13 - 11
client/src/store/reducers/theme.ts

@@ -7,20 +7,22 @@ export interface State {
 
 const initialState: State = {
   theme: {
-    name: 'blues',
+    name: 'tron',
     colors: {
-      background: '#2B2C56',
-      primary: '#EFF1FC',
-      accent: '#6677EB'
-    }
-  }
-}
+      background: '#242B33',
+      primary: '#EFFBFF',
+      accent: '#6EE2FF',
+    },
+  },
+};
 
 const themeReducer = (state = initialState, action: Action) => {
   switch (action.type) {
-    case ActionTypes.setTheme: return { theme: action.payload };
-    default: return state;
+    case ActionTypes.setTheme:
+      return { theme: action.payload };
+    default:
+      return state;
   }
-}
+};
 
-export default themeReducer;
+export default themeReducer;

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

@@ -1,7 +1,8 @@
 export * from './iconParser';
 export * from './urlParser';
-export * from './searchConfig';
 export * from './checkVersion';
 export * from './sortData';
 export * from './searchParser';
 export * from './redirectUrl';
+export * from './templateObjects';
+export * from './inputHandler';

+ 39 - 0
client/src/utility/inputHandler.ts

@@ -0,0 +1,39 @@
+import { ChangeEvent, SetStateAction } from 'react';
+
+type Event = ChangeEvent<HTMLInputElement | HTMLSelectElement>;
+
+interface Options {
+  isNumber?: boolean;
+  isBool?: boolean;
+}
+
+interface Params<T> {
+  e: Event;
+  options?: Options;
+  setStateHandler: (v: SetStateAction<T>) => void;
+  state: T;
+}
+
+export const inputHandler = <T>(params: Params<T>): void => {
+  const { e, options, setStateHandler, state } = params;
+
+  const rawValue = e.target.value;
+  let value: string | number | boolean = e.target.value;
+
+  if (options) {
+    const { isNumber = false, isBool = false } = options;
+
+    if (isNumber) {
+      value = parseFloat(rawValue);
+    }
+
+    if (isBool) {
+      value = !!parseInt(rawValue);
+    }
+  }
+
+  setStateHandler({
+    ...state,
+    [e.target.name]: value,
+  });
+};

+ 0 - 24
client/src/utility/searchConfig.ts

@@ -1,24 +0,0 @@
-import { store } from '../store/store';
-
-/**
- * Search config store with given key
- * @param key Config pair key to search
- * @param _default Value to return if key is not found
- */
-export const searchConfig = (key: string, _default: any) => {
-  const state = store.getState();
-
-  const pair = state.config.config.find(p => p.key === key);
-
-  if (pair) {
-    if (pair.valueType === 'number') {
-      return parseFloat(pair.value);
-    } else if (pair.valueType === 'boolean') {
-      return parseInt(pair.value);
-    } else {
-      return pair.value;
-    }
-  }
-  
-  return _default;
-}

+ 4 - 7
client/src/utility/searchParser.ts

@@ -1,7 +1,6 @@
 import { queries } from './searchQueries.json';
 import { Query, SearchResult } from '../interfaces';
 import { store } from '../store/store';
-import { searchConfig } from '.';
 
 export const searchParser = (searchQuery: string): SearchResult => {
   const result: SearchResult = {
@@ -16,20 +15,18 @@ export const searchParser = (searchQuery: string): SearchResult => {
     },
   };
 
-  const customQueries = store.getState().config.customQueries;
+  const { customQueries, config } = store.getState().config;
 
   // Check if url or ip was passed
   const urlRegex =
-    /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/;
+    /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/i;
 
   result.isURL = urlRegex.test(searchQuery);
 
   // Match prefix and query
   const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
 
-  const prefix = splitQuery
-    ? splitQuery[1]
-    : searchConfig('defaultSearchProvider', 'l');
+  const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
 
   const search = splitQuery
     ? encodeURIComponent(splitQuery[2])
@@ -47,7 +44,7 @@ export const searchParser = (searchQuery: string): SearchResult => {
     if (prefix === 'l') {
       result.isLocal = true;
     } else {
-      result.sameTab = searchConfig('searchSameTab', false);
+      result.sameTab = config.searchSameTab;
     }
 
     return result;

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

@@ -0,0 +1,25 @@
+import { Config } from '../../interfaces';
+
+export const configTemplate: Config = {
+  WEATHER_API_KEY: '',
+  lat: 0,
+  long: 0,
+  isCelsius: true,
+  customTitle: 'Flame',
+  pinAppsByDefault: true,
+  pinCategoriesByDefault: true,
+  hideHeader: false,
+  useOrdering: 'createdAt',
+  appsSameTab: false,
+  bookmarksSameTab: false,
+  searchSameTab: false,
+  hideApps: false,
+  hideCategories: false,
+  hideSearch: false,
+  defaultSearchProvider: 'l',
+  dockerApps: false,
+  dockerHost: 'localhost',
+  kubernetesApps: false,
+  unpinStoppedApps: false,
+  useAmericanDate: false,
+};

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

@@ -0,0 +1,2 @@
+export * from './configTemplate';
+export * from './settingsTemplate';

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

@@ -0,0 +1,31 @@
+import { OtherSettingsForm, SearchForm, WeatherForm } from '../../interfaces';
+
+export const otherSettingsTemplate: OtherSettingsForm = {
+  customTitle: document.title,
+  pinAppsByDefault: true,
+  pinCategoriesByDefault: true,
+  hideHeader: false,
+  hideApps: false,
+  hideCategories: false,
+  useOrdering: 'createdAt',
+  appsSameTab: false,
+  bookmarksSameTab: false,
+  dockerApps: true,
+  dockerHost: 'localhost',
+  kubernetesApps: true,
+  unpinStoppedApps: true,
+  useAmericanDate: false,
+};
+
+export const weatherSettingsTemplate: WeatherForm = {
+  WEATHER_API_KEY: '',
+  lat: 0,
+  long: 0,
+  isCelsius: true,
+};
+
+export const searchSettingsTemplate: SearchForm = {
+  hideSearch: false,
+  searchSameTab: false,
+  defaultSearchProvider: 'l',
+};

+ 0 - 326
controllers/apps.js

@@ -1,326 +0,0 @@
-const asyncWrapper = require('../middleware/asyncWrapper');
-const ErrorResponse = require('../utils/ErrorResponse');
-const App = require('../models/App');
-const Config = require('../models/Config');
-const { Sequelize } = require('sequelize');
-const axios = require('axios');
-const Logger = require('../utils/Logger');
-const logger = new Logger();
-const k8s = require('@kubernetes/client-node');
-
-// @desc      Create new app
-// @route     POST /api/apps
-// @access    Public
-exports.createApp = asyncWrapper(async (req, res, next) => {
-  // Get config from database
-  const pinApps = await Config.findOne({
-    where: { key: 'pinAppsByDefault' },
-  });
-
-  let app;
-  let _body = { ...req.body };
-
-  if (req.file) {
-    _body.icon = req.file.filename;
-  }
-
-  if (pinApps) {
-    if (parseInt(pinApps.value)) {
-      app = await App.create({
-        ..._body,
-        isPinned: true,
-      });
-    } else {
-      app = await App.create(req.body);
-    }
-  }
-
-  res.status(201).json({
-    success: true,
-    data: app,
-  });
-});
-
-// @desc      Get all apps
-// @route     GET /api/apps
-// @access    Public
-exports.getApps = asyncWrapper(async (req, res, next) => {
-  // Get config from database
-  const useOrdering = await Config.findOne({
-    where: { key: 'useOrdering' },
-  });
-  const useDockerApi = await Config.findOne({
-    where: { key: 'dockerApps' },
-  });
-  const useKubernetesApi = await Config.findOne({
-    where: { key: 'kubernetesApps' },
-  });
-  const unpinStoppedApps = await Config.findOne({
-    where: { key: 'unpinStoppedApps' },
-  });
-
-  const orderType = useOrdering ? useOrdering.value : 'createdAt';
-  let apps;
-
-  if (useDockerApi && useDockerApi.value == 1) {
-    let containers = null;
-
-    const host = await Config.findOne({
-      where: { key: 'dockerHost' },
-    });
-
-    try {
-      if (host.value.includes('localhost')) {
-        let { data } = await axios.get(
-          `http://${host.value}/containers/json?{"status":["running"]}`,
-          {
-            socketPath: '/var/run/docker.sock',
-          }
-        );
-        containers = data;
-      } else {
-        let { data } = await axios.get(
-          `http://${host.value}/containers/json?{"status":["running"]}`
-        );
-        containers = data;
-      }
-    } catch {
-      logger.log(`Can't connect to the docker api on ${host.value}`, 'ERROR');
-    }
-
-    if (containers) {
-      apps = await App.findAll({
-        order: [[orderType, 'ASC']],
-      });
-
-      containers = containers.filter((e) => Object.keys(e.Labels).length !== 0);
-      const dockerApps = [];
-      for (const container of containers) {
-        const labels = container.Labels;
-
-        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',
-            });
-          }
-        }
-      }
-
-      if (unpinStoppedApps && unpinStoppedApps.value == 1) {
-        for (const app of apps) {
-          await app.update({ isPinned: false });
-        }
-      }
-
-      for (const item of dockerApps) {
-        if (apps.some((app) => app.name === item.name)) {
-          const app = apps.filter((e) => e.name === item.name)[0];
-
-          if (
-            item.icon === 'custom' ||
-            (item.icon === 'docker' && app.icon != 'docker')
-          ) {
-            await app.update({
-              name: item.name,
-              url: item.url,
-              isPinned: true,
-            });
-          } else {
-            await app.update({
-              name: item.name,
-              url: item.url,
-              icon: item.icon,
-              isPinned: true,
-            });
-          }
-        } else {
-          await App.create({
-            name: item.name,
-            url: item.url,
-            icon: item.icon === 'custom' ? 'docker' : item.icon,
-            isPinned: true,
-          });
-        }
-      }
-    }
-  }
-
-  if (useKubernetesApi && useKubernetesApi.value == 1) {
-    let ingresses = null;
-
-    try {
-      const kc = new k8s.KubeConfig();
-      kc.loadFromCluster();
-      const k8sNetworkingV1Api = kc.makeApiClient(k8s.NetworkingV1Api);
-      await k8sNetworkingV1Api.listIngressForAllNamespaces().then((res) => {
-        ingresses = res.body.items;
-      });
-    } catch {
-      logger.log("Can't connect to the kubernetes api", 'ERROR');
-    }
-
-    if (ingresses) {
-      apps = await App.findAll({
-        order: [[orderType, 'ASC']],
-      });
-
-      ingresses = ingresses.filter(
-        (e) => Object.keys(e.metadata.annotations).length !== 0
-      );
-      const kubernetesApps = [];
-      for (const ingress of ingresses) {
-        const annotations = ingress.metadata.annotations;
-
-        if (
-          'flame.pawelmalak/name' in annotations &&
-          'flame.pawelmalak/url' in annotations &&
-          /^app/.test(annotations['flame.pawelmalak/type'])
-        ) {
-          kubernetesApps.push({
-            name: annotations['flame.pawelmalak/name'],
-            url: annotations['flame.pawelmalak/url'],
-            icon: annotations['flame.pawelmalak/icon'] || 'kubernetes',
-          });
-        }
-      }
-
-      if (unpinStoppedApps && unpinStoppedApps.value == 1) {
-        for (const app of apps) {
-          await app.update({ isPinned: false });
-        }
-      }
-
-      for (const item of kubernetesApps) {
-        if (apps.some((app) => app.name === item.name)) {
-          const app = apps.filter((e) => e.name === item.name)[0];
-          await app.update({ ...item, isPinned: true });
-        } else {
-          await App.create({
-            ...item,
-            isPinned: true,
-          });
-        }
-      }
-    }
-  }
-
-  if (orderType == 'name') {
-    apps = await App.findAll({
-      order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']],
-    });
-  } else {
-    apps = await App.findAll({
-      order: [[orderType, 'ASC']],
-    });
-  }
-
-  if (process.env.NODE_ENV === 'production') {
-    // Set header to fetch containers info every time
-    res.status(200).setHeader('Cache-Control', 'no-store').json({
-      success: true,
-      data: apps,
-    });
-    return;
-  }
-
-  res.status(200).json({
-    success: true,
-    data: apps,
-  });
-});
-
-// @desc      Get single app
-// @route     GET /api/apps/:id
-// @access    Public
-exports.getApp = asyncWrapper(async (req, res, next) => {
-  const app = await App.findOne({
-    where: { id: req.params.id },
-  });
-
-  if (!app) {
-    return next(
-      new ErrorResponse(`App with id of ${req.params.id} was not found`, 404)
-    );
-  }
-
-  res.status(200).json({
-    success: true,
-    data: app,
-  });
-});
-
-// @desc      Update app
-// @route     PUT /api/apps/:id
-// @access    Public
-exports.updateApp = asyncWrapper(async (req, res, next) => {
-  let app = await App.findOne({
-    where: { id: req.params.id },
-  });
-
-  if (!app) {
-    return next(
-      new ErrorResponse(`App with id of ${req.params.id} was not found`, 404)
-    );
-  }
-
-  let _body = { ...req.body };
-
-  if (req.file) {
-    _body.icon = req.file.filename;
-  }
-
-  app = await app.update(_body);
-
-  res.status(200).json({
-    success: true,
-    data: app,
-  });
-});
-
-// @desc      Delete app
-// @route     DELETE /api/apps/:id
-// @access    Public
-exports.deleteApp = asyncWrapper(async (req, res, next) => {
-  await App.destroy({
-    where: { id: req.params.id },
-  });
-
-  res.status(200).json({
-    success: true,
-    data: {},
-  });
-});
-
-// @desc      Reorder apps
-// @route     PUT /api/apps/0/reorder
-// @access    Public
-exports.reorderApps = asyncWrapper(async (req, res, next) => {
-  req.body.apps.forEach(async ({ id, orderId }) => {
-    await App.update(
-      { orderId },
-      {
-        where: { id },
-      }
-    );
-  });
-
-  res.status(200).json({
-    success: true,
-    data: {},
-  });
-});

+ 33 - 0
controllers/apps/createApp.js

@@ -0,0 +1,33 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const App = require('../../models/App');
+const loadConfig = require('../../utils/loadConfig');
+
+// @desc      Create new app
+// @route     POST /api/apps
+// @access    Public
+const createApp = asyncWrapper(async (req, res, next) => {
+  const { pinAppsByDefault } = await loadConfig();
+
+  let app;
+  let _body = { ...req.body };
+
+  if (req.file) {
+    _body.icon = req.file.filename;
+  }
+
+  if (pinAppsByDefault) {
+    app = await App.create({
+      ..._body,
+      isPinned: true,
+    });
+  } else {
+    app = await App.create(req.body);
+  }
+
+  res.status(201).json({
+    success: true,
+    data: app,
+  });
+});
+
+module.exports = createApp;

+ 18 - 0
controllers/apps/deleteApp.js

@@ -0,0 +1,18 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const App = require('../../models/App');
+
+// @desc      Delete app
+// @route     DELETE /api/apps/:id
+// @access    Public
+const deleteApp = asyncWrapper(async (req, res, next) => {
+  await App.destroy({
+    where: { id: req.params.id },
+  });
+
+  res.status(200).json({
+    success: true,
+    data: {},
+  });
+});
+
+module.exports = deleteApp;

+ 4 - 0
controllers/apps/docker/index.js

@@ -0,0 +1,4 @@
+module.exports = {
+  useKubernetes: require('./useKubernetes'),
+  useDocker: require('./useDocker'),
+};

+ 148 - 0
controllers/apps/docker/useDocker.js

@@ -0,0 +1,148 @@
+const App = require('../../../models/App');
+const axios = require('axios');
+const Logger = require('../../../utils/Logger');
+const logger = new Logger();
+const loadConfig = require('../../../utils/loadConfig');
+
+const useDocker = async (apps) => {
+  const {
+    useOrdering: orderType,
+    unpinStoppedApps,
+    dockerHost: host,
+  } = await loadConfig();
+
+  let containers = null;
+
+  // Get list of containers
+  try {
+    if (host.includes('localhost')) {
+      // Use default host
+      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(
+        `http://${host}/containers/json?{"status":["running"]}`
+      );
+
+      containers = data;
+    }
+  } catch {
+    logger.log(`Can't connect to the Docker API on ${host}`, 'ERROR');
+  }
+
+  if (containers) {
+    apps = await App.findAll({
+      order: [[orderType, 'ASC']],
+    });
+
+    // Filter out containers without any annotations
+    containers = containers.filter((e) => Object.keys(e.Labels).length !== 0);
+
+    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)) {
+            // 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]);
+              }
+
+              if (domains.length > 0) {
+                labels['flame.url'] = domains.join(';');
+              }
+            }
+          }
+        }
+      }
+
+      // 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',
+          });
+        }
+      }
+    }
+
+    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,
+          });
+        } else {
+          await app.update({
+            ...item,
+            isPinned: true,
+          });
+        }
+      } else {
+        // else create new app
+        await App.create({
+          ...item,
+          icon: item.icon === 'custom' ? 'docker' : item.icon,
+          isPinned: true,
+        });
+      }
+    }
+  }
+};
+
+module.exports = useDocker;

+ 70 - 0
controllers/apps/docker/useKubernetes.js

@@ -0,0 +1,70 @@
+const App = require('../../../models/App');
+const k8s = require('@kubernetes/client-node');
+const Logger = require('../../../utils/Logger');
+const logger = new Logger();
+const loadConfig = require('../../../utils/loadConfig');
+
+const useKubernetes = async (apps) => {
+  const { useOrdering: orderType, unpinStoppedApps } = await loadConfig();
+
+  let ingresses = null;
+
+  try {
+    const kc = new k8s.KubeConfig();
+    kc.loadFromCluster();
+    const k8sNetworkingV1Api = kc.makeApiClient(k8s.NetworkingV1Api);
+    await k8sNetworkingV1Api.listIngressForAllNamespaces().then((res) => {
+      ingresses = res.body.items;
+    });
+  } catch {
+    logger.log("Can't connect to the Kubernetes API", 'ERROR');
+  }
+
+  if (ingresses) {
+    apps = await App.findAll({
+      order: [[orderType, 'ASC']],
+    });
+
+    ingresses = ingresses.filter(
+      (e) => Object.keys(e.metadata.annotations).length !== 0
+    );
+
+    const kubernetesApps = [];
+
+    for (const ingress of ingresses) {
+      const annotations = ingress.metadata.annotations;
+
+      if (
+        'flame.pawelmalak/name' in annotations &&
+        'flame.pawelmalak/url' in annotations &&
+        /^app/.test(annotations['flame.pawelmalak/type'])
+      ) {
+        kubernetesApps.push({
+          name: annotations['flame.pawelmalak/name'],
+          url: annotations['flame.pawelmalak/url'],
+          icon: annotations['flame.pawelmalak/icon'] || 'kubernetes',
+        });
+      }
+    }
+
+    if (unpinStoppedApps) {
+      for (const app of apps) {
+        await app.update({ isPinned: false });
+      }
+    }
+
+    for (const item of kubernetesApps) {
+      if (apps.some((app) => app.name === item.name)) {
+        const app = apps.find((a) => a.name === item.name);
+        await app.update({ ...item, isPinned: true });
+      } else {
+        await App.create({
+          ...item,
+          isPinned: true,
+        });
+      }
+    }
+  }
+};
+
+module.exports = useKubernetes;

+ 52 - 0
controllers/apps/getAllApps.js

@@ -0,0 +1,52 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const App = require('../../models/App');
+const { Sequelize } = require('sequelize');
+const loadConfig = require('../../utils/loadConfig');
+
+const { useKubernetes, useDocker } = require('./docker');
+
+// @desc      Get all apps
+// @route     GET /api/apps
+// @access    Public
+const getAllApps = asyncWrapper(async (req, res, next) => {
+  const {
+    useOrdering: orderType,
+    dockerApps: useDockerAPI,
+    kubernetesApps: useKubernetesAPI,
+  } = await loadConfig();
+
+  let apps;
+
+  if (useDockerAPI) {
+    await useDocker(apps);
+  }
+
+  if (useKubernetesAPI) {
+    await useKubernetes(apps);
+  }
+
+  if (orderType == 'name') {
+    apps = await App.findAll({
+      order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']],
+    });
+  } else {
+    apps = await App.findAll({
+      order: [[orderType, 'ASC']],
+    });
+  }
+
+  if (process.env.NODE_ENV === 'production') {
+    // Set header to fetch containers info every time
+    return res.status(200).setHeader('Cache-Control', 'no-store').json({
+      success: true,
+      data: apps,
+    });
+  }
+
+  res.status(200).json({
+    success: true,
+    data: apps,
+  });
+});
+
+module.exports = getAllApps;

+ 27 - 0
controllers/apps/getSingleApp.js

@@ -0,0 +1,27 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const App = require('../../models/App');
+
+// @desc      Get single app
+// @route     GET /api/apps/:id
+// @access    Public
+const getSingleApp = asyncWrapper(async (req, res, next) => {
+  const app = await App.findOne({
+    where: { id: req.params.id },
+  });
+
+  if (!app) {
+    return next(
+      new ErrorResponse(
+        `App with the id of ${req.params.id} was not found`,
+        404
+      )
+    );
+  }
+
+  res.status(200).json({
+    success: true,
+    data: app,
+  });
+});
+
+module.exports = getSingleApp;

+ 8 - 0
controllers/apps/index.js

@@ -0,0 +1,8 @@
+module.exports = {
+  createApp: require('./createApp'),
+  getSingleApp: require('./getSingleApp'),
+  deleteApp: require('./deleteApp'),
+  updateApp: require('./updateApp'),
+  reorderApps: require('./reorderApps'),
+  getAllApps: require('./getAllApps'),
+};

+ 23 - 0
controllers/apps/reorderApps.js

@@ -0,0 +1,23 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const App = require('../../models/App');
+
+// @desc      Reorder apps
+// @route     PUT /api/apps/0/reorder
+// @access    Public
+const reorderApps = asyncWrapper(async (req, res, next) => {
+  req.body.apps.forEach(async ({ id, orderId }) => {
+    await App.update(
+      { orderId },
+      {
+        where: { id },
+      }
+    );
+  });
+
+  res.status(200).json({
+    success: true,
+    data: {},
+  });
+});
+
+module.exports = reorderApps;

+ 35 - 0
controllers/apps/updateApp.js

@@ -0,0 +1,35 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const App = require('../../models/App');
+
+// @desc      Update app
+// @route     PUT /api/apps/:id
+// @access    Public
+const updateApp = asyncWrapper(async (req, res, next) => {
+  let app = await App.findOne({
+    where: { id: req.params.id },
+  });
+
+  if (!app) {
+    return next(
+      new ErrorResponse(
+        `App with the id of ${req.params.id} was not found`,
+        404
+      )
+    );
+  }
+
+  let _body = { ...req.body };
+
+  if (req.file) {
+    _body.icon = req.file.filename;
+  }
+
+  app = await app.update(_body);
+
+  res.status(200).json({
+    success: true,
+    data: app,
+  });
+});
+
+module.exports = updateApp;

+ 0 - 112
controllers/bookmark.js

@@ -1,112 +0,0 @@
-const asyncWrapper = require('../middleware/asyncWrapper');
-const ErrorResponse = require('../utils/ErrorResponse');
-const Bookmark = require('../models/Bookmark');
-const { Sequelize } = require('sequelize');
-
-// @desc      Create new bookmark
-// @route     POST /api/bookmarks
-// @access    Public
-exports.createBookmark = asyncWrapper(async (req, res, next) => {
-  let bookmark;
-
-  let _body = {
-    ...req.body,
-    categoryId: parseInt(req.body.categoryId),
-  };
-
-  if (req.file) {
-    _body.icon = req.file.filename;
-  }
-
-  bookmark = await Bookmark.create(_body);
-
-  res.status(201).json({
-    success: true,
-    data: bookmark,
-  });
-});
-
-// @desc      Get all bookmarks
-// @route     GET /api/bookmarks
-// @access    Public
-exports.getBookmarks = asyncWrapper(async (req, res, next) => {
-  const bookmarks = await Bookmark.findAll({
-    order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']],
-  });
-
-  res.status(200).json({
-    success: true,
-    data: bookmarks,
-  });
-});
-
-// @desc      Get single bookmark
-// @route     GET /api/bookmarks/:id
-// @access    Public
-exports.getBookmark = asyncWrapper(async (req, res, next) => {
-  const bookmark = await Bookmark.findOne({
-    where: { id: req.params.id },
-  });
-
-  if (!bookmark) {
-    return next(
-      new ErrorResponse(
-        `Bookmark with id of ${req.params.id} was not found`,
-        404
-      )
-    );
-  }
-
-  res.status(200).json({
-    success: true,
-    data: bookmark,
-  });
-});
-
-// @desc      Update bookmark
-// @route     PUT /api/bookmarks/:id
-// @access    Public
-exports.updateBookmark = asyncWrapper(async (req, res, next) => {
-  let bookmark = await Bookmark.findOne({
-    where: { id: req.params.id },
-  });
-
-  if (!bookmark) {
-    return next(
-      new ErrorResponse(
-        `Bookmark with id of ${req.params.id} was not found`,
-        404
-      )
-    );
-  }
-
-  let _body = {
-    ...req.body,
-    categoryId: parseInt(req.body.categoryId),
-  };
-
-  if (req.file) {
-    _body.icon = req.file.filename;
-  }
-
-  bookmark = await bookmark.update(_body);
-
-  res.status(200).json({
-    success: true,
-    data: bookmark,
-  });
-});
-
-// @desc      Delete bookmark
-// @route     DELETE /api/bookmarks/:id
-// @access    Public
-exports.deleteBookmark = asyncWrapper(async (req, res, next) => {
-  await Bookmark.destroy({
-    where: { id: req.params.id },
-  });
-
-  res.status(200).json({
-    success: true,
-    data: {},
-  });
-});

+ 27 - 0
controllers/bookmarks/createBookmark.js

@@ -0,0 +1,27 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const Bookmark = require('../../models/Bookmark');
+
+// @desc      Create new bookmark
+// @route     POST /api/bookmarks
+// @access    Public
+const createBookmark = asyncWrapper(async (req, res, next) => {
+  let bookmark;
+
+  let _body = {
+    ...req.body,
+    categoryId: parseInt(req.body.categoryId),
+  };
+
+  if (req.file) {
+    _body.icon = req.file.filename;
+  }
+
+  bookmark = await Bookmark.create(_body);
+
+  res.status(201).json({
+    success: true,
+    data: bookmark,
+  });
+});
+
+module.exports = createBookmark;

+ 18 - 0
controllers/bookmarks/deleteBookmark.js

@@ -0,0 +1,18 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const Bookmark = require('../../models/Bookmark');
+
+// @desc      Delete bookmark
+// @route     DELETE /api/bookmarks/:id
+// @access    Public
+const deleteBookmark = asyncWrapper(async (req, res, next) => {
+  await Bookmark.destroy({
+    where: { id: req.params.id },
+  });
+
+  res.status(200).json({
+    success: true,
+    data: {},
+  });
+});
+
+module.exports = deleteBookmark;

+ 19 - 0
controllers/bookmarks/getAllBookmarks.js

@@ -0,0 +1,19 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const Bookmark = require('../../models/Bookmark');
+const { Sequelize } = require('sequelize');
+
+// @desc      Get all bookmarks
+// @route     GET /api/bookmarks
+// @access    Public
+const getAllBookmarks = asyncWrapper(async (req, res, next) => {
+  const bookmarks = await Bookmark.findAll({
+    order: [[Sequelize.fn('lower', Sequelize.col('name')), 'ASC']],
+  });
+
+  res.status(200).json({
+    success: true,
+    data: bookmarks,
+  });
+});
+
+module.exports = getAllBookmarks;

+ 28 - 0
controllers/bookmarks/getSingleBookmark.js

@@ -0,0 +1,28 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const ErrorResponse = require('../../utils/ErrorResponse');
+const Bookmark = require('../../models/Bookmark');
+
+// @desc      Get single bookmark
+// @route     GET /api/bookmarks/:id
+// @access    Public
+const getSingleBookmark = asyncWrapper(async (req, res, next) => {
+  const bookmark = await Bookmark.findOne({
+    where: { id: req.params.id },
+  });
+
+  if (!bookmark) {
+    return next(
+      new ErrorResponse(
+        `Bookmark with the id of ${req.params.id} was not found`,
+        404
+      )
+    );
+  }
+
+  res.status(200).json({
+    success: true,
+    data: bookmark,
+  });
+});
+
+module.exports = getSingleBookmark;

+ 7 - 0
controllers/bookmarks/index.js

@@ -0,0 +1,7 @@
+module.exports = {
+  createBookmark: require('./createBookmark'),
+  getAllBookmarks: require('./getAllBookmarks'),
+  getSingleBookmark: require('./getSingleBookmark'),
+  updateBookmark: require('./updateBookmark'),
+  deleteBookmark: require('./deleteBookmark'),
+};

+ 39 - 0
controllers/bookmarks/updateBookmark.js

@@ -0,0 +1,39 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const ErrorResponse = require('../../utils/ErrorResponse');
+const Bookmark = require('../../models/Bookmark');
+
+// @desc      Update bookmark
+// @route     PUT /api/bookmarks/:id
+// @access    Public
+const updateBookmark = asyncWrapper(async (req, res, next) => {
+  let bookmark = await Bookmark.findOne({
+    where: { id: req.params.id },
+  });
+
+  if (!bookmark) {
+    return next(
+      new ErrorResponse(
+        `Bookmark with id of ${req.params.id} was not found`,
+        404
+      )
+    );
+  }
+
+  let _body = {
+    ...req.body,
+    categoryId: parseInt(req.body.categoryId),
+  };
+
+  if (req.file) {
+    _body.icon = req.file.filename;
+  }
+
+  bookmark = await bookmark.update(_body);
+
+  res.status(200).json({
+    success: true,
+    data: bookmark,
+  });
+});
+
+module.exports = updateBookmark;

+ 9 - 17
controllers/category.js

@@ -4,27 +4,23 @@ const Category = require('../models/Category');
 const Bookmark = require('../models/Bookmark');
 const Config = require('../models/Config');
 const { Sequelize } = require('sequelize');
+const loadConfig = require('../utils/loadConfig');
 
 // @desc      Create new category
 // @route     POST /api/categories
 // @access    Public
 exports.createCategory = asyncWrapper(async (req, res, next) => {
-  // Get config from database
-  const pinCategories = await Config.findOne({
-    where: { key: 'pinCategoriesByDefault' },
-  });
+  const { pinCategoriesByDefault: pinCategories } = await loadConfig();
 
   let category;
 
   if (pinCategories) {
-    if (parseInt(pinCategories.value)) {
-      category = await Category.create({
-        ...req.body,
-        isPinned: true,
-      });
-    } else {
-      category = await Category.create(req.body);
-    }
+    category = await Category.create({
+      ...req.body,
+      isPinned: true,
+    });
+  } else {
+    category = await Category.create(req.body);
   }
 
   res.status(201).json({
@@ -37,12 +33,8 @@ exports.createCategory = asyncWrapper(async (req, res, next) => {
 // @route     GET /api/categories
 // @access    Public
 exports.getCategories = asyncWrapper(async (req, res, next) => {
-  // Get config from database
-  const useOrdering = await Config.findOne({
-    where: { key: 'useOrdering' },
-  });
+  const { useOrdering: orderType } = await loadConfig();
 
-  const orderType = useOrdering ? useOrdering.value : 'createdAt';
   let categories;
 
   if (orderType == 'name') {

+ 0 - 177
controllers/config.js

@@ -1,177 +0,0 @@
-const asyncWrapper = require('../middleware/asyncWrapper');
-const ErrorResponse = require('../utils/ErrorResponse');
-const Config = require('../models/Config');
-const { Op } = require('sequelize');
-const File = require('../utils/File');
-const { join } = require('path');
-const fs = require('fs');
-
-// @desc      Insert new key:value pair
-// @route     POST /api/config
-// @access    Public
-exports.createPair = asyncWrapper(async (req, res, next) => {
-  const pair = await Config.create(req.body);
-
-  res.status(201).json({
-    success: true,
-    data: pair,
-  });
-});
-
-// @desc      Get all key:value pairs
-// @route     GET /api/config
-// @route     GET /api/config?keys=foo,bar,baz
-// @access    Public
-exports.getAllPairs = asyncWrapper(async (req, res, next) => {
-  let pairs;
-
-  if (req.query.keys) {
-    // Check for specific keys to get in a single query
-    const keys = req.query.keys.split(',').map((key) => {
-      return { key };
-    });
-
-    pairs = await Config.findAll({
-      where: {
-        [Op.or]: keys,
-      },
-    });
-  } else {
-    // Else get all
-    pairs = await Config.findAll();
-  }
-
-  res.status(200).json({
-    success: true,
-    data: pairs,
-  });
-});
-
-// @desc      Get single key:value pair
-// @route     GET /api/config/:key
-// @access    Public
-exports.getSinglePair = asyncWrapper(async (req, res, next) => {
-  const pair = await Config.findOne({
-    where: { key: req.params.key },
-  });
-
-  if (!pair) {
-    return next(new ErrorResponse(`Key ${req.params.key} was not found`, 404));
-  }
-
-  res.status(200).json({
-    success: true,
-    data: pair,
-  });
-});
-
-// @desc      Update value
-// @route     PUT /api/config/:key
-// @access    Public
-exports.updateValue = asyncWrapper(async (req, res, next) => {
-  let pair = await Config.findOne({
-    where: { key: req.params.key },
-  });
-
-  if (!pair) {
-    return next(new ErrorResponse(`Key ${req.params.key} was not found`, 404));
-  }
-
-  if (pair.isLocked) {
-    return next(
-      new ErrorResponse(
-        `Value of key ${req.params.key} is locked and can not be changed`,
-        400
-      )
-    );
-  }
-
-  pair = await pair.update({ ...req.body });
-
-  res.status(200).json({
-    success: true,
-    data: pair,
-  });
-});
-
-// @desc      Update multiple values
-// @route     PUT /api/config/
-// @access    Public
-exports.updateValues = asyncWrapper(async (req, res, next) => {
-  Object.entries(req.body).forEach(async ([key, value]) => {
-    await Config.update(
-      { value },
-      {
-        where: { key },
-      }
-    );
-  });
-
-  const config = await Config.findAll();
-
-  res.status(200).send({
-    success: true,
-    data: config,
-  });
-});
-
-// @desc      Delete key:value pair
-// @route     DELETE /api/config/:key
-// @access    Public
-exports.deletePair = asyncWrapper(async (req, res, next) => {
-  const pair = await Config.findOne({
-    where: { key: req.params.key },
-  });
-
-  if (!pair) {
-    return next(new ErrorResponse(`Key ${req.params.key} was not found`, 404));
-  }
-
-  if (pair.isLocked) {
-    return next(
-      new ErrorResponse(
-        `Value of key ${req.params.key} is locked and can not be deleted`,
-        400
-      )
-    );
-  }
-
-  await pair.destroy();
-
-  res.status(200).json({
-    success: true,
-    data: {},
-  });
-});
-
-// @desc      Get custom CSS file
-// @route     GET /api/config/0/css
-// @access    Public
-exports.getCss = asyncWrapper(async (req, res, next) => {
-  const file = new File(join(__dirname, '../public/flame.css'));
-  const content = file.read();
-
-  res.status(200).json({
-    success: true,
-    data: content,
-  });
-});
-
-// @desc      Update custom CSS file
-// @route     PUT /api/config/0/css
-// @access    Public
-exports.updateCss = asyncWrapper(async (req, res, next) => {
-  const file = new File(join(__dirname, '../public/flame.css'));
-  file.write(req.body.styles, false);
-
-  // Copy file to docker volume
-  fs.copyFileSync(
-    join(__dirname, '../public/flame.css'),
-    join(__dirname, '../data/flame.css')
-  );
-
-  res.status(200).json({
-    success: true,
-    data: {},
-  });
-});

+ 18 - 0
controllers/config/getCSS.js

@@ -0,0 +1,18 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const File = require('../../utils/File');
+const { join } = require('path');
+
+// @desc      Get custom CSS file
+// @route     GET /api/config/0/css
+// @access    Public
+const getCSS = asyncWrapper(async (req, res, next) => {
+  const file = new File(join(__dirname, '../../public/flame.css'));
+  const content = file.read();
+
+  res.status(200).json({
+    success: true,
+    data: content,
+  });
+});
+
+module.exports = getCSS;

+ 16 - 0
controllers/config/getConfig.js

@@ -0,0 +1,16 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const loadConfig = require('../../utils/loadConfig');
+
+// @desc      Get config
+// @route     GET /api/config
+// @access    Public
+const getConfig = asyncWrapper(async (req, res, next) => {
+  const config = await loadConfig();
+
+  res.status(200).json({
+    success: true,
+    data: config,
+  });
+});
+
+module.exports = getConfig;

+ 6 - 0
controllers/config/index.js

@@ -0,0 +1,6 @@
+module.exports = {
+  getCSS: require('./getCSS'),
+  updateCSS: require('./updateCSS'),
+  getConfig: require('./getConfig'),
+  updateConfig: require('./updateConfig'),
+};

+ 24 - 0
controllers/config/updateCSS.js

@@ -0,0 +1,24 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const File = require('../../utils/File');
+const { join } = require('path');
+
+// @desc      Update custom CSS file
+// @route     PUT /api/config/0/css
+// @access    Public
+const updateCSS = asyncWrapper(async (req, res, next) => {
+  const file = new File(join(__dirname, '../../public/flame.css'));
+  file.write(req.body.styles, false);
+
+  // Copy file to docker volume
+  fs.copyFileSync(
+    join(__dirname, '../../public/flame.css'),
+    join(__dirname, '../../data/flame.css')
+  );
+
+  res.status(200).json({
+    success: true,
+    data: {},
+  });
+});
+
+module.exports = updateCSS;

+ 24 - 0
controllers/config/updateConfig.js

@@ -0,0 +1,24 @@
+const asyncWrapper = require('../../middleware/asyncWrapper');
+const loadConfig = require('../../utils/loadConfig');
+const { writeFile } = require('fs/promises');
+
+// @desc      Update config
+// @route     PUT /api/config/
+// @access    Public
+const updateConfig = asyncWrapper(async (req, res, next) => {
+  const existingConfig = await loadConfig();
+
+  const newConfig = {
+    ...existingConfig,
+    ...req.body,
+  };
+
+  await writeFile('data/config.json', JSON.stringify(newConfig));
+
+  res.status(200).send({
+    success: true,
+    data: newConfig,
+  });
+});
+
+module.exports = updateConfig;

+ 0 - 1
db/index.js

@@ -1,6 +1,5 @@
 const { Sequelize } = require('sequelize');
 const { join } = require('path');
-const fs = require('fs');
 const Umzug = require('umzug');
 const backupDB = require('./utils/backupDb');
 

+ 37 - 0
db/migrations/01_new-config.js

@@ -0,0 +1,37 @@
+const { readFile, writeFile, copyFile } = require('fs/promises');
+const Config = require('../../models/Config');
+
+const up = async (query) => {
+  await copyFile('utils/init/initialConfig.json', 'data/config.json');
+
+  const initConfigFile = await readFile('data/config.json', 'utf-8');
+  const parsedNewConfig = JSON.parse(initConfigFile);
+
+  const existingConfig = await Config.findAll({ raw: true });
+
+  for (let pair of existingConfig) {
+    const { key, value, valueType } = pair;
+
+    let newValue = value;
+
+    if (valueType == 'number') {
+      newValue = parseFloat(value);
+    } else if (valueType == 'boolean') {
+      newValue = value == 1;
+    }
+
+    parsedNewConfig[key] = newValue;
+  }
+
+  const newConfig = JSON.stringify(parsedNewConfig);
+  await writeFile('data/config.json', newConfig);
+
+  await query.dropTable('config');
+};
+
+const down = async (query) => {};
+
+module.exports = {
+  up,
+  down,
+};

+ 3 - 13
middleware/asyncWrapper.js

@@ -1,17 +1,7 @@
-// const asyncWrapper = foo => (req, res, next) => {
-//   return Promise
-//     .resolve(foo(req, res, next))
-//     .catch(next);
-// }
-
-// module.exports = asyncWrapper;
-
 function asyncWrapper(foo) {
   return function (req, res, next) {
-    return Promise
-      .resolve(foo(req, res, next))
-      .catch(next);
-  }
+    return Promise.resolve(foo(req, res, next)).catch(next);
+  };
 }
 
-module.exports = asyncWrapper;
+module.exports = asyncWrapper;

+ 8 - 4
middleware/errorHandler.js

@@ -14,10 +14,14 @@ const errorHandler = (err, req, res, next) => {
 
   logger.log(error.message.split(',')[0], 'ERROR');
 
+  if (process.env.NODE_ENV == 'development') {
+    console.log(err);
+  }
+
   res.status(err.statusCode || 500).json({
     success: false,
-    error: error.message || 'Server Error'
-  })
-}
+    error: error.message || 'Server Error',
+  });
+};
 
-module.exports = errorHandler;
+module.exports = errorHandler;

+ 1 - 1
middleware/multer.js

@@ -11,7 +11,7 @@ const storage = multer.diskStorage({
   },
   filename: (req, file, cb) => {
     cb(null, Date.now() + '--' + file.originalname);
-  }
+  },
 });
 
 const supportedTypes = ['jpg', 'jpeg', 'png', 'svg', 'svg+xml'];

+ 7 - 16
routes/apps.js

@@ -4,26 +4,17 @@ const upload = require('../middleware/multer');
 
 const {
   createApp,
-  getApps,
-  getApp,
+  getAllApps,
+  getSingleApp,
   updateApp,
   deleteApp,
-  reorderApps
+  reorderApps,
 } = require('../controllers/apps');
 
-router
-  .route('/')
-  .post(upload, createApp)
-  .get(getApps);
+router.route('/').post(upload, createApp).get(getAllApps);
 
-router
-  .route('/:id')
-  .get(getApp)
-  .put(upload, updateApp)
-  .delete(deleteApp);
+router.route('/:id').get(getSingleApp).put(upload, updateApp).delete(deleteApp);
 
-router
-  .route('/0/reorder')
-  .put(reorderApps);
+router.route('/0/reorder').put(reorderApps);
 
-module.exports = router;
+module.exports = router;

+ 7 - 10
routes/bookmark.js

@@ -4,21 +4,18 @@ const upload = require('../middleware/multer');
 
 const {
   createBookmark,
-  getBookmarks,
-  getBookmark,
+  getAllBookmarks,
+  getSingleBookmark,
   updateBookmark,
-  deleteBookmark
-} = require('../controllers/bookmark');
+  deleteBookmark,
+} = require('../controllers/bookmarks');
 
-router
-  .route('/')
-  .post(upload, createBookmark)
-  .get(getBookmarks);
+router.route('/').post(upload, createBookmark).get(getAllBookmarks);
 
 router
   .route('/:id')
-  .get(getBookmark)
+  .get(getSingleBookmark)
   .put(upload, updateBookmark)
   .delete(deleteBookmark);
 
-module.exports = router;
+module.exports = router;

+ 6 - 12
routes/config.js

@@ -2,20 +2,14 @@ const express = require('express');
 const router = express.Router();
 
 const {
-  createPair,
-  getAllPairs,
-  getSinglePair,
-  updateValue,
-  updateValues,
-  deletePair,
-  updateCss,
-  getCss,
+  getCSS,
+  updateCSS,
+  getConfig,
+  updateConfig,
 } = require('../controllers/config');
 
-router.route('/').post(createPair).get(getAllPairs).put(updateValues);
+router.route('/').get(getConfig).put(updateConfig);
 
-router.route('/:key').get(getSinglePair).put(updateValue).delete(deletePair);
-
-router.route('/0/css').get(getCss).put(updateCss);
+router.route('/0/css').get(getCSS).put(updateCSS);
 
 module.exports = router;

+ 10 - 0
utils/checkFileExists.js

@@ -0,0 +1,10 @@
+const fs = require('fs');
+
+const checkFileExists = (path) => {
+  return fs.promises
+    .access(path, fs.constants.F_OK)
+    .then(() => true)
+    .catch(() => false);
+};
+
+module.exports = checkFileExists;

+ 8 - 12
utils/getExternalWeather.js

@@ -1,15 +1,9 @@
-const Config = require('../models/Config');
 const Weather = require('../models/Weather');
 const axios = require('axios');
+const loadConfig = require('./loadConfig');
 
 const getExternalWeather = async () => {
-  // Get config from database
-  const config = await Config.findAll();
-
-  // Find and check values
-  const secret = config.find(pair => pair.key === 'WEATHER_API_KEY');
-  const lat = config.find(pair => pair.key === 'lat');
-  const long = config.find(pair => pair.key === 'long');
+  const { WEATHER_API_KEY: secret, lat, long } = await loadConfig();
 
   if (!secret) {
     throw new Error('API key was not found. Weather updated failed');
@@ -21,7 +15,9 @@ const getExternalWeather = async () => {
 
   // Fetch data from external API
   try {
-    const res = await axios.get(`http://api.weatherapi.com/v1/current.json?key=${secret.value}&q=${lat.value},${long.value}`);
+    const res = await axios.get(
+      `http://api.weatherapi.com/v1/current.json?key=${secret}&q=${lat},${long}`
+    );
 
     // Save weather data
     const cursor = res.data.current;
@@ -32,12 +28,12 @@ const getExternalWeather = async () => {
       isDay: cursor.is_day,
       cloud: cursor.cloud,
       conditionText: cursor.condition.text,
-      conditionCode: cursor.condition.code
+      conditionCode: cursor.condition.code,
     });
     return weatherData;
   } catch (err) {
     throw new Error('External API request failed');
   }
-}
+};
 
-module.exports = getExternalWeather;
+module.exports = getExternalWeather;

+ 1 - 1
utils/init/index.js

@@ -2,8 +2,8 @@ const initConfig = require('./initConfig');
 const initFiles = require('./initFiles');
 
 const initApp = async () => {
-  await initConfig();
   await initFiles();
+  await initConfig();
 };
 
 module.exports = initApp;

+ 15 - 29
utils/init/initConfig.js

@@ -1,39 +1,25 @@
-const { Op } = require('sequelize');
-const Config = require('../../models/Config');
-const { config } = require('./initialConfig.json');
-
-const Logger = require('../Logger');
-const logger = new Logger();
+const { copyFile, readFile, writeFile } = require('fs/promises');
+const checkFileExists = require('../checkFileExists');
+const initialConfig = require('./initialConfig.json');
 
 const initConfig = async () => {
-  // Get config values
-  const configPairs = await Config.findAll({
-    where: {
-      key: {
-        [Op.or]: config.map((pair) => pair.key),
-      },
-    },
-  });
+  const configExists = await checkFileExists('data/config.json');
+
+  if (!configExists) {
+    await copyFile('utils/init/initialConfig.json', 'data/config.json');
+  }
 
-  // Get key from each pair
-  const configKeys = configPairs.map((pair) => pair.key);
+  const existingConfig = await readFile('data/config.json', 'utf-8');
+  const parsedConfig = JSON.parse(existingConfig);
 
-  // Create missing pairs
-  config.forEach(async ({ key, value }) => {
-    if (!configKeys.includes(key)) {
-      await Config.create({
-        key,
-        value,
-        valueType: typeof value,
-      });
+  // Add new config pairs if necessary
+  for (let key in initialConfig) {
+    if (!Object.keys(parsedConfig).includes(key)) {
+      parsedConfig[key] = initialConfig[key];
     }
-  });
-
-  if (process.env.NODE_ENV == 'development') {
-    logger.log('Initial config created');
   }
 
-  return;
+  await writeFile('data/config.json', JSON.stringify(parsedConfig));
 };
 
 module.exports = initConfig;

+ 20 - 82
utils/init/initialConfig.json

@@ -1,84 +1,22 @@
 {
-  "config": [
-    {
-      "key": "WEATHER_API_KEY",
-      "value": ""
-    },
-    {
-      "key": "lat",
-      "value": 0
-    },
-    {
-      "key": "long",
-      "value": 0
-    },
-    {
-      "key": "isCelsius",
-      "value": true
-    },
-    {
-      "key": "customTitle",
-      "value": "Flame"
-    },
-    {
-      "key": "pinAppsByDefault",
-      "value": true
-    },
-    {
-      "key": "pinCategoriesByDefault",
-      "value": true
-    },
-    {
-      "key": "hideHeader",
-      "value": false
-    },
-    {
-      "key": "useOrdering",
-      "value": "createdAt"
-    },
-    {
-      "key": "appsSameTab",
-      "value": false
-    },
-    {
-      "key": "bookmarksSameTab",
-      "value": false
-    },
-    {
-      "key": "searchSameTab",
-      "value": false
-    },
-    {
-      "key": "hideApps",
-      "value": false
-    },
-    {
-      "key": "hideCategories",
-      "value": false
-    },
-    {
-      "key": "hideSearch",
-      "value": false
-    },
-    {
-      "key": "defaultSearchProvider",
-      "value": "l"
-    },
-    {
-      "key": "dockerApps",
-      "value": false
-    },
-    {
-      "key": "dockerHost",
-      "value": "localhost"
-    },
-    {
-      "key": "kubernetesApps",
-      "value": false
-    },
-    {
-      "key": "unpinStoppedApps",
-      "value": false
-    }
-  ]
+  "WEATHER_API_KEY": "",
+  "lat": 0,
+  "long": 0,
+  "isCelsius": true,
+  "customTitle": "Flame",
+  "pinAppsByDefault": true,
+  "pinCategoriesByDefault": true,
+  "hideHeader": false,
+  "useOrdering": "createdAt",
+  "appsSameTab": false,
+  "bookmarksSameTab": false,
+  "searchSameTab": false,
+  "hideApps": false,
+  "hideCategories": false,
+  "hideSearch": false,
+  "defaultSearchProvider": "l",
+  "dockerApps": false,
+  "dockerHost": "localhost",
+  "kubernetesApps": false,
+  "unpinStoppedApps": false
 }

+ 18 - 0
utils/loadConfig.js

@@ -0,0 +1,18 @@
+const { readFile } = require('fs/promises');
+const checkFileExists = require('../utils/checkFileExists');
+const initConfig = require('../utils/init/initConfig');
+
+const loadConfig = async () => {
+  const configExists = await checkFileExists('data/config.json');
+
+  if (!configExists) {
+    await initConfig();
+  }
+
+  const config = await readFile('data/config.json', 'utf-8');
+  const parsedConfig = JSON.parse(config);
+
+  return parsedConfig;
+};
+
+module.exports = loadConfig;