diff --git a/Dockerfile b/.docker/Dockerfile similarity index 93% rename from Dockerfile rename to .docker/Dockerfile index 26b822b..a0e1488 100644 --- a/Dockerfile +++ b/.docker/Dockerfile @@ -25,5 +25,6 @@ WORKDIR /app EXPOSE 5005 ENV NODE_ENV=production +ENV PASSWORD=flame_password CMD ["node", "server.js"] diff --git a/Dockerfile.dev b/.docker/Dockerfile.dev similarity index 88% rename from Dockerfile.dev rename to .docker/Dockerfile.dev index 680ed26..951afd4 100644 --- a/Dockerfile.dev +++ b/.docker/Dockerfile.dev @@ -1,16 +1,26 @@ FROM node:lts-alpine as build-front + RUN apk add --no-cache curl + WORKDIR /app + COPY ./client . + RUN npm install --production \ && npm run build FROM node:lts-alpine + WORKDIR /app + RUN mkdir -p ./public + COPY --from=build-front /app/build/ ./public COPY package*.json ./ + RUN npm install + COPY . . -CMD ["npm", "run", "skaffold"] + +CMD ["npm", "run", "skaffold"] \ No newline at end of file diff --git a/Dockerfile.multiarch b/.docker/Dockerfile.multiarch similarity index 60% rename from Dockerfile.multiarch rename to .docker/Dockerfile.multiarch index d0bf6ab..6d4c34a 100644 --- a/Dockerfile.multiarch +++ b/.docker/Dockerfile.multiarch @@ -1,10 +1,11 @@ -FROM node:14 as builder +FROM node:14-alpine3.11 as builder WORKDIR /app COPY package*.json ./ -RUN npm install --production +RUN apk --no-cache --virtual build-dependencies add python make g++ \ + && npm install --production COPY . . @@ -16,7 +17,7 @@ RUN mkdir -p ./public ./data \ && mv ./client/build/* ./public \ && rm -rf ./client -FROM node:14-alpine +FROM node:14-alpine3.11 COPY --from=builder /app /app @@ -25,5 +26,6 @@ WORKDIR /app EXPOSE 5005 ENV NODE_ENV=production +ENV PASSWORD=flame_password -CMD ["node", "server.js"] +CMD ["node", "server.js"] \ No newline at end of file diff --git a/docker-compose.yml b/.docker/docker-compose.yml similarity index 79% rename from docker-compose.yml rename to .docker/docker-compose.yml index d4884b7..3758dcb 100644 --- a/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -7,4 +7,6 @@ services: - /path/to/data:/app/data ports: - 5005:5005 + environment: + - PASSWORD=flame_password restart: unless-stopped diff --git a/.dockerignore b/.dockerignore index 6c10c72..134be5d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ node_modules -github +.github public -build.sh k8s -skaffold.yaml +skaffold.yaml \ No newline at end of file diff --git a/.env b/.env index 52324f0..4390587 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ PORT=5005 NODE_ENV=development -VERSION=1.7.4 \ No newline at end of file +VERSION=2.0.0 +PASSWORD=flame_password +SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b \ No newline at end of file diff --git a/.github/_apps.png b/.github/_apps.png deleted file mode 100644 index 39096dc..0000000 Binary files a/.github/_apps.png and /dev/null differ diff --git a/.github/_bookmarks.png b/.github/_bookmarks.png deleted file mode 100644 index fe6999b..0000000 Binary files a/.github/_bookmarks.png and /dev/null differ diff --git a/.github/_home.png b/.github/_home.png deleted file mode 100644 index c24050f..0000000 Binary files a/.github/_home.png and /dev/null differ diff --git a/.github/apps.png b/.github/apps.png new file mode 100644 index 0000000..17a1676 Binary files /dev/null and b/.github/apps.png differ diff --git a/.github/bookmarks.png b/.github/bookmarks.png new file mode 100644 index 0000000..dcb63ba Binary files /dev/null and b/.github/bookmarks.png differ diff --git a/.github/home.png b/.github/home.png new file mode 100644 index 0000000..5e9f578 Binary files /dev/null and b/.github/home.png differ diff --git a/.github/settings.png b/.github/settings.png new file mode 100644 index 0000000..73393b5 Binary files /dev/null and b/.github/settings.png differ diff --git a/.github/_themes.png b/.github/themes.png similarity index 100% rename from .github/_themes.png rename to .github/themes.png diff --git a/CHANGELOG.md b/CHANGELOG.md index d199acc..1b755d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +### v2.0.0 (2021-11-15) +- Added authentication system: + - Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33)) + - User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45)) + - Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature +- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110)) +- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138)) +- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157)) +- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158)) +- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164)) +- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170)) +- Added Wikipedia to search queries +- Updated project wiki +- Lots of changes and refactors under the hood to make future development easier + ### v1.7.4 (2021-11-08) - Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103)) - Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129)) @@ -62,12 +77,12 @@ - Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58)) - Added changelog file -### v1.6 (2021-07-17) +### v1.6.0 (2021-07-17) - Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62)) - Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64)) - Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65)) -### v1.5 (2021-06-24) +### v1.5.0 (2021-06-24) - Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental) - Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12)) - Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27)) @@ -75,7 +90,7 @@ - Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48)) - Improved Logger -### v1.4 (2021-06-18) +### v1.4.0 (2021-06-18) - Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13)) - Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13)) - Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36)) @@ -84,14 +99,14 @@ - Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38)) - Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40)) -### v1.3 (2021-06-14) +### v1.3.0 (2021-06-14) - Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24)) - Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26)) - Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28)) - Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29)) - Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34)) -### v1.2 (2021-06-10) +### v1.2.0 (2021-06-10) - Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2)) - Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7)) - Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11)) @@ -100,11 +115,11 @@ - Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18)) - Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20)) -### v1.1 (2021-06-09) +### v1.1.0 (2021-06-09) - Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3)) - Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3)) - Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4)) - Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5)) -### v1.0 (2021-06-08) +### v1.0.0 (2021-06-08) Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend. diff --git a/README.md b/README.md index 2a30fa9..6995444 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,19 @@ # Flame -![Homescreen screenshot](./.github/_home.png) +![Homescreen screenshot](.github/home.png) ## Description -Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary. +Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary. -## Technology - -- Backend - - Node.js + Express - - Sequelize ORM + SQLite -- Frontend - - React - - Redux - - TypeScript -- Deployment - - Docker - - Kubernetes - -## Development - -```sh -# clone repository -git clone https://github.com/pawelmalak/flame -cd flame - -# run only once -npm run dev-init - -# start backend and frontend development servers -npm run dev -``` +## Functionality +- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors +- 📌 Pin your favourite items to the homescreen for quick and easy access +- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own +- 🔑 Authentication system to protect your settings, apps and bookmarks +- 🔨 Dozens of option to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes +- ☀️ Weather widget with current temperature, cloud coverage and animated weather status +- 🐳 Docker integration to automatically pick and add apps based on their labels ## Installation @@ -40,32 +22,34 @@ npm run dev [Docker Hub link](https://hub.docker.com/r/pawelmalak/flame) ```sh -docker pull pawelmalak/flame:latest +docker pull pawelmalak/flame # for ARM architecture (e.g. RaspberryPi) docker pull pawelmalak/flame:multiarch -``` - -#### Building images - -```sh -# build image for amd64 only -docker build -t flame . - -# build multiarch image for amd64, armv7 and arm64 -# building failed multiple times with 2GB memory usage limit so you might want to increase it -docker buildx build \ - --platform linux/arm/v7,linux/arm64,linux/amd64 \ - -f Dockerfile.multiarch \ - -t flame:multiarch . +# installing specific version +docker pull pawelmalak/flame:2.0.0 ``` #### Deployment ```sh # run container -docker run -p 5005:5005 -v /path/to/data:/app/data flame +docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password flame +``` + +#### Building images + +```sh +# build image for amd64 only +docker build -t flame -f .docker/Dockerfile . + +# build multiarch image for amd64, armv7 and arm64 +# building failed multiple times with 2GB memory usage limit so you might want to increase it +docker buildx build \ + --platform linux/arm/v7,linux/arm64,linux/amd64 \ + -f .docker/Dockerfile.multiarch \ + -t flame:multiarch . ``` #### Docker-Compose @@ -81,6 +65,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature ports: - 5005:5005 + environment: + - PASSWORD=flame_password restart: unless-stopped ``` @@ -95,39 +81,56 @@ skaffold dev Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker) -## Functionality +## Development -- Applications - - Create, update, delete and organize applications using GUI - - Pin your favourite apps to the homescreen +### Technology -![Homescreen screenshot](./.github/_apps.png) +- Backend + - Node.js + Express + - Sequelize ORM + SQLite +- Frontend + - React + - Redux + - TypeScript +- Deployment + - Docker + - Kubernetes -- Bookmarks - - Create, update, delete and organize bookmarks and categories using GUI - - Pin your favourite categories to the homescreen - - Import html bookmarks (experimental) +### Creating dev environment -![Homescreen screenshot](./.github/_bookmarks.png) +```sh +# clone repository +git clone https://github.com/pawelmalak/flame +cd flame -- Weather +# run only once +npm run dev-init - - Get current temperature, cloud coverage and weather status with animated icons +# start backend and frontend development servers +npm run dev +``` -- Themes - - Customize your page by choosing from 15 color themes +## Screenshots -![Homescreen screenshot](./.github/_themes.png) +![Apps screenshot](.github/apps.png) + +![Bookmarks screenshot](.github/bookmarks.png) + +![Settings screenshot](.github/settings.png) + +![Themes screenshot](.github/themes.png) ## Usage +### Authentication + +Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication + ### Search bar #### Searching -To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`. - -> You can change where to open search results (same/new tab) in the settings +The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`. For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar). @@ -151,7 +154,7 @@ labels: # - flame.icon=custom to make changes in app. ie: custom icon upload ``` -> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section +> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker You can also set up different apps in the same label adding `;` between each one. @@ -199,7 +202,7 @@ metadata: - flame.pawelmalak/icon=icon-name # optional, default is "kubernetes" ``` -> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section +> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker ### Import HTML Bookmarks (Experimental) diff --git a/api.js b/api.js index 9eb9b9f..840529a 100644 --- a/api.js +++ b/api.js @@ -1,6 +1,6 @@ const { join } = require('path'); const express = require('express'); -const errorHandler = require('./middleware/errorHandler'); +const { errorHandler } = require('./middleware'); const api = express(); @@ -21,6 +21,7 @@ api.use('/api/weather', require('./routes/weather')); api.use('/api/categories', require('./routes/category')); api.use('/api/bookmarks', require('./routes/bookmark')); api.use('/api/queries', require('./routes/queries')); +api.use('/api/auth', require('./routes/auth')); // Custom error handler api.use(errorHandler); diff --git a/client/.env b/client/.env index 78f7843..6a5c8f3 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -REACT_APP_VERSION=1.7.4 \ No newline at end of file +REACT_APP_VERSION=2.0.0 \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index c693eec..fe99789 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9876,6 +9876,11 @@ "object.assign": "^4.1.2" } }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -11123,9 +11128,9 @@ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" }, "path-parse": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { "version": "0.1.7", @@ -14729,9 +14734,9 @@ "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" }, "tar": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", - "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14977,9 +14982,9 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, "tmpl": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", - "integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" }, "to-arraybuffer": { "version": "1.0.1", diff --git a/client/package.json b/client/package.json index 1c4a25d..7c57f0e 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "axios": "^0.24.0", "external-svg-loader": "^1.3.4", "http-proxy-middleware": "^2.0.1", + "jwt-decode": "^3.1.2", "react": "^17.0.2", "react-autosuggest": "^10.1.0", "react-beautiful-dnd": "^13.1.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 3968bcd..2e4c72e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,38 +1,67 @@ import { BrowserRouter, Route, Switch } from 'react-router-dom'; -import { fetchQueries, getConfig, setTheme } from './store/actions'; +import { autoLogin, getConfig } from './store/action-creators'; +import { actionCreators, store } from './store'; import 'external-svg-loader'; -// Redux -import { store } from './store/store'; -import { Provider } from 'react-redux'; - // Utils -import { checkVersion } from './utility'; +import { checkVersion, decodeToken } from './utility'; // Routes -import Home from './components/Home/Home'; -import Apps from './components/Apps/Apps'; -import Settings from './components/Settings/Settings'; -import Bookmarks from './components/Bookmarks/Bookmarks'; -import NotificationCenter from './components/NotificationCenter/NotificationCenter'; +import { Home } from './components/Home/Home'; +import { Apps } from './components/Apps/Apps'; +import { Settings } from './components/Settings/Settings'; +import { Bookmarks } from './components/Bookmarks/Bookmarks'; +import { NotificationCenter } from './components/NotificationCenter/NotificationCenter'; +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { useEffect } from 'react'; -// Load config +// Get config store.dispatch(getConfig()); -// Set theme -if (localStorage.theme) { - store.dispatch(setTheme(localStorage.theme)); +// Validate token +if (localStorage.token) { + store.dispatch(autoLogin()); } -// Check for updates -checkVersion(); +export const App = (): JSX.Element => { + const dispath = useDispatch(); + const { fetchQueries, setTheme, logout, createNotification } = + bindActionCreators(actionCreators, dispath); -// fetch queries -store.dispatch(fetchQueries()); + useEffect(() => { + // check if token is valid + const tokenIsValid = setInterval(() => { + if (localStorage.token) { + const expiresIn = decodeToken(localStorage.token).exp * 1000; + const now = new Date().getTime(); + + if (now > expiresIn) { + logout(); + createNotification({ + title: 'Info', + message: 'Session expired. You have been logged out', + }); + } + } + }, 1000); + + // set theme + if (localStorage.theme) { + setTheme(localStorage.theme); + } + + // check for updated + checkVersion(); + + // load custom search queries + fetchQueries(); + + return () => window.clearInterval(tokenIsValid); + }, []); -const App = (): JSX.Element => { return ( - + <> @@ -42,8 +71,6 @@ const App = (): JSX.Element => { - + ); }; - -export default App; diff --git a/client/src/components/Apps/AppCard/AppCard.tsx b/client/src/components/Apps/AppCard/AppCard.tsx index 803e5dd..2fe3b21 100644 --- a/client/src/components/Apps/AppCard/AppCard.tsx +++ b/client/src/components/Apps/AppCard/AppCard.tsx @@ -1,35 +1,41 @@ import classes from './AppCard.module.css'; -import Icon from '../../UI/Icons/Icon/Icon'; -import { iconParser, urlParser } from '../../../utility'; +import { Icon } from '../../UI'; +import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; -import { App, Config, GlobalState } from '../../../interfaces'; -import { connect } from 'react-redux'; +import { App } from '../../../interfaces'; +import { useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; -interface ComponentProps { +interface Props { app: App; pinHandler?: Function; - config: Config; } -const AppCard = (props: ComponentProps): JSX.Element => { +export const AppCard = (props: Props): JSX.Element => { + const { config } = useSelector((state: State) => state.config); + const [displayUrl, redirectUrl] = urlParser(props.app.url); let iconEl: JSX.Element; const { icon } = props.app; - if (/.(jpeg|jpg|png)$/i.test(icon)) { + if (isImage(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + iconEl = ( {`${props.app.name} ); - } else if (/.(svg)$/i.test(icon)) { + } else if (isSvg(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + iconEl = (
@@ -42,7 +48,7 @@ const AppCard = (props: ComponentProps): JSX.Element => { return ( @@ -54,11 +60,3 @@ const AppCard = (props: ComponentProps): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - config: state.config.config, - }; -}; - -export default connect(mapStateToProps)(AppCard); diff --git a/client/src/components/Apps/AppForm/AppForm.tsx b/client/src/components/Apps/AppForm/AppForm.tsx index d44418e..bdfa170 100644 --- a/client/src/components/Apps/AppForm/AppForm.tsx +++ b/client/src/components/Apps/AppForm/AppForm.tsx @@ -1,50 +1,46 @@ import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react'; -import { connect } from 'react-redux'; -import { addApp, updateApp } from '../../../store/actions'; +import { useDispatch } from 'react-redux'; import { App, NewApp } from '../../../interfaces'; import classes from './AppForm.module.css'; -import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; -import Button from '../../UI/Buttons/Button/Button'; +import { ModalForm, InputGroup, Button } from '../../UI'; +import { inputHandler, newAppTemplate } from '../../../utility'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; -interface ComponentProps { +interface Props { modalHandler: () => void; - addApp: (formData: NewApp | FormData) => any; - updateApp: (id: number, formData: NewApp | FormData) => any; app?: App; } -const AppForm = (props: ComponentProps): JSX.Element => { +export const AppForm = ({ app, modalHandler }: Props): JSX.Element => { + const dispatch = useDispatch(); + const { addApp, updateApp } = bindActionCreators(actionCreators, dispatch); + const [useCustomIcon, toggleUseCustomIcon] = useState(false); const [customIcon, setCustomIcon] = useState(null); - const [formData, setFormData] = useState({ - name: '', - url: '', - icon: '', - }); + const [formData, setFormData] = useState(newAppTemplate); useEffect(() => { - if (props.app) { + if (app) { setFormData({ - name: props.app.name, - url: props.app.url, - icon: props.app.icon, + ...app, }); } else { - setFormData({ - name: '', - url: '', - icon: '', - }); + setFormData(newAppTemplate); } - }, [props.app]); + }, [app]); - const inputChangeHandler = (e: ChangeEvent): void => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, }); }; @@ -65,39 +61,34 @@ const AppForm = (props: ComponentProps): JSX.Element => { } data.append('name', formData.name); data.append('url', formData.url); + data.append('isPublic', `${formData.isPublic}`); return data; }; - if (!props.app) { + if (!app) { if (customIcon) { const data = createFormData(); - props.addApp(data); + addApp(data); } else { - props.addApp(formData); + addApp(formData); } } else { if (customIcon) { const data = createFormData(); - props.updateApp(props.app.id, data); + updateApp(app.id, data); } else { - props.updateApp(props.app.id, formData); - props.modalHandler(); + updateApp(app.id, formData); + modalHandler(); } } - setFormData({ - name: '', - url: '', - icon: '', - }); + setFormData(newAppTemplate); }; return ( - + + {/* NAME */} { onChange={(e) => inputChangeHandler(e)} /> + + {/* URL */} { value={formData.url} onChange={(e) => inputChangeHandler(e)} /> - - - {' '} - Check supported URL formats - - + + {/* ICON */} {!useCustomIcon ? ( // use mdi icon @@ -146,7 +131,7 @@ const AppForm = (props: ComponentProps): JSX.Element => { onChange={(e) => inputChangeHandler(e)} /> - Use icon name from MDI. + Use icon name from MDI or pass a valid URL. {' '} Click here for reference @@ -182,7 +167,22 @@ const AppForm = (props: ComponentProps): JSX.Element => { )} - {!props.app ? ( + + {/* VISIBILITY */} + + + + + + {!app ? ( ) : ( @@ -190,5 +190,3 @@ const AppForm = (props: ComponentProps): JSX.Element => { ); }; - -export default connect(null, { addApp, updateApp })(AppForm); diff --git a/client/src/components/Apps/AppGrid/AppGrid.tsx b/client/src/components/Apps/AppGrid/AppGrid.tsx index 30d5c8c..6b02443 100644 --- a/client/src/components/Apps/AppGrid/AppGrid.tsx +++ b/client/src/components/Apps/AppGrid/AppGrid.tsx @@ -2,15 +2,15 @@ import classes from './AppGrid.module.css'; import { Link } from 'react-router-dom'; import { App } from '../../../interfaces/App'; -import AppCard from '../AppCard/AppCard'; +import { AppCard } from '../AppCard/AppCard'; -interface ComponentProps { +interface Props { apps: App[]; totalApps?: number; searching: boolean; } -const AppGrid = (props: ComponentProps): JSX.Element => { +export const AppGrid = (props: Props): JSX.Element => { let apps: JSX.Element; if (props.apps.length > 0) { @@ -49,5 +49,3 @@ const AppGrid = (props: ComponentProps): JSX.Element => { return apps; }; - -export default AppGrid; diff --git a/client/src/components/Apps/AppTable/AppTable.tsx b/client/src/components/Apps/AppTable/AppTable.tsx index 3f68d76..ee82144 100644 --- a/client/src/components/Apps/AppTable/AppTable.tsx +++ b/client/src/components/Apps/AppTable/AppTable.tsx @@ -8,48 +8,45 @@ import { import { Link } from 'react-router-dom'; // Redux -import { connect } from 'react-redux'; -import { - pinApp, - deleteApp, - reorderApps, - updateConfig, - createNotification, -} from '../../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; // Typescript -import { App, Config, GlobalState, NewNotification } from '../../../interfaces'; +import { App } from '../../../interfaces'; // CSS import classes from './AppTable.module.css'; // UI -import Icon from '../../UI/Icons/Icon/Icon'; -import Table from '../../UI/Table/Table'; +import { Icon, Table } from '../../UI'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; -interface ComponentProps { - apps: App[]; - config: Config; - pinApp: (app: App) => void; - deleteApp: (id: number) => void; +interface Props { updateAppHandler: (app: App) => void; - reorderApps: (apps: App[]) => void; - updateConfig: (formData: any) => void; - createNotification: (notification: NewNotification) => void; } -const AppTable = (props: ComponentProps): JSX.Element => { +export const AppTable = (props: Props): JSX.Element => { + const { + apps: { apps }, + config: { config }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { pinApp, deleteApp, reorderApps, updateConfig, createNotification } = + bindActionCreators(actionCreators, dispatch); + const [localApps, setLocalApps] = useState([]); const [isCustomOrder, setIsCustomOrder] = useState(false); // Copy apps array useEffect(() => { - setLocalApps([...props.apps]); - }, [props.apps]); + setLocalApps([...apps]); + }, [apps]); // Check ordering useEffect(() => { - const order = props.config.useOrdering; + const order = config.useOrdering; if (order === 'orderId') { setIsCustomOrder(true); @@ -62,7 +59,7 @@ const AppTable = (props: ComponentProps): JSX.Element => { ); if (proceed) { - props.deleteApp(app.id); + deleteApp(app.id); } }; @@ -79,7 +76,7 @@ const AppTable = (props: ComponentProps): JSX.Element => { const dragEndHanlder = (result: DropResult): void => { if (!isCustomOrder) { - props.createNotification({ + createNotification({ title: 'Error', message: 'Custom order is disabled', }); @@ -95,7 +92,7 @@ const AppTable = (props: ComponentProps): JSX.Element => { tmpApps.splice(result.destination.index, 0, movedApp); setLocalApps(tmpApps); - props.reorderApps(tmpApps); + reorderApps(tmpApps); }; return ( @@ -114,7 +111,7 @@ const AppTable = (props: ComponentProps): JSX.Element => { {(provided) => ( {localApps.map((app: App, index): JSX.Element => { @@ -143,6 +140,9 @@ const AppTable = (props: ComponentProps): JSX.Element => { + {!snapshot.isDragging && (
{app.name} {app.url} {app.icon} + {app.isPublic ? 'Visible' : 'Hidden'} +
{
props.pinApp(app)} + onClick={() => pinApp(app)} onKeyDown={(e) => - keyboardActionHandler(e, app, props.pinApp) + keyboardActionHandler(e, app, pinApp) } tabIndex={0} > @@ -205,20 +205,3 @@ const AppTable = (props: ComponentProps): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - apps: state.app.apps, - config: state.config.config, - }; -}; - -const actions = { - pinApp, - deleteApp, - reorderApps, - updateConfig, - createNotification, -}; - -export default connect(mapStateToProps, actions)(AppTable); diff --git a/client/src/components/Apps/Apps.tsx b/client/src/components/Apps/Apps.tsx index 751a196..9ccb0d4 100644 --- a/client/src/components/Apps/Apps.tsx +++ b/client/src/components/Apps/Apps.tsx @@ -2,56 +2,59 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; // Redux -import { connect } from 'react-redux'; -import { getApps } from '../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; // Typescript -import { App, GlobalState } from '../../interfaces'; +import { App } from '../../interfaces'; // CSS import classes from './Apps.module.css'; // UI -import { Container } from '../UI/Layout/Layout'; -import Headline from '../UI/Headlines/Headline/Headline'; -import Spinner from '../UI/Spinner/Spinner'; -import ActionButton from '../UI/Buttons/ActionButton/ActionButton'; -import Modal from '../UI/Modal/Modal'; +import { Headline, Spinner, ActionButton, Modal, Container } from '../UI'; // Subcomponents -import AppGrid from './AppGrid/AppGrid'; -import AppForm from './AppForm/AppForm'; -import AppTable from './AppTable/AppTable'; +import { AppGrid } from './AppGrid/AppGrid'; +import { AppForm } from './AppForm/AppForm'; +import { AppTable } from './AppTable/AppTable'; -interface ComponentProps { - getApps: Function; - apps: App[]; - loading: boolean; +// Utils +import { appTemplate } from '../../utility'; +import { State } from '../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../store'; + +interface Props { searching: boolean; } -const Apps = (props: ComponentProps): JSX.Element => { - const { getApps, apps, loading, searching = false } = props; +export const Apps = (props: Props): JSX.Element => { + const { + apps: { apps, loading }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { getApps } = bindActionCreators(actionCreators, dispatch); const [modalIsOpen, setModalIsOpen] = useState(false); const [isInEdit, setIsInEdit] = useState(false); const [isInUpdate, setIsInUpdate] = useState(false); - const [appInUpdate, setAppInUpdate] = useState({ - name: 'string', - url: 'string', - icon: 'string', - isPinned: false, - orderId: 0, - id: 0, - createdAt: new Date(), - updatedAt: new Date(), - }); + const [appInUpdate, setAppInUpdate] = useState(appTemplate); useEffect(() => { - if (apps.length === 0) { + if (!apps.length) { getApps(); } - }, [getApps]); + }, []); + + // observe if user is authenticated -> set default view if not + useEffect(() => { + if (!isAuthenticated) { + setIsInEdit(false); + setModalIsOpen(false); + } + }, [isAuthenticated]); const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); @@ -84,16 +87,18 @@ const Apps = (props: ComponentProps): JSX.Element => { subtitle={Go back} /> -
- - -
+ {isAuthenticated && ( +
+ + +
+ )}
{loading ? ( ) : !isInEdit ? ( - + ) : ( )} @@ -101,12 +106,3 @@ const Apps = (props: ComponentProps): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - apps: state.app.apps, - loading: state.app.loading, - }; -}; - -export default connect(mapStateToProps, { getApps })(Apps); diff --git a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx index 93ead02..146bf67 100644 --- a/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx +++ b/client/src/components/Bookmarks/BookmarkCard/BookmarkCard.tsx @@ -1,17 +1,23 @@ -import { Bookmark, Category, Config, GlobalState } from '../../../interfaces'; +import { Fragment } from 'react'; + +import { useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; + +import { Bookmark, Category } from '../../../interfaces'; + import classes from './BookmarkCard.module.css'; -import Icon from '../../UI/Icons/Icon/Icon'; -import { iconParser, urlParser } from '../../../utility'; -import { Fragment } from 'react'; -import { connect } from 'react-redux'; +import { Icon } from '../../UI'; -interface ComponentProps { +import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; + +interface Props { category: Category; - config: Config; } -const BookmarkCard = (props: ComponentProps): JSX.Element => { +export const BookmarkCard = (props: Props): JSX.Element => { + const { config } = useSelector((state: State) => state.config); + return (

{props.category.name}

@@ -24,21 +30,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => { if (bookmark.icon) { const { icon, name } = bookmark; - if (/.(jpeg|jpg|png)$/i.test(icon)) { + if (isImage(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + iconEl = (
{`${name}
); - } else if (/.(svg)$/i.test(icon)) { + } else if (isSvg(icon)) { + const source = isUrl(icon) ? icon : `/uploads/${icon}`; + iconEl = (
@@ -56,7 +66,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => { return ( @@ -69,11 +79,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
); }; - -const mapStateToProps = (state: GlobalState) => { - return { - config: state.config.config, - }; -}; - -export default connect(mapStateToProps)(BookmarkCard); diff --git a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx deleted file mode 100644 index 5162c89..0000000 --- a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.tsx +++ /dev/null @@ -1,363 +0,0 @@ -// React -import { - useState, - SyntheticEvent, - Fragment, - ChangeEvent, - useEffect, -} from 'react'; - -// Redux -import { connect } from 'react-redux'; -import { - getCategories, - addCategory, - addBookmark, - updateCategory, - updateBookmark, - createNotification, -} from '../../../store/actions'; - -// Typescript -import { - Bookmark, - Category, - GlobalState, - NewBookmark, - NewCategory, - NewNotification, -} from '../../../interfaces'; -import { ContentType } from '../Bookmarks'; - -// UI -import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; -import Button from '../../UI/Buttons/Button/Button'; - -// CSS -import classes from './BookmarkForm.module.css'; - -interface ComponentProps { - modalHandler: () => void; - contentType: ContentType; - categories: Category[]; - category?: Category; - bookmark?: Bookmark; - addCategory: (formData: NewCategory) => void; - addBookmark: (formData: NewBookmark | FormData) => void; - updateCategory: (id: number, formData: NewCategory) => void; - updateBookmark: ( - id: number, - formData: NewBookmark | FormData, - category: { - prev: number; - curr: number; - } - ) => void; - createNotification: (notification: NewNotification) => void; -} - -const BookmarkForm = (props: ComponentProps): JSX.Element => { - const [useCustomIcon, toggleUseCustomIcon] = useState(false); - const [customIcon, setCustomIcon] = useState(null); - const [categoryName, setCategoryName] = useState({ - name: '', - }); - - const [formData, setFormData] = useState({ - name: '', - url: '', - categoryId: -1, - icon: '', - }); - - // Load category data if provided for editing - useEffect(() => { - if (props.category) { - setCategoryName({ name: props.category.name }); - } else { - setCategoryName({ name: '' }); - } - }, [props.category]); - - // Load bookmark data if provided for editing - useEffect(() => { - if (props.bookmark) { - setFormData({ - name: props.bookmark.name, - url: props.bookmark.url, - categoryId: props.bookmark.categoryId, - icon: props.bookmark.icon, - }); - } else { - setFormData({ - name: '', - url: '', - categoryId: -1, - icon: '', - }); - } - }, [props.bookmark]); - - const formSubmitHandler = (e: SyntheticEvent): void => { - e.preventDefault(); - - const createFormData = (): FormData => { - const data = new FormData(); - if (customIcon) { - data.append('icon', customIcon); - } - data.append('name', formData.name); - data.append('url', formData.url); - data.append('categoryId', `${formData.categoryId}`); - - return data; - }; - - if (!props.category && !props.bookmark) { - // Add new - if (props.contentType === ContentType.category) { - // Add category - props.addCategory(categoryName); - setCategoryName({ name: '' }); - } else if (props.contentType === ContentType.bookmark) { - // Add bookmark - if (formData.categoryId === -1) { - props.createNotification({ - title: 'Error', - message: 'Please select category', - }); - return; - } - - if (customIcon) { - const data = createFormData(); - props.addBookmark(data); - } else { - props.addBookmark(formData); - } - - setFormData({ - name: '', - url: '', - categoryId: formData.categoryId, - icon: '', - }); - - // setCustomIcon(null); - } - } else { - // Update - if (props.contentType === ContentType.category && props.category) { - // Update category - props.updateCategory(props.category.id, categoryName); - setCategoryName({ name: '' }); - } else if (props.contentType === ContentType.bookmark && props.bookmark) { - // Update bookmark - if (customIcon) { - const data = createFormData(); - props.updateBookmark(props.bookmark.id, data, { - prev: props.bookmark.categoryId, - curr: formData.categoryId, - }); - } else { - props.updateBookmark(props.bookmark.id, formData, { - prev: props.bookmark.categoryId, - curr: formData.categoryId, - }); - } - - setFormData({ - name: '', - url: '', - categoryId: -1, - icon: '', - }); - - setCustomIcon(null); - } - - props.modalHandler(); - } - }; - - const inputChangeHandler = (e: ChangeEvent): void => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); - }; - - const selectChangeHandler = (e: ChangeEvent): void => { - setFormData({ - ...formData, - categoryId: parseInt(e.target.value), - }); - }; - - const fileChangeHandler = (e: ChangeEvent): void => { - if (e.target.files) { - setCustomIcon(e.target.files[0]); - } - }; - - let button = ; - - if (!props.category && !props.bookmark) { - if (props.contentType === ContentType.category) { - button = ; - } else { - button = ; - } - } else if (props.category) { - button = ; - } else if (props.bookmark) { - button = ; - } - - return ( - - {props.contentType === ContentType.category ? ( - - - - setCategoryName({ name: e.target.value })} - /> - - - ) : ( - - - - inputChangeHandler(e)} - /> - - - - inputChangeHandler(e)} - /> - - - {' '} - Check supported URL formats - - - - - - - - {!useCustomIcon ? ( - // mdi - - - inputChangeHandler(e)} - /> - - Use icon name from MDI. - - {' '} - Click here for reference - - - toggleUseCustomIcon(!useCustomIcon)} - className={classes.Switch} - > - Switch to custom icon upload - - - ) : ( - // custom - - - fileChangeHandler(e)} - accept=".jpg,.jpeg,.png,.svg" - /> - { - setCustomIcon(null); - toggleUseCustomIcon(!useCustomIcon); - }} - className={classes.Switch} - > - Switch to MDI - - - )} - - )} - {button} - - ); -}; - -const mapStateToProps = (state: GlobalState) => { - return { - categories: state.bookmark.categories, - }; -}; - -const dispatchMap = { - getCategories, - addCategory, - addBookmark, - updateCategory, - updateBookmark, - createNotification, -}; - -export default connect(mapStateToProps, dispatchMap)(BookmarkForm); diff --git a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx index bf17c81..516c3b2 100644 --- a/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx +++ b/client/src/components/Bookmarks/BookmarkGrid/BookmarkGrid.tsx @@ -4,19 +4,19 @@ import classes from './BookmarkGrid.module.css'; import { Category } from '../../../interfaces'; -import BookmarkCard from '../BookmarkCard/BookmarkCard'; +import { BookmarkCard } from '../BookmarkCard/BookmarkCard'; -interface ComponentProps { +interface Props { categories: Category[]; totalCategories?: number; searching: boolean; } -const BookmarkGrid = (props: ComponentProps): JSX.Element => { +export const BookmarkGrid = (props: Props): JSX.Element => { let bookmarks: JSX.Element; - if (props.categories.length > 0) { - if (props.searching && props.categories[0].bookmarks.length === 0) { + if (props.categories.length) { + if (props.searching && !props.categories[0].bookmarks.length) { bookmarks = (

No bookmarks match your search criteria @@ -53,5 +53,3 @@ const BookmarkGrid = (props: ComponentProps): JSX.Element => { return bookmarks; }; - -export default BookmarkGrid; diff --git a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx index 90c34aa..2cc4878 100644 --- a/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx +++ b/client/src/components/Bookmarks/BookmarkTable/BookmarkTable.tsx @@ -8,45 +8,39 @@ import { import { Link } from 'react-router-dom'; // Redux -import { connect } from 'react-redux'; -import { - pinCategory, - deleteCategory, - deleteBookmark, - createNotification, - reorderCategories, -} from '../../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; // Typescript -import { - Bookmark, - Category, - Config, - GlobalState, - NewNotification, -} from '../../../interfaces'; +import { Bookmark, Category } from '../../../interfaces'; import { ContentType } from '../Bookmarks'; // CSS import classes from './BookmarkTable.module.css'; // UI -import Table from '../../UI/Table/Table'; -import Icon from '../../UI/Icons/Icon/Icon'; +import { Table, Icon } from '../../UI'; -interface ComponentProps { +interface Props { contentType: ContentType; categories: Category[]; - config: Config; - pinCategory: (category: Category) => void; - deleteCategory: (id: number) => void; updateHandler: (data: Category | Bookmark) => void; - deleteBookmark: (bookmarkId: number, categoryId: number) => void; - createNotification: (notification: NewNotification) => void; - reorderCategories: (categories: Category[]) => void; } -const BookmarkTable = (props: ComponentProps): JSX.Element => { +export const BookmarkTable = (props: Props): JSX.Element => { + const { config } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { + pinCategory, + deleteCategory, + deleteBookmark, + createNotification, + reorderCategories, + } = bindActionCreators(actionCreators, dispatch); + const [localCategories, setLocalCategories] = useState([]); const [isCustomOrder, setIsCustomOrder] = useState(false); @@ -57,7 +51,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { // Check ordering useEffect(() => { - const order = props.config.useOrdering; + const order = config.useOrdering; if (order === 'orderId') { setIsCustomOrder(true); @@ -70,7 +64,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { ); if (proceed) { - props.deleteCategory(category.id); + deleteCategory(category.id); } }; @@ -80,7 +74,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { ); if (proceed) { - props.deleteBookmark(bookmark.id, bookmark.categoryId); + deleteBookmark(bookmark.id, bookmark.categoryId); } }; @@ -96,7 +90,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { const dragEndHanlder = (result: DropResult): void => { if (!isCustomOrder) { - props.createNotification({ + createNotification({ title: 'Error', message: 'Custom order is disabled', }); @@ -112,7 +106,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { tmpCategories.splice(result.destination.index, 0, movedApp); setLocalCategories(tmpCategories); - props.reorderCategories(tmpCategories); + reorderCategories(tmpCategories); }; if (props.contentType === ContentType.category) { @@ -131,7 +125,10 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { {(provided) => ( - +
{localCategories.map( (category: Category, index): JSX.Element => { return ( @@ -156,7 +153,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { ref={provided.innerRef} style={style} > - + + {!snapshot.isDragging && (
{category.name} + {category.name} + + {category.isPublic ? 'Visible' : 'Hidden'} +
{
props.pinCategory(category)} + onClick={() => pinCategory(category)} onKeyDown={(e) => keyboardActionHandler( e, category, - props.pinCategory + pinCategory ) } tabIndex={0} @@ -232,7 +234,9 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { }); return ( - +
{bookmarks.map( (bookmark: { bookmark: Bookmark; categoryName: string }) => { return ( @@ -240,6 +244,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => { +
{bookmark.bookmark.name} {bookmark.bookmark.url} {bookmark.bookmark.icon}{bookmark.bookmark.isPublic ? 'Visible' : 'Hidden'} {bookmark.categoryName}
{ ); } }; - -const mapStateToProps = (state: GlobalState) => { - return { - config: state.config.config, - }; -}; - -const actions = { - pinCategory, - deleteCategory, - deleteBookmark, - createNotification, - reorderCategories, -}; - -export default connect(mapStateToProps, actions)(BookmarkTable); diff --git a/client/src/components/Bookmarks/Bookmarks.tsx b/client/src/components/Bookmarks/Bookmarks.tsx index 88d9cdb..62a2e15 100644 --- a/client/src/components/Bookmarks/Bookmarks.tsx +++ b/client/src/components/Bookmarks/Bookmarks.tsx @@ -1,25 +1,30 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { connect } from 'react-redux'; -import { getCategories } from '../../store/actions'; +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../store'; + +// Typescript +import { Category, Bookmark } from '../../interfaces'; + +// CSS import classes from './Bookmarks.module.css'; -import { Container } from '../UI/Layout/Layout'; -import Headline from '../UI/Headlines/Headline/Headline'; -import ActionButton from '../UI/Buttons/ActionButton/ActionButton'; +// UI +import { Container, Headline, ActionButton, Spinner, Modal } from '../UI'; -import BookmarkGrid from './BookmarkGrid/BookmarkGrid'; -import { Category, GlobalState, Bookmark } from '../../interfaces'; -import Spinner from '../UI/Spinner/Spinner'; -import Modal from '../UI/Modal/Modal'; -import BookmarkForm from './BookmarkForm/BookmarkForm'; -import BookmarkTable from './BookmarkTable/BookmarkTable'; +// Components +import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid'; +import { BookmarkTable } from './BookmarkTable/BookmarkTable'; +import { Form } from './Form/Form'; -interface ComponentProps { - loading: boolean; - categories: Category[]; - getCategories: () => void; +// Utils +import { bookmarkTemplate, categoryTemplate } from '../../utility'; + +interface Props { searching: boolean; } @@ -28,8 +33,14 @@ export enum ContentType { bookmark, } -const Bookmarks = (props: ComponentProps): JSX.Element => { - const { getCategories, categories, loading, searching = false } = props; +export const Bookmarks = (props: Props): JSX.Element => { + const { + bookmarks: { loading, categories }, + auth: { isAuthenticated }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { getCategories } = bindActionCreators(actionCreators, dispatch); const [modalIsOpen, setModalIsOpen] = useState(false); const [formContentType, setFormContentType] = useState(ContentType.category); @@ -38,30 +49,24 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { ContentType.category ); const [isInUpdate, setIsInUpdate] = useState(false); - const [categoryInUpdate, setCategoryInUpdate] = useState({ - name: '', - id: -1, - isPinned: false, - orderId: 0, - bookmarks: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - const [bookmarkInUpdate, setBookmarkInUpdate] = useState({ - name: '', - url: '', - categoryId: -1, - icon: '', - id: -1, - createdAt: new Date(), - updatedAt: new Date(), - }); + const [categoryInUpdate, setCategoryInUpdate] = + useState(categoryTemplate); + const [bookmarkInUpdate, setBookmarkInUpdate] = + useState(bookmarkTemplate); useEffect(() => { - if (categories.length === 0) { + if (!categories.length) { getCategories(); } - }, [getCategories]); + }, []); + + // observe if user is authenticated -> set default view if not + useEffect(() => { + if (!isAuthenticated) { + setIsInEdit(false); + setModalIsOpen(false); + } + }, [isAuthenticated]); const toggleModal = (): void => { setModalIsOpen(!modalIsOpen); @@ -102,55 +107,46 @@ const Bookmarks = (props: ComponentProps): JSX.Element => { return ( - {!isInUpdate ? ( - - ) : formContentType === ContentType.category ? ( - - ) : ( - - )} +
Go back} /> -
- addActionHandler(ContentType.category)} - /> - addActionHandler(ContentType.bookmark)} - /> - editActionHandler(ContentType.category)} - /> - editActionHandler(ContentType.bookmark)} - /> -
+ {isAuthenticated && ( +
+ addActionHandler(ContentType.category)} + /> + addActionHandler(ContentType.bookmark)} + /> + editActionHandler(ContentType.category)} + /> + editActionHandler(ContentType.bookmark)} + /> +
+ )} {loading ? ( ) : !isInEdit ? ( - + ) : ( { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - loading: state.bookmark.loading, - categories: state.bookmark.categories, - }; -}; - -export default connect(mapStateToProps, { getCategories })(Bookmarks); diff --git a/client/src/components/Bookmarks/Form/BookmarksForm.tsx b/client/src/components/Bookmarks/Form/BookmarksForm.tsx new file mode 100644 index 0000000..e5f790b --- /dev/null +++ b/client/src/components/Bookmarks/Form/BookmarksForm.tsx @@ -0,0 +1,260 @@ +import { useState, ChangeEvent, useEffect, FormEvent } from 'react'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Bookmark, Category, NewBookmark } from '../../../interfaces'; + +// UI +import { ModalForm, InputGroup, Button } from '../../UI'; + +// CSS +import classes from './Form.module.css'; + +// Utils +import { inputHandler, newBookmarkTemplate } from '../../../utility'; + +interface Props { + modalHandler: () => void; + bookmark?: Bookmark; +} + +export const BookmarksForm = ({ + bookmark, + modalHandler, +}: Props): JSX.Element => { + const { categories } = useSelector((state: State) => state.bookmarks); + + const dispatch = useDispatch(); + const { addBookmark, updateBookmark, createNotification } = + bindActionCreators(actionCreators, dispatch); + + const [useCustomIcon, toggleUseCustomIcon] = useState(false); + const [customIcon, setCustomIcon] = useState(null); + + const [formData, setFormData] = useState(newBookmarkTemplate); + + // Load bookmark data if provided for editing + useEffect(() => { + if (bookmark) { + setFormData({ ...bookmark }); + } else { + setFormData(newBookmarkTemplate); + } + }, [bookmark]); + + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + const fileChangeHandler = (e: ChangeEvent): void => { + if (e.target.files) { + setCustomIcon(e.target.files[0]); + } + }; + + // Bookmarks form handler + const formSubmitHandler = (e: FormEvent): void => { + e.preventDefault(); + + const createFormData = (): FormData => { + const data = new FormData(); + if (customIcon) { + data.append('icon', customIcon); + } + data.append('name', formData.name); + data.append('url', formData.url); + data.append('categoryId', `${formData.categoryId}`); + data.append('isPublic', `${formData.isPublic}`); + + return data; + }; + + const checkCategory = (): boolean => { + if (formData.categoryId < 0) { + createNotification({ + title: 'Error', + message: 'Please select category', + }); + + return false; + } + + return true; + }; + + if (!bookmark) { + // add new bookmark + if (!checkCategory()) return; + + if (formData.categoryId < 0) { + createNotification({ + title: 'Error', + message: 'Please select category', + }); + return; + } + + if (customIcon) { + const data = createFormData(); + addBookmark(data); + } else { + addBookmark(formData); + } + + setFormData({ + ...newBookmarkTemplate, + categoryId: formData.categoryId, + isPublic: formData.isPublic, + }); + } else { + // update + if (!checkCategory()) return; + + if (customIcon) { + const data = createFormData(); + updateBookmark(bookmark.id, data, { + prev: bookmark.categoryId, + curr: formData.categoryId, + }); + } else { + updateBookmark(bookmark.id, formData, { + prev: bookmark.categoryId, + curr: formData.categoryId, + }); + } + + modalHandler(); + + setFormData(newBookmarkTemplate); + + setCustomIcon(null); + } + }; + + return ( + + + + inputChangeHandler(e)} + /> + + + + + inputChangeHandler(e)} + /> + + + + + + + + {!useCustomIcon ? ( + // mdi + + + inputChangeHandler(e)} + /> + + Use icon name from MDI or pass a valid URL. + + {' '} + Click here for reference + + + toggleUseCustomIcon(!useCustomIcon)} + className={classes.Switch} + > + Switch to custom icon upload + + + ) : ( + // custom + + + fileChangeHandler(e)} + accept=".jpg,.jpeg,.png,.svg" + /> + { + setCustomIcon(null); + toggleUseCustomIcon(!useCustomIcon); + }} + className={classes.Switch} + > + Switch to MDI + + + )} + + + + + + + + + ); +}; diff --git a/client/src/components/Bookmarks/Form/CategoryForm.tsx b/client/src/components/Bookmarks/Form/CategoryForm.tsx new file mode 100644 index 0000000..c7e0105 --- /dev/null +++ b/client/src/components/Bookmarks/Form/CategoryForm.tsx @@ -0,0 +1,100 @@ +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; + +// Redux +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { Category, NewCategory } from '../../../interfaces'; + +// UI +import { ModalForm, InputGroup, Button } from '../../UI'; + +// Utils +import { inputHandler, newCategoryTemplate } from '../../../utility'; + +interface Props { + modalHandler: () => void; + category?: Category; +} + +export const CategoryForm = ({ + category, + modalHandler, +}: Props): JSX.Element => { + const dispatch = useDispatch(); + const { addCategory, updateCategory } = bindActionCreators( + actionCreators, + dispatch + ); + + const [formData, setFormData] = useState(newCategoryTemplate); + + // Load category data if provided for editing + useEffect(() => { + if (category) { + setFormData({ ...category }); + } else { + setFormData(newCategoryTemplate); + } + }, [category]); + + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + // Category form handler + const formSubmitHandler = (e: FormEvent): void => { + e.preventDefault(); + + if (!category) { + addCategory(formData); + } else { + updateCategory(category.id, formData); + } + + setFormData(newCategoryTemplate); + modalHandler(); + }; + + return ( + + + + inputChangeHandler(e)} + /> + + + + + + + + + + ); +}; diff --git a/client/src/components/Bookmarks/BookmarkForm/BookmarkForm.module.css b/client/src/components/Bookmarks/Form/Form.module.css similarity index 100% rename from client/src/components/Bookmarks/BookmarkForm/BookmarkForm.module.css rename to client/src/components/Bookmarks/Form/Form.module.css diff --git a/client/src/components/Bookmarks/Form/Form.tsx b/client/src/components/Bookmarks/Form/Form.tsx new file mode 100644 index 0000000..41ed1bb --- /dev/null +++ b/client/src/components/Bookmarks/Form/Form.tsx @@ -0,0 +1,44 @@ +// Typescript +import { Bookmark, Category } from '../../../interfaces'; +import { ContentType } from '../Bookmarks'; + +// Utils +import { CategoryForm } from './CategoryForm'; +import { BookmarksForm } from './BookmarksForm'; +import { Fragment } from 'react'; + +interface Props { + modalHandler: () => void; + contentType: ContentType; + inUpdate?: boolean; + category?: Category; + bookmark?: Bookmark; +} + +export const Form = (props: Props): JSX.Element => { + const { modalHandler, contentType, inUpdate, category, bookmark } = props; + + return ( + + {!inUpdate ? ( + // form: add new + + {contentType === ContentType.category ? ( + + ) : ( + + )} + + ) : ( + // form: update + + {contentType === ContentType.category ? ( + + ) : ( + + )} + + )} + + ); +}; diff --git a/client/src/components/Home/Header/Header.tsx b/client/src/components/Home/Header/Header.tsx index 3b2841b..f059b49 100644 --- a/client/src/components/Home/Header/Header.tsx +++ b/client/src/components/Home/Header/Header.tsx @@ -1,17 +1,17 @@ import { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; -import { Config, GlobalState } from '../../../interfaces'; -import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget'; -import { getDateTime } from './functions/getDateTime'; -import { greeter } from './functions/greeter'; + +// CSS import classes from './Header.module.css'; -interface Props { - config: Config; -} +// Components +import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget'; -const Header = (props: Props): JSX.Element => { +// Utils +import { getDateTime } from './functions/getDateTime'; +import { greeter } from './functions/greeter'; + +export const Header = (): JSX.Element => { const [dateTime, setDateTime] = useState(getDateTime()); const [greeting, setGreeting] = useState(greeter()); @@ -39,11 +39,3 @@ const Header = (props: Props): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - config: state.config.config, - }; -}; - -export default connect(mapStateToProps)(Header); diff --git a/client/src/components/Home/Header/functions/getDateTime.ts b/client/src/components/Home/Header/functions/getDateTime.ts index db79c69..69cd78b 100644 --- a/client/src/components/Home/Header/functions/getDateTime.ts +++ b/client/src/components/Home/Header/functions/getDateTime.ts @@ -1,3 +1,5 @@ +import { parseTime } from '../../../../utility'; + export const getDateTime = (): string => { const days = localStorage.getItem('daySchema')?.split(';') || [ 'Sunday', @@ -27,14 +29,23 @@ export const getDateTime = (): string => { const now = new Date(); const useAmericanDate = localStorage.useAmericanDate === 'true'; + const showTime = localStorage.showTime === 'true'; + + const p = parseTime; + + const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p( + now.getSeconds() + )}`; + + const timeEl = showTime ? ` - ${time}` : ''; if (!useAmericanDate) { return `${days[now.getDay()]}, ${now.getDate()} ${ months[now.getMonth()] - } ${now.getFullYear()}`; + } ${now.getFullYear()}${timeEl}`; } else { return `${days[now.getDay()]}, ${ months[now.getMonth()] - } ${now.getDate()} ${now.getFullYear()}`; + } ${now.getDate()} ${now.getFullYear()}${timeEl}`; } }; diff --git a/client/src/components/Home/Home.tsx b/client/src/components/Home/Home.tsx index 017df9c..43b2373 100644 --- a/client/src/components/Home/Home.tsx +++ b/client/src/components/Home/Home.tsx @@ -2,47 +2,41 @@ import { useState, useEffect, Fragment } from 'react'; import { Link } from 'react-router-dom'; // Redux -import { connect } from 'react-redux'; -import { getApps, getCategories } from '../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../store'; // Typescript -import { GlobalState } from '../../interfaces/GlobalState'; -import { App, Category, Config } from '../../interfaces'; +import { App, Category } from '../../interfaces'; // UI -import Icon from '../UI/Icons/Icon/Icon'; -import { Container } from '../UI/Layout/Layout'; -import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline'; -import Spinner from '../UI/Spinner/Spinner'; +import { Icon, Container, SectionHeadline, Spinner } from '../UI'; // CSS import classes from './Home.module.css'; // Components -import AppGrid from '../Apps/AppGrid/AppGrid'; -import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid'; -import SearchBar from '../SearchBar/SearchBar'; -import Header from './Header/Header'; +import { AppGrid } from '../Apps/AppGrid/AppGrid'; +import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid'; +import { SearchBar } from '../SearchBar/SearchBar'; +import { Header } from './Header/Header'; -interface ComponentProps { - getApps: Function; - getCategories: Function; - appsLoading: boolean; - apps: App[]; - categoriesLoading: boolean; - categories: Category[]; - config: Config; -} +// Utils +import { escapeRegex } from '../../utility'; -const Home = (props: ComponentProps): JSX.Element => { +export const Home = (): JSX.Element => { const { - getApps, - apps, - appsLoading, - getCategories, - categories, - categoriesLoading, - } = props; + apps: { apps, loading: appsLoading }, + bookmarks: { categories, loading: bookmarksLoading }, + config: { config }, + } = useSelector((state: State) => state); + + const dispatch = useDispatch(); + const { getApps, getCategories } = bindActionCreators( + actionCreators, + dispatch + ); // Local search query const [localSearch, setLocalSearch] = useState(null); @@ -56,20 +50,22 @@ const Home = (props: ComponentProps): JSX.Element => { if (!apps.length) { getApps(); } - }, [getApps]); + }, []); // Load bookmark categories useEffect(() => { if (!categories.length) { getCategories(); } - }, [getCategories]); + }, []); useEffect(() => { if (localSearch) { // Search through apps setAppSearchResult([ - ...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)), + ...apps.filter(({ name }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) + ), ]); // Search through bookmarks @@ -79,7 +75,9 @@ const Home = (props: ComponentProps): JSX.Element => { category.bookmarks = categories .map(({ bookmarks }) => bookmarks) .flat() - .filter(({ name }) => new RegExp(localSearch, 'i').test(name)); + .filter(({ name }) => + new RegExp(escapeRegex(localSearch), 'i').test(name) + ); setBookmarkSearchResult([category]); } else { @@ -90,7 +88,7 @@ const Home = (props: ComponentProps): JSX.Element => { return ( - {!props.config.hideSearch ? ( + {!config.hideSearch ? ( {
)} - {!props.config.hideHeader ?
:
} + {!config.hideHeader ?
:
} - {!props.config.hideApps ? ( + {!config.hideApps ? ( {appsLoading ? ( @@ -124,16 +122,18 @@ const Home = (props: ComponentProps): JSX.Element => {
)} - {!props.config.hideCategories ? ( + {!config.hideCategories ? ( - {categoriesLoading ? ( + {bookmarksLoading ? ( ) : ( isPinned) + ? categories.filter( + ({ isPinned, bookmarks }) => isPinned && bookmarks.length + ) : bookmarkSearchResult } totalCategories={categories.length} @@ -151,15 +151,3 @@ const Home = (props: ComponentProps): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - appsLoading: state.app.loading, - apps: state.app.apps, - categoriesLoading: state.bookmark.loading, - categories: state.bookmark.categories, - config: state.config.config, - }; -}; - -export default connect(mapStateToProps, { getApps, getCategories })(Home); diff --git a/client/src/components/NotificationCenter/NotificationCenter.tsx b/client/src/components/NotificationCenter/NotificationCenter.tsx index 733316b..4bda8bf 100644 --- a/client/src/components/NotificationCenter/NotificationCenter.tsx +++ b/client/src/components/NotificationCenter/NotificationCenter.tsx @@ -1,21 +1,20 @@ -import { connect } from 'react-redux'; -import { GlobalState, Notification as _Notification } from '../../interfaces'; +import { useSelector } from 'react-redux'; +import { Notification as NotificationInterface } from '../../interfaces'; import classes from './NotificationCenter.module.css'; -import Notification from '../UI/Notification/Notification'; +import { Notification } from '../UI'; +import { State } from '../../store/reducers'; -interface ComponentProps { - notifications: _Notification[]; -} +export const NotificationCenter = (): JSX.Element => { + const { notifications } = useSelector((state: State) => state.notification); -const NotificationCenter = (props: ComponentProps): JSX.Element => { return (
- {props.notifications.map((notification: _Notification) => { + {notifications.map((notification: NotificationInterface) => { return ( {
); }; - -const mapStateToProps = (state: GlobalState) => { - return { - notifications: state.notification.notifications, - }; -}; - -export default connect(mapStateToProps)(NotificationCenter); diff --git a/client/src/components/Routing/ProtectedRoute.tsx b/client/src/components/Routing/ProtectedRoute.tsx new file mode 100644 index 0000000..45b6504 --- /dev/null +++ b/client/src/components/Routing/ProtectedRoute.tsx @@ -0,0 +1,13 @@ +import { useSelector } from 'react-redux'; +import { Redirect, Route, RouteProps } from 'react-router'; +import { State } from '../../store/reducers'; + +export const ProtectedRoute = ({ ...rest }: RouteProps) => { + const { isAuthenticated } = useSelector((state: State) => state.auth); + + if (isAuthenticated) { + return ; + } else { + return ; + } +}; diff --git a/client/src/components/SearchBar/SearchBar.tsx b/client/src/components/SearchBar/SearchBar.tsx index 2dad112..9920073 100644 --- a/client/src/components/SearchBar/SearchBar.tsx +++ b/client/src/components/SearchBar/SearchBar.tsx @@ -1,42 +1,33 @@ import { useRef, useEffect, KeyboardEvent } from 'react'; // Redux -import { connect } from 'react-redux'; -import { createNotification } from '../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; // Typescript -import { - App, - Category, - Config, - GlobalState, - NewNotification, -} from '../../interfaces'; +import { App, Category } from '../../interfaces'; // CSS import classes from './SearchBar.module.css'; // Utils import { searchParser, urlParser, redirectUrl } from '../../utility'; +import { State } from '../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../store'; -interface ComponentProps { - createNotification: (notification: NewNotification) => void; +interface Props { setLocalSearch: (query: string) => void; appSearchResult: App[] | null; bookmarkSearchResult: Category[] | null; - config: Config; - loading: boolean; } -const SearchBar = (props: ComponentProps): JSX.Element => { - const { - setLocalSearch, - createNotification, - config, - loading, - appSearchResult, - bookmarkSearchResult, - } = props; +export const SearchBar = (props: Props): JSX.Element => { + const { config, loading } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { createNotification } = bindActionCreators(actionCreators, dispatch); + + const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props; const inputRef = useRef(document.createElement('input')); @@ -54,12 +45,17 @@ const SearchBar = (props: ComponentProps): JSX.Element => { if (key === 'Escape') { clearSearch(); + } else if (document.activeElement !== inputRef.current) { + if (key === '`') { + inputRef.current.focus(); + clearSearch(); + } } }; - window.addEventListener('keydown', keyOutsideFocus); + window.addEventListener('keyup', keyOutsideFocus); - return () => window.removeEventListener('keydown', keyOutsideFocus); + return () => window.removeEventListener('keyup', keyOutsideFocus); }, []); const clearSearch = () => { @@ -126,12 +122,3 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
); }; - -const mapStateToProps = (state: GlobalState) => { - return { - config: state.config.config, - loading: state.config.loading, - }; -}; - -export default connect(mapStateToProps, { createNotification })(SearchBar); diff --git a/client/src/components/Settings/AppDetails/AppDetails.module.css b/client/src/components/Settings/AppDetails/AppDetails.module.css index 8f5fae3..6a7b939 100644 --- a/client/src/components/Settings/AppDetails/AppDetails.module.css +++ b/client/src/components/Settings/AppDetails/AppDetails.module.css @@ -1,8 +1,14 @@ -.AppVersion { +.text { color: var(--color-primary); margin-bottom: 15px; } -.AppVersion a { +.text a, +.text span { color: var(--color-accent); -} \ No newline at end of file +} + +.separator { + margin: 30px 0; + border: 1px solid var(--color-primary); +} diff --git a/client/src/components/Settings/AppDetails/AppDetails.tsx b/client/src/components/Settings/AppDetails/AppDetails.tsx index 109053a..1829a4d 100644 --- a/client/src/components/Settings/AppDetails/AppDetails.tsx +++ b/client/src/components/Settings/AppDetails/AppDetails.tsx @@ -1,34 +1,43 @@ import { Fragment } from 'react'; - +import { Button, SettingsHeadline } from '../../UI'; import classes from './AppDetails.module.css'; -import Button from '../../UI/Buttons/Button/Button'; import { checkVersion } from '../../../utility'; +import { AuthForm } from './AuthForm/AuthForm'; -const AppDetails = (): JSX.Element => { +export const AppDetails = (): JSX.Element => { return ( -

- - Flame - - {' '} - version {process.env.REACT_APP_VERSION} -

-

- See changelog {' '} - - here - -

- -
- ) -} + + -export default AppDetails; +
+ +
+ +

+ + Flame + {' '} + version {process.env.REACT_APP_VERSION} +

+ +

+ See changelog{' '} + + here + +

+ + +
+ + ); +}; diff --git a/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx new file mode 100644 index 0000000..2742d76 --- /dev/null +++ b/client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx @@ -0,0 +1,103 @@ +import { FormEvent, Fragment, useEffect, useState } from 'react'; + +// Redux +import { useSelector, useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; +import { State } from '../../../../store/reducers'; +import { decodeToken, parseTokenExpire } from '../../../../utility'; + +// Other +import { InputGroup, Button } from '../../../UI'; +import classes from '../AppDetails.module.css'; + +export const AuthForm = (): JSX.Element => { + const { isAuthenticated, token } = useSelector((state: State) => state.auth); + + const dispatch = useDispatch(); + const { login, logout } = bindActionCreators(actionCreators, dispatch); + + const [tokenExpires, setTokenExpires] = useState(''); + const [formData, setFormData] = useState({ + password: '', + duration: '14d', + }); + + useEffect(() => { + if (token) { + const decoded = decodeToken(token); + const expiresIn = parseTokenExpire(decoded.exp); + setTokenExpires(expiresIn); + } + }, [token]); + + const formHandler = (e: FormEvent) => { + e.preventDefault(); + login(formData); + setFormData({ + password: '', + duration: '14d', + }); + }; + + return ( + + {!isAuthenticated ? ( + + + + + setFormData({ ...formData, password: e.target.value }) + } + /> + + See + + {` project wiki `} + + to read more about authentication + + + + + + + + + + + ) : ( +
+

+ You are logged in. Your session will expire{' '} + {tokenExpires} +

+ +
+ )} +
+ ); +}; diff --git a/client/src/components/Settings/DockerSettings/DockerSettings.tsx b/client/src/components/Settings/DockerSettings/DockerSettings.tsx new file mode 100644 index 0000000..54410d8 --- /dev/null +++ b/client/src/components/Settings/DockerSettings/DockerSettings.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; + +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +// Typescript +import { DockerSettingsForm } from '../../../interfaces'; + +// UI +import { InputGroup, Button, SettingsHeadline } from '../../UI'; + +// Utils +import { inputHandler, dockerSettingsTemplate } from '../../../utility'; + +export const DockerSettings = (): JSX.Element => { + const { loading, config } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { updateConfig } = bindActionCreators(actionCreators, dispatch); + + // Initial state + const [formData, setFormData] = useState( + dockerSettingsTemplate + ); + + // Get config + useEffect(() => { + setFormData({ + ...config, + }); + }, [loading]); + + // Form handler + const formSubmitHandler = async (e: FormEvent) => { + e.preventDefault(); + + // Save settings + await updateConfig(formData); + }; + + // Input handler + const inputChangeHandler = ( + e: ChangeEvent, + options?: { isNumber?: boolean; isBool?: boolean } + ) => { + inputHandler({ + e, + options, + setStateHandler: setFormData, + state: formData, + }); + }; + + return ( +
formSubmitHandler(e)}> + + {/* CUSTOM DOCKER SOCKET HOST */} + + + inputChangeHandler(e)} + /> + + + {/* USE DOCKER API */} + + + + + + {/* UNPIN DOCKER APPS */} + + + + + + {/* KUBERNETES SETTINGS */} + + {/* USE KUBERNETES */} + + + + + + + + ); +}; diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx index a694f42..747be3b 100644 --- a/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx +++ b/client/src/components/Settings/SearchSettings/CustomQueries/CustomQueries.tsx @@ -1,29 +1,31 @@ import { Fragment, useState } from 'react'; -import { connect } from 'react-redux'; +// Redux +import { useDispatch, useSelector } from 'react-redux'; +import { State } from '../../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; + +// Typescript +import { Query } from '../../../../interfaces'; + +// CSS import classes from './CustomQueries.module.css'; -import Modal from '../../../UI/Modal/Modal'; -import Icon from '../../../UI/Icons/Icon/Icon'; -import { - Config, - GlobalState, - NewNotification, - Query, -} from '../../../../interfaces'; -import QueriesForm from './QueriesForm'; -import { deleteQuery, createNotification } from '../../../../store/actions'; -import Button from '../../../UI/Buttons/Button/Button'; +// UI +import { Modal, Icon, Button } from '../../../UI'; -interface Props { - customQueries: Query[]; - deleteQuery: (prefix: string) => {}; - createNotification: (notification: NewNotification) => void; - config: Config; -} +// Components +import { QueriesForm } from './QueriesForm'; -const CustomQueries = (props: Props): JSX.Element => { - const { customQueries, deleteQuery, createNotification } = props; +export const CustomQueries = (): JSX.Element => { + const { customQueries, config } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { deleteQuery, createNotification } = bindActionCreators( + actionCreators, + dispatch + ); const [modalIsOpen, setModalIsOpen] = useState(false); const [editableQuery, setEditableQuery] = useState(null); @@ -34,7 +36,7 @@ const CustomQueries = (props: Props): JSX.Element => { }; const deleteHandler = (query: Query) => { - const currentProvider = props.config.defaultSearchProvider; + const currentProvider = config.defaultSearchProvider; const isCurrent = currentProvider === query.prefix; if (isCurrent) { @@ -105,14 +107,3 @@ const CustomQueries = (props: Props): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - customQueries: state.config.customQueries, - config: state.config.config, - }; -}; - -export default connect(mapStateToProps, { deleteQuery, createNotification })( - CustomQueries -); diff --git a/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx b/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx index 42ad654..2cb76a9 100644 --- a/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx +++ b/client/src/components/Settings/SearchSettings/CustomQueries/QueriesForm.tsx @@ -1,20 +1,26 @@ import { ChangeEvent, FormEvent, useState, useEffect } from 'react'; + +import { useDispatch } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../../store'; + import { Query } from '../../../../interfaces'; -import Button from '../../../UI/Buttons/Button/Button'; -import InputGroup from '../../../UI/Forms/InputGroup/InputGroup'; -import ModalForm from '../../../UI/Forms/ModalForm/ModalForm'; -import { connect } from 'react-redux'; -import { addQuery, updateQuery } from '../../../../store/actions'; + +import { Button, InputGroup, ModalForm } from '../../../UI'; interface Props { modalHandler: () => void; - addQuery: (query: Query) => {}; - updateQuery: (query: Query, Oldprefix: string) => {}; query?: Query; } -const QueriesForm = (props: Props): JSX.Element => { - const { modalHandler, addQuery, updateQuery, query } = props; +export const QueriesForm = (props: Props): JSX.Element => { + const dispatch = useDispatch(); + const { addQuery, updateQuery } = bindActionCreators( + actionCreators, + dispatch + ); + + const { modalHandler, query } = props; const [formData, setFormData] = useState({ name: '', @@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => { onChange={(e) => inputChangeHandler(e)} /> + { onChange={(e) => inputChangeHandler(e)} /> + { onChange={(e) => inputChangeHandler(e)} /> + {query ? : } ); }; - -export default connect(null, { addQuery, updateQuery })(QueriesForm); diff --git a/client/src/components/Settings/SearchSettings/SearchSettings.tsx b/client/src/components/Settings/SearchSettings/SearchSettings.tsx index d05def5..1a931df 100644 --- a/client/src/components/Settings/SearchSettings/SearchSettings.tsx +++ b/client/src/components/Settings/SearchSettings/SearchSettings.tsx @@ -1,26 +1,15 @@ // React import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react'; -import { connect } from 'react-redux'; - -// State -import { createNotification, updateConfig } from '../../../store/actions'; +import { useDispatch, useSelector } from 'react-redux'; // Typescript -import { - Config, - GlobalState, - NewNotification, - Query, - SearchForm, -} from '../../../interfaces'; +import { Query, SearchForm } from '../../../interfaces'; // Components -import CustomQueries from './CustomQueries/CustomQueries'; +import { CustomQueries } from './CustomQueries/CustomQueries'; // UI -import Button from '../../UI/Buttons/Button/Button'; -import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline'; -import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; +import { Button, SettingsHeadline, InputGroup } from '../../UI'; // Utils import { inputHandler, searchSettingsTemplate } from '../../../utility'; @@ -28,31 +17,35 @@ import { inputHandler, searchSettingsTemplate } from '../../../utility'; // Data import { queries } from '../../../utility/searchQueries.json'; -interface Props { - createNotification: (notification: NewNotification) => void; - updateConfig: (formData: SearchForm) => void; - loading: boolean; - customQueries: Query[]; - config: Config; -} +// Redux +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +export const SearchSettings = (): JSX.Element => { + const { loading, customQueries, config } = useSelector( + (state: State) => state.config + ); + + const dispatch = useDispatch(); + const { updateConfig } = bindActionCreators(actionCreators, dispatch); -const SearchSettings = (props: Props): JSX.Element => { // Initial state const [formData, setFormData] = useState(searchSettingsTemplate); // Get config useEffect(() => { setFormData({ - ...props.config, + ...config, }); - }, [props.loading]); + }, [loading]); // Form handler const formSubmitHandler = async (e: FormEvent) => { e.preventDefault(); // Save settings - await props.updateConfig(formData); + await updateConfig(formData); }; // Input handler @@ -84,7 +77,7 @@ const SearchSettings = (props: Props): JSX.Element => { value={formData.defaultSearchProvider} onChange={(e) => inputChangeHandler(e)} > - {[...queries, ...props.customQueries].map((query: Query, idx) => { + {[...queries, ...customQueries].map((query: Query, idx) => { const isCustom = idx >= queries.length; return ( @@ -95,6 +88,7 @@ const SearchSettings = (props: Props): JSX.Element => { })} + + + + @@ -142,18 +139,3 @@ const SearchSettings = (props: Props): JSX.Element => { ); }; - -const mapStateToProps = (state: GlobalState) => { - return { - loading: state.config.loading, - customQueries: state.config.customQueries, - config: state.config.config, - }; -}; - -const actions = { - createNotification, - updateConfig, -}; - -export default connect(mapStateToProps, actions)(SearchSettings); diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 5df8ec6..5196b6f 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -1,6 +1,9 @@ -// import { NavLink, Link, Switch, Route } from 'react-router-dom'; +// Redux +import { useSelector } from 'react-redux'; +import { State } from '../../store/reducers'; + // Typescript import { Route as SettingsRoute } from '../../interfaces'; @@ -8,28 +11,33 @@ import { Route as SettingsRoute } from '../../interfaces'; import classes from './Settings.module.css'; // Components -import Themer from '../Themer/Themer'; -import WeatherSettings from './WeatherSettings/WeatherSettings'; -import OtherSettings from './OtherSettings/OtherSettings'; -import AppDetails from './AppDetails/AppDetails'; -import StyleSettings from './StyleSettings/StyleSettings'; -import SearchSettings from './SearchSettings/SearchSettings'; +import { Themer } from '../Themer/Themer'; +import { WeatherSettings } from './WeatherSettings/WeatherSettings'; +import { UISettings } from './UISettings/UISettings'; +import { AppDetails } from './AppDetails/AppDetails'; +import { StyleSettings } from './StyleSettings/StyleSettings'; +import { SearchSettings } from './SearchSettings/SearchSettings'; +import { DockerSettings } from './DockerSettings/DockerSettings'; +import { ProtectedRoute } from '../Routing/ProtectedRoute'; // UI -import { Container } from '../UI/Layout/Layout'; -import Headline from '../UI/Headlines/Headline/Headline'; +import { Container, Headline } from '../UI'; // Data import { routes } from './settings.json'; -const Settings = (): JSX.Element => { +export const Settings = (): JSX.Element => { + const { isAuthenticated } = useSelector((state: State) => state.auth); + + const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired); + return ( Go back} />
{/* NAVIGATION MENU */}