Przeglądaj źródła

refactor(dashboard): move from chakra-ui to tabler

Complete redesign of the dashboard to use tabler as CSS
Nicolas Meienberger 2 lat temu
rodzic
commit
59b12c2679
100 zmienionych plików z 1443 dodań i 1142 usunięć
  1. 26 5
      packages/dashboard/.eslintrc.js
  2. 15 17
      packages/dashboard/package.json
  3. 0 6
      packages/dashboard/postcss.config.js
  4. 1 0
      packages/dashboard/public/empty.svg
  5. BIN
      packages/dashboard/public/placeholder.png
  6. 3 0
      packages/dashboard/src/components/AppLogo/AppLogo.module.scss
  7. 6 5
      packages/dashboard/src/components/AppLogo/AppLogo.tsx
  8. 1 1
      packages/dashboard/src/components/AppLogo/index.tsx
  9. 3 0
      packages/dashboard/src/components/AppStatus/AppStatus.module.scss
  10. 20 0
      packages/dashboard/src/components/AppStatus/AppStatus.tsx
  11. 1 0
      packages/dashboard/src/components/AppStatus/index.tsx
  12. 0 33
      packages/dashboard/src/components/AppTile/AppStatus.tsx
  13. 3 0
      packages/dashboard/src/components/AppTile/AppTile.module.scss
  14. 40 0
      packages/dashboard/src/components/AppTile/AppTile.tsx
  15. 1 44
      packages/dashboard/src/components/AppTile/index.tsx
  16. 0 27
      packages/dashboard/src/components/Form/FormInput.tsx
  17. 0 23
      packages/dashboard/src/components/Form/FormSwitch.tsx
  18. 0 28
      packages/dashboard/src/components/Layout/Header.tsx
  19. 3 0
      packages/dashboard/src/components/Layout/Layout.module.scss
  20. 39 52
      packages/dashboard/src/components/Layout/Layout.tsx
  21. 0 25
      packages/dashboard/src/components/Layout/MenuDrawer.tsx
  22. 0 96
      packages/dashboard/src/components/Layout/SideMenu.tsx
  23. 0 36
      packages/dashboard/src/components/Layout/UpdateBanner.tsx
  24. 1 1
      packages/dashboard/src/components/Layout/index.ts
  25. 0 12
      packages/dashboard/src/components/LoadingScreen.tsx
  26. 25 24
      packages/dashboard/src/components/Markdown/Markdown.tsx
  27. 19 0
      packages/dashboard/src/components/StatusScreen/StatusScreen.tsx
  28. 1 0
      packages/dashboard/src/components/StatusScreen/index.ts
  29. 0 14
      packages/dashboard/src/components/StatusScreens/RestartingScreen.tsx
  30. 0 14
      packages/dashboard/src/components/StatusScreens/UpdatingScreen.tsx
  31. 29 0
      packages/dashboard/src/components/hoc/AuthProvider/AuthProvider.tsx
  32. 1 0
      packages/dashboard/src/components/hoc/AuthProvider/index.ts
  33. 9 21
      packages/dashboard/src/components/hoc/StatusProvider/StatusProvider.tsx
  34. 1 0
      packages/dashboard/src/components/hoc/StatusProvider/index.ts
  35. 24 0
      packages/dashboard/src/components/hoc/ToastProvider/ToastProvider.tsx
  36. 1 0
      packages/dashboard/src/components/hoc/ToastProvider/index.ts
  37. 21 0
      packages/dashboard/src/components/ui/Button/Button.tsx
  38. 1 0
      packages/dashboard/src/components/ui/Button/index.ts
  39. 13 0
      packages/dashboard/src/components/ui/DataGrid/DataGrid.tsx
  40. 13 0
      packages/dashboard/src/components/ui/DataGrid/DataGridItem.tsx
  41. 2 0
      packages/dashboard/src/components/ui/DataGrid/index.tsx
  42. 4 0
      packages/dashboard/src/components/ui/EmptyPage/EmptyPage.module.scss
  43. 27 0
      packages/dashboard/src/components/ui/EmptyPage/EmptyPage.tsx
  44. 1 0
      packages/dashboard/src/components/ui/EmptyPage/index.ts
  45. 4 0
      packages/dashboard/src/components/ui/Header/Header.module.scss
  46. 61 0
      packages/dashboard/src/components/ui/Header/Header.tsx
  47. 1 0
      packages/dashboard/src/components/ui/Header/index.ts
  48. 38 0
      packages/dashboard/src/components/ui/Input/Input.tsx
  49. 1 0
      packages/dashboard/src/components/ui/Input/index.ts
  50. 37 0
      packages/dashboard/src/components/ui/Modal/Modal.module.scss
  51. 49 0
      packages/dashboard/src/components/ui/Modal/Modal.tsx
  52. 9 0
      packages/dashboard/src/components/ui/Modal/ModalBody.tsx
  53. 7 0
      packages/dashboard/src/components/ui/Modal/ModalFooter.tsx
  54. 7 0
      packages/dashboard/src/components/ui/Modal/ModalHeader.tsx
  55. 4 0
      packages/dashboard/src/components/ui/Modal/index.ts
  56. 45 0
      packages/dashboard/src/components/ui/NavBar/NavBar.tsx
  57. 1 0
      packages/dashboard/src/components/ui/NavBar/index.ts
  58. 20 0
      packages/dashboard/src/components/ui/Switch/Switch.tsx
  59. 1 0
      packages/dashboard/src/components/ui/Switch/index.ts
  60. 18 0
      packages/dashboard/src/components/ui/Toast/Toast.module.scss
  61. 51 0
      packages/dashboard/src/components/ui/Toast/Toast.tsx
  62. 1 0
      packages/dashboard/src/components/ui/Toast/index.ts
  63. 1 1
      packages/dashboard/src/core/apollo/client.ts
  64. 1 0
      packages/dashboard/src/core/constants.ts
  65. 1 1
      packages/dashboard/src/core/helpers/url-helpers.ts
  66. 1 0
      packages/dashboard/src/generated/graphql.tsx
  67. 2 2
      packages/dashboard/src/hooks/useCachedRessources.ts
  68. 17 0
      packages/dashboard/src/hooks/useDisclosure.ts
  69. 0 38
      packages/dashboard/src/modules/AppStore/components/AppStoreTable.tsx
  70. 16 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.loading.tsx
  71. 27 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.tsx
  72. 1 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTable/index.ts
  73. 0 34
      packages/dashboard/src/modules/AppStore/components/AppStoreTile.tsx
  74. 17 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTile/AppStoreTile.loading.tsx
  75. 18 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTile/AppStoreTile.module.scss
  76. 33 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTile/AppStoreTile.tsx
  77. 1 0
      packages/dashboard/src/modules/AppStore/components/AppStoreTile/index.tsx
  78. 0 46
      packages/dashboard/src/modules/AppStore/components/CategorySelect.tsx
  79. 69 0
      packages/dashboard/src/modules/AppStore/components/CategorySelector/CategorySelector.tsx
  80. 1 0
      packages/dashboard/src/modules/AppStore/components/CategorySelector/index.ts
  81. 0 25
      packages/dashboard/src/modules/AppStore/components/FeaturedApps.tsx
  82. 0 35
      packages/dashboard/src/modules/AppStore/components/FeaturedCard.tsx
  83. 0 50
      packages/dashboard/src/modules/AppStore/containers/AppStoreContainer.tsx
  84. 16 0
      packages/dashboard/src/modules/AppStore/containers/AppStoreContainer/AppStoreContainer.tsx
  85. 1 0
      packages/dashboard/src/modules/AppStore/containers/AppStoreContainer/index.ts
  86. 28 21
      packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts
  87. 5 0
      packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.module.scss
  88. 36 0
      packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.tsx
  89. 1 0
      packages/dashboard/src/modules/AppStore/pages/AppStorePage/index.ts
  90. 25 0
      packages/dashboard/src/modules/AppStore/state/appStoreState.ts
  91. 18 38
      packages/dashboard/src/modules/Apps/components/AppActions.tsx
  92. 58 0
      packages/dashboard/src/modules/Apps/components/AppDetailsTabs.tsx
  93. 65 63
      packages/dashboard/src/modules/Apps/components/InstallForm.tsx
  94. 11 15
      packages/dashboard/src/modules/Apps/components/InstallModal.tsx
  95. 17 18
      packages/dashboard/src/modules/Apps/components/StopModal.tsx
  96. 21 18
      packages/dashboard/src/modules/Apps/components/UninstallModal.tsx
  97. 21 21
      packages/dashboard/src/modules/Apps/components/UpdateModal.tsx
  98. 11 15
      packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx
  99. 0 217
      packages/dashboard/src/modules/Apps/containers/AppDetails.tsx
  100. 193 0
      packages/dashboard/src/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

+ 26 - 5
packages/dashboard/.eslintrc.js

@@ -1,5 +1,17 @@
 module.exports = {
 module.exports = {
-  extends: ['next/core-web-vitals', 'airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
+  plugins: ['@typescript-eslint', 'import', 'react'],
+  extends: [
+    'plugin:@typescript-eslint/recommended',
+    'next/core-web-vitals',
+    'next',
+    // 'plugin:react-hooks/recommended',
+    'airbnb',
+    'airbnb-typescript',
+    'eslint:recommended',
+    'plugin:import/typescript',
+    'prettier',
+    'plugin:react/recommended',
+  ],
   parser: '@typescript-eslint/parser',
   parser: '@typescript-eslint/parser',
   parserOptions: {
   parserOptions: {
     ecmaVersion: 'latest',
     ecmaVersion: 'latest',
@@ -7,12 +19,21 @@ module.exports = {
     project: './tsconfig.json',
     project: './tsconfig.json',
     tsconfigRootDir: __dirname,
     tsconfigRootDir: __dirname,
   },
   },
-  plugins: ['@typescript-eslint', 'import'],
   rules: {
   rules: {
-    'arrow-body-style': 0,
+    // 'arrow-body-style': 0,
     'no-restricted-exports': 0,
     'no-restricted-exports': 0,
-    'max-len': [1, { code: 200 }],
-    'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
+    // 'max-len': [1, { code: 200 }],
+    // 'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
+    'react/display-name': 0,
+    'react/prop-types': 0,
+    'react/function-component-definition': 0,
+    'react/require-default-props': 0,
+    'import/prefer-default-export': 0,
+    'react/jsx-props-no-spreading': 0,
+    // '@typescript-eslint/no-misused-promises': 0,
+    // '@typescript-eslint/no-unsafe-assignment': 0,
+    'react/no-unused-prop-types': 0,
+    'react/button-has-type': 0,
   },
   },
   globals: {
   globals: {
     JSX: true,
     JSX: true,

+ 15 - 17
packages/dashboard/package.json

@@ -13,29 +13,28 @@
   },
   },
   "dependencies": {
   "dependencies": {
     "@apollo/client": "^3.6.8",
     "@apollo/client": "^3.6.8",
-    "@chakra-ui/react": "^2.1.2",
-    "@emotion/react": "^11",
-    "@emotion/styled": "^11",
-    "@fontsource/open-sans": "^4.5.8",
+    "@hookform/resolvers": "^2.9.10",
+    "@tabler/core": "1.0.0-beta16",
+    "@tabler/icons": "^1.109.0",
     "clsx": "^1.1.1",
     "clsx": "^1.1.1",
-    "final-form": "^4.20.6",
-    "framer-motion": "^6",
     "graphql": "^15.8.0",
     "graphql": "^15.8.0",
     "graphql-tag": "^2.12.6",
     "graphql-tag": "^2.12.6",
-    "next": "12.3.1",
-    "react": "18.1.0",
-    "react-dom": "18.1.0",
-    "react-final-form": "^6.5.9",
-    "react-icons": "^4.3.1",
+    "next": "13.0.3",
+    "react": "18.2.0",
+    "react-dom": "18.2.0",
+    "react-hook-form": "^7.38.0",
     "react-markdown": "^8.0.3",
     "react-markdown": "^8.0.3",
-    "react-select": "^5.3.2",
+    "react-select": "^5.6.1",
+    "react-tooltip": "^4.4.3",
     "remark-breaks": "^3.0.2",
     "remark-breaks": "^3.0.2",
     "remark-gfm": "^3.0.1",
     "remark-gfm": "^3.0.1",
     "remark-mdx": "^2.1.1",
     "remark-mdx": "^2.1.1",
+    "sass": "^1.55.0",
     "semver": "^7.3.7",
     "semver": "^7.3.7",
     "swr": "^1.3.0",
     "swr": "^1.3.0",
     "tslib": "^2.4.0",
     "tslib": "^2.4.0",
     "validator": "^13.7.0",
     "validator": "^13.7.0",
+    "zod": "^3.19.1",
     "zustand": "^3.7.2"
     "zustand": "^3.7.2"
   },
   },
   "devDependencies": {
   "devDependencies": {
@@ -44,23 +43,22 @@
     "@graphql-codegen/typescript": "^2.5.1",
     "@graphql-codegen/typescript": "^2.5.1",
     "@graphql-codegen/typescript-operations": "^2.4.2",
     "@graphql-codegen/typescript-operations": "^2.4.2",
     "@graphql-codegen/typescript-react-apollo": "^3.2.16",
     "@graphql-codegen/typescript-react-apollo": "^3.2.16",
-    "@types/js-cookie": "^3.0.2",
     "@types/node": "17.0.31",
     "@types/node": "17.0.31",
     "@types/react": "18.0.8",
     "@types/react": "18.0.8",
     "@types/react-dom": "18.0.3",
     "@types/react-dom": "18.0.3",
-    "@types/react-slick": "^0.23.8",
     "@types/semver": "^7.3.12",
     "@types/semver": "^7.3.12",
     "@types/validator": "^13.7.2",
     "@types/validator": "^13.7.2",
     "@typescript-eslint/eslint-plugin": "^5.18.0",
     "@typescript-eslint/eslint-plugin": "^5.18.0",
     "@typescript-eslint/parser": "^5.0.0",
     "@typescript-eslint/parser": "^5.0.0",
-    "autoprefixer": "^10.4.4",
     "eslint": "8.12.0",
     "eslint": "8.12.0",
+    "eslint-config-airbnb": "^19.0.4",
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-airbnb-typescript": "^17.0.0",
     "eslint-config-next": "12.1.4",
     "eslint-config-next": "12.1.4",
     "eslint-plugin-import": "^2.25.3",
     "eslint-plugin-import": "^2.25.3",
+    "eslint-plugin-jsx-a11y": "^6.6.1",
+    "eslint-plugin-react": "^7.31.10",
+    "eslint-plugin-react-hooks": "^4.6.0",
     "jest": "^28.1.0",
     "jest": "^28.1.0",
-    "postcss": "^8.4.12",
-    "tailwindcss": "^3.0.23",
     "ts-jest": "^28.0.2",
     "ts-jest": "^28.0.2",
     "typescript": "4.6.4"
     "typescript": "4.6.4"
   }
   }

+ 0 - 6
packages/dashboard/postcss.config.js

@@ -1,6 +0,0 @@
-module.exports = {
-  plugins: {
-    tailwindcss: {},
-    autoprefixer: {},
-  },
-}

+ 1 - 0
packages/dashboard/public/empty.svg

@@ -0,0 +1 @@
+<svg xmlns:xlink="http://www.w3.org/1999/xlink" class="ant-empty-img-simple" width="64" height="41" viewBox="0 0 64 41" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 1)" fill="none" fill-rule="evenodd"><ellipse class="ant-empty-img-simple-ellipse" cx="32" cy="33" rx="32" ry="7" fill="#F5F5F5"></ellipse><g class="ant-empty-img-simple-g" fill-rule="nonzero" stroke="#D9D9D9" fill="none"><path d="M55 12.76L44.854 1.258C44.367.474 43.656 0 42.907 0H21.093c-.749 0-1.46.474-1.947 1.257L9 12.761V22h46v-9.24z" stroke="#D9D9D9" fill="none"></path><path d="M41.613 15.931c0-1.605.994-2.93 2.227-2.931H55v18.137C55 33.26 53.68 35 52.05 35h-40.1C10.32 35 9 33.259 9 31.137V13h11.16c1.233 0 2.227 1.323 2.227 2.928v.022c0 1.605 1.005 2.901 2.237 2.901h14.752c1.232 0 2.237-1.308 2.237-2.913v-.007z" class="ant-empty-img-simple-path" stroke="#D9D9D9" fill="#FAFAFA"></path></g></g></svg>

BIN
packages/dashboard/public/placeholder.png


+ 3 - 0
packages/dashboard/src/components/AppLogo/AppLogo.module.scss

@@ -0,0 +1,3 @@
+.dropShadow {
+  filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.1)) drop-shadow(0 1px 1px rgb(0 0 0 / 0.06));
+}

+ 6 - 5
packages/dashboard/src/components/AppLogo/AppLogo.tsx

@@ -1,10 +1,13 @@
+import clsx from 'clsx';
 import React from 'react';
 import React from 'react';
+import { getUrl } from '../../core/helpers/url-helpers';
+import styles from './AppLogo.module.scss';
 
 
-const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
-  const logoUrl = `/api/apps/${id}/metadata/logo.jpg`;
+export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
+  const logoUrl = id ? `/api/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
 
 
   return (
   return (
-    <div aria-label={alt} className={`drop-shadow ${className}`} style={{ width: size, height: size }}>
+    <div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>
       <svg width={size} height={size} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
       <svg width={size} height={size} viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
         <mask id="mask0" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
         <mask id="mask0" maskUnits="userSpaceOnUse" x="0" y="0" width="200" height="200">
           <path fillRule="evenodd" clipRule="evenodd" d="M0 100C0 0 0 0 100 0S200 0 200 100 200 200 100 200 0 200 0 100" fill="white" />
           <path fillRule="evenodd" clipRule="evenodd" d="M0 100C0 0 0 0 100 0S200 0 200 100 200 200 100 200 0 200 0 100" fill="white" />
@@ -14,5 +17,3 @@ const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: s
     </div>
     </div>
   );
   );
 };
 };
-
-export default AppLogo;

+ 1 - 1
packages/dashboard/src/components/AppLogo/index.tsx

@@ -1 +1 @@
-export * from './AppLogo';
+export { AppLogo } from './AppLogo';

+ 3 - 0
packages/dashboard/src/components/AppStatus/AppStatus.module.scss

@@ -0,0 +1,3 @@
+.text {
+    margin-bottom: 1px;
+}

+ 20 - 0
packages/dashboard/src/components/AppStatus/AppStatus.tsx

@@ -0,0 +1,20 @@
+import clsx from 'clsx';
+import React from 'react';
+import styles from './AppStatus.module.scss';
+import { AppStatusEnum } from '../../generated/graphql';
+
+export const AppStatus: React.FC<{ status: AppStatusEnum; lite?: boolean }> = ({ status, lite }) => {
+  const formattedStatus = `${status[0]}${status.substring(1, status.length).toLowerCase()}`;
+
+  const classes = clsx('status-dot status-gray', {
+    'status-dot-animated status-green': status === AppStatusEnum.Running,
+    'status-red': status === AppStatusEnum.Stopped,
+  });
+
+  return (
+    <div data-place="top" data-tip={lite && formattedStatus} className="d-flex align-items-center">
+      <span className={classes} />
+      {!lite && <span className={clsx(styles.text, 'ms-2 text-muted')}>{formattedStatus}</span>}
+    </div>
+  );
+};

+ 1 - 0
packages/dashboard/src/components/AppStatus/index.tsx

@@ -0,0 +1 @@
+export { AppStatus } from './AppStatus';

+ 0 - 33
packages/dashboard/src/components/AppTile/AppStatus.tsx

@@ -1,33 +0,0 @@
-import React from 'react';
-import { FiPauseCircle, FiPlayCircle } from 'react-icons/fi';
-import { RiLoader4Line } from 'react-icons/ri';
-import { AppStatusEnum } from '../../generated/graphql';
-
-const AppStatus: React.FC<{ status: AppStatusEnum }> = ({ status }) => {
-  if (status === AppStatusEnum.Running) {
-    return (
-      <>
-        <FiPlayCircle className="text-green-500 mr-1" size={20} />
-        <span className="text-gray-400 text-sm">Running</span>
-      </>
-    );
-  }
-
-  if (status === AppStatusEnum.Stopped) {
-    return (
-      <>
-        <FiPauseCircle className="text-red-500 mr-1" size={20} />
-        <span className="text-gray-400 text-sm">Stopped</span>
-      </>
-    );
-  }
-
-  return (
-    <>
-      <RiLoader4Line className="text-gray-500 mr-1" size={20} />
-      <span className="text-gray-400 text-sm">{`${status[0]}${status.substring(1, status.length).toLowerCase()}...`}</span>
-    </>
-  );
-};
-
-export default AppStatus;

+ 3 - 0
packages/dashboard/src/components/AppTile/AppTile.module.scss

@@ -0,0 +1,3 @@
+.statusContainer {
+  margin-bottom: 4px;
+}

+ 40 - 0
packages/dashboard/src/components/AppTile/AppTile.tsx

@@ -0,0 +1,40 @@
+import Link from 'next/link';
+import React from 'react';
+import { IconDownload } from '@tabler/icons';
+import { AppStatus } from '../AppStatus';
+import { AppLogo } from '../AppLogo/AppLogo';
+import { limitText } from '../../modules/AppStore/helpers/table.helpers';
+import { AppInfo, AppStatusEnum } from '../../generated/graphql';
+import styles from './AppTile.module.scss';
+
+type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
+
+export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => (
+  <div className="col-sm-6 col-lg-4">
+    <div className="card card-sm card-link">
+      <Link href={`/apps/${app.id}`} className="nav-link" passHref>
+        <div className="card-body">
+          <div className="d-flex align-items-center">
+            <span className="me-3">
+              <AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
+            </span>
+            <div>
+              <div className="d-flex h-3 align-items-center">
+                <span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
+                <div className={styles.statusContainer}>
+                  <AppStatus lite status={status} />
+                </div>
+              </div>
+              <div className="text-muted">{limitText(app.short_desc, 50)}</div>
+            </div>
+          </div>
+        </div>
+        {updateAvailable && (
+          <div data-tip="Update available" className="ribbon bg-green ribbon-top">
+            <IconDownload />
+          </div>
+        )}
+      </Link>
+    </div>
+  </div>
+);

+ 1 - 44
packages/dashboard/src/components/AppTile/index.tsx

@@ -1,44 +1 @@
-import { Box, SlideFade, Tag, useColorModeValue, Tooltip } from '@chakra-ui/react';
-import Link from 'next/link';
-import React from 'react';
-import { FiChevronRight } from 'react-icons/fi';
-import { MdSystemUpdateAlt } from 'react-icons/md';
-import AppStatus from './AppStatus';
-import AppLogo from '../AppLogo/AppLogo';
-import { limitText } from '../../modules/AppStore/helpers/table.helpers';
-import { AppInfo, AppStatusEnum } from '../../generated/graphql';
-
-type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
-
-const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
-  const bg = useColorModeValue('white', '#1a202c');
-
-  return (
-    <Link href={`/apps/${app.id}`} passHref>
-      <SlideFade in className="flex flex-1" offsetY="20px">
-        <Box bg={bg} className="flex flex-1 border-2 drop-shadow-sm rounded-lg p-3 items-center cursor-pointer group hover:drop-shadow-md transition-all">
-          <AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={100} />
-          <div className="mr-3 flex-1">
-            <div className="flex">
-              <h3 className="font-bold text-xl mr-2">{app.name}</h3>
-              {updateAvailable && (
-                <Tooltip label="Update available">
-                  <Tag colorScheme="gray">
-                    <MdSystemUpdateAlt size={15} />
-                  </Tag>
-                </Tooltip>
-              )}
-            </div>
-            <span>{limitText(app.short_desc, 50)}</span>
-            <div className="flex mt-1">
-              <AppStatus status={status} />
-            </div>
-          </div>
-          <FiChevronRight className="text-slate-300" size={30} />
-        </Box>
-      </SlideFade>
-    </Link>
-  );
-};
-
-export default AppTile;
+export { AppTile } from './AppTile';

+ 0 - 27
packages/dashboard/src/components/Form/FormInput.tsx

@@ -1,27 +0,0 @@
-import React from 'react';
-import { Input } from '@chakra-ui/react';
-import clsx from 'clsx';
-
-interface IProps {
-  placeholder?: string;
-  error?: string;
-  type?: Parameters<typeof Input>[0]['type'];
-  label?: string;
-  className?: string;
-  isInvalid?: boolean;
-  size?: Parameters<typeof Input>[0]['size'];
-  hint?: string;
-}
-
-const FormInput: React.FC<IProps> = ({ placeholder, error, type, label, className, isInvalid, size, hint, ...rest }) => {
-  return (
-    <div className={clsx('transition-all', className)}>
-      {label && <label className="mb-1">{label}</label>}
-      {hint && <div className="text-sm text-gray-500 mb-1">{hint}</div>}
-      <Input type={type} placeholder={placeholder} isInvalid={isInvalid} size={size} {...rest} />
-      {isInvalid && <span className="text-red-500 text-sm">{error}</span>}
-    </div>
-  );
-};
-
-export default FormInput;

+ 0 - 23
packages/dashboard/src/components/Form/FormSwitch.tsx

@@ -1,23 +0,0 @@
-import React from 'react';
-import { Input, Switch } from '@chakra-ui/react';
-import clsx from 'clsx';
-
-interface IProps {
-  placeholder?: string;
-  type?: Parameters<typeof Input>[0]['type'];
-  label?: string;
-  className?: string;
-  size?: Parameters<typeof Input>[0]['size'];
-  checked?: boolean;
-}
-
-const FormSwitch: React.FC<IProps> = ({ placeholder, type, label, className, size, ...rest }) => {
-  return (
-    <div className={clsx('transition-all', className)}>
-      {label && <label className="mr-2">{label}</label>}
-      <Switch isChecked={rest.checked} type={type} placeholder={placeholder} size={size} {...rest} />
-    </div>
-  );
-};
-
-export default FormSwitch;

+ 0 - 28
packages/dashboard/src/components/Layout/Header.tsx

@@ -1,28 +0,0 @@
-import React from 'react';
-import Link from 'next/link';
-import { Flex } from '@chakra-ui/react';
-import { FiMenu } from 'react-icons/fi';
-import { getUrl } from '../../core/helpers/url-helpers';
-
-interface IProps {
-  onClickMenu: () => void;
-}
-
-const Header: React.FC<IProps> = ({ onClickMenu }) => {
-  return (
-    <header style={{ width: '100%' }} className="flex h-12 md:h-0">
-      <Flex className="items-center border-b-2 bg-graycool px-5 flex-1 py-2">
-        <div onClick={onClickMenu} className="visible md:invisible absolute cursor-pointer py-2">
-          <FiMenu color="black" />
-        </div>
-        <Flex justifyContent="center" flex="1">
-          <Link href="/" passHref>
-            <img src={getUrl('tipi.png')} alt="Tipi Logo" width={30} height={30} />
-          </Link>
-        </Flex>
-      </Flex>
-    </header>
-  );
-};
-
-export default Header;

+ 3 - 0
packages/dashboard/src/components/Layout/Layout.module.scss

@@ -0,0 +1,3 @@
+.topActions {
+  min-height: 50px;
+}

+ 39 - 52
packages/dashboard/src/components/Layout/Layout.tsx

@@ -1,81 +1,68 @@
-import { Flex, useDisclosure, Spinner, Breadcrumb, BreadcrumbItem, useColorModeValue, Box } from '@chakra-ui/react';
 import Head from 'next/head';
 import Head from 'next/head';
 import Link from 'next/link';
 import Link from 'next/link';
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
-import { FiChevronRight } from 'react-icons/fi';
-import Header from './Header';
-import Menu from './SideMenu';
-import MenuDrawer from './MenuDrawer';
+import clsx from 'clsx';
+import ReactTooltip from 'react-tooltip';
 import { useRefreshTokenQuery } from '../../generated/graphql';
 import { useRefreshTokenQuery } from '../../generated/graphql';
+import { Header } from '../ui/Header';
+import styles from './Layout.module.scss';
 
 
 interface IProps {
 interface IProps {
   loading?: boolean;
   loading?: boolean;
   breadcrumbs?: { name: string; href: string; current?: boolean }[];
   breadcrumbs?: { name: string; href: string; current?: boolean }[];
   children: React.ReactNode;
   children: React.ReactNode;
+  title?: string;
+  actions?: React.ReactNode;
 }
 }
 
 
-const Layout: React.FC<IProps> = ({ children, loading, breadcrumbs }) => {
-  const { isOpen, onClose, onOpen } = useDisclosure();
+export const Layout: React.FC<IProps> = ({ children, breadcrumbs, title, actions }) => {
   const { data } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
   const { data } = useRefreshTokenQuery({ fetchPolicy: 'network-only' });
 
 
   useEffect(() => {
   useEffect(() => {
     if (data?.refreshToken?.token) {
     if (data?.refreshToken?.token) {
       localStorage.setItem('token', data.refreshToken.token);
       localStorage.setItem('token', data.refreshToken.token);
     }
     }
-  }, [data]);
+  }, [data?.refreshToken?.token]);
 
 
-  const menubg = useColorModeValue('#F1F3F4', '#202736');
-  const bg = useColorModeValue('white', '#1a202c');
-
-  const renderContent = () => {
-    if (loading) {
-      return (
-        <Flex className="justify-center flex-1">
-          <Spinner />
-        </Flex>
-      );
+  const renderBreadcrumbs = () => {
+    if (!breadcrumbs) {
+      return null;
     }
     }
 
 
-    return children;
-  };
-
-  const renderBreadcrumbs = () => {
     return (
     return (
-      <Breadcrumb spacing="8px" separator={<FiChevronRight color="gray.500" />}>
-        {breadcrumbs?.map((breadcrumb, index) => {
-          return (
-            <BreadcrumbItem className="hover:underline" isCurrentPage={breadcrumb.current} key={index}>
-              <Link href={breadcrumb.href}>{breadcrumb.name}</Link>
-            </BreadcrumbItem>
-          );
-        })}
-      </Breadcrumb>
+      <ol className="breadcrumb" aria-label="breadcrumbs">
+        {breadcrumbs.map((breadcrumb) => (
+          <li key={breadcrumb.name} className={clsx('breadcrumb-item', { active: breadcrumb.current })}>
+            <Link href={breadcrumb.href}>{breadcrumb.name}</Link>
+          </li>
+        ))}
+      </ol>
     );
     );
   };
   };
 
 
   return (
   return (
-    <>
+    <div className="page">
       <Head>
       <Head>
-        <title>Tipi</title>
+        <title>{title} - Tipi</title>
       </Head>
       </Head>
-      <Flex height="100vh" direction="column">
-        <MenuDrawer isOpen={isOpen} onClose={onClose}>
-          <Menu />
-        </MenuDrawer>
-        <Header onClickMenu={onOpen} />
-        <Flex flex={1}>
-          <Flex height="100vh" bg={menubg} className="sticky top-0 invisible md:visible w-0 md:w-64">
-            <Menu />
-          </Flex>
-          <Box bg={bg} className="flex-1 px-4 py-4 md:px-10 md:py-8">
-            {/* <UpdateBanner /> */}
-            {renderBreadcrumbs()}
-            {renderContent()}
-          </Box>
-        </Flex>
-      </Flex>
-    </>
+      <ReactTooltip offset={{ right: 3 }} effect="solid" place="bottom" />
+      <Header />
+      <div className="page-wrapper">
+        <div className="page-header d-print-none">
+          <div className="container-xl">
+            <div className={clsx('align-items-stretch align-items-md-center d-flex flex-column flex-md-row ', styles.topActions)}>
+              <div className="me-3 text-white">
+                <div className="page-pretitle">{renderBreadcrumbs()}</div>
+                <h2 className="page-title">{title}</h2>
+              </div>
+              <div className="flex-fill">{actions}</div>
+            </div>
+          </div>
+        </div>
+        <div className="page-body">
+          <div className="container-xl">{children}</div>
+        </div>
+      </div>
+    </div>
   );
   );
 };
 };
-
-export default Layout;

+ 0 - 25
packages/dashboard/src/components/Layout/MenuDrawer.tsx

@@ -1,25 +0,0 @@
-import { Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, useColorModeValue } from '@chakra-ui/react';
-import React from 'react';
-
-interface IProps {
-  isOpen: boolean;
-  onClose: () => void;
-  children: React.ReactNode;
-}
-
-const MenuDrawer: React.FC<IProps> = ({ children, isOpen, onClose }) => {
-  const menubg = useColorModeValue('#F1F3F4', '#202736');
-
-  return (
-    <Drawer size="xs" isOpen={isOpen} placement="left" onClose={onClose}>
-      <DrawerOverlay />
-      <DrawerContent bg={menubg}>
-        <DrawerCloseButton />
-        <DrawerHeader>My Tipi</DrawerHeader>
-        <DrawerBody display="flex">{children}</DrawerBody>
-      </DrawerContent>
-    </Drawer>
-  );
-};
-
-export default MenuDrawer;

+ 0 - 96
packages/dashboard/src/components/Layout/SideMenu.tsx

@@ -1,96 +0,0 @@
-import { AiOutlineDashboard, AiOutlineSetting, AiOutlineAppstore } from 'react-icons/ai';
-import { FaAppStore, FaRegMoon } from 'react-icons/fa';
-import { FiLogOut } from 'react-icons/fi';
-import Package from '../../../package.json';
-import { Badge, Box, Divider, Flex, List, ListItem, Switch, useColorMode } from '@chakra-ui/react';
-import React from 'react';
-import Link from 'next/link';
-import clsx from 'clsx';
-import { useRouter } from 'next/router';
-import { IconType } from 'react-icons';
-import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
-import { getUrl } from '../../core/helpers/url-helpers';
-import { BsHeart } from 'react-icons/bs';
-import semver from 'semver';
-
-const SideMenu: React.FC = () => {
-  const router = useRouter();
-  const { colorMode, setColorMode } = useColorMode();
-  const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
-  const versionQuery = useVersionQuery();
-  const path = router.pathname.split('/')[1];
-
-  const defaultVersion = '0.0.0';
-  const isLatest = semver.gte(versionQuery.data?.version.current || defaultVersion, versionQuery.data?.version.latest || defaultVersion);
-
-  const renderMenuItem = (title: string, name: string, Icon: IconType) => {
-    const selected = path === name;
-
-    const itemClass = clsx('mx-3 border-transparent rounded-lg p-3 transition-colors border-1', {
-      'drop-shadow-sm border-gray-200': selected && colorMode === 'light',
-      'bg-white': selected && colorMode === 'light',
-    });
-
-    return (
-      <Link href={`/${name}`} passHref>
-        <div className={itemClass}>
-          <ListItem className={'flex items-center cursor-pointer hover:font-bold'}>
-            <Icon size={20} className={clsx('mr-3', { 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })} />
-            <p className={clsx({ 'font-bold': selected, 'text-red-600': selected && colorMode === 'light', 'text-red-200': selected && colorMode === 'dark' })}>{title}</p>
-          </ListItem>
-        </div>
-      </Link>
-    );
-  };
-
-  const handleChangeColorMode = (checked: boolean) => {
-    setColorMode(checked ? 'dark' : 'light');
-  };
-
-  const handleLogout = async () => {
-    localStorage.removeItem('token');
-    logout();
-  };
-
-  return (
-    <Box className="flex-1 flex flex-col p-0 md:p-4">
-      <img className="self-center mb-5 logo mt-0 md:mt-5" src={getUrl('tipi.png')} width={512} height={512} />
-      <List spacing={3} className="pt-5">
-        {renderMenuItem('Dashboard', '', AiOutlineDashboard)}
-        {renderMenuItem('My Apps', 'apps', AiOutlineAppstore)}
-        {renderMenuItem('App Store', 'app-store', FaAppStore)}
-        {renderMenuItem('Settings', 'settings', AiOutlineSetting)}
-      </List>
-      <Divider className="my-3" />
-      <Flex flex="1" />
-      <List>
-        <div className="mx-3">
-          <a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer">
-            <ListItem className="cursor-pointer hover:font-bold flex items-center mb-4">
-              <BsHeart size={20} className="mr-3" />
-              <p className="flex-1 mb-1 text-md">Donate</p>
-            </ListItem>
-          </a>
-          <ListItem onClick={handleLogout} className="cursor-pointer hover:font-bold flex items-center mb-5">
-            <FiLogOut size={20} className="mr-3" />
-            <p className="flex-1">Log out</p>
-          </ListItem>
-          <ListItem className="flex items-center">
-            <FaRegMoon size={20} className="mr-3" />
-            <p className="flex-1">Dark mode</p>
-            <Switch isChecked={colorMode === 'dark'} onChange={(event) => handleChangeColorMode(event.target.checked)} />
-          </ListItem>
-        </div>
-      </List>
-
-      <div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
-      {!isLatest && (
-        <Badge className="self-center mt-1" colorScheme="green">
-          New version available
-        </Badge>
-      )}
-    </Box>
-  );
-};
-
-export default SideMenu;

+ 0 - 36
packages/dashboard/src/components/Layout/UpdateBanner.tsx

@@ -1,36 +0,0 @@
-import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, CloseButton } from '@chakra-ui/react';
-import React from 'react';
-import { useVersionQuery } from '../../generated/graphql';
-
-const UpdateBanner = () => {
-  const { data, loading } = useVersionQuery();
-
-  const isLatest = data?.version.latest === data?.version.current;
-
-  if (isLatest || (loading && !data?.version)) {
-    return null;
-  }
-
-  const onClose = () => {};
-
-  return (
-    <div>
-      <Alert status="info" className="flex mb-3">
-        <AlertIcon />
-        <Box className="flex-1">
-          <AlertTitle>New version available!</AlertTitle>
-          <AlertDescription>
-            There is a new version of Tipi available ({data?.version.latest}). Visit{' '}
-            <a className="text-blue-600" target="_blank" rel="noreferrer" href={'https://github.com/meienberger/runtipi/releases/latest'}>
-              GitHub
-            </a>{' '}
-            for update instructions.
-          </AlertDescription>
-        </Box>
-        <CloseButton alignSelf="flex-start" position="relative" right={-1} top={-1} onClick={onClose} />
-      </Alert>
-    </div>
-  );
-};
-
-export default UpdateBanner;

+ 1 - 1
packages/dashboard/src/components/Layout/index.ts

@@ -1 +1 @@
-export { default } from './Layout';
+export { Layout } from './Layout';

+ 0 - 12
packages/dashboard/src/components/LoadingScreen.tsx

@@ -1,12 +0,0 @@
-import { Flex, Spinner } from '@chakra-ui/react';
-import React from 'react';
-
-const LoadingScreen = () => {
-  return (
-    <Flex height="100vh" alignItems="center" justifyContent="center">
-      <Spinner size="lg" />
-    </Flex>
-  );
-};
-
-export default LoadingScreen;

+ 25 - 24
packages/dashboard/src/components/Markdown/Markdown.tsx

@@ -4,29 +4,30 @@ import remarkBreaks from 'remark-breaks';
 import remarkGfm from 'remark-gfm';
 import remarkGfm from 'remark-gfm';
 import remarkMdx from 'remark-mdx';
 import remarkMdx from 'remark-mdx';
 
 
-const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => {
-  return (
-    <ReactMarkdown
-      className={className}
-      components={{
-        h1: (props) => <h1 {...props} className="text-2xl font-bold mb-4 text-center md:text-left" />,
-        h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
-        h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
-        ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
-        img: (props) => (
-          <div className="flex justify-center py-2">
-            <img {...props} className="w-full lg:w-2/3" />
-          </div>
-        ),
-        p: (props) => <p {...props} className="mb-4 text-center md:text-left" />,
-        a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
-        div: (props) => <div {...props} className="mb-4" />,
-      }}
-      remarkPlugins={[remarkBreaks, remarkGfm, remarkMdx]}
-    >
-      {children}
-    </ReactMarkdown>
-  );
-};
+const MarkdownImg = (props: Pick<React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>, 'key' | keyof React.ImgHTMLAttributes<HTMLImageElement>>) => (
+  <div className="d-flex justify-content-center">
+    {/* eslint-disable-next-line @next/next/no-img-element */}
+    <img alt="app-demonstration" {...props} />
+  </div>
+);
+
+const Markdown: React.FC<{ children: string; className: string }> = ({ children, className }) => (
+  <ReactMarkdown
+    className={className}
+    components={{
+      // h1: (props) => <h1 {...props} className="text-2xl font-bold mb-4 text-center md:text-left" />,
+      // h2: (props) => <h2 {...props} className="text-xl font-bold mb-4 text-center md:text-left" />,
+      // h3: (props) => <h3 {...props} className="text-lg font-bold mb-4 text-center md:text-left" />,
+      // ul: (props) => <ul {...props} className="list-disc pl-4 mb-4" />,
+      img: MarkdownImg,
+      // p: (props) => <p {...props} className="mb-4 text-left md:text-left" />,
+      // a: (props) => <a target="_blank" rel="noreferrer" {...props} className="text-blue-500" href={props.href} />,
+      // div: (props) => <div {...props} className="mb-4" />,
+    }}
+    remarkPlugins={[remarkBreaks, remarkGfm, remarkMdx]}
+  >
+    {children}
+  </ReactMarkdown>
+);
 
 
 export default Markdown;
 export default Markdown;

+ 19 - 0
packages/dashboard/src/components/StatusScreen/StatusScreen.tsx

@@ -0,0 +1,19 @@
+import Image from 'next/image';
+import React from 'react';
+import { getUrl } from '../../core/helpers/url-helpers';
+
+interface IProps {
+  title: string;
+  subtitle: string;
+}
+
+export const StatusScreen: React.FC<IProps> = ({ title, subtitle }) => (
+  <div className="page page-center">
+    <div className="container container-tight py-4 d-flex align-items-center flex-column">
+      <Image alt="Tipi log" className="mb-3" layout="intrinsic" src={getUrl('tipi.png')} height={50} width={50} />
+      <h1 className="text-center mb-1">{title}</h1>
+      <div className="text-center text-muted mb-3">{subtitle}</div>
+      <div className="spinner-border spinner-border-sm text-muted" />
+    </div>
+  </div>
+);

+ 1 - 0
packages/dashboard/src/components/StatusScreen/index.ts

@@ -0,0 +1 @@
+export { StatusScreen } from './StatusScreen';

+ 0 - 14
packages/dashboard/src/components/StatusScreens/RestartingScreen.tsx

@@ -1,14 +0,0 @@
-import { Flex, Spinner, Text } from '@chakra-ui/react';
-import React from 'react';
-
-const RestartingScreen = () => {
-  return (
-    <Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
-      <Text fontSize="2xl">Your system is restarting...</Text>
-      <Text color="gray.500">Please do not refresh this page</Text>
-      <Spinner size="lg" className="mt-5" />
-    </Flex>
-  );
-};
-
-export default RestartingScreen;

+ 0 - 14
packages/dashboard/src/components/StatusScreens/UpdatingScreen.tsx

@@ -1,14 +0,0 @@
-import { Text, Flex, Spinner } from '@chakra-ui/react';
-import React from 'react';
-
-const UpdatingScreen = () => {
-  return (
-    <Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
-      <Text fontSize="2xl">Your system is updating...</Text>
-      <Text color="gray.500">Please do not refresh this page</Text>
-      <Spinner size="lg" className="mt-5" />
-    </Flex>
-  );
-};
-
-export default UpdatingScreen;

+ 29 - 0
packages/dashboard/src/components/hoc/AuthProvider/AuthProvider.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+import { useConfiguredQuery, useMeQuery } from '../../../generated/graphql';
+import { LoginContainer } from '../../../modules/Auth/containers/LoginContainer';
+import { RegisterContainer } from '../../../modules/Auth/containers/RegisterContainer';
+import { StatusScreen } from '../../StatusScreen';
+
+interface IProps {
+  children: React.ReactElement;
+}
+
+export const AuthProvider: React.FC<IProps> = ({ children }) => {
+  const user = useMeQuery();
+  const isConfigured = useConfiguredQuery();
+  const loading = user.loading || isConfigured.loading;
+
+  if (loading && !user.data?.me) {
+    return <StatusScreen title="" subtitle="" />;
+  }
+
+  if (user.data?.me) {
+    return children;
+  }
+
+  if (!isConfigured?.data?.isConfigured) {
+    return <RegisterContainer />;
+  }
+
+  return <LoginContainer />;
+};

+ 1 - 0
packages/dashboard/src/components/hoc/AuthProvider/index.ts

@@ -0,0 +1 @@
+export { AuthProvider } from './AuthProvider';

+ 9 - 21
packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx → packages/dashboard/src/components/hoc/StatusProvider/StatusProvider.tsx

@@ -1,19 +1,17 @@
-import { SlideFade } from '@chakra-ui/react';
-import React, { useEffect, useState } from 'react';
+import React, { ReactElement, useEffect, useState } from 'react';
 import useSWR from 'swr';
 import useSWR from 'swr';
-import { SystemStatus } from '../../state/systemStore';
-import RestartingScreen from './RestartingScreen';
-import UpdatingScreen from './UpdatingScreen';
+import { SystemStatus } from '../../../state/systemStore';
+import { StatusScreen } from '../../StatusScreen';
 
 
 interface IProps {
 interface IProps {
-  children: React.ReactNode;
+  children: ReactElement;
 }
 }
 
 
 const fetcher = (url: string) => fetch(url).then((res) => res.json());
 const fetcher = (url: string) => fetch(url).then((res) => res.json());
 
 
-const StatusWrapper: React.FC<IProps> = ({ children }) => {
+export const StatusProvider: React.FC<IProps> = ({ children }) => {
   const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
   const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
-  const { data } = useSWR('/api/status', fetcher, { refreshInterval: 1000 });
+  const { data } = useSWR<{ status: SystemStatus }>('/api/status', fetcher, { refreshInterval: 1000 });
 
 
   useEffect(() => {
   useEffect(() => {
     // If previous was not running and current is running, we need to refresh the page
     // If previous was not running and current is running, we need to refresh the page
@@ -33,22 +31,12 @@ const StatusWrapper: React.FC<IProps> = ({ children }) => {
   }, [data?.status, s]);
   }, [data?.status, s]);
 
 
   if (s === SystemStatus.RESTARTING) {
   if (s === SystemStatus.RESTARTING) {
-    return (
-      <SlideFade in>
-        <RestartingScreen />
-      </SlideFade>
-    );
+    return <StatusScreen title="Your system is restarting..." subtitle="Please do not refresh this page" />;
   }
   }
 
 
   if (s === SystemStatus.UPDATING) {
   if (s === SystemStatus.UPDATING) {
-    return (
-      <SlideFade in>
-        <UpdatingScreen />
-      </SlideFade>
-    );
+    return <StatusScreen title="Your system is updating..." subtitle="Please do not refresh this page" />;
   }
   }
 
 
-  return <>{children}</>;
+  return children;
 };
 };
-
-export default StatusWrapper;

+ 1 - 0
packages/dashboard/src/components/hoc/StatusProvider/index.ts

@@ -0,0 +1 @@
+export { StatusProvider } from './StatusProvider';

+ 24 - 0
packages/dashboard/src/components/hoc/ToastProvider/ToastProvider.tsx

@@ -0,0 +1,24 @@
+import React from 'react';
+import { IToast, useToastStore } from '../../../state/toastStore';
+import { Toast } from '../../ui/Toast';
+
+export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+  const { toasts, removeToast } = useToastStore();
+
+  const renderToast = (toast: IToast) => {
+    const { status, title, description, id } = toast;
+
+    return <Toast status={status} title={title} message={description} id={id} onClose={() => removeToast(id)} />;
+  };
+
+  return (
+    <>
+      {toasts.map((toast) => (
+        <div key={toast.id} className="position-fixed bottom-0 end-0 p-3" style={{ zIndex: 11 }}>
+          {renderToast(toast)}
+        </div>
+      ))}
+      {children}
+    </>
+  );
+};

+ 1 - 0
packages/dashboard/src/components/hoc/ToastProvider/index.ts

@@ -0,0 +1 @@
+export { ToastProvider } from './ToastProvider';

+ 21 - 0
packages/dashboard/src/components/ui/Button/Button.tsx

@@ -0,0 +1,21 @@
+import React from 'react';
+import clsx from 'clsx';
+
+interface IProps {
+  className?: string;
+  type?: 'submit' | 'reset' | 'button';
+  disabled?: boolean;
+  loading?: boolean;
+  onClick?: () => void;
+  children: React.ReactNode;
+  width?: number | null;
+}
+
+export const Button = React.forwardRef<HTMLButtonElement, IProps>(({ type, className, children, loading, disabled, onClick, width, ...rest }, ref) => {
+  const styles = { width: width ? `${width}px` : 'auto' };
+  return (
+    <button style={styles} onClick={onClick} disabled={disabled || loading} ref={ref} className={clsx('btn', className, { disabled: disabled || loading })} type={type} {...rest}>
+      {loading ? <span className="spinner-border spinner-border-sm mb-1 mx-2" role="status" aria-hidden="true" /> : children}
+    </button>
+  );
+});

+ 1 - 0
packages/dashboard/src/components/ui/Button/index.ts

@@ -0,0 +1 @@
+export { Button } from './Button';

+ 13 - 0
packages/dashboard/src/components/ui/DataGrid/DataGrid.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+
+interface IProps {
+  children: React.ReactNode;
+}
+
+export const DataGrid: React.FC<IProps> = ({ children }) => (
+  <div className="card">
+    <div className="card-body">
+      <div className="datagrid">{children}</div>
+    </div>
+  </div>
+);

+ 13 - 0
packages/dashboard/src/components/ui/DataGrid/DataGridItem.tsx

@@ -0,0 +1,13 @@
+import React from 'react';
+
+interface IProps {
+  title: string;
+  children: React.ReactNode;
+}
+
+export const DataGridItem: React.FC<IProps> = ({ children, title }) => (
+  <div className="datagrid-item">
+    <div className="datagrid-title">{title}</div>
+    <div className="datagrid-content">{children}</div>
+  </div>
+);

+ 2 - 0
packages/dashboard/src/components/ui/DataGrid/index.tsx

@@ -0,0 +1,2 @@
+export { DataGrid } from './DataGrid';
+export { DataGridItem } from './DataGridItem';

+ 4 - 0
packages/dashboard/src/components/ui/EmptyPage/EmptyPage.module.scss

@@ -0,0 +1,4 @@
+.emptyImage {
+  height: 80px;
+  width: 80px;
+}

+ 27 - 0
packages/dashboard/src/components/ui/EmptyPage/EmptyPage.tsx

@@ -0,0 +1,27 @@
+import Image from 'next/image';
+import React from 'react';
+import { getUrl } from '../../../core/helpers/url-helpers';
+import { Button } from '../Button';
+import styles from './EmptyPage.module.scss';
+
+interface IProps {
+  title: string;
+  subtitle?: string;
+  onAction?: () => void;
+  actionLabel?: string;
+}
+
+export const EmptyPage: React.FC<IProps> = ({ title, subtitle, onAction, actionLabel }) => (
+  <div className="card empty">
+    <Image src={getUrl('empty.svg')} alt="Empty box" height="80" width="80" className={styles.emptyImage} />
+    <p className="empty-title">{title}</p>
+    <p className="empty-subtitle text-muted">{subtitle}</p>
+    <div className="empty-action">
+      {onAction && (
+        <Button onClick={onAction} className="btn-primary">
+          {actionLabel}
+        </Button>
+      )}
+    </div>
+  </div>
+);

+ 1 - 0
packages/dashboard/src/components/ui/EmptyPage/index.ts

@@ -0,0 +1 @@
+export { EmptyPage } from './EmptyPage';

+ 4 - 0
packages/dashboard/src/components/ui/Header/Header.module.scss

@@ -0,0 +1,4 @@
+.logo {
+  //   filter: hue-rotate(290deg);
+  //   filter: invert(100%);
+}

+ 61 - 0
packages/dashboard/src/components/ui/Header/Header.tsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import { IconBrandGithub, IconHeart, IconLogout, IconMoon, IconSun } from '@tabler/icons';
+import Image from 'next/image';
+import clsx from 'clsx';
+import { getUrl } from '../../../core/helpers/url-helpers';
+import { useUIStore } from '../../../state/uiStore';
+import { NavBar } from '../NavBar';
+import { useLogoutMutation } from '../../../generated/graphql';
+
+export const Header: React.FC = () => {
+  const { setDarkMode } = useUIStore();
+  const [logout] = useLogoutMutation();
+
+  const handleLogout = async () => {
+    await logout();
+    localStorage.removeItem('token');
+    window.location.reload();
+  };
+
+  return (
+    <header className="navbar navbar-expand-md navbar-dark navbar-overlap d-print-none">
+      <div className="container-xl">
+        <button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu">
+          <span className="navbar-toggler-icon" />
+        </button>
+        <a href="/dashboard">
+          <h1 className="navbar-brand d-none-navbar-horizontal pe-0 pe-md-3">
+            <Image alt="Tipi logo" className={clsx('navbar-brand-image me-3')} width={100} height={100} src={getUrl('tipi.png')} />
+            Tipi
+          </h1>
+        </a>
+        <div className="navbar-nav flex-row order-md-last">
+          <div className="nav-item d-none d-xl-flex me-3">
+            <div className="btn-list">
+              <a href="https://github.com/meienberger/runtipi" target="_blank" rel="noreferrer" className="btn btn-dark">
+                <IconBrandGithub className="me-1 icon" size={24} />
+                Source code
+              </a>
+              <a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer" className="btn btn-dark">
+                <IconHeart className="me-1 icon text-pink" size={24} />
+                Sponsor
+              </a>
+            </div>
+          </div>
+          <div className="d-flex">
+            <div onClick={() => setDarkMode(true)} role="button" aria-hidden="true" className="nav-link px-0 hide-theme-dark cursor-pointer" data-tip="Dark mode">
+              <IconMoon size={24} />
+            </div>
+            <div onClick={() => setDarkMode(false)} aria-hidden="true" className="nav-link px-0 hide-theme-light cursor-pointer" data-tip="Light mode">
+              <IconSun size={24} />
+            </div>
+            <div onClick={handleLogout} tabIndex={0} onKeyPress={handleLogout} role="button" className="nav-link px-0 cursor-pointer" data-tip="Log out">
+              <IconLogout size={24} />
+            </div>
+          </div>
+        </div>
+        <NavBar />
+      </div>
+    </header>
+  );
+};

+ 1 - 0
packages/dashboard/src/components/ui/Header/index.ts

@@ -0,0 +1 @@
+export { Header } from './Header';

+ 38 - 0
packages/dashboard/src/components/ui/Input/Input.tsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import clsx from 'clsx';
+
+interface IProps {
+  placeholder?: string;
+  error?: string;
+  label?: string;
+  className?: string;
+  isInvalid?: boolean;
+  type?: HTMLInputElement['type'];
+  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+  name?: string;
+  onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
+  disabled?: boolean;
+  value?: string;
+}
+
+export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value }, ref) => (
+  <div className={clsx(className)}>
+    {label && (
+      <label htmlFor={name} className="form-label">
+        {label}
+      </label>
+    )}
+    <input
+      name={name}
+      id={name}
+      onBlur={onBlur}
+      onChange={onChange}
+      value={value}
+      type={type}
+      ref={ref}
+      className={clsx('form-control', { 'is-invalid is-invalid-lite': error })}
+      placeholder={placeholder}
+    />
+    {error && <div className="invalid-feedback">{error}</div>}
+  </div>
+));

+ 1 - 0
packages/dashboard/src/components/ui/Input/index.ts

@@ -0,0 +1 @@
+export { Input } from './Input';

+ 37 - 0
packages/dashboard/src/components/ui/Modal/Modal.module.scss

@@ -0,0 +1,37 @@
+@keyframes zoomIn {
+  0% {
+    transform: scale(0.7);
+  }
+  50% {
+    transform: scale(1.05);
+  }
+  100% {
+    transform: scale(1);
+  }
+}
+
+@keyframes dimmedBackground {
+  from {
+    background-color: rgba(0, 0, 0, 0);
+  }
+  to {
+    background-color: rgba(0, 0, 0, 0.8);
+  }
+}
+
+.dimmedBackground {
+  background-color: rgba(0, 0, 0, 0.8);
+  animation-name: dimmedBackground;
+  animation-duration: 0.2s;
+  animation-iteration-count: 1;
+  animation-timing-function: ease-in-out;
+  animation-fill-mode: forwards;
+}
+
+.zoomIn {
+  animation-name: zoomIn;
+  animation-duration: 0.25s;
+  animation-iteration-count: 1;
+  animation-timing-function: spring;
+  animation-fill-mode: forwards;
+}

+ 49 - 0
packages/dashboard/src/components/ui/Modal/Modal.tsx

@@ -0,0 +1,49 @@
+import clsx from 'clsx';
+import React, { useCallback, useEffect, useState } from 'react';
+import styles from './Modal.module.scss';
+
+interface IProps {
+  children: React.ReactNode;
+  isOpen?: boolean;
+  onClose: () => void;
+  size?: 'sm' | 'md' | 'lg' | 'xl';
+  type?: 'default' | 'primary' | 'success' | 'info' | 'warning' | 'danger';
+}
+
+export const Modal: React.FC<IProps> = ({ children, isOpen, onClose, size = 'lg', type }) => {
+  const style = { display: 'none' };
+
+  if (isOpen) {
+    style.display = 'block';
+  }
+
+  const [modal, setModal] = useState<HTMLDivElement | null>(null);
+
+  // On click outside
+  const handleClickOutside = useCallback(
+    (event: MouseEvent) => {
+      if (modal && !modal.contains(event.target as Node)) {
+        onClose();
+      }
+    },
+    [modal, onClose],
+  );
+
+  // On click outside
+  useEffect(() => {
+    document.addEventListener('click', handleClickOutside, true);
+    return () => document.removeEventListener('click', handleClickOutside, true);
+  }, [handleClickOutside]);
+
+  return (
+    <div className={clsx('modal modal-sm', styles.dimmedBackground)} tabIndex={-1} style={style}>
+      <div ref={setModal} className={clsx(`modal-dialog modal-dialog-centered modal-${size}`, styles.zoomIn)} role="document">
+        <div className="shadow modal-content">
+          <button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close" onClick={onClose} />
+          <div className={clsx('modal-status', { [`bg-${type}`]: Boolean(type), 'd-none': !type })} />
+          {children}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 9 - 0
packages/dashboard/src/components/ui/Modal/ModalBody.tsx

@@ -0,0 +1,9 @@
+import clsx from 'clsx';
+import React from 'react';
+
+interface IProps {
+  children: React.ReactNode;
+  className?: string;
+}
+
+export const ModalBody: React.FC<IProps> = ({ children, className }) => <div className={clsx('modal-body', className)}>{children}</div>;

+ 7 - 0
packages/dashboard/src/components/ui/Modal/ModalFooter.tsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+interface IProps {
+  children: React.ReactNode;
+}
+
+export const ModalFooter: React.FC<IProps> = ({ children }) => <div className="modal-footer">{children}</div>;

+ 7 - 0
packages/dashboard/src/components/ui/Modal/ModalHeader.tsx

@@ -0,0 +1,7 @@
+import React from 'react';
+
+interface IProps {
+  children: React.ReactNode;
+}
+
+export const ModalHeader: React.FC<IProps> = ({ children }) => <div className="modal-header">{children}</div>;

+ 4 - 0
packages/dashboard/src/components/ui/Modal/index.ts

@@ -0,0 +1,4 @@
+export { Modal } from './Modal';
+export { ModalBody } from './ModalBody';
+export { ModalFooter } from './ModalFooter';
+export { ModalHeader } from './ModalHeader';

+ 45 - 0
packages/dashboard/src/components/ui/NavBar/NavBar.tsx

@@ -0,0 +1,45 @@
+import { IconApps, IconBrandAppstore, IconHome, IconSettings, TablerIcon } from '@tabler/icons';
+import clsx from 'clsx';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
+import React from 'react';
+import semver from 'semver';
+import { useVersionQuery } from '../../../generated/graphql';
+
+export const NavBar: React.FC = () => {
+  const { data } = useVersionQuery();
+  const router = useRouter();
+  const path = router.pathname.split('/')[1];
+  const defaultVersion = '0.0.0';
+  const isLatest = semver.gte(data?.version.current || defaultVersion, data?.version.latest || defaultVersion);
+
+  const renderItem = (title: string, name: string, Icon: TablerIcon) => {
+    const isActive = path === name;
+    const itemClass = clsx('nav-item', { active: isActive, 'border-primary': isActive, 'border-bottom-wide': isActive });
+
+    return (
+      <li className={itemClass}>
+        <Link href={`/${name}`} className="nav-link" passHref>
+          <span className="nav-link-icon d-md-none d-lg-inline-block">
+            <Icon size={24} />
+          </span>
+          <span className="nav-link-title">{title}</span>
+        </Link>
+      </li>
+    );
+  };
+
+  return (
+    <div id="navbar-menu" className="collapse navbar-collapse" style={{}}>
+      <div className="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
+        <ul className="navbar-nav">
+          {renderItem('Dashboard', '', IconHome)}
+          {renderItem('My Apps', 'apps', IconApps)}
+          {renderItem('App Store', 'app-store', IconBrandAppstore)}
+          {renderItem('Settings', 'settings', IconSettings)}
+        </ul>
+        {isLatest && <span className="ms-2 badge bg-green d-none d-lg-block">Update available</span>}
+      </div>
+    </div>
+  );
+};

+ 1 - 0
packages/dashboard/src/components/ui/NavBar/index.ts

@@ -0,0 +1 @@
+export { NavBar } from './NavBar';

+ 20 - 0
packages/dashboard/src/components/ui/Switch/Switch.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import clsx from 'clsx';
+
+interface IProps {
+  label?: string;
+  className?: string;
+  checked?: boolean;
+  onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
+  name?: string;
+  onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
+}
+
+export const Switch = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, checked, className }, ref) => (
+  <div className={clsx('', className)}>
+    <label htmlFor={`switch-${name}`} className="form-check form-switch">
+      <input name={name} ref={ref} onChange={onChange} onBlur={onBlur} className="form-check-input" type="checkbox" checked={checked} />
+      <span className="form-check-label">{label}</span>
+    </label>
+  </div>
+));

+ 1 - 0
packages/dashboard/src/components/ui/Switch/index.ts

@@ -0,0 +1 @@
+export { Switch } from './Switch';

+ 18 - 0
packages/dashboard/src/components/ui/Toast/Toast.module.scss

@@ -0,0 +1,18 @@
+@keyframes slideInAndOut {
+  0% {
+    transform: translateX(100%);
+  }
+  5% {
+    transform: translateX(0);
+  }
+  95% {
+    transform: translateX(0);
+  }
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+.slideIn {
+  animation: slideInAndOut 5s ease-in-out;
+}

+ 51 - 0
packages/dashboard/src/components/ui/Toast/Toast.tsx

@@ -0,0 +1,51 @@
+import clsx from 'clsx';
+import React from 'react';
+import styles from './Toast.module.scss';
+
+interface IProps {
+  onClose: () => void;
+  status: 'success' | 'error' | 'warning' | 'info';
+  title: string;
+  message?: string;
+  id: string;
+}
+
+export const Toast: React.FC<IProps> = ({ status, onClose, title, message, id }) => (
+  <div
+    id={id}
+    className={clsx(styles.slideIn, 'show fade alert alert-important alert-dismissible tipi-toast', {
+      'alert-danger': status === 'error',
+      'alert-success': status === 'success',
+      'alert-info': status === 'info',
+      warning: status === 'warning',
+    })}
+    role="alert"
+  >
+    <div className="d-flex align-items-center">
+      <div>
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          className="icon alert-icon"
+          width="24"
+          height="24"
+          viewBox="0 0 24 24"
+          strokeWidth="2"
+          stroke="currentColor"
+          fill="none"
+          strokeLinecap="round"
+          strokeLinejoin="round"
+        >
+          <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+          <circle cx="12" cy="12" r="9" />
+          <line x1="12" y1="8" x2="12" y2="12" />
+          <line x1="12" y1="16" x2="12.01" y2="16" />
+        </svg>
+      </div>
+      <div className="flex-fill">
+        <h4 className="alert-title text-white font-weight-bold">{title}</h4>
+        {message && <div className="text-white">{message}</div>}
+      </div>
+    </div>
+    <button onClick={onClose} className="btn-close btn-close-white" data-bs-dismiss="alert" aria-label="close" />
+  </div>
+);

+ 1 - 0
packages/dashboard/src/components/ui/Toast/index.ts

@@ -0,0 +1 @@
+export { Toast } from './Toast';

+ 1 - 1
packages/dashboard/src/core/apollo/client.ts

@@ -1,7 +1,7 @@
 import { ApolloClient, from, InMemoryCache } from '@apollo/client';
 import { ApolloClient, from, InMemoryCache } from '@apollo/client';
 import links from './links';
 import links from './links';
 
 
-export const createApolloClient = async (): Promise<ApolloClient<any>> => {
+export const createApolloClient = (): ApolloClient<unknown> => {
   const additiveLink = from([links.errorLink, links.authLink, links.httpLink]);
   const additiveLink = from([links.errorLink, links.authLink, links.httpLink]);
 
 
   return new ApolloClient({
   return new ApolloClient({

+ 1 - 0
packages/dashboard/src/core/constants.ts

@@ -14,4 +14,5 @@ export const APP_CATEGORIES = [
   { name: 'Data', id: AppCategoriesEnum.Data, icon: 'FaDatabase' },
   { name: 'Data', id: AppCategoriesEnum.Data, icon: 'FaDatabase' },
   { name: 'Music', id: AppCategoriesEnum.Music, icon: 'FaMusic' },
   { name: 'Music', id: AppCategoriesEnum.Music, icon: 'FaMusic' },
   { name: 'Finance', id: AppCategoriesEnum.Finance, icon: 'FaMoneyBillAlt' },
   { name: 'Finance', id: AppCategoriesEnum.Finance, icon: 'FaMoneyBillAlt' },
+  { name: 'Gaming', id: AppCategoriesEnum.Gaming, icon: 'FaGamepad' },
 ];
 ];

+ 1 - 1
packages/dashboard/src/core/helpers/url-helpers.ts

@@ -1,5 +1,5 @@
 export const getUrl = (url: string) => {
 export const getUrl = (url: string) => {
-  let prefix = 'dashboard';
+  const prefix = 'dashboard';
 
 
   return `/${prefix}/${url}`;
   return `/${prefix}/${url}`;
 };
 };

+ 1 - 0
packages/dashboard/src/generated/graphql.tsx

@@ -1,5 +1,6 @@
 import { gql } from '@apollo/client';
 import { gql } from '@apollo/client';
 import * as Apollo from '@apollo/client';
 import * as Apollo from '@apollo/client';
+
 export type Maybe<T> = T | null;
 export type Maybe<T> = T | null;
 export type InputMaybe<T> = Maybe<T>;
 export type InputMaybe<T> = Maybe<T>;
 export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
 export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };

+ 2 - 2
packages/dashboard/src/hooks/useCachedRessources.ts

@@ -11,9 +11,9 @@ export default function useCachedResources(): IReturnProps {
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [client, setClient] = useState<ApolloClient<unknown>>();
   const [client, setClient] = useState<ApolloClient<unknown>>();
 
 
-  async function loadResourcesAndDataAsync() {
+  function loadResourcesAndDataAsync() {
     try {
     try {
-      const restoredClient = await createApolloClient();
+      const restoredClient = createApolloClient();
 
 
       setClient(restoredClient);
       setClient(restoredClient);
     } catch (error) {
     } catch (error) {

+ 17 - 0
packages/dashboard/src/hooks/useDisclosure.ts

@@ -0,0 +1,17 @@
+import { useCallback, useState } from 'react';
+
+export const useDisclosure = (isOpenDefault = false) => {
+  const [isOpen, setIsOpen] = useState(isOpenDefault);
+
+  const open = useCallback(() => setIsOpen(true), []);
+  const close = useCallback(() => setIsOpen(false), []);
+  const toggle = useCallback((toSet: boolean) => {
+    if (typeof toSet === 'undefined') {
+      setIsOpen((state) => !state);
+    } else {
+      setIsOpen(toSet);
+    }
+  }, []);
+
+  return { isOpen, open, close, toggle };
+};

+ 0 - 38
packages/dashboard/src/modules/AppStore/components/AppStoreTable.tsx

@@ -1,38 +0,0 @@
-import { Flex, Input, SimpleGrid } from '@chakra-ui/react';
-import React from 'react';
-import { AppCategoriesEnum } from '../../../generated/graphql';
-import { AppTableData, SortableColumns, SortDirection } from '../helpers/table.types';
-import AppStoreTile from './AppStoreTile';
-import CategorySelect from './CategorySelect';
-
-interface IProps {
-  data: AppTableData;
-  onSearch: (value: string) => void;
-  onSelectCategories: (value: AppCategoriesEnum[]) => void;
-  onSortBy: (value: SortableColumns) => void;
-  onChangeDirection: (value: SortDirection) => void;
-}
-
-const AppStoreTable: React.FC<IProps> = ({ data, onSearch, onSelectCategories }) => {
-  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => onSearch(e.target.value);
-
-  return (
-    <Flex className="flex-col">
-      <div className="flex">
-        <div className="flex-1 mr-2">
-          <Input placeholder="Search app..." onChange={handleSearch} />
-        </div>
-        <div className="flex-1">
-          <CategorySelect onSelect={onSelectCategories} />
-        </div>
-      </div>
-      <SimpleGrid className="flex-1" minChildWidth="280px" spacing="20px">
-        {data.map((app) => (
-          <AppStoreTile key={app.id} app={app} />
-        ))}
-      </SimpleGrid>
-    </Flex>
-  );
-};
-
-export default AppStoreTable;

+ 16 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.loading.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import AppStoreTileLoading from '../AppStoreTile/AppStoreTile.loading';
+
+const AppStoreTableLoading: React.FC = () => {
+  const elements = Array.from({ length: 30 }, (_, i) => i);
+
+  return (
+    <div className="row row-cards">
+      {elements.map((n) => (
+        <AppStoreTileLoading key={n} />
+      ))}
+    </div>
+  );
+};
+
+export default AppStoreTableLoading;

+ 27 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTable/AppStoreTable.tsx

@@ -0,0 +1,27 @@
+import React from 'react';
+import { AppTableData, SortableColumns, SortDirection } from '../../helpers/table.types';
+import AppStoreTile from '../AppStoreTile';
+import AppStoreTableLoading from './AppStoreTable.loading';
+
+interface IProps {
+  data: AppTableData;
+  onSortBy?: (value: SortableColumns) => void;
+  onChangeDirection?: (value: SortDirection) => void;
+  loading?: boolean;
+}
+
+const AppStoreTable: React.FC<IProps> = ({ data, loading }) => {
+  if (loading) {
+    return <AppStoreTableLoading />;
+  }
+
+  return (
+    <div className="row row-cards">
+      {data.map((app) => (
+        <AppStoreTile key={app.id} app={app} />
+      ))}
+    </div>
+  );
+};
+
+export default AppStoreTable;

+ 1 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTable/index.ts

@@ -0,0 +1 @@
+export { default } from './AppStoreTable';

+ 0 - 34
packages/dashboard/src/modules/AppStore/components/AppStoreTile.tsx

@@ -1,34 +0,0 @@
-import { Tag, TagLabel } from '@chakra-ui/react';
-import Link from 'next/link';
-import React from 'react';
-import AppLogo from '../../../components/AppLogo/AppLogo';
-import { AppCategoriesEnum } from '../../../generated/graphql';
-import { colorSchemeForCategory, limitText } from '../helpers/table.helpers';
-
-type App = {
-  id: string;
-  name: string;
-  categories: string[];
-  short_desc: string;
-};
-
-const AppStoreTile: React.FC<{ app: App }> = ({ app }) => {
-  return (
-    <Link href={`/app-store/${app.id}`} passHref>
-      <div key={app.id} className="p-2 rounded-md app-store-tile flex items-center group">
-        <AppLogo id={app.id} className="group-hover:scale-105 transition-all" />
-        <div className="ml-2">
-          <div className="font-bold">{limitText(app.name, 20)}</div>
-          <div className="text-sm mb-1">{limitText(app.short_desc, 45)}</div>
-          {app.categories?.map((category) => (
-            <Tag colorScheme={colorSchemeForCategory[category as AppCategoriesEnum]} className="mr-1" borderRadius="full" key={`${app.id}-${category}`} size="sm" variant="solid">
-              <TagLabel>{category.toLocaleLowerCase()}</TagLabel>
-            </Tag>
-          ))}
-        </div>
-      </div>
-    </Link>
-  );
-};
-
-export default AppStoreTile;

+ 17 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTile/AppStoreTile.loading.tsx

@@ -0,0 +1,17 @@
+import React from 'react';
+import { AppLogo } from '../../../../components/AppLogo/AppLogo';
+
+const AppStoreTile: React.FC = () => (
+  <div className="cursor-progress col-sm-6 col-lg-4 p-2 mt-4">
+    <div className="d-flex overflow-hidden align-items-center py-2 ps-2 placeholder-glow">
+      <AppLogo />
+      <div className="card-body">
+        <div className="placeholder col-6 mb-2" />
+        <div className="text-bold h-3 placeholder col-9 mb-2" />
+        <div className="text-bold h-3 placeholder col-4 mt-1 mb-2" />
+      </div>
+    </div>
+  </div>
+);
+
+export default AppStoreTile;

+ 18 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTile/AppStoreTile.module.scss

@@ -0,0 +1,18 @@
+.appTile {
+  color: inherit;
+  text-decoration: none;
+
+  &:hover {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  &:hover .logo {
+    transform: scale(1.1);
+  }
+}
+
+.logo {
+  transition: transform 0.15s ease-in-out;
+  scale: 1;
+}

+ 33 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTile/AppStoreTile.tsx

@@ -0,0 +1,33 @@
+import clsx from 'clsx';
+import Link from 'next/link';
+import React from 'react';
+import { AppLogo } from '../../../../components/AppLogo/AppLogo';
+import { AppCategoriesEnum } from '../../../../generated/graphql';
+import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
+import styles from './AppStoreTile.module.scss';
+
+type App = {
+  id: string;
+  name: string;
+  categories: string[];
+  short_desc: string;
+};
+
+const AppStoreTile: React.FC<{ app: App }> = ({ app }) => (
+  <Link className={clsx('cursor-pointer col-sm-6 col-lg-4 p-2 mt-4', styles.appTile)} href={`/app-store/${app.id}`} passHref>
+    <div key={app.id} className="d-flex overflow-hidden align-items-center py-2 ps-2">
+      <AppLogo className={styles.logo} id={app.id} />
+      <div className="card-body">
+        <h3 className="text-bold h-3 mb-2">{limitText(app.name, 20)}</h3>
+        <p className="text-muted text-nowrap mb-2">{limitText(app.short_desc, 30)}</p>
+        {app.categories?.map((category) => (
+          <div className={`badge me-1 bg-${colorSchemeForCategory[category as AppCategoriesEnum]}`} key={`${app.id}-${category}`}>
+            {category.toLocaleLowerCase()}
+          </div>
+        ))}
+      </div>
+    </div>
+  </Link>
+);
+
+export default AppStoreTile;

+ 1 - 0
packages/dashboard/src/modules/AppStore/components/AppStoreTile/index.tsx

@@ -0,0 +1 @@
+export { default } from './AppStoreTile';

+ 0 - 46
packages/dashboard/src/modules/AppStore/components/CategorySelect.tsx

@@ -1,46 +0,0 @@
-import { useColorModeValue } from '@chakra-ui/react';
-import React from 'react';
-import Select, { Options } from 'react-select';
-import { APP_CATEGORIES } from '../../../core/constants';
-import { AppCategoriesEnum } from '../../../generated/graphql';
-
-interface IProps {
-  onSelect: (value: AppCategoriesEnum[]) => void;
-}
-
-type OptionsType = Options<{ value: AppCategoriesEnum; label: string }>;
-
-const CategorySelect: React.FC<IProps> = ({ onSelect }) => {
-  const bg = useColorModeValue('white', '#1a202c');
-
-  const options: OptionsType = APP_CATEGORIES.map((category) => ({
-    value: category.id,
-    label: category.name,
-  }));
-
-  const handleChange = (values: OptionsType) => {
-    const categories = values.map((category) => category.value);
-    onSelect(categories);
-  };
-
-  return (
-    <Select
-      styles={{
-        control: (base) => ({ ...base, borderColor: 'gray.600', background: bg, height: 40 }),
-        placeholder: (base) => ({ ...base, color: 'gray' }),
-        option: (base) => ({ ...base, background: bg, color: 'gray.800' }),
-        menu: (base) => ({ ...base, background: bg }),
-      }}
-      onChange={handleChange}
-      defaultValue={[]}
-      isMulti
-      name="categories"
-      options={options as any}
-      placeholder="Category..."
-      className="basic-multi-select"
-      classNamePrefix="select"
-    />
-  );
-};
-
-export default CategorySelect;

+ 69 - 0
packages/dashboard/src/modules/AppStore/components/CategorySelector/CategorySelector.tsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import Select, { SingleValue } from 'react-select';
+import { APP_CATEGORIES } from '../../../../core/constants';
+import { AppCategoriesEnum } from '../../../../generated/graphql';
+import { useUIStore } from '../../../../state/uiStore';
+
+interface IProps {
+  onSelect: (value?: AppCategoriesEnum) => void;
+  className?: string;
+  initialValue?: AppCategoriesEnum;
+}
+
+type OptionsType = { value: AppCategoriesEnum; label: string };
+
+const CategorySelector: React.FC<IProps> = ({ onSelect, className, initialValue }) => {
+  const { darkMode } = useUIStore();
+  const options: OptionsType[] = APP_CATEGORIES.map((category) => ({
+    value: category.id,
+    label: category.name,
+  }));
+
+  const [value, setValue] = React.useState<OptionsType | null>(options.find((o) => o.value === initialValue) || null);
+
+  const handleChange = (option: SingleValue<OptionsType>) => {
+    setValue(option as OptionsType);
+    onSelect(option?.value);
+  };
+
+  const color = darkMode ? '#fff' : '#1a2234';
+  const bgColor = darkMode ? '#1a2234' : '#fff';
+  const borderColor = darkMode ? '#243049' : '#e5e5e5';
+
+  return (
+    <Select<OptionsType>
+      isClearable
+      className={className}
+      value={value}
+      styles={{
+        menu: (provided: any) => ({
+          ...provided,
+          backgroundColor: bgColor,
+          color,
+        }),
+        control: (provided: any) => ({
+          ...provided,
+          backgroundColor: bgColor,
+          color,
+          borderColor,
+        }),
+        option: (provided: any, state: any) => ({
+          ...provided,
+          backgroundColor: state.isFocused ? '#243049' : bgColor,
+          color: state.isFocused ? '#fff' : color,
+        }),
+        singleValue: (provided: any) => ({
+          ...provided,
+          color,
+        }),
+      }}
+      onChange={handleChange}
+      defaultValue={[]}
+      name="categories"
+      options={options as any}
+      placeholder="Category..."
+    />
+  );
+};
+
+export default CategorySelector;

+ 1 - 0
packages/dashboard/src/modules/AppStore/components/CategorySelector/index.ts

@@ -0,0 +1 @@
+export { default } from './CategorySelector';

+ 0 - 25
packages/dashboard/src/modules/AppStore/components/FeaturedApps.tsx

@@ -1,25 +0,0 @@
-import React from 'react';
-import { Box, Button, Flex } from '@chakra-ui/react';
-import FeaturedCard from './FeaturedCard';
-import { AppInfo } from '../../../generated/graphql';
-
-interface IProps {
-  apps: AppInfo[];
-}
-
-const FeaturedApps: React.FC<IProps> = ({ apps }) => {
-  const [appIndex, setAppIndex] = React.useState(0);
-
-  return (
-    <Flex className="flex-col relative">
-      <Box className="relative mb-3" height={200}>
-        {apps.map((app, index) => {
-          return <FeaturedCard show={index === appIndex} key={app.id} app={app} />;
-        })}
-      </Box>
-      <Button onClick={() => setAppIndex(1)}>Next</Button>
-    </Flex>
-  );
-};
-
-export default FeaturedApps;

+ 0 - 35
packages/dashboard/src/modules/AppStore/components/FeaturedCard.tsx

@@ -1,35 +0,0 @@
-import { Flex, ScaleFade } from '@chakra-ui/react';
-import React from 'react';
-import { AppInfo } from '../../../generated/graphql';
-
-interface IProps {
-  app: AppInfo;
-  show: boolean;
-}
-
-const FeaturedCard: React.FC<IProps> = ({ app, show }) => {
-  return (
-    <ScaleFade initialScale={0.9} in={show}>
-      <Flex
-        className="overflow-hidden absolute left-0 right-0 border-2"
-        height={200}
-        rounded="md"
-        shadow="md"
-        style={{
-          backgroundImage: 'url(https://images.unsplash.com/photo-1488590528505-98d2b5aba04b?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80)',
-        }}
-      >
-        <div className="relative flex flex-1 w-max lg:bg-gradient-to-r from-white via-white">
-          <div className="flex absolute bottom-0 flex-row p-3">
-            <div className="self-end mb-1">
-              <div className="font-bold text-xl">{app.name}</div>
-              <div className="text-md">{app.short_desc}</div>
-            </div>
-          </div>
-        </div>
-      </Flex>
-    </ScaleFade>
-  );
-};
-
-export default FeaturedCard;

+ 0 - 50
packages/dashboard/src/modules/AppStore/containers/AppStoreContainer.tsx

@@ -1,50 +0,0 @@
-import { Flex } from '@chakra-ui/react';
-import React from 'react';
-import { AppCategoriesEnum } from '../../../generated/graphql';
-import AppStoreTable from '../components/AppStoreTable';
-import { sortTable } from '../helpers/table.helpers';
-import { AppTableData, SortableColumns, SortDirection } from '../helpers/table.types';
-
-// function nonNullable<T>(value: T): value is NonNullable<T> {
-//   return value !== null && value !== undefined;
-// }
-
-interface IProps {
-  apps: AppTableData;
-}
-
-const AppStoreContainer: React.FC<IProps> = ({ apps }) => {
-  const [search, setSearch] = React.useState('');
-  const [categories, setCategories] = React.useState<AppCategoriesEnum[]>([]);
-  const [sort, setSort] = React.useState<SortableColumns>('name');
-  const [sortDirection, setSortDirection] = React.useState<SortDirection>('asc');
-
-  const tableData = React.useMemo(() => {
-    return sortTable(apps, sort, sortDirection, categories, search);
-  }, [categories, apps, sort, sortDirection, search]);
-
-  const handleSearch = React.useCallback((value: string) => {
-    setSearch(value);
-  }, []);
-
-  const handleCategory = React.useCallback((value: AppCategoriesEnum[]) => {
-    setCategories(value);
-  }, []);
-
-  const handleSort = React.useCallback((value: SortableColumns) => {
-    setSort(value);
-  }, []);
-
-  const handleSortDirection = React.useCallback((value: SortDirection) => {
-    setSortDirection(value);
-  }, []);
-
-  return (
-    <Flex className="flex-col">
-      <h1 className="font-bold text-3xl mb-5">App Store</h1>
-      <AppStoreTable data={tableData} onSearch={handleSearch} onSelectCategories={handleCategory} onSortBy={handleSort} onChangeDirection={handleSortDirection} />
-    </Flex>
-  );
-};
-
-export default AppStoreContainer;

+ 16 - 0
packages/dashboard/src/modules/AppStore/containers/AppStoreContainer/AppStoreContainer.tsx

@@ -0,0 +1,16 @@
+import React from 'react';
+import AppStoreTable from '../../components/AppStoreTable';
+import { AppTableData } from '../../helpers/table.types';
+
+interface IProps {
+  apps: AppTableData;
+  loading?: boolean;
+}
+
+const AppStoreContainer: React.FC<IProps> = ({ apps, loading }) => (
+  <div className="card px-3 pb-3">
+    <AppStoreTable loading={loading} data={apps} />
+  </div>
+);
+
+export default AppStoreContainer;

+ 1 - 0
packages/dashboard/src/modules/AppStore/containers/AppStoreContainer/index.ts

@@ -0,0 +1 @@
+export { default } from './AppStoreContainer';

+ 28 - 21
packages/dashboard/src/modules/AppStore/helpers/table.helpers.ts

@@ -1,7 +1,17 @@
 import { AppCategoriesEnum, AppInfo } from '../../../generated/graphql';
 import { AppCategoriesEnum, AppInfo } from '../../../generated/graphql';
 import { AppTableData } from './table.types';
 import { AppTableData } from './table.types';
 
 
-export const sortTable = (data: AppTableData, col: keyof Pick<AppInfo, 'name'>, direction: 'asc' | 'desc', categories: AppCategoriesEnum[], search: string) => {
+type SortParams = {
+  data: AppTableData;
+  col: keyof Pick<AppInfo, 'name'>;
+  direction: 'asc' | 'desc';
+  category?: AppCategoriesEnum;
+  search: string;
+};
+
+export const sortTable = (params: SortParams) => {
+  const { data, col, direction, category, search } = params;
+
   const sortedData = [...data].sort((a, b) => {
   const sortedData = [...data].sort((a, b) => {
     const aVal = a[col];
     const aVal = a[col];
     const bVal = b[col];
     const bVal = b[col];
@@ -14,30 +24,27 @@ export const sortTable = (data: AppTableData, col: keyof Pick<AppInfo, 'name'>,
     return 0;
     return 0;
   });
   });
 
 
-  if (categories.length > 0) {
-    return sortedData.filter((app) => app.categories.some((c) => categories.includes(c))).filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
-  } else {
-    return sortedData.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
+  if (category) {
+    return sortedData.filter((app) => app.categories.some((c) => c === category)).filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
   }
   }
+  return sortedData.filter((app) => app.name.toLowerCase().includes(search.toLowerCase()));
 };
 };
 
 
-export const limitText = (text: string, limit: number) => {
-  return text.length > limit ? `${text.substring(0, limit)}...` : text;
-};
+export const limitText = (text: string, limit: number) => (text.length > limit ? `${text.substring(0, limit)}...` : text);
 
 
 export const colorSchemeForCategory: Record<AppCategoriesEnum, string> = {
 export const colorSchemeForCategory: Record<AppCategoriesEnum, string> = {
   [AppCategoriesEnum.Network]: 'blue',
   [AppCategoriesEnum.Network]: 'blue',
-  [AppCategoriesEnum.Media]: 'green',
-  [AppCategoriesEnum.Automation]: 'orange',
-  [AppCategoriesEnum.Development]: 'purple',
-  [AppCategoriesEnum.Utilities]: 'gray',
-  [AppCategoriesEnum.Photography]: 'red',
-  [AppCategoriesEnum.Security]: 'yellow',
-  [AppCategoriesEnum.Social]: 'teal',
-  [AppCategoriesEnum.Featured]: 'pink',
-  [AppCategoriesEnum.Data]: 'red',
-  [AppCategoriesEnum.Books]: 'blue',
-  [AppCategoriesEnum.Music]: 'green',
-  [AppCategoriesEnum.Finance]: 'orange',
-  [AppCategoriesEnum.Gaming]: 'purple',
+  [AppCategoriesEnum.Media]: 'azure',
+  [AppCategoriesEnum.Automation]: 'indigo',
+  [AppCategoriesEnum.Development]: 'red',
+  [AppCategoriesEnum.Utilities]: 'muted',
+  [AppCategoriesEnum.Photography]: 'purple',
+  [AppCategoriesEnum.Security]: 'organge',
+  [AppCategoriesEnum.Social]: 'yellow',
+  [AppCategoriesEnum.Featured]: 'lime',
+  [AppCategoriesEnum.Data]: 'green',
+  [AppCategoriesEnum.Books]: 'teal',
+  [AppCategoriesEnum.Music]: 'cyan',
+  [AppCategoriesEnum.Finance]: 'dark',
+  [AppCategoriesEnum.Gaming]: 'pink',
 };
 };

+ 5 - 0
packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.module.scss

@@ -0,0 +1,5 @@
+.selector {
+  @media screen and (min-width: 768px) {
+    max-width: 300px;
+  }
+}

+ 36 - 0
packages/dashboard/src/modules/AppStore/pages/AppStorePage/AppStorePage.tsx

@@ -0,0 +1,36 @@
+import React from 'react';
+import type { NextPage } from 'next';
+import clsx from 'clsx';
+import styles from './AppStorePage.module.scss';
+import { useListAppsQuery } from '../../../../generated/graphql';
+import { useAppStoreState } from '../../state/appStoreState';
+import { Input } from '../../../../components/ui/Input';
+import CategorySelector from '../../components/CategorySelector';
+import { sortTable } from '../../helpers/table.helpers';
+import { Layout } from '../../../../components/Layout';
+import { EmptyPage } from '../../../../components/ui/EmptyPage';
+import AppStoreContainer from '../../containers/AppStoreContainer';
+
+export const AppStorePage: NextPage = () => {
+  const { loading, data } = useListAppsQuery();
+  const { setCategory, setSearch, category, search, sort, sortDirection } = useAppStoreState();
+
+  const actions = (
+    <div className="d-flex align-items-stretch align-items-md-center flex-column flex-md-row justify-content-end">
+      <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="search" className={clsx('flex-fill mt-2 mt-md-0 me-md-2', styles.selector)} />
+      <CategorySelector initialValue={category} className={clsx('flex-fill mt-2 mt-md-0', styles.selector)} onSelect={setCategory} />
+    </div>
+  );
+
+  const tableData = React.useMemo(
+    () => sortTable({ data: data?.listAppsInfo.apps || [], col: sort, direction: sortDirection, category, search }),
+    [data?.listAppsInfo.apps, sort, sortDirection, category, search],
+  );
+
+  return (
+    <Layout loading={loading && !data} title="App Store" actions={actions}>
+      {(tableData.length > 0 || loading) && <AppStoreContainer loading={loading} apps={tableData} />}
+      {tableData.length === 0 && <EmptyPage title="No app found" subtitle="Try to refine your search" />}
+    </Layout>
+  );
+};

+ 1 - 0
packages/dashboard/src/modules/AppStore/pages/AppStorePage/index.ts

@@ -0,0 +1 @@
+export { AppStorePage } from './AppStorePage';

+ 25 - 0
packages/dashboard/src/modules/AppStore/state/appStoreState.ts

@@ -0,0 +1,25 @@
+import create from 'zustand';
+import { AppCategoriesEnum } from '../../../generated/graphql';
+import { SortableColumns } from '../helpers/table.types';
+
+type Store = {
+  search: string;
+  setSearch: (textSearch: string) => void;
+  category?: AppCategoriesEnum;
+  setCategory: (selectedCategories?: AppCategoriesEnum) => void;
+  sort: SortableColumns;
+  setSort: (sort: SortableColumns) => void;
+  sortDirection: 'asc' | 'desc';
+  setSortDirection: (sortDirection: 'asc' | 'desc') => void;
+};
+
+export const useAppStoreState = create<Store>((set) => ({
+  category: undefined,
+  search: '',
+  setSearch: (search) => set({ search }),
+  setCategory: (category) => set({ category }),
+  sort: 'name',
+  setSort: (sort) => set({ sort }),
+  sortDirection: 'asc',
+  setSortDirection: (sortDirection) => set({ sortDirection }),
+}));

+ 18 - 38
packages/dashboard/src/modules/Apps/components/AppActions.tsx

@@ -1,9 +1,8 @@
-import { Button, Tooltip } from '@chakra-ui/react';
+import { IconDownload, IconExternalLink, IconPlayerPause, IconPlayerPlay, IconSettings, IconTrash, IconX, TablerIcon } from '@tabler/icons';
+import clsx from 'clsx';
 import React from 'react';
 import React from 'react';
-import { IconType } from 'react-icons';
-import { FiExternalLink, FiPause, FiPlay, FiSettings, FiTrash2 } from 'react-icons/fi';
-import { MdSystemUpdateAlt } from 'react-icons/md';
-import { TiCancel } from 'react-icons/ti';
+
+import { Button } from '../../../components/ui/Button';
 import { AppInfo, AppStatusEnum } from '../../../generated/graphql';
 import { AppInfo, AppStatusEnum } from '../../../generated/graphql';
 
 
 interface IProps {
 interface IProps {
@@ -21,7 +20,7 @@ interface IProps {
 }
 }
 
 
 interface BtnProps {
 interface BtnProps {
-  Icon?: IconType;
+  Icon?: TablerIcon;
   onClick: () => void;
   onClick: () => void;
   width?: number | null;
   width?: number | null;
   title?: string;
   title?: string;
@@ -30,12 +29,12 @@ interface BtnProps {
 }
 }
 
 
 const ActionButton: React.FC<BtnProps> = (props) => {
 const ActionButton: React.FC<BtnProps> = (props) => {
-  const { Icon, onClick, title, loading, width = 150, color = 'gray' } = props;
+  const { Icon, onClick, title, loading, color, width = 140 } = props;
 
 
   return (
   return (
-    <Button isLoading={loading} onClick={onClick} width={width || undefined} colorScheme={color} className="mt-3 mr-2">
+    <Button loading={loading} onClick={onClick} width={width} className={clsx('me-2 px-4 mt-2', [`btn-${color}`])}>
       {title}
       {title}
-      {Icon && <Icon className="ml-1" />}
+      {Icon && <Icon className="ms-1" size={14} />}
     </Button>
     </Button>
   );
   );
 };
 };
@@ -45,25 +44,15 @@ const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onS
 
 
   const buttons: JSX.Element[] = [];
   const buttons: JSX.Element[] = [];
 
 
-  const renderStatus = () => {
-    if (status === AppStatusEnum.Installing || status === AppStatusEnum.Uninstalling || status === AppStatusEnum.Starting || status === AppStatusEnum.Stopping || status === AppStatusEnum.Updating) {
-      return <span className="text-gray-500 text-sm ml-2 mt-3 self-center text-center sm:text-left">{`App is ${status.toLowerCase()} please wait...`}</span>;
-    }
-  };
-
-  const StartButton = <ActionButton Icon={FiPlay} onClick={onStart} title="Start" color="green" />;
-  const RemoveButton = <ActionButton Icon={FiTrash2} onClick={onUninstall} title="Remove" />;
-  const SettingsButton = <ActionButton Icon={FiSettings} width={null} onClick={onUpdateSettings} />;
-  const StopButton = <ActionButton Icon={FiPause} onClick={onStop} title="Stop" color="red" />;
-  const OpenButton = <ActionButton Icon={FiExternalLink} onClick={onOpen} title="Open" />;
-  const LoadingButtion = <ActionButton loading onClick={() => null} color="green" />;
-  const CancelButton = <ActionButton Icon={TiCancel} onClick={onCancel} title="Cancel" />;
-  const InstallButton = <ActionButton onClick={onInstall} title="Install" color="green" />;
-  const UpdateButton = (
-    <Tooltip label="Download update">
-      <ActionButton Icon={MdSystemUpdateAlt} onClick={onUpdate} width={null} />
-    </Tooltip>
-  );
+  const StartButton = <ActionButton Icon={IconPlayerPlay} onClick={onStart} title="Start" color="success" />;
+  const RemoveButton = <ActionButton Icon={IconTrash} onClick={onUninstall} title="Remove" color="danger" />;
+  const SettingsButton = <ActionButton Icon={IconSettings} onClick={onUpdateSettings} title="Settings" />;
+  const StopButton = <ActionButton Icon={IconPlayerPause} onClick={onStop} title="Stop" color="danger" />;
+  const OpenButton = <ActionButton Icon={IconExternalLink} onClick={onOpen} title="Open" />;
+  const LoadingButtion = <ActionButton loading onClick={() => null} color="success" />;
+  const CancelButton = <ActionButton Icon={IconX} onClick={onCancel} title="Cancel" />;
+  const InstallButton = <ActionButton onClick={onInstall} title="Install" color="success" />;
+  const UpdateButton = <ActionButton Icon={IconDownload} onClick={onUpdate} width={null} title="Update" color="success" />;
 
 
   switch (status) {
   switch (status) {
     case AppStatusEnum.Stopped:
     case AppStatusEnum.Stopped:
@@ -104,16 +93,7 @@ const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onS
       break;
       break;
   }
   }
 
 
-  return (
-    <div className="flex flex-1 flex-col justify-start">
-      <div className="flex flex-1 justify-center md:justify-start flex-wrap">
-        {buttons.map((button) => {
-          return button;
-        })}
-      </div>
-      <div className="mt-1 flex justify-center md:justify-start">{renderStatus()}</div>
-    </div>
-  );
+  return <div className="d-flex justify-content-center flex-wrap">{buttons.map((button) => button)}</div>;
 };
 };
 
 
 export default AppActions;
 export default AppActions;

+ 58 - 0
packages/dashboard/src/modules/Apps/components/AppDetailsTabs.tsx

@@ -0,0 +1,58 @@
+import { IconExternalLink } from '@tabler/icons';
+import React from 'react';
+import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
+import Markdown from '../../../components/Markdown/Markdown';
+import { AppInfo } from '../../../generated/graphql';
+
+interface IProps {
+  info: AppInfo;
+}
+
+const AppDetailsTabs: React.FC<IProps> = ({ info }) => (
+  <div className="card">
+    <div style={{ marginTop: -1, marginBottom: -3 }} className="card-header">
+      <ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs" role="tablist">
+        <li className="nav-item">
+          <a className="nav-link active" href="#tabs-description" data-bs-toggle="tab" role="tab" aria-selected="true">
+            Description
+          </a>
+        </li>
+        <li className="nav-item">
+          <a className="nav-link" href="#tabs-links" data-bs-toggle="tab" role="tab" aria-selected="true">
+            Base Info
+          </a>
+        </li>
+      </ul>
+    </div>
+    <div className="card-body">
+      <div className="tab-content">
+        <div className="tab-pane active" id="tabs-description" role="tabpanel">
+          <Markdown className="markdown">{info.description}</Markdown>
+        </div>
+        <div className="tab-pane" id="tabs-links" role="tabpanel">
+          <DataGrid>
+            <DataGridItem title="Source code">
+              <a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
+                Link
+                <IconExternalLink size={15} className="ms-1 mb-1" />
+              </a>
+            </DataGridItem>
+            <DataGridItem title="Author">{info.author}</DataGridItem>
+            <DataGridItem title="Port">{info.port}</DataGridItem>
+            <DataGridItem title="Categories">
+              {info.categories.map((c) => (
+                <div key={c} className="badge bg-green me-1">
+                  {c.toLowerCase()}
+                </div>
+              ))}
+            </DataGridItem>
+            <DataGridItem title="Version">{info.version}</DataGridItem>
+            {info.supported_architectures && <DataGridItem title="Supported architectures">{info.supported_architectures}</DataGridItem>}
+          </DataGrid>
+        </div>
+      </div>
+    </div>
+  </div>
+);
+
+export default AppDetailsTabs;

+ 65 - 63
packages/dashboard/src/modules/Apps/components/InstallForm.tsx

@@ -1,19 +1,21 @@
-import { Button } from '@chakra-ui/react';
-import React from 'react';
-import { Form, Field } from 'react-final-form';
-import FormInput from '../../../components/Form/FormInput';
-import FormSwitch from '../../../components/Form/FormSwitch';
-import { validateAppConfig } from '../../../components/Form/validators';
+import React, { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+
 import { AppInfo, FormField } from '../../../generated/graphql';
 import { AppInfo, FormField } from '../../../generated/graphql';
+import { Button } from '../../../components/ui/Button';
+import { Switch } from '../../../components/ui/Switch';
+import { Input } from '../../../components/ui/Input';
+import { validateAppConfig } from '../utils/validators';
 
 
 interface IProps {
 interface IProps {
   formFields: AppInfo['form_fields'];
   formFields: AppInfo['form_fields'];
   onSubmit: (values: Record<string, unknown>) => void;
   onSubmit: (values: Record<string, unknown>) => void;
-  initalValues?: Record<string, string>;
+  initalValues?: { exposed?: boolean; domain?: string } & { [key: string]: string };
+  loading?: boolean;
   exposable?: boolean | null;
   exposable?: boolean | null;
 }
 }
 
 
-export type IFormValues = {
+export type FormValues = {
   exposed?: boolean;
   exposed?: boolean;
   domain?: string;
   domain?: string;
   [key: string]: string | boolean | undefined;
   [key: string]: string | boolean | undefined;
@@ -22,65 +24,65 @@ export type IFormValues = {
 const hiddenTypes = ['random'];
 const hiddenTypes = ['random'];
 const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
 const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
 
 
-const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exposable }) => {
-  const renderField = (field: FormField) => {
-    return (
-      <Field
-        key={field.env_variable}
-        name={field.env_variable}
-        render={({ input, meta }) => (
-          <FormInput
-            hint={field.hint || ''}
-            placeholder={field.placeholder || ''}
-            className="mb-3"
-            error={meta.error}
-            isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)}
-            label={field.label}
-            {...input}
-          />
-        )}
-      />
-    );
-  };
+const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exposable, loading }) => {
+  const {
+    register,
+    handleSubmit,
+    formState: { errors },
+    setValue,
+    watch,
+    setError,
+  } = useForm<FormValues>({});
+  const watchExposed = watch('exposed', false);
+
+  useEffect(() => {
+    if (initalValues) {
+      Object.entries(initalValues).forEach(([key, value]) => {
+        setValue(key, value);
+      });
+    }
+  }, [initalValues, setValue]);
 
 
-  const renderExposeForm = (isExposedChecked?: boolean) => {
-    return (
-      <>
-        <Field key="exposed" name="exposed" type="checkbox" render={({ input }) => <FormSwitch className="mb-3" label="Expose app ?" {...input} />} />
-        {isExposedChecked && (
-          <>
-            <Field
-              key="domain"
-              name="domain"
-              render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label="Domain name" {...input} />}
-            />
-            <span className="text-sm">
-              Make sure this exact domain contains an <strong>A</strong> record pointing to your IP.
-            </span>
-          </>
-        )}
-      </>
-    );
+  const renderField = (field: FormField) => (
+    <Input {...register(field.env_variable)} label={field.label} error={errors[field.env_variable]?.message} disabled={loading} className="mb-3" placeholder={field.hint || field.label} />
+  );
+
+  const renderExposeForm = () => (
+    <>
+      <Switch className="mb-3" {...register('exposed')} label="Expose app" />
+      {watchExposed && (
+        <div className="mb-3">
+          <Input {...register('domain')} label="Domain name" error={errors.domain?.message} disabled={loading} placeholder="Domain name" />
+          <span className="text-muted">
+            Make sure this exact domain contains an <strong>A</strong> record pointing to your IP.
+          </span>
+        </div>
+      )}
+    </>
+  );
+
+  const validate = (values: FormValues) => {
+    const validationErrors = validateAppConfig(values, formFields);
+
+    Object.entries(validationErrors).forEach(([key, value]) => {
+      if (value) {
+        setError(key, { message: value });
+      }
+    });
+
+    if (Object.keys(validationErrors).length === 0) {
+      onSubmit(values);
+    }
   };
   };
 
 
   return (
   return (
-    <Form<IFormValues>
-      initialValues={initalValues}
-      onSubmit={onSubmit}
-      validateOnBlur={true}
-      validate={(values) => validateAppConfig(values, formFields)}
-      render={({ handleSubmit, validating, submitting, values }) => (
-        <form className="flex flex-col" onSubmit={handleSubmit}>
-          <>
-            {formFields.filter(typeFilter).map(renderField)}
-            {exposable && renderExposeForm(values.exposed)}
-            <Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
-              {initalValues ? 'Update' : 'Install'}
-            </Button>
-          </>
-        </form>
-      )}
-    />
+    <form className="flex flex-col" onSubmit={handleSubmit(validate)}>
+      {formFields.filter(typeFilter).map(renderField)}
+      {exposable && renderExposeForm()}
+      <Button type="submit" className="btn-success">
+        {initalValues ? 'Update' : 'Install'}
+      </Button>
+    </form>
   );
   );
 };
 };
 
 

+ 11 - 15
packages/dashboard/src/modules/Apps/components/InstallModal.tsx

@@ -1,7 +1,7 @@
-import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
 import InstallForm from './InstallForm';
 import InstallForm from './InstallForm';
 import { AppInfo } from '../../../generated/graphql';
 import { AppInfo } from '../../../generated/graphql';
+import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
 
 
 interface IProps {
 interface IProps {
   app: AppInfo;
   app: AppInfo;
@@ -10,19 +10,15 @@ interface IProps {
   onSubmit: (values: Record<string, any>) => void;
   onSubmit: (values: Record<string, any>) => void;
 }
 }
 
 
-const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
-  return (
-    <Modal isOpen={isOpen} onClose={onClose}>
-      <ModalOverlay />
-      <ModalContent>
-        <ModalHeader>Install {app.name}</ModalHeader>
-        <ModalCloseButton />
-        <ModalBody>
-          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
-        </ModalBody>
-      </ModalContent>
-    </Modal>
-  );
-};
+const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => (
+  <Modal onClose={onClose} isOpen={isOpen}>
+    <ModalHeader>
+      <h5 className="modal-title">Install {app.name}</h5>
+    </ModalHeader>
+    <ModalBody>
+      <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
+    </ModalBody>
+  </Modal>
+);
 
 
 export default InstallModal;
 export default InstallModal;

+ 17 - 18
packages/dashboard/src/modules/Apps/components/StopModal.tsx

@@ -1,6 +1,7 @@
-import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
 import { AppInfo } from '../../../generated/graphql';
 import { AppInfo } from '../../../generated/graphql';
+import { Button } from '../../../components/ui/Button';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
 
 
 interface IProps {
 interface IProps {
   app: AppInfo;
   app: AppInfo;
@@ -9,22 +10,20 @@ interface IProps {
   onConfirm: () => void;
   onConfirm: () => void;
 }
 }
 
 
-const StopModal: React.FC<IProps> = ({ app, isOpen, onClose, onConfirm }) => {
-  return (
-    <Modal isOpen={isOpen} onClose={onClose}>
-      <ModalOverlay />
-      <ModalContent>
-        <ModalHeader>Stop {app.name} ?</ModalHeader>
-        <ModalCloseButton />
-        <ModalBody>All the data will be retained.</ModalBody>
-        <ModalFooter>
-          <Button onClick={onConfirm} colorScheme="red">
-            Stop
-          </Button>
-        </ModalFooter>
-      </ModalContent>
-    </Modal>
-  );
-};
+const StopModal: React.FC<IProps> = ({ app, isOpen, onClose, onConfirm }) => (
+  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
+    <ModalHeader>
+      <h5 className="modal-title">Stop {app.name} ?</h5>
+    </ModalHeader>
+    <ModalBody>
+      <div className="text-muted">All data will be retained</div>
+    </ModalBody>
+    <ModalFooter>
+      <Button onClick={onConfirm} className="btn-danger">
+        Stop
+      </Button>
+    </ModalFooter>
+  </Modal>
+);
 
 
 export default StopModal;
 export default StopModal;

+ 21 - 18
packages/dashboard/src/modules/Apps/components/UninstallModal.tsx

@@ -1,5 +1,8 @@
-import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
+import { IconAlertTriangle } from '@tabler/icons';
 import React from 'react';
 import React from 'react';
+import { Button } from '../../../components/ui/Button';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
+
 import { AppInfo } from '../../../generated/graphql';
 import { AppInfo } from '../../../generated/graphql';
 
 
 interface IProps {
 interface IProps {
@@ -9,22 +12,22 @@ interface IProps {
   onConfirm: () => void;
   onConfirm: () => void;
 }
 }
 
 
-const UninstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onConfirm }) => {
-  return (
-    <Modal isOpen={isOpen} onClose={onClose}>
-      <ModalOverlay />
-      <ModalContent>
-        <ModalHeader>Uninstall {app.name} ?</ModalHeader>
-        <ModalCloseButton />
-        <ModalBody>All data for this app will be lost.</ModalBody>
-        <ModalFooter>
-          <Button onClick={onConfirm} colorScheme="red">
-            Uninstall
-          </Button>
-        </ModalFooter>
-      </ModalContent>
-    </Modal>
-  );
-};
+const UninstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onConfirm }) => (
+  <Modal size="sm" type="danger" onClose={onClose} isOpen={isOpen}>
+    <ModalHeader>
+      <h5 className="modal-title">Uninstall {app.name} ?</h5>
+    </ModalHeader>
+    <ModalBody className="text-center py-4">
+      <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
+      <h3>Are you sure?</h3>
+      <div className="text-muted">All data for this app will be lost.</div>
+    </ModalBody>
+    <ModalFooter>
+      <Button onClick={onConfirm} className="btn-danger">
+        Uninstall
+      </Button>
+    </ModalFooter>
+  </Modal>
+);
 
 
 export default UninstallModal;
 export default UninstallModal;

+ 21 - 21
packages/dashboard/src/modules/Apps/components/UpdateModal.tsx

@@ -1,5 +1,7 @@
-import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
+import { Button } from '../../../components/ui/Button';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../../components/ui/Modal';
+
 import { AppInfo } from '../../../generated/graphql';
 import { AppInfo } from '../../../generated/graphql';
 
 
 interface IProps {
 interface IProps {
@@ -10,25 +12,23 @@ interface IProps {
   onConfirm: () => void;
   onConfirm: () => void;
 }
 }
 
 
-const UpdateModal: React.FC<IProps> = ({ app, newVersion, isOpen, onClose, onConfirm }) => {
-  return (
-    <Modal isOpen={isOpen} onClose={onClose}>
-      <ModalOverlay />
-      <ModalContent>
-        <ModalHeader>Update {app.name} ?</ModalHeader>
-        <ModalCloseButton />
-        <ModalBody>
-          Update app to latest verion : <b>{newVersion}</b> ?<br />
-          This will reset your custom configuration (e.g. changes in docker-compose.yml)
-        </ModalBody>
-        <ModalFooter>
-          <Button onClick={onConfirm} colorScheme="green">
-            Update
-          </Button>
-        </ModalFooter>
-      </ModalContent>
-    </Modal>
-  );
-};
+const UpdateModal: React.FC<IProps> = ({ app, newVersion, isOpen, onClose, onConfirm }) => (
+  <Modal size="sm" onClose={onClose} isOpen={isOpen}>
+    <ModalHeader>
+      <h5 className="modal-title">Update {app.name} ?</h5>
+    </ModalHeader>
+    <ModalBody>
+      <div className="text-muted">
+        Update app to latest verion : <b>{newVersion}</b> ?<br />
+        This will reset your custom configuration (e.g. changes in docker-compose.yml)
+      </div>
+    </ModalBody>
+    <ModalFooter>
+      <Button onClick={onConfirm} className="btn-success">
+        Update
+      </Button>
+    </ModalFooter>
+  </Modal>
+);
 
 
 export default UpdateModal;
 export default UpdateModal;

+ 11 - 15
packages/dashboard/src/modules/Apps/components/UpdateSettingsModal.tsx

@@ -1,7 +1,7 @@
-import { Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
 import InstallForm from './InstallForm';
 import InstallForm from './InstallForm';
 import { App, AppInfo } from '../../../generated/graphql';
 import { App, AppInfo } from '../../../generated/graphql';
+import { Modal, ModalBody, ModalHeader } from '../../../components/ui/Modal';
 
 
 interface IProps {
 interface IProps {
   app: AppInfo;
   app: AppInfo;
@@ -13,19 +13,15 @@ interface IProps {
   onSubmit: (values: Record<string, any>) => void;
   onSubmit: (values: Record<string, any>) => void;
 }
 }
 
 
-const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => {
-  return (
-    <Modal isOpen={isOpen} onClose={onClose}>
-      <ModalOverlay />
-      <ModalContent>
-        <ModalHeader>Update {app.name} config</ModalHeader>
-        <ModalCloseButton />
-        <ModalBody>
-          <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} initalValues={{ ...config, exposed, domain }} />
-        </ModalBody>
-      </ModalContent>
-    </Modal>
-  );
-};
+const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => (
+  <Modal onClose={onClose} isOpen={isOpen}>
+    <ModalHeader>
+      <h5 className="modal-title">Update {app.name} config</h5>
+    </ModalHeader>
+    <ModalBody>
+      <InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} initalValues={{ ...config, exposed, domain }} />
+    </ModalBody>
+  </Modal>
+);
 
 
 export default UpdateSettingsModal;
 export default UpdateSettingsModal;

+ 0 - 217
packages/dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -1,217 +0,0 @@
-import { SlideFade, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
-import React from 'react';
-import { FiExternalLink } from 'react-icons/fi';
-import AppActions from '../components/AppActions';
-import InstallModal from '../components/InstallModal';
-import StopModal from '../components/StopModal';
-import UninstallModal from '../components/UninstallModal';
-import UpdateSettingsModal from '../components/UpdateSettingsModal';
-import AppLogo from '../../../components/AppLogo/AppLogo';
-import Markdown from '../../../components/Markdown/Markdown';
-import {
-  App,
-  AppInfo,
-  AppStatusEnum,
-  GetAppDocument,
-  InstalledAppsDocument,
-  useInstallAppMutation,
-  useStartAppMutation,
-  useStopAppMutation,
-  useUninstallAppMutation,
-  useUpdateAppConfigMutation,
-  useUpdateAppMutation,
-} from '../../../generated/graphql';
-import UpdateModal from '../components/UpdateModal';
-import { IFormValues } from '../components/InstallForm';
-
-interface IProps {
-  app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo' | 'exposed' | 'domain'>;
-  info: AppInfo;
-}
-
-const AppDetails: React.FC<IProps> = ({ app, info }) => {
-  const toast = useToast();
-  const installDisclosure = useDisclosure();
-  const uninstallDisclosure = useDisclosure();
-  const stopDisclosure = useDisclosure();
-  const updateDisclosure = useDisclosure();
-  const updateSettingsDisclosure = useDisclosure();
-
-  // Mutations
-  const [update] = useUpdateAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
-  const [install] = useInstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
-  const [uninstall] = useUninstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
-  const [stop] = useStopAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
-  const [start] = useStartAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
-  const [updateConfig] = useUpdateAppConfigMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
-
-  const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
-
-  const handleError = (error: unknown) => {
-    if (error instanceof Error) {
-      toast({
-        title: 'Error',
-        description: error.message,
-        status: 'error',
-        position: 'top',
-        isClosable: true,
-      });
-    }
-  };
-
-  const handleInstallSubmit = async (values: IFormValues) => {
-    installDisclosure.onClose();
-    const { exposed, domain, ...form } = values;
-    try {
-      await install({
-        variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } },
-        optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
-      });
-    } catch (error) {
-      handleError(error);
-    }
-  };
-
-  const handleUnistallSubmit = async () => {
-    uninstallDisclosure.onClose();
-    try {
-      await uninstall({ variables: { id: info.id }, optimisticResponse: { uninstallApp: { id: info.id, status: AppStatusEnum.Uninstalling, __typename: 'App' } } });
-    } catch (error) {
-      handleError(error);
-    }
-  };
-
-  const handleStopSubmit = async () => {
-    stopDisclosure.onClose();
-    try {
-      await stop({ variables: { id: info.id }, optimisticResponse: { stopApp: { id: info.id, status: AppStatusEnum.Stopping, __typename: 'App' } } });
-    } catch (error) {
-      handleError(error);
-    }
-  };
-
-  const handleStartSubmit = async () => {
-    try {
-      await start({ variables: { id: info.id }, optimisticResponse: { startApp: { id: info.id, status: AppStatusEnum.Starting, __typename: 'App' } } });
-    } catch (e: unknown) {
-      handleError(e);
-    }
-  };
-
-  const handleUpdateSettingsSubmit = async (values: IFormValues) => {
-    try {
-      const { exposed, domain, ...form } = values;
-      await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
-      toast({
-        title: 'Success',
-        description: 'App config updated successfully. Restart the app to apply the changes.',
-        position: 'top',
-        status: 'success',
-        isClosable: true,
-      });
-      updateSettingsDisclosure.onClose();
-    } catch (error) {
-      handleError(error);
-    }
-  };
-
-  const handleUpdateSubmit = async () => {
-    updateDisclosure.onClose();
-    try {
-      await update({ variables: { id: info.id }, optimisticResponse: { updateApp: { id: info.id, status: AppStatusEnum.Updating, __typename: 'App' } } });
-      toast({
-        title: 'Success',
-        description: 'App updated successfully',
-        position: 'top',
-        status: 'success',
-        isClosable: true,
-      });
-    } catch (error) {
-      handleError(error);
-    }
-  };
-
-  const handleOpen = () => {
-    const { https } = info;
-    const protocol = https ? 'https' : 'http';
-
-    if (typeof window !== 'undefined') {
-      // Current domain
-      const domain = window.location.hostname;
-      window.open(`${protocol}://${domain}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
-    }
-  };
-
-  const version = [info?.version || 'unknown', app?.version ? `(${app.version})` : ''].join(' ');
-  const newVersion = [app?.updateInfo?.dockerVersion ? `${app?.updateInfo?.dockerVersion}` : '', `(${app?.updateInfo?.latest})`].join(' ');
-
-  return (
-    <SlideFade in className="flex flex-1" offsetY="20px">
-      <div className="flex flex-1 p-4 mt-3 rounded-lg flex-col">
-        <Flex className="flex-col md:flex-row">
-          <AppLogo id={info.id} size={180} className="self-center md:self-auto" alt={info.name} />
-          <div className="flex flex-col justify-between flex-1 ml-0 md:ml-4">
-            <div className="mt-3 items-center self-center flex flex-col md:items-start md:self-start md:mt-0">
-              <h1 className="font-bold text-2xl">{info.name}</h1>
-              {app?.domain && app.exposed && (
-                <a target="_blank" rel="noreferrer" className="text-blue-500 text-md" href={`https://${app.domain}`}>
-                  <Flex className="items-center">
-                    {app.domain}
-                    <FiExternalLink className="ml-1" />
-                  </Flex>
-                </a>
-              )}
-
-              <h2 className="text-center md:text-left">{info.short_desc}</h2>
-              <h3 className="text-center md:text-left text-sm">
-                version: <b>{version}</b>
-              </h3>
-              {info.source && (
-                <a target="_blank" rel="noreferrer" className="text-blue-500 text-xs" href={info.source}>
-                  <Flex className="mt-2 items-center">
-                    Source
-                    <FiExternalLink className="ml-1" />
-                  </Flex>
-                </a>
-              )}
-              <p className="text-xs text-gray-600">By {info.author}</p>
-            </div>
-
-            <div className="flex flex-1">
-              <AppActions
-                updateAvailable={updateAvailable}
-                onUpdate={updateDisclosure.onOpen}
-                onUpdateSettings={updateSettingsDisclosure.onOpen}
-                onOpen={handleOpen}
-                onStart={handleStartSubmit}
-                onStop={stopDisclosure.onOpen}
-                onCancel={stopDisclosure.onOpen}
-                onUninstall={uninstallDisclosure.onOpen}
-                onInstall={installDisclosure.onOpen}
-                app={info}
-                status={app?.status}
-              />
-            </div>
-          </div>
-        </Flex>
-        <Divider className="mt-5" />
-        <Markdown className="mt-3">{info.description}</Markdown>
-        <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
-        <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
-        <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
-        <UpdateSettingsModal
-          onSubmit={handleUpdateSettingsSubmit}
-          isOpen={updateSettingsDisclosure.isOpen}
-          onClose={updateSettingsDisclosure.onClose}
-          app={info}
-          config={app?.config}
-          exposed={app?.exposed}
-          domain={app?.domain || ''}
-        />
-        <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
-      </div>
-    </SlideFade>
-  );
-};
-
-export default AppDetails;

+ 193 - 0
packages/dashboard/src/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

@@ -0,0 +1,193 @@
+import React from 'react';
+import { useDisclosure } from '../../../../hooks/useDisclosure';
+import { useToastStore } from '../../../../state/toastStore';
+import { AppLogo } from '../../../../components/AppLogo/AppLogo';
+import { AppStatus } from '../../../../components/AppStatus';
+import {
+  App,
+  AppInfo,
+  AppStatusEnum,
+  GetAppDocument,
+  InstalledAppsDocument,
+  useInstallAppMutation,
+  useStartAppMutation,
+  useStopAppMutation,
+  useUninstallAppMutation,
+  useUpdateAppConfigMutation,
+  useUpdateAppMutation,
+} from '../../../../generated/graphql';
+import AppActions from '../../components/AppActions';
+import AppDetailsTabs from '../../components/AppDetailsTabs';
+import { FormValues } from '../../components/InstallForm';
+import InstallModal from '../../components/InstallModal';
+import StopModal from '../../components/StopModal';
+import UninstallModal from '../../components/UninstallModal';
+import UpdateModal from '../../components/UpdateModal';
+import UpdateSettingsModal from '../../components/UpdateSettingsModal';
+
+interface IProps {
+  app: Pick<App, 'id' | 'updateInfo' | 'config' | 'exposed' | 'domain' | 'status'>;
+  info: AppInfo;
+}
+
+const AppDetailsContainer: React.FC<IProps> = ({ app, info }) => {
+  const { addToast } = useToastStore();
+  const installDisclosure = useDisclosure();
+  const uninstallDisclosure = useDisclosure();
+  const stopDisclosure = useDisclosure();
+  const updateDisclosure = useDisclosure();
+  const updateSettingsDisclosure = useDisclosure();
+
+  // Mutations
+  const [install] = useInstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
+  const [update] = useUpdateAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
+  const [uninstall] = useUninstallAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }, { query: InstalledAppsDocument }] });
+  const [stop] = useStopAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
+  const [start] = useStartAppMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
+  const [updateConfig] = useUpdateAppConfigMutation({ refetchQueries: [{ query: GetAppDocument, variables: { appId: info.id } }] });
+
+  const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
+
+  const handleError = (error: unknown) => {
+    if (error instanceof Error) {
+      addToast({
+        title: 'Error',
+        description: error.message,
+        status: 'error',
+        position: 'top',
+        isClosable: true,
+      });
+    }
+  };
+
+  const handleInstallSubmit = async (values: FormValues) => {
+    installDisclosure.close();
+    const { exposed, domain, ...form } = values;
+
+    try {
+      await install({
+        variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } },
+        optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
+      });
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleUnistallSubmit = async () => {
+    uninstallDisclosure.close();
+    try {
+      await uninstall({ variables: { id: info.id }, optimisticResponse: { uninstallApp: { id: info.id, status: AppStatusEnum.Uninstalling, __typename: 'App' } } });
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleStopSubmit = async () => {
+    stopDisclosure.close();
+    try {
+      await stop({ variables: { id: info.id }, optimisticResponse: { stopApp: { id: info.id, status: AppStatusEnum.Stopping, __typename: 'App' } } });
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleStartSubmit = async () => {
+    try {
+      await start({ variables: { id: info.id }, optimisticResponse: { startApp: { id: info.id, status: AppStatusEnum.Starting, __typename: 'App' } } });
+    } catch (e: unknown) {
+      handleError(e);
+    }
+  };
+
+  const handleUpdateSettingsSubmit = async (values: FormValues) => {
+    try {
+      const { exposed, domain, ...form } = values;
+      await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
+      addToast({
+        title: 'Success',
+        description: 'App config updated successfully. Restart the app to apply the changes.',
+        position: 'top',
+        status: 'success',
+        isClosable: true,
+      });
+      updateSettingsDisclosure.close();
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleUpdateSubmit = async () => {
+    updateDisclosure.close();
+    try {
+      await update({ variables: { id: info.id }, optimisticResponse: { updateApp: { id: info.id, status: AppStatusEnum.Updating, __typename: 'App' } } });
+      addToast({
+        title: 'Success',
+        description: 'App updated successfully',
+        position: 'top',
+        status: 'success',
+        isClosable: true,
+      });
+    } catch (error) {
+      handleError(error);
+    }
+  };
+
+  const handleOpen = () => {
+    const { https } = info;
+    const protocol = https ? 'https' : 'http';
+
+    if (typeof window !== 'undefined') {
+      // Current domain
+      const domain = window.location.hostname;
+      window.open(`${protocol}://${domain}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
+    }
+  };
+
+  const newVersion = [app?.updateInfo?.dockerVersion ? `${app?.updateInfo?.dockerVersion}` : '', `(${String(app?.updateInfo?.latest)})`].join(' ');
+
+  return (
+    <div className="card">
+      <InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} app={info} />
+      <StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} app={info} />
+      <UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} app={info} />
+      <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} app={info} newVersion={newVersion} />
+      <UpdateSettingsModal
+        onSubmit={handleUpdateSettingsSubmit}
+        isOpen={updateSettingsDisclosure.isOpen}
+        onClose={updateSettingsDisclosure.close}
+        app={info}
+        config={app?.config}
+        exposed={app?.exposed}
+        domain={app?.domain || ''}
+      />
+      <div className="card-header d-flex flex-column flex-md-row">
+        <AppLogo id={info.id} size={130} alt={info.name} />
+        <div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
+          <div className="">
+            <span className="mt-1 me-1">Version: </span>
+            <span className="badge bg-gray mt-2">{info?.version}</span>
+          </div>
+          <span className="mt-2 text-muted text-center mb-2">{info.short_desc}</span>
+          {app && app?.status !== AppStatusEnum.Missing && <AppStatus status={app.status} />}
+          <AppActions
+            updateAvailable={updateAvailable}
+            onUpdate={updateDisclosure.open}
+            onUpdateSettings={updateSettingsDisclosure.open}
+            onStop={stopDisclosure.open}
+            onCancel={stopDisclosure.open}
+            onUninstall={uninstallDisclosure.open}
+            onInstall={installDisclosure.open}
+            onOpen={handleOpen}
+            onStart={handleStartSubmit}
+            app={info}
+            status={app?.status}
+          />
+        </div>
+      </div>
+      <AppDetailsTabs info={info} />
+    </div>
+  );
+};
+
+export default AppDetailsContainer;

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików