diff --git a/Dockerfile b/.docker/Dockerfile similarity index 93% rename from Dockerfile rename to .docker/Dockerfile index 26b822b6aa90979498f4f9f8d07c202c70d552cb..a0e1488ed2bd515cd167f0e5ec6f2c1c28530b33 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 680ed26f2ad0e88036d952c0f32ce49ec510cb63..951afd4d7fdbba85e6ee9a79f315da3194beb619 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 d0bf6abc4c0a59565907006486665bf4fd0332fa..6d4c34a94c04150179ff7845ccf95320afff81ff 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 d4884b76a13d93ad9c7542f018c6742c7323b3d7..3758dcb4574d8a2264efef71f258b0a9ee0ed10a 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 6c10c72c1b1f9c00daf36549560258019dd245f2..134be5daafaff68d3e69fec3266662d0a26daa67 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 52324f014b7fc2f8e6395125a5f6e7dbe89702d5..439058736ef56ab8979a53e6c0f523a0783d7a39 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 39096dc120847384157d88aa67321936d59ffac3..0000000000000000000000000000000000000000 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 fe6999b0857f49947474ac527645fb2786965fc0..0000000000000000000000000000000000000000 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 c24050f080c41e75c3eb4f22486539885024b83c..0000000000000000000000000000000000000000 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 0000000000000000000000000000000000000000..17a167637e7890defcd1abfe97fc709d5918aead 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 0000000000000000000000000000000000000000..dcb63baa21fdeb46f80cadad5e44a401090a3842 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 0000000000000000000000000000000000000000..5e9f57880d3295a4fb6c6a6cbb7550c3c8d2232a 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 0000000000000000000000000000000000000000..73393b5ca83a4ce4fc73fe45d98b9e23e28f338e 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 d199acc3ce4c80afad9f82b26ff4559ac634db91..1b755d2cc0c93dd1d5a50bb72e57078f373fbdda 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 2a30fa9af3d00d85baa1d835908801b7bfa7d9bf..69954449f13ff2ca53a914d4b71586eec888cb0f 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,34 +22,36 @@ 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 + +# 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 -e PASSWORD=flame_password flame +``` #### Building images ```sh # build image for amd64 only -docker build -t flame . +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 Dockerfile.multiarch \ + -f .docker/Dockerfile.multiarch \ -t flame:multiarch . ``` -#### Deployment - -```sh -# run container -docker run -p 5005:5005 -v /path/to/data:/app/data flame -``` - #### Docker-Compose ```yaml @@ -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 + +### Technology + +- Backend + - Node.js + Express + - Sequelize ORM + SQLite +- Frontend + - React + - Redux + - TypeScript +- Deployment + - Docker + - Kubernetes + +### Creating dev environment -- Applications - - Create, update, delete and organize applications using GUI - - Pin your favourite apps to the homescreen +```sh +# clone repository +git clone https://github.com/pawelmalak/flame +cd flame -![Homescreen screenshot](./.github/_apps.png) +# run only once +npm run dev-init -- Bookmarks - - Create, update, delete and organize bookmarks and categories using GUI - - Pin your favourite categories to the homescreen - - Import html bookmarks (experimental) +# start backend and frontend development servers +npm run dev +``` -![Homescreen screenshot](./.github/_bookmarks.png) +## Screenshots -- Weather +![Apps screenshot](.github/apps.png) - - Get current temperature, cloud coverage and weather status with animated icons +![Bookmarks screenshot](.github/bookmarks.png) -- Themes - - Customize your page by choosing from 15 color themes +![Settings screenshot](.github/settings.png) -![Homescreen screenshot](./.github/_themes.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 9eb9b9f61148305f83f00346667f768bd3f9e162..840529a549e27b4c5769aea6aeaf226753a60305 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 78f7843a85de47de2a8209f074e1ed29b2f61183..6a5c8f35b44c0d9b6236f94e6e1b02105cd91932 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 c693eecee9eba533212bb810b07615fc0765394a..fe99789d156cec38a557d841782846b0f4e0f68a 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 1c4a25d695a8d735137c98ca88663787bf7046d1..7c57f0e1ad71175e9fc3699ef7bdee5f6d8606d3 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 3968bcd687b5f446cd87969bd3a02358db8ff6b6..2e4c72e257627de12e24d8934cc9d33971be26cd 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); + + 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); + } -// fetch queries -store.dispatch(fetchQueries()); + // 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 803e5dd7637af12bd1b0e199f3ee9044dbad3571..2fe3b212e5a8d25dc97b543ebbb873f8fb081da9 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 d44418ee8131944d24f9c4e22148d7ca0c89439c..bdfa170e7564f62d78d8482c4c7ef1e8dcf2e8c4 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 30d5c8c7378fea2a6af70a916b22e32fc92aa7fe..6b02443ae392561b481beb1f2cb5ca5b4274b5bd 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 3f68d76b556bbbd6ac354d7806dc4feb94a308db..ee821447070fe87efb418e16e23ab2c8b208f74a 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'; - -interface ComponentProps { - apps: App[]; - config: Config; - pinApp: (app: App) => void; - deleteApp: (id: number) => void; +import { Icon, Table } from '../../UI'; +import { State } from '../../../store/reducers'; +import { bindActionCreators } from 'redux'; +import { actionCreators } from '../../../store'; + +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 751a196604a63deac102f4c12bb99f541fb59653..9ccb0d48b48ab9ed3c662afb620ccc286fa4d07e 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'; - -interface ComponentProps { - getApps: Function; - apps: App[]; - loading: boolean; +import { AppGrid } from './AppGrid/AppGrid'; +import { AppForm } from './AppForm/AppForm'; +import { AppTable } from './AppTable/AppTable'; + +// 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 93ead026b787a09063c1b8337d636d4ab7d5abb0..146bf67a6610f134a4bcf5edb53562341a49377a 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'; + +import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility'; -interface ComponentProps { +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 5162c89d1c7cf8e0131e98ef5c51c023d4e64b21..0000000000000000000000000000000000000000 --- 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 bf17c81440c3a25e3fc48966dc3873ad640e14ba..516c3b2c853234d299ea042c99a521d6b0633be9 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 90c34aafd8617db1b18e152e6b42e619748913cf..2cc487860b0dc8876f8da91f8a847a1318848fd6 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 88d9cdbcdead15420ec9ac0db66369f5bd4b720d..62a2e1597f7ed238d2754cd0709025eec9cdaf02 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'; - -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'; - -interface ComponentProps { - loading: boolean; - categories: Category[]; - getCategories: () => void; +// UI +import { Container, Headline, ActionButton, Spinner, Modal } from '../UI'; + +// Components +import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid'; +import { BookmarkTable } from './BookmarkTable/BookmarkTable'; +import { Form } from './Form/Form'; + +// 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 0000000000000000000000000000000000000000..e5f790b2678d17f86dec7e04599a21bb77ccb40f --- /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 0000000000000000000000000000000000000000..c7e01052b229eb461b6d08f76c7db5f5b139bb73 --- /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 0000000000000000000000000000000000000000..41ed1bb72b6ce2e6ece03cb0c1a2474a39fbae1a --- /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 3b2841b479410c8bae9dae2d76fe932723f92743..f059b497836a4d2d0b0abb257d95a28c7f3252d2 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 db79c69aa73d4687660332c8405a590be41d03a2..69cd78bcd8f8539e7915a043a0677f14853512e3 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 017df9c3b7efd4c65b67f96837fa24c1ee5ef4a7..43b2373544c603c5d035afc158f67806b70f96ca 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'; - -interface ComponentProps { - getApps: Function; - getCategories: Function; - appsLoading: boolean; - apps: App[]; - categoriesLoading: boolean; - categories: Category[]; - config: Config; -} - -const Home = (props: ComponentProps): JSX.Element => { +import { AppGrid } from '../Apps/AppGrid/AppGrid'; +import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid'; +import { SearchBar } from '../SearchBar/SearchBar'; +import { Header } from './Header/Header'; + +// Utils +import { escapeRegex } from '../../utility'; + +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 733316b2d6131bb2eed128e0c4f61a0e4c36c727..4bda8bf8c1f65c477f8a92276a0c9bc082c672ce 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 0000000000000000000000000000000000000000..45b650482391f228db840af09c86ab41337ca5dd --- /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 2dad1124d9730ad294782ebfd7231c623d13e9de..9920073058d579ce8f50a0e0eba662e974d08dd1 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 8f5fae3db9021f33b752bcc6860c276ec79dcddf..6a7b939ba09a40f6392bf62cdf0e75fa83ce5f77 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 109053aa245b208c3c3a297684b7f660939f6ab2..1829a4df9107d7ae05225dea362db15dd6bb4f09 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 0000000000000000000000000000000000000000..2742d76eb75a32b182b061ef0e7a017db70b20df --- /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 0000000000000000000000000000000000000000..54410d8bef211e8cde6bc8837c399b5ad4715d98 --- /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 a694f427044b5193bdec59713a6ec1f61a2591e9..747be3bb52597cd75a9938afe4f41947d0880ce4 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'; - -interface Props { - customQueries: Query[]; - deleteQuery: (prefix: string) => {}; - createNotification: (notification: NewNotification) => void; - config: Config; -} - -const CustomQueries = (props: Props): JSX.Element => { - const { customQueries, deleteQuery, createNotification } = props; +// UI +import { Modal, Icon, Button } from '../../../UI'; + +// Components +import { QueriesForm } from './QueriesForm'; + +export const CustomQueries = (): JSX.Element => { + const { customQueries, config } = useSelector((state: State) => state.config); + + const dispatch = useDispatch(); + const { deleteQuery, createNotification } = bindActionCreators( + actionCreators, + dispatch + ); const [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 42ad6542aa24465ae70f06460dbb95a090879d46..2cb76a96d19fb75866d923629ba8d0e041c3e2f9 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 d05def549c1938533d7aa8c798ceeaa892cec927..1a931df7c84cbf5ac6cd6c465613584959e0a95e 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 5df8ec64d1e4fa5356f70970033e340237254fe1..5196b6fd74e7f60cb79caff185f482fbee332654 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 */}