1
0
Fork 0
mirror of https://github.com/pawelmalak/flame.git synced 2025-07-30 16:09:38 +02:00

Merge pull request #169 from pawelmalak/v2

Version 2.0.0
This commit is contained in:
pawelmalak 2021-11-15 13:46:05 +01:00 committed by GitHub
commit 7eb8ec228a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
159 changed files with 3650 additions and 2787 deletions

View file

@ -25,5 +25,6 @@ WORKDIR /app
EXPOSE 5005 EXPOSE 5005
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PASSWORD=flame_password
CMD ["node", "server.js"] CMD ["node", "server.js"]

View file

@ -1,16 +1,26 @@
FROM node:lts-alpine as build-front FROM node:lts-alpine as build-front
RUN apk add --no-cache curl RUN apk add --no-cache curl
WORKDIR /app WORKDIR /app
COPY ./client . COPY ./client .
RUN npm install --production \ RUN npm install --production \
&& npm run build && npm run build
FROM node:lts-alpine FROM node:lts-alpine
WORKDIR /app WORKDIR /app
RUN mkdir -p ./public RUN mkdir -p ./public
COPY --from=build-front /app/build/ ./public COPY --from=build-front /app/build/ ./public
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
CMD ["npm", "run", "skaffold"] CMD ["npm", "run", "skaffold"]

View file

@ -1,10 +1,11 @@
FROM node:14 as builder FROM node:14-alpine3.11 as builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install --production RUN apk --no-cache --virtual build-dependencies add python make g++ \
&& npm install --production
COPY . . COPY . .
@ -16,7 +17,7 @@ RUN mkdir -p ./public ./data \
&& mv ./client/build/* ./public \ && mv ./client/build/* ./public \
&& rm -rf ./client && rm -rf ./client
FROM node:14-alpine FROM node:14-alpine3.11
COPY --from=builder /app /app COPY --from=builder /app /app
@ -25,5 +26,6 @@ WORKDIR /app
EXPOSE 5005 EXPOSE 5005
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PASSWORD=flame_password
CMD ["node", "server.js"] CMD ["node", "server.js"]

View file

@ -7,4 +7,6 @@ services:
- /path/to/data:/app/data - /path/to/data:/app/data
ports: ports:
- 5005:5005 - 5005:5005
environment:
- PASSWORD=flame_password
restart: unless-stopped restart: unless-stopped

View file

@ -1,6 +1,5 @@
node_modules node_modules
github .github
public public
build.sh
k8s k8s
skaffold.yaml skaffold.yaml

4
.env
View file

@ -1,3 +1,5 @@
PORT=5005 PORT=5005
NODE_ENV=development NODE_ENV=development
VERSION=1.7.4 VERSION=2.0.0
PASSWORD=flame_password
SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b

BIN
.github/_apps.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/_bookmarks.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

BIN
.github/_home.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

BIN
.github/apps.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
.github/bookmarks.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
.github/home.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
.github/settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Before After
Before After

View file

@ -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) ### v1.7.4 (2021-11-08)
- Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103)) - 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)) - 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)) - Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
- Added changelog file - 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)) - 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)) - 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)) - 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 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 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)) - 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)) - Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
- Improved Logger - 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 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)) - 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)) - 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)) - 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)) - 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 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 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)) - 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)) - 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)) - 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 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 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)) - 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)) - 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)) - 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 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)) - 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)) - 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)) - 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. Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.

131
README.md
View file

@ -1,37 +1,19 @@
# Flame # Flame
![Homescreen screenshot](./.github/_home.png) ![Homescreen screenshot](.github/home.png)
## Description ## 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 ## Functionality
- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
- Backend - 📌 Pin your favourite items to the homescreen for quick and easy access
- Node.js + Express - 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
- Sequelize ORM + SQLite - 🔑 Authentication system to protect your settings, apps and bookmarks
- Frontend - 🔨 Dozens of option to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes
- React - ☀️ Weather widget with current temperature, cloud coverage and animated weather status
- Redux - 🐳 Docker integration to automatically pick and add apps based on their labels
- 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
```
## Installation ## Installation
@ -40,32 +22,34 @@ npm run dev
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame) [Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
```sh ```sh
docker pull pawelmalak/flame:latest docker pull pawelmalak/flame
# for ARM architecture (e.g. RaspberryPi) # for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch docker pull pawelmalak/flame:multiarch
```
# installing specific version
#### Building images docker pull pawelmalak/flame:2.0.0
```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 .
``` ```
#### Deployment #### Deployment
```sh ```sh
# run container # 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 #### Docker-Compose
@ -81,6 +65,8 @@ services:
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
ports: ports:
- 5005:5005 - 5005:5005
environment:
- PASSWORD=flame_password
restart: unless-stopped 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) Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
## Functionality ## Development
- Applications ### Technology
- Create, update, delete and organize applications using GUI
- Pin your favourite apps to the homescreen
![Homescreen screenshot](./.github/_apps.png) - Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- Redux
- TypeScript
- Deployment
- Docker
- Kubernetes
- Bookmarks ### Creating dev environment
- Create, update, delete and organize bookmarks and categories using GUI
- Pin your favourite categories to the homescreen
- Import html bookmarks (experimental)
![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 ## Screenshots
- Customize your page by choosing from 15 color themes
![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 ## Usage
### Authentication
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
### Search bar ### Search bar
#### Searching #### 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`. 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`.
> You can change where to open search results (same/new tab) in the settings
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar). 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 # - 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. 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" - 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) ### Import HTML Bookmarks (Experimental)

3
api.js
View file

@ -1,6 +1,6 @@
const { join } = require('path'); const { join } = require('path');
const express = require('express'); const express = require('express');
const errorHandler = require('./middleware/errorHandler'); const { errorHandler } = require('./middleware');
const api = express(); const api = express();
@ -21,6 +21,7 @@ api.use('/api/weather', require('./routes/weather'));
api.use('/api/categories', require('./routes/category')); api.use('/api/categories', require('./routes/category'));
api.use('/api/bookmarks', require('./routes/bookmark')); api.use('/api/bookmarks', require('./routes/bookmark'));
api.use('/api/queries', require('./routes/queries')); api.use('/api/queries', require('./routes/queries'));
api.use('/api/auth', require('./routes/auth'));
// Custom error handler // Custom error handler
api.use(errorHandler); api.use(errorHandler);

View file

@ -1 +1 @@
REACT_APP_VERSION=1.7.4 REACT_APP_VERSION=2.0.0

View file

@ -9876,6 +9876,11 @@
"object.assign": "^4.1.2" "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": { "killable": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
@ -11123,9 +11128,9 @@
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=" "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
}, },
"path-parse": { "path-parse": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
}, },
"path-to-regexp": { "path-to-regexp": {
"version": "0.1.7", "version": "0.1.7",
@ -14729,9 +14734,9 @@
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==" "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
}, },
"tar": { "tar": {
"version": "6.1.0", "version": "6.1.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
"integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
"requires": { "requires": {
"chownr": "^2.0.0", "chownr": "^2.0.0",
"fs-minipass": "^2.0.0", "fs-minipass": "^2.0.0",
@ -14977,9 +14982,9 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
}, },
"tmpl": { "tmpl": {
"version": "1.0.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=" "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
}, },
"to-arraybuffer": { "to-arraybuffer": {
"version": "1.0.1", "version": "1.0.1",

View file

@ -19,6 +19,7 @@
"axios": "^0.24.0", "axios": "^0.24.0",
"external-svg-loader": "^1.3.4", "external-svg-loader": "^1.3.4",
"http-proxy-middleware": "^2.0.1", "http-proxy-middleware": "^2.0.1",
"jwt-decode": "^3.1.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-autosuggest": "^10.1.0", "react-autosuggest": "^10.1.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",

View file

@ -1,38 +1,67 @@
import { BrowserRouter, Route, Switch } from 'react-router-dom'; 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'; import 'external-svg-loader';
// Redux
import { store } from './store/store';
import { Provider } from 'react-redux';
// Utils // Utils
import { checkVersion } from './utility'; import { checkVersion, decodeToken } from './utility';
// Routes // Routes
import Home from './components/Home/Home'; import { Home } from './components/Home/Home';
import Apps from './components/Apps/Apps'; import { Apps } from './components/Apps/Apps';
import Settings from './components/Settings/Settings'; import { Settings } from './components/Settings/Settings';
import Bookmarks from './components/Bookmarks/Bookmarks'; import { Bookmarks } from './components/Bookmarks/Bookmarks';
import NotificationCenter from './components/NotificationCenter/NotificationCenter'; 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<any>(getConfig()); store.dispatch<any>(getConfig());
// Set theme // Validate token
if (localStorage.theme) { if (localStorage.token) {
store.dispatch<any>(setTheme(localStorage.theme)); store.dispatch<any>(autoLogin());
} }
// Check for updates 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);
}
// check for updated
checkVersion(); checkVersion();
// fetch queries // load custom search queries
store.dispatch<any>(fetchQueries()); fetchQueries();
return () => window.clearInterval(tokenIsValid);
}, []);
const App = (): JSX.Element => {
return ( return (
<Provider store={store}> <>
<BrowserRouter> <BrowserRouter>
<Switch> <Switch>
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
@ -42,8 +71,6 @@ const App = (): JSX.Element => {
</Switch> </Switch>
</BrowserRouter> </BrowserRouter>
<NotificationCenter /> <NotificationCenter />
</Provider> </>
); );
}; };
export default App;

View file

@ -1,35 +1,41 @@
import classes from './AppCard.module.css'; import classes from './AppCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon'; import { Icon } from '../../UI';
import { iconParser, urlParser } from '../../../utility'; import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
import { App, Config, GlobalState } from '../../../interfaces'; import { App } from '../../../interfaces';
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
interface ComponentProps { interface Props {
app: App; app: App;
pinHandler?: Function; 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); const [displayUrl, redirectUrl] = urlParser(props.app.url);
let iconEl: JSX.Element; let iconEl: JSX.Element;
const { icon } = props.app; const { icon } = props.app;
if (/.(jpeg|jpg|png)$/i.test(icon)) { if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = ( iconEl = (
<img <img
src={`/uploads/${icon}`} src={source}
alt={`${props.app.name} icon`} alt={`${props.app.name} icon`}
className={classes.CustomIcon} className={classes.CustomIcon}
/> />
); );
} else if (/.(svg)$/i.test(icon)) { } else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = ( iconEl = (
<div className={classes.CustomIcon}> <div className={classes.CustomIcon}>
<svg <svg
data-src={`/uploads/${icon}`} data-src={source}
fill="var(--color-primary)" fill="var(--color-primary)"
className={classes.CustomIcon} className={classes.CustomIcon}
></svg> ></svg>
@ -42,7 +48,7 @@ const AppCard = (props: ComponentProps): JSX.Element => {
return ( return (
<a <a
href={redirectUrl} href={redirectUrl}
target={props.config.appsSameTab ? '' : '_blank'} target={config.appsSameTab ? '' : '_blank'}
rel="noreferrer" rel="noreferrer"
className={classes.AppCard} className={classes.AppCard}
> >
@ -54,11 +60,3 @@ const AppCard = (props: ComponentProps): JSX.Element => {
</a> </a>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(AppCard);

View file

@ -1,50 +1,46 @@
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react'; import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import { addApp, updateApp } from '../../../store/actions';
import { App, NewApp } from '../../../interfaces'; import { App, NewApp } from '../../../interfaces';
import classes from './AppForm.module.css'; import classes from './AppForm.module.css';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm'; import { ModalForm, InputGroup, Button } from '../../UI';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import { inputHandler, newAppTemplate } from '../../../utility';
import Button from '../../UI/Buttons/Button/Button'; import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
interface ComponentProps { interface Props {
modalHandler: () => void; modalHandler: () => void;
addApp: (formData: NewApp | FormData) => any;
updateApp: (id: number, formData: NewApp | FormData) => any;
app?: App; 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<boolean>(false); const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null); const [customIcon, setCustomIcon] = useState<File | null>(null);
const [formData, setFormData] = useState<NewApp>({ const [formData, setFormData] = useState<NewApp>(newAppTemplate);
name: '',
url: '',
icon: '',
});
useEffect(() => { useEffect(() => {
if (props.app) { if (app) {
setFormData({ setFormData({
name: props.app.name, ...app,
url: props.app.url,
icon: props.app.icon,
}); });
} else { } else {
setFormData({ setFormData(newAppTemplate);
name: '',
url: '',
icon: '',
});
} }
}, [props.app]); }, [app]);
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => { const inputChangeHandler = (
setFormData({ e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
...formData, options?: { isNumber?: boolean; isBool?: boolean }
[e.target.name]: e.target.value, ) => {
inputHandler<NewApp>({
e,
options,
setStateHandler: setFormData,
state: formData,
}); });
}; };
@ -65,39 +61,34 @@ const AppForm = (props: ComponentProps): JSX.Element => {
} }
data.append('name', formData.name); data.append('name', formData.name);
data.append('url', formData.url); data.append('url', formData.url);
data.append('isPublic', `${formData.isPublic}`);
return data; return data;
}; };
if (!props.app) { if (!app) {
if (customIcon) { if (customIcon) {
const data = createFormData(); const data = createFormData();
props.addApp(data); addApp(data);
} else { } else {
props.addApp(formData); addApp(formData);
} }
} else { } else {
if (customIcon) { if (customIcon) {
const data = createFormData(); const data = createFormData();
props.updateApp(props.app.id, data); updateApp(app.id, data);
} else { } else {
props.updateApp(props.app.id, formData); updateApp(app.id, formData);
props.modalHandler(); modalHandler();
} }
} }
setFormData({ setFormData(newAppTemplate);
name: '',
url: '',
icon: '',
});
}; };
return ( return (
<ModalForm <ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
modalHandler={props.modalHandler} {/* NAME */}
formHandler={formSubmitHandler}
>
<InputGroup> <InputGroup>
<label htmlFor="name">App Name</label> <label htmlFor="name">App Name</label>
<input <input
@ -110,6 +101,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
</InputGroup> </InputGroup>
{/* URL */}
<InputGroup> <InputGroup>
<label htmlFor="url">App URL</label> <label htmlFor="url">App URL</label>
<input <input
@ -121,17 +114,9 @@ const AppForm = (props: ComponentProps): JSX.Element => {
value={formData.url} value={formData.url}
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span>
<a
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
{' '}
Check supported URL formats
</a>
</span>
</InputGroup> </InputGroup>
{/* ICON */}
{!useCustomIcon ? ( {!useCustomIcon ? (
// use mdi icon // use mdi icon
<InputGroup> <InputGroup>
@ -146,7 +131,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
<span> <span>
Use icon name from MDI. Use icon name from MDI or pass a valid URL.
<a href="https://materialdesignicons.com/" target="blank"> <a href="https://materialdesignicons.com/" target="blank">
{' '} {' '}
Click here for reference Click here for reference
@ -182,7 +167,22 @@ const AppForm = (props: ComponentProps): JSX.Element => {
</span> </span>
</InputGroup> </InputGroup>
)} )}
{!props.app ? (
{/* VISIBILITY */}
<InputGroup>
<label htmlFor="isPublic">App visibility</label>
<select
id="isPublic"
name="isPublic"
value={formData.isPublic ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Visible (anyone can access it)</option>
<option value={0}>Hidden (authentication required)</option>
</select>
</InputGroup>
{!app ? (
<Button>Add new application</Button> <Button>Add new application</Button>
) : ( ) : (
<Button>Update application</Button> <Button>Update application</Button>
@ -190,5 +190,3 @@ const AppForm = (props: ComponentProps): JSX.Element => {
</ModalForm> </ModalForm>
); );
}; };
export default connect(null, { addApp, updateApp })(AppForm);

View file

@ -2,15 +2,15 @@ import classes from './AppGrid.module.css';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { App } from '../../../interfaces/App'; import { App } from '../../../interfaces/App';
import AppCard from '../AppCard/AppCard'; import { AppCard } from '../AppCard/AppCard';
interface ComponentProps { interface Props {
apps: App[]; apps: App[];
totalApps?: number; totalApps?: number;
searching: boolean; searching: boolean;
} }
const AppGrid = (props: ComponentProps): JSX.Element => { export const AppGrid = (props: Props): JSX.Element => {
let apps: JSX.Element; let apps: JSX.Element;
if (props.apps.length > 0) { if (props.apps.length > 0) {
@ -49,5 +49,3 @@ const AppGrid = (props: ComponentProps): JSX.Element => {
return apps; return apps;
}; };
export default AppGrid;

View file

@ -8,48 +8,45 @@ import {
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification,
} from '../../../store/actions';
// Typescript // Typescript
import { App, Config, GlobalState, NewNotification } from '../../../interfaces'; import { App } from '../../../interfaces';
// CSS // CSS
import classes from './AppTable.module.css'; import classes from './AppTable.module.css';
// UI // UI
import Icon from '../../UI/Icons/Icon/Icon'; import { Icon, Table } from '../../UI';
import Table from '../../UI/Table/Table'; import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
interface ComponentProps { interface Props {
apps: App[];
config: Config;
pinApp: (app: App) => void;
deleteApp: (id: number) => void;
updateAppHandler: (app: App) => void; 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<App[]>([]); const [localApps, setLocalApps] = useState<App[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false); const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
// Copy apps array // Copy apps array
useEffect(() => { useEffect(() => {
setLocalApps([...props.apps]); setLocalApps([...apps]);
}, [props.apps]); }, [apps]);
// Check ordering // Check ordering
useEffect(() => { useEffect(() => {
const order = props.config.useOrdering; const order = config.useOrdering;
if (order === 'orderId') { if (order === 'orderId') {
setIsCustomOrder(true); setIsCustomOrder(true);
@ -62,7 +59,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
); );
if (proceed) { if (proceed) {
props.deleteApp(app.id); deleteApp(app.id);
} }
}; };
@ -79,7 +76,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
const dragEndHanlder = (result: DropResult): void => { const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) { if (!isCustomOrder) {
props.createNotification({ createNotification({
title: 'Error', title: 'Error',
message: 'Custom order is disabled', message: 'Custom order is disabled',
}); });
@ -95,7 +92,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
tmpApps.splice(result.destination.index, 0, movedApp); tmpApps.splice(result.destination.index, 0, movedApp);
setLocalApps(tmpApps); setLocalApps(tmpApps);
props.reorderApps(tmpApps); reorderApps(tmpApps);
}; };
return ( return (
@ -114,7 +111,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
<Droppable droppableId="apps"> <Droppable droppableId="apps">
{(provided) => ( {(provided) => (
<Table <Table
headers={['Name', 'URL', 'Icon', 'Actions']} headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
innerRef={provided.innerRef} innerRef={provided.innerRef}
> >
{localApps.map((app: App, index): JSX.Element => { {localApps.map((app: App, index): JSX.Element => {
@ -143,6 +140,9 @@ const AppTable = (props: ComponentProps): JSX.Element => {
<td style={{ width: '200px' }}>{app.name}</td> <td style={{ width: '200px' }}>{app.name}</td>
<td style={{ width: '200px' }}>{app.url}</td> <td style={{ width: '200px' }}>{app.url}</td>
<td style={{ width: '200px' }}>{app.icon}</td> <td style={{ width: '200px' }}>{app.icon}</td>
<td style={{ width: '200px' }}>
{app.isPublic ? 'Visible' : 'Hidden'}
</td>
{!snapshot.isDragging && ( {!snapshot.isDragging && (
<td className={classes.TableActions}> <td className={classes.TableActions}>
<div <div
@ -175,9 +175,9 @@ const AppTable = (props: ComponentProps): JSX.Element => {
</div> </div>
<div <div
className={classes.TableAction} className={classes.TableAction}
onClick={() => props.pinApp(app)} onClick={() => pinApp(app)}
onKeyDown={(e) => onKeyDown={(e) =>
keyboardActionHandler(e, app, props.pinApp) keyboardActionHandler(e, app, pinApp)
} }
tabIndex={0} tabIndex={0}
> >
@ -205,20 +205,3 @@ const AppTable = (props: ComponentProps): JSX.Element => {
</Fragment> </Fragment>
); );
}; };
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);

View file

@ -2,56 +2,59 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { getApps } from '../../store/actions';
// Typescript // Typescript
import { App, GlobalState } from '../../interfaces'; import { App } from '../../interfaces';
// CSS // CSS
import classes from './Apps.module.css'; import classes from './Apps.module.css';
// UI // UI
import { Container } from '../UI/Layout/Layout'; import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
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';
// Subcomponents // Subcomponents
import AppGrid from './AppGrid/AppGrid'; import { AppGrid } from './AppGrid/AppGrid';
import AppForm from './AppForm/AppForm'; import { AppForm } from './AppForm/AppForm';
import AppTable from './AppTable/AppTable'; import { AppTable } from './AppTable/AppTable';
interface ComponentProps { // Utils
getApps: Function; import { appTemplate } from '../../utility';
apps: App[]; import { State } from '../../store/reducers';
loading: boolean; import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
interface Props {
searching: boolean; searching: boolean;
} }
const Apps = (props: ComponentProps): JSX.Element => { export const Apps = (props: Props): JSX.Element => {
const { getApps, apps, loading, searching = false } = props; const {
apps: { apps, loading },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { getApps } = bindActionCreators(actionCreators, dispatch);
const [modalIsOpen, setModalIsOpen] = useState(false); const [modalIsOpen, setModalIsOpen] = useState(false);
const [isInEdit, setIsInEdit] = useState(false); const [isInEdit, setIsInEdit] = useState(false);
const [isInUpdate, setIsInUpdate] = useState(false); const [isInUpdate, setIsInUpdate] = useState(false);
const [appInUpdate, setAppInUpdate] = useState<App>({ const [appInUpdate, setAppInUpdate] = useState<App>(appTemplate);
name: 'string',
url: 'string',
icon: 'string',
isPinned: false,
orderId: 0,
id: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
useEffect(() => { useEffect(() => {
if (apps.length === 0) { if (!apps.length) {
getApps(); getApps();
} }
}, [getApps]); }, []);
// observe if user is authenticated -> set default view if not
useEffect(() => {
if (!isAuthenticated) {
setIsInEdit(false);
setModalIsOpen(false);
}
}, [isAuthenticated]);
const toggleModal = (): void => { const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen); setModalIsOpen(!modalIsOpen);
@ -84,16 +87,18 @@ const Apps = (props: ComponentProps): JSX.Element => {
subtitle={<Link to="/">Go back</Link>} subtitle={<Link to="/">Go back</Link>}
/> />
{isAuthenticated && (
<div className={classes.ActionsContainer}> <div className={classes.ActionsContainer}>
<ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} /> <ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} /> <ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
</div> </div>
)}
<div className={classes.Apps}> <div className={classes.Apps}>
{loading ? ( {loading ? (
<Spinner /> <Spinner />
) : !isInEdit ? ( ) : !isInEdit ? (
<AppGrid apps={apps} searching /> <AppGrid apps={apps} searching={props.searching} />
) : ( ) : (
<AppTable updateAppHandler={toggleUpdate} /> <AppTable updateAppHandler={toggleUpdate} />
)} )}
@ -101,12 +106,3 @@ const Apps = (props: ComponentProps): JSX.Element => {
</Container> </Container>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps,
loading: state.app.loading,
};
};
export default connect(mapStateToProps, { getApps })(Apps);

View file

@ -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 classes from './BookmarkCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon'; import { Icon } from '../../UI';
import { iconParser, urlParser } from '../../../utility';
import { Fragment } from 'react';
import { connect } from 'react-redux';
interface ComponentProps { import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
interface Props {
category: Category; 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 ( return (
<div className={classes.BookmarkCard}> <div className={classes.BookmarkCard}>
<h3>{props.category.name}</h3> <h3>{props.category.name}</h3>
@ -24,21 +30,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
if (bookmark.icon) { if (bookmark.icon) {
const { icon, name } = bookmark; const { icon, name } = bookmark;
if (/.(jpeg|jpg|png)$/i.test(icon)) { if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = ( iconEl = (
<div className={classes.BookmarkIcon}> <div className={classes.BookmarkIcon}>
<img <img
src={`/uploads/${icon}`} src={source}
alt={`${name} icon`} alt={`${name} icon`}
className={classes.CustomIcon} className={classes.CustomIcon}
/> />
</div> </div>
); );
} else if (/.(svg)$/i.test(icon)) { } else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
iconEl = ( iconEl = (
<div className={classes.BookmarkIcon}> <div className={classes.BookmarkIcon}>
<svg <svg
data-src={`/uploads/${icon}`} data-src={source}
fill="var(--color-primary)" fill="var(--color-primary)"
className={classes.BookmarkIconSvg} className={classes.BookmarkIconSvg}
></svg> ></svg>
@ -56,7 +66,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
return ( return (
<a <a
href={redirectUrl} href={redirectUrl}
target={props.config.bookmarksSameTab ? '' : '_blank'} target={config.bookmarksSameTab ? '' : '_blank'}
rel="noreferrer" rel="noreferrer"
key={`bookmark-${bookmark.id}`} key={`bookmark-${bookmark.id}`}
> >
@ -69,11 +79,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
</div> </div>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(BookmarkCard);

View file

@ -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<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [categoryName, setCategoryName] = useState<NewCategory>({
name: '',
});
const [formData, setFormData] = useState<NewBookmark>({
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<HTMLFormElement>): 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<HTMLInputElement>): void => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
setFormData({
...formData,
categoryId: parseInt(e.target.value),
});
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
if (e.target.files) {
setCustomIcon(e.target.files[0]);
}
};
let button = <Button>Submit</Button>;
if (!props.category && !props.bookmark) {
if (props.contentType === ContentType.category) {
button = <Button>Add new category</Button>;
} else {
button = <Button>Add new bookmark</Button>;
}
} else if (props.category) {
button = <Button>Update category</Button>;
} else if (props.bookmark) {
button = <Button>Update bookmark</Button>;
}
return (
<ModalForm
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
{props.contentType === ContentType.category ? (
<Fragment>
<InputGroup>
<label htmlFor="categoryName">Category Name</label>
<input
type="text"
name="categoryName"
id="categoryName"
placeholder="Social Media"
required
value={categoryName.name}
onChange={(e) => setCategoryName({ name: e.target.value })}
/>
</InputGroup>
</Fragment>
) : (
<Fragment>
<InputGroup>
<label htmlFor="name">Bookmark Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Reddit"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="url">Bookmark URL</label>
<input
type="text"
name="url"
id="url"
placeholder="reddit.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
{' '}
Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor="categoryId">Bookmark Category</label>
<select
name="categoryId"
id="categoryId"
required
onChange={(e) => selectChangeHandler(e)}
value={formData.categoryId}
>
<option value={-1}>Select category</option>
{props.categories.map((category: Category): JSX.Element => {
return (
<option key={category.id} value={category.id}>
{category.name}
</option>
);
})}
</select>
</InputGroup>
{!useCustomIcon ? (
// mdi
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a href="https://materialdesignicons.com/" target="blank">
{' '}
Click here for reference
</a>
</span>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}
>
Switch to custom icon upload
</span>
</InputGroup>
) : (
// custom
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="file"
name="icon"
id="icon"
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
</Fragment>
)}
{button}
</ModalForm>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
categories: state.bookmark.categories,
};
};
const dispatchMap = {
getCategories,
addCategory,
addBookmark,
updateCategory,
updateBookmark,
createNotification,
};
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

View file

@ -4,19 +4,19 @@ import classes from './BookmarkGrid.module.css';
import { Category } from '../../../interfaces'; import { Category } from '../../../interfaces';
import BookmarkCard from '../BookmarkCard/BookmarkCard'; import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
interface ComponentProps { interface Props {
categories: Category[]; categories: Category[];
totalCategories?: number; totalCategories?: number;
searching: boolean; searching: boolean;
} }
const BookmarkGrid = (props: ComponentProps): JSX.Element => { export const BookmarkGrid = (props: Props): JSX.Element => {
let bookmarks: JSX.Element; let bookmarks: JSX.Element;
if (props.categories.length > 0) { if (props.categories.length) {
if (props.searching && props.categories[0].bookmarks.length === 0) { if (props.searching && !props.categories[0].bookmarks.length) {
bookmarks = ( bookmarks = (
<p className={classes.BookmarksMessage}> <p className={classes.BookmarksMessage}>
No bookmarks match your search criteria No bookmarks match your search criteria
@ -53,5 +53,3 @@ const BookmarkGrid = (props: ComponentProps): JSX.Element => {
return bookmarks; return bookmarks;
}; };
export default BookmarkGrid;

View file

@ -8,45 +8,39 @@ import {
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import { State } from '../../../store/reducers';
pinCategory, import { bindActionCreators } from 'redux';
deleteCategory, import { actionCreators } from '../../../store';
deleteBookmark,
createNotification,
reorderCategories,
} from '../../../store/actions';
// Typescript // Typescript
import { import { Bookmark, Category } from '../../../interfaces';
Bookmark,
Category,
Config,
GlobalState,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks'; import { ContentType } from '../Bookmarks';
// CSS // CSS
import classes from './BookmarkTable.module.css'; import classes from './BookmarkTable.module.css';
// UI // UI
import Table from '../../UI/Table/Table'; import { Table, Icon } from '../../UI';
import Icon from '../../UI/Icons/Icon/Icon';
interface ComponentProps { interface Props {
contentType: ContentType; contentType: ContentType;
categories: Category[]; categories: Category[];
config: Config;
pinCategory: (category: Category) => void;
deleteCategory: (id: number) => void;
updateHandler: (data: Category | Bookmark) => 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<Category[]>([]); const [localCategories, setLocalCategories] = useState<Category[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false); const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
@ -57,7 +51,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
// Check ordering // Check ordering
useEffect(() => { useEffect(() => {
const order = props.config.useOrdering; const order = config.useOrdering;
if (order === 'orderId') { if (order === 'orderId') {
setIsCustomOrder(true); setIsCustomOrder(true);
@ -70,7 +64,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
); );
if (proceed) { if (proceed) {
props.deleteCategory(category.id); deleteCategory(category.id);
} }
}; };
@ -80,7 +74,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
); );
if (proceed) { 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 => { const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) { if (!isCustomOrder) {
props.createNotification({ createNotification({
title: 'Error', title: 'Error',
message: 'Custom order is disabled', message: 'Custom order is disabled',
}); });
@ -112,7 +106,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
tmpCategories.splice(result.destination.index, 0, movedApp); tmpCategories.splice(result.destination.index, 0, movedApp);
setLocalCategories(tmpCategories); setLocalCategories(tmpCategories);
props.reorderCategories(tmpCategories); reorderCategories(tmpCategories);
}; };
if (props.contentType === ContentType.category) { if (props.contentType === ContentType.category) {
@ -131,7 +125,10 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
<DragDropContext onDragEnd={dragEndHanlder}> <DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="categories"> <Droppable droppableId="categories">
{(provided) => ( {(provided) => (
<Table headers={['Name', 'Actions']} innerRef={provided.innerRef}> <Table
headers={['Name', 'Visibility', 'Actions']}
innerRef={provided.innerRef}
>
{localCategories.map( {localCategories.map(
(category: Category, index): JSX.Element => { (category: Category, index): JSX.Element => {
return ( return (
@ -156,7 +153,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
ref={provided.innerRef} ref={provided.innerRef}
style={style} style={style}
> >
<td>{category.name}</td> <td style={{ width: '300px' }}>
{category.name}
</td>
<td style={{ width: '300px' }}>
{category.isPublic ? 'Visible' : 'Hidden'}
</td>
{!snapshot.isDragging && ( {!snapshot.isDragging && (
<td className={classes.TableActions}> <td className={classes.TableActions}>
<div <div
@ -186,12 +188,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
</div> </div>
<div <div
className={classes.TableAction} className={classes.TableAction}
onClick={() => props.pinCategory(category)} onClick={() => pinCategory(category)}
onKeyDown={(e) => onKeyDown={(e) =>
keyboardActionHandler( keyboardActionHandler(
e, e,
category, category,
props.pinCategory pinCategory
) )
} }
tabIndex={0} tabIndex={0}
@ -232,7 +234,9 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
}); });
return ( return (
<Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}> <Table
headers={['Name', 'URL', 'Icon', 'Visibility', 'Category', 'Actions']}
>
{bookmarks.map( {bookmarks.map(
(bookmark: { bookmark: Bookmark; categoryName: string }) => { (bookmark: { bookmark: Bookmark; categoryName: string }) => {
return ( return (
@ -240,6 +244,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
<td>{bookmark.bookmark.name}</td> <td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td> <td>{bookmark.bookmark.url}</td>
<td>{bookmark.bookmark.icon}</td> <td>{bookmark.bookmark.icon}</td>
<td>{bookmark.bookmark.isPublic ? 'Visible' : 'Hidden'}</td>
<td>{bookmark.categoryName}</td> <td>{bookmark.categoryName}</td>
<td className={classes.TableActions}> <td className={classes.TableActions}>
<div <div
@ -265,19 +270,3 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
); );
} }
}; };
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
const actions = {
pinCategory,
deleteCategory,
deleteBookmark,
createNotification,
reorderCategories,
};
export default connect(mapStateToProps, actions)(BookmarkTable);

View file

@ -1,25 +1,30 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; 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 classes from './Bookmarks.module.css';
import { Container } from '../UI/Layout/Layout'; // UI
import Headline from '../UI/Headlines/Headline/Headline'; import { Container, Headline, ActionButton, Spinner, Modal } from '../UI';
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
import BookmarkGrid from './BookmarkGrid/BookmarkGrid'; // Components
import { Category, GlobalState, Bookmark } from '../../interfaces'; import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
import Spinner from '../UI/Spinner/Spinner'; import { BookmarkTable } from './BookmarkTable/BookmarkTable';
import Modal from '../UI/Modal/Modal'; import { Form } from './Form/Form';
import BookmarkForm from './BookmarkForm/BookmarkForm';
import BookmarkTable from './BookmarkTable/BookmarkTable';
interface ComponentProps { // Utils
loading: boolean; import { bookmarkTemplate, categoryTemplate } from '../../utility';
categories: Category[];
getCategories: () => void; interface Props {
searching: boolean; searching: boolean;
} }
@ -28,8 +33,14 @@ export enum ContentType {
bookmark, bookmark,
} }
const Bookmarks = (props: ComponentProps): JSX.Element => { export const Bookmarks = (props: Props): JSX.Element => {
const { getCategories, categories, loading, searching = false } = props; const {
bookmarks: { loading, categories },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { getCategories } = bindActionCreators(actionCreators, dispatch);
const [modalIsOpen, setModalIsOpen] = useState(false); const [modalIsOpen, setModalIsOpen] = useState(false);
const [formContentType, setFormContentType] = useState(ContentType.category); const [formContentType, setFormContentType] = useState(ContentType.category);
@ -38,30 +49,24 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
ContentType.category ContentType.category
); );
const [isInUpdate, setIsInUpdate] = useState(false); const [isInUpdate, setIsInUpdate] = useState(false);
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({ const [categoryInUpdate, setCategoryInUpdate] =
name: '', useState<Category>(categoryTemplate);
id: -1, const [bookmarkInUpdate, setBookmarkInUpdate] =
isPinned: false, useState<Bookmark>(bookmarkTemplate);
orderId: 0,
bookmarks: [],
createdAt: new Date(),
updatedAt: new Date(),
});
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
name: '',
url: '',
categoryId: -1,
icon: '',
id: -1,
createdAt: new Date(),
updatedAt: new Date(),
});
useEffect(() => { useEffect(() => {
if (categories.length === 0) { if (!categories.length) {
getCategories(); getCategories();
} }
}, [getCategories]); }, []);
// observe if user is authenticated -> set default view if not
useEffect(() => {
if (!isAuthenticated) {
setIsInEdit(false);
setModalIsOpen(false);
}
}, [isAuthenticated]);
const toggleModal = (): void => { const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen); setModalIsOpen(!modalIsOpen);
@ -102,28 +107,18 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
return ( return (
<Container> <Container>
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}> <Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
{!isInUpdate ? ( <Form
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
/>
) : formContentType === ContentType.category ? (
<BookmarkForm
modalHandler={toggleModal} modalHandler={toggleModal}
contentType={formContentType} contentType={formContentType}
inUpdate={isInUpdate}
category={categoryInUpdate} category={categoryInUpdate}
/>
) : (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
bookmark={bookmarkInUpdate} bookmark={bookmarkInUpdate}
/> />
)}
</Modal> </Modal>
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} /> <Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
{isAuthenticated && (
<div className={classes.ActionsContainer}> <div className={classes.ActionsContainer}>
<ActionButton <ActionButton
name="Add Category" name="Add Category"
@ -146,11 +141,12 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
handler={() => editActionHandler(ContentType.bookmark)} handler={() => editActionHandler(ContentType.bookmark)}
/> />
</div> </div>
)}
{loading ? ( {loading ? (
<Spinner /> <Spinner />
) : !isInEdit ? ( ) : !isInEdit ? (
<BookmarkGrid categories={categories} searching /> <BookmarkGrid categories={categories} searching={props.searching} />
) : ( ) : (
<BookmarkTable <BookmarkTable
contentType={tableContentType} contentType={tableContentType}
@ -161,12 +157,3 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
</Container> </Container>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.bookmark.loading,
categories: state.bookmark.categories,
};
};
export default connect(mapStateToProps, { getCategories })(Bookmarks);

View file

@ -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<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [formData, setFormData] = useState<NewBookmark>(newBookmarkTemplate);
// Load bookmark data if provided for editing
useEffect(() => {
if (bookmark) {
setFormData({ ...bookmark });
} else {
setFormData(newBookmarkTemplate);
}
}, [bookmark]);
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<NewBookmark>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): 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 (
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
<InputGroup>
<label htmlFor="name">Bookmark Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Reddit"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="url">Bookmark URL</label>
<input
type="text"
name="url"
id="url"
placeholder="reddit.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="categoryId">Bookmark Category</label>
<select
name="categoryId"
id="categoryId"
required
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
value={formData.categoryId}
>
<option value={-1}>Select category</option>
{categories.map((category: Category): JSX.Element => {
return (
<option key={category.id} value={category.id}>
{category.name}
</option>
);
})}
</select>
</InputGroup>
{!useCustomIcon ? (
// mdi
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI or pass a valid URL.
<a href="https://materialdesignicons.com/" target="blank">
{' '}
Click here for reference
</a>
</span>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}
>
Switch to custom icon upload
</span>
</InputGroup>
) : (
// custom
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="file"
name="icon"
id="icon"
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
<InputGroup>
<label htmlFor="isPublic">Bookmark visibility</label>
<select
id="isPublic"
name="isPublic"
value={formData.isPublic ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Visible (anyone can access it)</option>
<option value={0}>Hidden (authentication required)</option>
</select>
</InputGroup>
<Button>{bookmark ? 'Update bookmark' : 'Add new bookmark'}</Button>
</ModalForm>
);
};

View file

@ -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<NewCategory>(newCategoryTemplate);
// Load category data if provided for editing
useEffect(() => {
if (category) {
setFormData({ ...category });
} else {
setFormData(newCategoryTemplate);
}
}, [category]);
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<NewCategory>({
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 (
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
<InputGroup>
<label htmlFor="name">Category Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Social Media"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="isPublic">Category visibility</label>
<select
id="isPublic"
name="isPublic"
value={formData.isPublic ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Visible (anyone can access it)</option>
<option value={0}>Hidden (authentication required)</option>
</select>
</InputGroup>
<Button>{category ? 'Update category' : 'Add new category'}</Button>
</ModalForm>
);
};

View file

@ -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 (
<Fragment>
{!inUpdate ? (
// form: add new
<Fragment>
{contentType === ContentType.category ? (
<CategoryForm modalHandler={modalHandler} />
) : (
<BookmarksForm modalHandler={modalHandler} />
)}
</Fragment>
) : (
// form: update
<Fragment>
{contentType === ContentType.category ? (
<CategoryForm modalHandler={modalHandler} category={category} />
) : (
<BookmarksForm modalHandler={modalHandler} bookmark={bookmark} />
)}
</Fragment>
)}
</Fragment>
);
};

View file

@ -1,17 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Config, GlobalState } from '../../../interfaces';
import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget'; // CSS
import { getDateTime } from './functions/getDateTime';
import { greeter } from './functions/greeter';
import classes from './Header.module.css'; import classes from './Header.module.css';
interface Props { // Components
config: Config; 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<string>(getDateTime()); const [dateTime, setDateTime] = useState<string>(getDateTime());
const [greeting, setGreeting] = useState<string>(greeter()); const [greeting, setGreeting] = useState<string>(greeter());
@ -39,11 +39,3 @@ const Header = (props: Props): JSX.Element => {
</header> </header>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(Header);

View file

@ -1,3 +1,5 @@
import { parseTime } from '../../../../utility';
export const getDateTime = (): string => { export const getDateTime = (): string => {
const days = localStorage.getItem('daySchema')?.split(';') || [ const days = localStorage.getItem('daySchema')?.split(';') || [
'Sunday', 'Sunday',
@ -27,14 +29,23 @@ export const getDateTime = (): string => {
const now = new Date(); const now = new Date();
const useAmericanDate = localStorage.useAmericanDate === 'true'; 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) { if (!useAmericanDate) {
return `${days[now.getDay()]}, ${now.getDate()} ${ return `${days[now.getDay()]}, ${now.getDate()} ${
months[now.getMonth()] months[now.getMonth()]
} ${now.getFullYear()}`; } ${now.getFullYear()}${timeEl}`;
} else { } else {
return `${days[now.getDay()]}, ${ return `${days[now.getDay()]}, ${
months[now.getMonth()] months[now.getMonth()]
} ${now.getDate()} ${now.getFullYear()}`; } ${now.getDate()} ${now.getFullYear()}${timeEl}`;
} }
}; };

View file

@ -2,47 +2,41 @@ import { useState, useEffect, Fragment } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { getApps, getCategories } from '../../store/actions'; import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
// Typescript // Typescript
import { GlobalState } from '../../interfaces/GlobalState'; import { App, Category } from '../../interfaces';
import { App, Category, Config } from '../../interfaces';
// UI // UI
import Icon from '../UI/Icons/Icon/Icon'; import { Icon, Container, SectionHeadline, Spinner } from '../UI';
import { Container } from '../UI/Layout/Layout';
import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline';
import Spinner from '../UI/Spinner/Spinner';
// CSS // CSS
import classes from './Home.module.css'; import classes from './Home.module.css';
// Components // Components
import AppGrid from '../Apps/AppGrid/AppGrid'; import { AppGrid } from '../Apps/AppGrid/AppGrid';
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid'; import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
import SearchBar from '../SearchBar/SearchBar'; import { SearchBar } from '../SearchBar/SearchBar';
import Header from './Header/Header'; import { Header } from './Header/Header';
interface ComponentProps { // Utils
getApps: Function; import { escapeRegex } from '../../utility';
getCategories: Function;
appsLoading: boolean;
apps: App[];
categoriesLoading: boolean;
categories: Category[];
config: Config;
}
const Home = (props: ComponentProps): JSX.Element => { export const Home = (): JSX.Element => {
const { const {
getApps, apps: { apps, loading: appsLoading },
apps, bookmarks: { categories, loading: bookmarksLoading },
appsLoading, config: { config },
getCategories, } = useSelector((state: State) => state);
categories,
categoriesLoading, const dispatch = useDispatch();
} = props; const { getApps, getCategories } = bindActionCreators(
actionCreators,
dispatch
);
// Local search query // Local search query
const [localSearch, setLocalSearch] = useState<null | string>(null); const [localSearch, setLocalSearch] = useState<null | string>(null);
@ -56,20 +50,22 @@ const Home = (props: ComponentProps): JSX.Element => {
if (!apps.length) { if (!apps.length) {
getApps(); getApps();
} }
}, [getApps]); }, []);
// Load bookmark categories // Load bookmark categories
useEffect(() => { useEffect(() => {
if (!categories.length) { if (!categories.length) {
getCategories(); getCategories();
} }
}, [getCategories]); }, []);
useEffect(() => { useEffect(() => {
if (localSearch) { if (localSearch) {
// Search through apps // Search through apps
setAppSearchResult([ setAppSearchResult([
...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)), ...apps.filter(({ name }) =>
new RegExp(escapeRegex(localSearch), 'i').test(name)
),
]); ]);
// Search through bookmarks // Search through bookmarks
@ -79,7 +75,9 @@ const Home = (props: ComponentProps): JSX.Element => {
category.bookmarks = categories category.bookmarks = categories
.map(({ bookmarks }) => bookmarks) .map(({ bookmarks }) => bookmarks)
.flat() .flat()
.filter(({ name }) => new RegExp(localSearch, 'i').test(name)); .filter(({ name }) =>
new RegExp(escapeRegex(localSearch), 'i').test(name)
);
setBookmarkSearchResult([category]); setBookmarkSearchResult([category]);
} else { } else {
@ -90,7 +88,7 @@ const Home = (props: ComponentProps): JSX.Element => {
return ( return (
<Container> <Container>
{!props.config.hideSearch ? ( {!config.hideSearch ? (
<SearchBar <SearchBar
setLocalSearch={setLocalSearch} setLocalSearch={setLocalSearch}
appSearchResult={appSearchResult} appSearchResult={appSearchResult}
@ -100,9 +98,9 @@ const Home = (props: ComponentProps): JSX.Element => {
<div></div> <div></div>
)} )}
{!props.config.hideHeader ? <Header /> : <div></div>} {!config.hideHeader ? <Header /> : <div></div>}
{!props.config.hideApps ? ( {!config.hideApps ? (
<Fragment> <Fragment>
<SectionHeadline title="Applications" link="/applications" /> <SectionHeadline title="Applications" link="/applications" />
{appsLoading ? ( {appsLoading ? (
@ -124,16 +122,18 @@ const Home = (props: ComponentProps): JSX.Element => {
<div></div> <div></div>
)} )}
{!props.config.hideCategories ? ( {!config.hideCategories ? (
<Fragment> <Fragment>
<SectionHeadline title="Bookmarks" link="/bookmarks" /> <SectionHeadline title="Bookmarks" link="/bookmarks" />
{categoriesLoading ? ( {bookmarksLoading ? (
<Spinner /> <Spinner />
) : ( ) : (
<BookmarkGrid <BookmarkGrid
categories={ categories={
!bookmarkSearchResult !bookmarkSearchResult
? categories.filter(({ isPinned }) => isPinned) ? categories.filter(
({ isPinned, bookmarks }) => isPinned && bookmarks.length
)
: bookmarkSearchResult : bookmarkSearchResult
} }
totalCategories={categories.length} totalCategories={categories.length}
@ -151,15 +151,3 @@ const Home = (props: ComponentProps): JSX.Element => {
</Container> </Container>
); );
}; };
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);

View file

@ -1,21 +1,20 @@
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
import { GlobalState, Notification as _Notification } from '../../interfaces'; import { Notification as NotificationInterface } from '../../interfaces';
import classes from './NotificationCenter.module.css'; import classes from './NotificationCenter.module.css';
import Notification from '../UI/Notification/Notification'; import { Notification } from '../UI';
import { State } from '../../store/reducers';
interface ComponentProps { export const NotificationCenter = (): JSX.Element => {
notifications: _Notification[]; const { notifications } = useSelector((state: State) => state.notification);
}
const NotificationCenter = (props: ComponentProps): JSX.Element => {
return ( return (
<div <div
className={classes.NotificationCenter} className={classes.NotificationCenter}
style={{ height: `${props.notifications.length * 75}px` }} style={{ height: `${notifications.length * 75}px` }}
> >
{props.notifications.map((notification: _Notification) => { {notifications.map((notification: NotificationInterface) => {
return ( return (
<Notification <Notification
title={notification.title} title={notification.title}
@ -29,11 +28,3 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
</div> </div>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
notifications: state.notification.notifications,
};
};
export default connect(mapStateToProps)(NotificationCenter);

View file

@ -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 <Route {...rest} />;
} else {
return <Redirect to="/settings/app" />;
}
};

View file

@ -1,42 +1,33 @@
import { useRef, useEffect, KeyboardEvent } from 'react'; import { useRef, useEffect, KeyboardEvent } from 'react';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createNotification } from '../../store/actions';
// Typescript // Typescript
import { import { App, Category } from '../../interfaces';
App,
Category,
Config,
GlobalState,
NewNotification,
} from '../../interfaces';
// CSS // CSS
import classes from './SearchBar.module.css'; import classes from './SearchBar.module.css';
// Utils // Utils
import { searchParser, urlParser, redirectUrl } from '../../utility'; import { searchParser, urlParser, redirectUrl } from '../../utility';
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
interface ComponentProps { interface Props {
createNotification: (notification: NewNotification) => void;
setLocalSearch: (query: string) => void; setLocalSearch: (query: string) => void;
appSearchResult: App[] | null; appSearchResult: App[] | null;
bookmarkSearchResult: Category[] | null; bookmarkSearchResult: Category[] | null;
config: Config;
loading: boolean;
} }
const SearchBar = (props: ComponentProps): JSX.Element => { export const SearchBar = (props: Props): JSX.Element => {
const { const { config, loading } = useSelector((state: State) => state.config);
setLocalSearch,
createNotification, const dispatch = useDispatch();
config, const { createNotification } = bindActionCreators(actionCreators, dispatch);
loading,
appSearchResult, const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
bookmarkSearchResult,
} = props;
const inputRef = useRef<HTMLInputElement>(document.createElement('input')); const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
@ -54,12 +45,17 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
if (key === 'Escape') { if (key === 'Escape') {
clearSearch(); 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 = () => { const clearSearch = () => {
@ -126,12 +122,3 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
</div> </div>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
loading: state.config.loading,
};
};
export default connect(mapStateToProps, { createNotification })(SearchBar);

View file

@ -1,8 +1,14 @@
.AppVersion { .text {
color: var(--color-primary); color: var(--color-primary);
margin-bottom: 15px; margin-bottom: 15px;
} }
.AppVersion a { .text a,
.text span {
color: var(--color-accent); color: var(--color-accent);
} }
.separator {
margin: 30px 0;
border: 1px solid var(--color-primary);
}

View file

@ -1,34 +1,43 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { Button, SettingsHeadline } from '../../UI';
import classes from './AppDetails.module.css'; import classes from './AppDetails.module.css';
import Button from '../../UI/Buttons/Button/Button';
import { checkVersion } from '../../../utility'; import { checkVersion } from '../../../utility';
import { AuthForm } from './AuthForm/AuthForm';
const AppDetails = (): JSX.Element => { export const AppDetails = (): JSX.Element => {
return ( return (
<Fragment> <Fragment>
<p className={classes.AppVersion}> <SettingsHeadline text="Authentication" />
<AuthForm />
<hr className={classes.separator} />
<div>
<SettingsHeadline text="App version" />
<p className={classes.text}>
<a <a
href='https://github.com/pawelmalak/flame' href="https://github.com/pawelmalak/flame"
target='_blank' target="_blank"
rel='noreferrer'> rel="noreferrer"
>
Flame Flame
</a> </a>{' '}
{' '}
version {process.env.REACT_APP_VERSION} version {process.env.REACT_APP_VERSION}
</p> </p>
<p className={classes.AppVersion}>
<p className={classes.text}>
See changelog{' '} See changelog{' '}
<a <a
href='https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md' href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md"
target='_blank' target="_blank"
rel='noreferrer'> rel="noreferrer"
>
here here
</a> </a>
</p> </p>
<Button click={() => checkVersion(true)}>Check for updates</Button>
</Fragment>
)
}
export default AppDetails; <Button click={() => checkVersion(true)}>Check for updates</Button>
</div>
</Fragment>
);
};

View file

@ -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 (
<Fragment>
{!isAuthenticated ? (
<form onSubmit={formHandler}>
<InputGroup>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••"
autoComplete="current-password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
<span>
See
<a
href="https://github.com/pawelmalak/flame/wiki/Authentication"
target="blank"
>
{` project wiki `}
</a>
to read more about authentication
</span>
</InputGroup>
<InputGroup>
<label htmlFor="duration">Session duration</label>
<select
id="duration"
name="duration"
value={formData.duration}
onChange={(e) =>
setFormData({ ...formData, duration: e.target.value })
}
>
<option value="1h">1 hour</option>
<option value="1d">1 day</option>
<option value="14d">2 weeks</option>
<option value="30d">1 month</option>
<option value="1y">1 year</option>
</select>
</InputGroup>
<Button>Login</Button>
</form>
) : (
<div>
<p className={classes.text}>
You are logged in. Your session will expire{' '}
<span>{tokenExpires}</span>
</p>
<Button click={logout}>Logout</Button>
</div>
)}
</Fragment>
);
};

View file

@ -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<DockerSettingsForm>(
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<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<DockerSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<SettingsHeadline text="Docker" />
{/* CUSTOM DOCKER SOCKET HOST */}
<InputGroup>
<label htmlFor="dockerHost">Docker Host</label>
<input
type="text"
id="dockerHost"
name="dockerHost"
placeholder="dockerHost:port"
value={formData.dockerHost}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* USE DOCKER API */}
<InputGroup>
<label htmlFor="dockerApps">Use Docker API</label>
<select
id="dockerApps"
name="dockerApps"
value={formData.dockerApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* UNPIN DOCKER APPS */}
<InputGroup>
<label htmlFor="unpinStoppedApps">
Unpin stopped containers / other apps
</label>
<select
id="unpinStoppedApps"
name="unpinStoppedApps"
value={formData.unpinStoppedApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* KUBERNETES SETTINGS */}
<SettingsHeadline text="Kubernetes" />
{/* USE KUBERNETES */}
<InputGroup>
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
<select
id="kubernetesApps"
name="kubernetesApps"
value={formData.kubernetesApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
);
};

View file

@ -1,29 +1,31 @@
import { Fragment, useState } from 'react'; 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 classes from './CustomQueries.module.css';
import Modal from '../../../UI/Modal/Modal'; // UI
import Icon from '../../../UI/Icons/Icon/Icon'; import { Modal, Icon, Button } from '../../../UI';
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 { // Components
customQueries: Query[]; import { QueriesForm } from './QueriesForm';
deleteQuery: (prefix: string) => {};
createNotification: (notification: NewNotification) => void;
config: Config;
}
const CustomQueries = (props: Props): JSX.Element => { export const CustomQueries = (): JSX.Element => {
const { customQueries, deleteQuery, createNotification } = props; const { customQueries, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { deleteQuery, createNotification } = bindActionCreators(
actionCreators,
dispatch
);
const [modalIsOpen, setModalIsOpen] = useState(false); const [modalIsOpen, setModalIsOpen] = useState(false);
const [editableQuery, setEditableQuery] = useState<Query | null>(null); const [editableQuery, setEditableQuery] = useState<Query | null>(null);
@ -34,7 +36,7 @@ const CustomQueries = (props: Props): JSX.Element => {
}; };
const deleteHandler = (query: Query) => { const deleteHandler = (query: Query) => {
const currentProvider = props.config.defaultSearchProvider; const currentProvider = config.defaultSearchProvider;
const isCurrent = currentProvider === query.prefix; const isCurrent = currentProvider === query.prefix;
if (isCurrent) { if (isCurrent) {
@ -105,14 +107,3 @@ const CustomQueries = (props: Props): JSX.Element => {
</Fragment> </Fragment>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
customQueries: state.config.customQueries,
config: state.config.config,
};
};
export default connect(mapStateToProps, { deleteQuery, createNotification })(
CustomQueries
);

View file

@ -1,20 +1,26 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react'; 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 { Query } from '../../../../interfaces';
import Button from '../../../UI/Buttons/Button/Button';
import InputGroup from '../../../UI/Forms/InputGroup/InputGroup'; import { Button, InputGroup, ModalForm } from '../../../UI';
import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
import { connect } from 'react-redux';
import { addQuery, updateQuery } from '../../../../store/actions';
interface Props { interface Props {
modalHandler: () => void; modalHandler: () => void;
addQuery: (query: Query) => {};
updateQuery: (query: Query, Oldprefix: string) => {};
query?: Query; query?: Query;
} }
const QueriesForm = (props: Props): JSX.Element => { export const QueriesForm = (props: Props): JSX.Element => {
const { modalHandler, addQuery, updateQuery, query } = props; const dispatch = useDispatch();
const { addQuery, updateQuery } = bindActionCreators(
actionCreators,
dispatch
);
const { modalHandler, query } = props;
const [formData, setFormData] = useState<Query>({ const [formData, setFormData] = useState<Query>({
name: '', name: '',
@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="name">Prefix</label> <label htmlFor="name">Prefix</label>
<input <input
@ -89,6 +96,7 @@ const QueriesForm = (props: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="name">Query Template</label> <label htmlFor="name">Query Template</label>
<input <input
@ -101,9 +109,8 @@ const QueriesForm = (props: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
/> />
</InputGroup> </InputGroup>
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>} {query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
</ModalForm> </ModalForm>
); );
}; };
export default connect(null, { addQuery, updateQuery })(QueriesForm);

View file

@ -1,26 +1,15 @@
// React // React
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react'; import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
// State
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript // Typescript
import { import { Query, SearchForm } from '../../../interfaces';
Config,
GlobalState,
NewNotification,
Query,
SearchForm,
} from '../../../interfaces';
// Components // Components
import CustomQueries from './CustomQueries/CustomQueries'; import { CustomQueries } from './CustomQueries/CustomQueries';
// UI // UI
import Button from '../../UI/Buttons/Button/Button'; import { Button, SettingsHeadline, InputGroup } from '../../UI';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
// Utils // Utils
import { inputHandler, searchSettingsTemplate } from '../../../utility'; import { inputHandler, searchSettingsTemplate } from '../../../utility';
@ -28,31 +17,35 @@ import { inputHandler, searchSettingsTemplate } from '../../../utility';
// Data // Data
import { queries } from '../../../utility/searchQueries.json'; import { queries } from '../../../utility/searchQueries.json';
interface Props { // Redux
createNotification: (notification: NewNotification) => void; import { State } from '../../../store/reducers';
updateConfig: (formData: SearchForm) => void; import { bindActionCreators } from 'redux';
loading: boolean; import { actionCreators } from '../../../store';
customQueries: Query[];
config: Config; 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 // Initial state
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate); const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
// Get config // Get config
useEffect(() => { useEffect(() => {
setFormData({ setFormData({
...props.config, ...config,
}); });
}, [props.loading]); }, [loading]);
// Form handler // Form handler
const formSubmitHandler = async (e: FormEvent) => { const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
// Save settings // Save settings
await props.updateConfig(formData); await updateConfig(formData);
}; };
// Input handler // Input handler
@ -84,7 +77,7 @@ const SearchSettings = (props: Props): JSX.Element => {
value={formData.defaultSearchProvider} value={formData.defaultSearchProvider}
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
> >
{[...queries, ...props.customQueries].map((query: Query, idx) => { {[...queries, ...customQueries].map((query: Query, idx) => {
const isCustom = idx >= queries.length; const isCustom = idx >= queries.length;
return ( return (
@ -95,6 +88,7 @@ const SearchSettings = (props: Props): JSX.Element => {
})} })}
</select> </select>
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="searchSameTab"> <label htmlFor="searchSameTab">
Open search results in the same tab Open search results in the same tab
@ -109,6 +103,7 @@ const SearchSettings = (props: Props): JSX.Element => {
<option value={0}>False</option> <option value={0}>False</option>
</select> </select>
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="hideSearch">Hide search bar</label> <label htmlFor="hideSearch">Hide search bar</label>
<select <select
@ -121,6 +116,7 @@ const SearchSettings = (props: Props): JSX.Element => {
<option value={0}>False</option> <option value={0}>False</option>
</select> </select>
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="disableAutofocus">Disable search bar autofocus</label> <label htmlFor="disableAutofocus">Disable search bar autofocus</label>
<select <select
@ -133,6 +129,7 @@ const SearchSettings = (props: Props): JSX.Element => {
<option value={0}>False</option> <option value={0}>False</option>
</select> </select>
</InputGroup> </InputGroup>
<Button>Save changes</Button> <Button>Save changes</Button>
</form> </form>
@ -142,18 +139,3 @@ const SearchSettings = (props: Props): JSX.Element => {
</Fragment> </Fragment>
); );
}; };
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);

View file

@ -1,6 +1,9 @@
//
import { NavLink, Link, Switch, Route } from 'react-router-dom'; import { NavLink, Link, Switch, Route } from 'react-router-dom';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../store/reducers';
// Typescript // Typescript
import { Route as SettingsRoute } from '../../interfaces'; import { Route as SettingsRoute } from '../../interfaces';
@ -8,28 +11,33 @@ import { Route as SettingsRoute } from '../../interfaces';
import classes from './Settings.module.css'; import classes from './Settings.module.css';
// Components // Components
import Themer from '../Themer/Themer'; import { Themer } from '../Themer/Themer';
import WeatherSettings from './WeatherSettings/WeatherSettings'; import { WeatherSettings } from './WeatherSettings/WeatherSettings';
import OtherSettings from './OtherSettings/OtherSettings'; import { UISettings } from './UISettings/UISettings';
import AppDetails from './AppDetails/AppDetails'; import { AppDetails } from './AppDetails/AppDetails';
import StyleSettings from './StyleSettings/StyleSettings'; import { StyleSettings } from './StyleSettings/StyleSettings';
import SearchSettings from './SearchSettings/SearchSettings'; import { SearchSettings } from './SearchSettings/SearchSettings';
import { DockerSettings } from './DockerSettings/DockerSettings';
import { ProtectedRoute } from '../Routing/ProtectedRoute';
// UI // UI
import { Container } from '../UI/Layout/Layout'; import { Container, Headline } from '../UI';
import Headline from '../UI/Headlines/Headline/Headline';
// Data // Data
import { routes } from './settings.json'; 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 ( return (
<Container> <Container>
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} /> <Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.Settings}> <div className={classes.Settings}>
{/* NAVIGATION MENU */} {/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}> <nav className={classes.SettingsNav}>
{routes.map(({ name, dest }: SettingsRoute, idx) => ( {tabs.map(({ name, dest }: SettingsRoute, idx) => (
<NavLink <NavLink
className={classes.SettingsNavLink} className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive} activeClassName={classes.SettingsNavLinkActive}
@ -46,10 +54,20 @@ const Settings = (): JSX.Element => {
<section className={classes.SettingsContent}> <section className={classes.SettingsContent}>
<Switch> <Switch>
<Route exact path="/settings" component={Themer} /> <Route exact path="/settings" component={Themer} />
<Route path="/settings/weather" component={WeatherSettings} /> <ProtectedRoute
<Route path="/settings/search" component={SearchSettings} /> path="/settings/weather"
<Route path="/settings/other" component={OtherSettings} /> component={WeatherSettings}
<Route path="/settings/css" component={StyleSettings} /> />
<ProtectedRoute
path="/settings/search"
component={SearchSettings}
/>
<ProtectedRoute path="/settings/interface" component={UISettings} />
<ProtectedRoute
path="/settings/docker"
component={DockerSettings}
/>
<ProtectedRoute path="/settings/css" component={StyleSettings} />
<Route path="/settings/app" component={AppDetails} /> <Route path="/settings/app" component={AppDetails} />
</Switch> </Switch>
</section> </section>
@ -57,5 +75,3 @@ const Settings = (): JSX.Element => {
</Container> </Container>
); );
}; };
export default Settings;

View file

@ -2,54 +2,60 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import axios from 'axios'; import axios from 'axios';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import { createNotification } from '../../../store/actions'; import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript // Typescript
import { ApiResponse, NewNotification } from '../../../interfaces'; import { ApiResponse } from '../../../interfaces';
// UI // Other
import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import { InputGroup, Button } from '../../UI';
import Button from '../../UI/Buttons/Button/Button'; import { applyAuth } from '../../../utility';
interface ComponentProps { export const StyleSettings = (): JSX.Element => {
createNotification: (notification: NewNotification) => void; const dispatch = useDispatch();
} const { createNotification } = bindActionCreators(actionCreators, dispatch);
const StyleSettings = (props: ComponentProps): JSX.Element => {
const [customStyles, setCustomStyles] = useState<string>(''); const [customStyles, setCustomStyles] = useState<string>('');
useEffect(() => { useEffect(() => {
axios.get<ApiResponse<string>>('/api/config/0/css') axios
.then(data => setCustomStyles(data.data.data)) .get<ApiResponse<string>>('/api/config/0/css')
.catch(err => console.log(err.response)); .then((data) => setCustomStyles(data.data.data))
}, []) .catch((err) => console.log(err.response));
}, []);
const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => { const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
e.preventDefault(); e.preventDefault();
setCustomStyles(e.target.value); setCustomStyles(e.target.value);
} };
const formSubmitHandler = (e: FormEvent) => { const formSubmitHandler = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles }) axios
.put<ApiResponse<{}>>(
'/api/config/0/css',
{ styles: customStyles },
{ headers: applyAuth() }
)
.then(() => { .then(() => {
props.createNotification({ createNotification({
title: 'Success', title: 'Success',
message: 'CSS saved. Reload page to see changes' message: 'CSS saved. Reload page to see changes',
});
}) })
}) .catch((err) => console.log(err.response));
.catch(err => console.log(err.response)); };
}
return ( return (
<form onSubmit={(e) => formSubmitHandler(e)}> <form onSubmit={(e) => formSubmitHandler(e)}>
<InputGroup> <InputGroup>
<label htmlFor='customStyles'>Custom CSS</label> <label htmlFor="customStyles">Custom CSS</label>
<textarea <textarea
id='customStyles' id="customStyles"
name='customStyles' name="customStyles"
value={customStyles} value={customStyles}
onChange={(e) => inputChangeHandler(e)} onChange={(e) => inputChangeHandler(e)}
spellCheck={false} spellCheck={false}
@ -57,7 +63,5 @@ const StyleSettings = (props: ComponentProps): JSX.Element => {
</InputGroup> </InputGroup>
<Button>Save CSS</Button> <Button>Save CSS</Button>
</form> </form>
) );
} };
export default connect(null, { createNotification })(StyleSettings);

View file

@ -1,41 +1,28 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react'; import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { import { State } from '../../../store/reducers';
createNotification, import { bindActionCreators } from 'redux';
updateConfig, import { actionCreators } from '../../../store';
sortApps,
sortCategories,
} from '../../../store/actions';
// Typescript // Typescript
import { import { OtherSettingsForm } from '../../../interfaces';
Config,
GlobalState,
NewNotification,
OtherSettingsForm,
} from '../../../interfaces';
// UI // UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import { InputGroup, Button, SettingsHeadline } from '../../UI';
import Button from '../../UI/Buttons/Button/Button';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
// Utils // Utils
import { otherSettingsTemplate, inputHandler } from '../../../utility'; import { otherSettingsTemplate, inputHandler } from '../../../utility';
interface ComponentProps { export const UISettings = (): JSX.Element => {
createNotification: (notification: NewNotification) => void; const { loading, config } = useSelector((state: State) => state.config);
updateConfig: (formData: OtherSettingsForm) => void;
sortApps: () => void;
sortCategories: () => void;
loading: boolean;
config: Config;
}
const OtherSettings = (props: ComponentProps): JSX.Element => { const dispatch = useDispatch();
const { config } = props; const { updateConfig, sortApps, sortCategories } = bindActionCreators(
actionCreators,
dispatch
);
// Initial state // Initial state
const [formData, setFormData] = useState<OtherSettingsForm>( const [formData, setFormData] = useState<OtherSettingsForm>(
@ -47,21 +34,21 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
setFormData({ setFormData({
...config, ...config,
}); });
}, [props.loading]); }, [loading]);
// Form handler // Form handler
const formSubmitHandler = async (e: FormEvent) => { const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
// Save settings // Save settings
await props.updateConfig(formData); await updateConfig(formData);
// Update local page title // Update local page title
document.title = formData.customTitle; document.title = formData.customTitle;
// Sort apps and categories with new settings // Sort apps and categories with new settings
props.sortApps(); sortApps();
props.sortCategories(); sortCategories();
}; };
// Input handler // Input handler
@ -185,8 +172,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</select> </select>
</InputGroup> </InputGroup>
{/* MODULES OPTIONS */} {/* HEADER OPTIONS */}
<SettingsHeadline text="Modules" /> <SettingsHeadline text="Header" />
{/* HIDE HEADER */} {/* HIDE HEADER */}
<InputGroup> <InputGroup>
<label htmlFor="hideHeader">Hide greeting and date</label> <label htmlFor="hideHeader">Hide greeting and date</label>
@ -246,6 +233,22 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
<span>Names must be separated with semicolon</span> <span>Names must be separated with semicolon</span>
</InputGroup> </InputGroup>
{/* SHOW TIME */}
<InputGroup>
<label htmlFor="showTime">Show time</label>
<select
id="showTime"
name="showTime"
value={formData.showTime ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* MODULES OPTIONS */}
<SettingsHeadline text="Modules" />
{/* HIDE APPS */} {/* HIDE APPS */}
<InputGroup> <InputGroup>
<label htmlFor="hideApps">Hide applications</label> <label htmlFor="hideApps">Hide applications</label>
@ -274,83 +277,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</select> </select>
</InputGroup> </InputGroup>
{/* DOCKER SETTINGS */}
<SettingsHeadline text="Docker" />
{/* CUSTOM DOCKER SOCKET HOST */}
<InputGroup>
<label htmlFor="dockerHost">Docker Host</label>
<input
type="text"
id="dockerHost"
name="dockerHost"
placeholder="dockerHost:port"
value={formData.dockerHost}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* USE DOCKER API */}
<InputGroup>
<label htmlFor="dockerApps">Use Docker API</label>
<select
id="dockerApps"
name="dockerApps"
value={formData.dockerApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* UNPIN DOCKER APPS */}
<InputGroup>
<label htmlFor="unpinStoppedApps">
Unpin stopped containers / other apps
</label>
<select
id="unpinStoppedApps"
name="unpinStoppedApps"
value={formData.unpinStoppedApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* KUBERNETES SETTINGS */}
<SettingsHeadline text="Kubernetes" />
{/* USE KUBERNETES */}
<InputGroup>
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
<select
id="kubernetesApps"
name="kubernetesApps"
value={formData.kubernetesApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button> <Button>Save changes</Button>
</form> </form>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading,
config: state.config.config,
};
};
const actions = {
createNotification,
updateConfig,
sortApps,
sortCategories,
};
export default connect(mapStateToProps, actions)(OtherSettings);

View file

@ -2,34 +2,29 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
import axios from 'axios'; import axios from 'axios';
// Redux // Redux
import { connect } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { createNotification, updateConfig } from '../../../store/actions'; import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
// Typescript // Typescript
import { import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
ApiResponse,
Config,
GlobalState,
NewNotification,
Weather,
WeatherForm,
} from '../../../interfaces';
// UI // UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup'; import { InputGroup, Button } from '../../UI';
import Button from '../../UI/Buttons/Button/Button';
// Utils // Utils
import { inputHandler, weatherSettingsTemplate } from '../../../utility'; import { inputHandler, weatherSettingsTemplate } from '../../../utility';
interface ComponentProps { export const WeatherSettings = (): JSX.Element => {
createNotification: (notification: NewNotification) => void; const { loading, config } = useSelector((state: State) => state.config);
updateConfig: (formData: WeatherForm) => void;
loading: boolean; const dispatch = useDispatch();
config: Config; const { createNotification, updateConfig } = bindActionCreators(
} actionCreators,
dispatch
);
const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Initial state // Initial state
const [formData, setFormData] = useState<WeatherForm>( const [formData, setFormData] = useState<WeatherForm>(
weatherSettingsTemplate weatherSettingsTemplate
@ -38,9 +33,9 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Get config // Get config
useEffect(() => { useEffect(() => {
setFormData({ setFormData({
...props.config, ...config,
}); });
}, [props.loading]); }, [loading]);
// Form handler // Form handler
const formSubmitHandler = async (e: FormEvent) => { const formSubmitHandler = async (e: FormEvent) => {
@ -48,26 +43,26 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Check for api key input // Check for api key input
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) { if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
props.createNotification({ createNotification({
title: 'Warning', title: 'Warning',
message: 'API key is missing. Weather Module will NOT work', message: 'API key is missing. Weather Module will NOT work',
}); });
} }
// Save settings // Save settings
await props.updateConfig(formData); await updateConfig(formData);
// Update weather // Update weather
axios axios
.get<ApiResponse<Weather>>('/api/weather/update') .get<ApiResponse<Weather>>('/api/weather/update')
.then(() => { .then(() => {
props.createNotification({ createNotification({
title: 'Success', title: 'Success',
message: 'Weather updated', message: 'Weather updated',
}); });
}) })
.catch((err) => { .catch((err) => {
props.createNotification({ createNotification({
title: 'Error', title: 'Error',
message: err.response.data.error, message: err.response.data.error,
}); });
@ -108,6 +103,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
. Key is required for weather module to work. . Key is required for weather module to work.
</span> </span>
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="lat">Location latitude</label> <label htmlFor="lat">Location latitude</label>
<input <input
@ -131,6 +127,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
</a> </a>
</span> </span>
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="long">Location longitude</label> <label htmlFor="long">Location longitude</label>
<input <input
@ -144,6 +141,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
lang="en-150" lang="en-150"
/> />
</InputGroup> </InputGroup>
<InputGroup> <InputGroup>
<label htmlFor="isCelsius">Temperature unit</label> <label htmlFor="isCelsius">Temperature unit</label>
<select <select
@ -156,18 +154,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
<option value={0}>Fahrenheit</option> <option value={0}>Fahrenheit</option>
</select> </select>
</InputGroup> </InputGroup>
<Button>Save changes</Button> <Button>Save changes</Button>
</form> </form>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading,
config: state.config.config,
};
};
export default connect(mapStateToProps, { createNotification, updateConfig })(
WeatherSettings
);

View file

@ -2,27 +2,38 @@
"routes": [ "routes": [
{ {
"name": "Theme", "name": "Theme",
"dest": "/settings" "dest": "/settings",
"authRequired": false
}, },
{ {
"name": "Weather", "name": "Weather",
"dest": "/settings/weather" "dest": "/settings/weather",
"authRequired": true
}, },
{ {
"name": "Search", "name": "Search",
"dest": "/settings/search" "dest": "/settings/search",
"authRequired": true
}, },
{ {
"name": "Other", "name": "Interface",
"dest": "/settings/other" "dest": "/settings/interface",
"authRequired": true
},
{
"name": "Docker",
"dest": "/settings/docker",
"authRequired": true
}, },
{ {
"name": "CSS", "name": "CSS",
"dest": "/settings/css" "dest": "/settings/css",
"authRequired": true
}, },
{ {
"name": "App", "name": "App",
"dest": "/settings/app" "dest": "/settings/app",
"authRequired": false
} }
] ]
} }

View file

@ -1,14 +1,17 @@
import { Theme } from '../../interfaces/Theme'; import { Theme } from '../../interfaces/Theme';
import classes from './ThemePreview.module.css'; import classes from './ThemePreview.module.css';
interface ComponentProps { interface Props {
theme: Theme; theme: Theme;
applyTheme: Function; applyTheme: Function;
} }
const ThemePreview = (props: ComponentProps): JSX.Element => { export const ThemePreview = (props: Props): JSX.Element => {
return ( return (
<div className={classes.ThemePreview} onClick={() => props.applyTheme(props.theme.name)}> <div
className={classes.ThemePreview}
onClick={() => props.applyTheme(props.theme.name)}
>
<div className={classes.ColorsPreview}> <div className={classes.ColorsPreview}>
<div <div
className={classes.ColorPreview} className={classes.ColorPreview}
@ -25,7 +28,5 @@ const ThemePreview = (props: ComponentProps): JSX.Element => {
</div> </div>
<p>{props.theme.name}</p> <p>{props.theme.name}</p>
</div> </div>
) );
} };
export default ThemePreview;

View file

@ -1,37 +1,29 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
import classes from './Themer.module.css'; import classes from './Themer.module.css';
import { themes } from './themes.json'; import { themes } from './themes.json';
import { Theme } from '../../interfaces/Theme'; import { Theme } from '../../interfaces/Theme';
import ThemePreview from './ThemePreview'; import { ThemePreview } from './ThemePreview';
import { setTheme } from '../../store/actions'; export const Themer = (): JSX.Element => {
const dispatch = useDispatch();
interface ComponentProps { const { setTheme } = bindActionCreators(actionCreators, dispatch);
setTheme: Function;
}
const Themer = (props: ComponentProps): JSX.Element => {
return ( return (
<Fragment> <Fragment>
<div> <div>
<div className={classes.ThemerGrid}> <div className={classes.ThemerGrid}>
{themes.map((theme: Theme, idx: number): JSX.Element => ( {themes.map(
<ThemePreview (theme: Theme, idx: number): JSX.Element => (
key={idx} <ThemePreview key={idx} theme={theme} applyTheme={setTheme} />
theme={theme} )
applyTheme={props.setTheme} )}
/>
))}
</div> </div>
</div> </div>
</Fragment> </Fragment>
);
) };
}
export default connect(null, { setTheme })(Themer);

View file

@ -2,55 +2,45 @@ import { Fragment } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import classes from './ActionButton.module.css'; import classes from './ActionButton.module.css';
import Icon from '../../Icons/Icon/Icon'; import { Icon } from '../..';
interface ComponentProps { interface Props {
name: string; name: string;
icon: string; icon: string;
link?: string; link?: string;
handler?: () => void; handler?: () => void;
} }
const ActionButton = (props: ComponentProps): JSX.Element => { export const ActionButton = (props: Props): JSX.Element => {
const body = ( const body = (
<Fragment> <Fragment>
<div className={classes.ActionButtonIcon}> <div className={classes.ActionButtonIcon}>
<Icon icon={props.icon} /> <Icon icon={props.icon} />
</div> </div>
<div className={classes.ActionButtonName}> <div className={classes.ActionButtonName}>{props.name}</div>
{props.name}
</div>
</Fragment> </Fragment>
); );
if (props.link) { if (props.link) {
return ( return (
<Link <Link to={props.link} tabIndex={0}>
to={props.link}
tabIndex={0}>
{body} {body}
</Link> </Link>
) );
} else if (props.handler) { } else if (props.handler) {
return ( return (
<div <div
className={classes.ActionButton} className={classes.ActionButton}
onClick={props.handler} onClick={props.handler}
onKeyPress={(e) => { onKeyPress={(e) => {
if (e.key === 'Enter' && props.handler) props.handler() if (e.key === 'Enter' && props.handler) props.handler();
}} }}
tabIndex={0} tabIndex={0}
>{body} >
</div>
)
} else {
return (
<div
className={classes.ActionButton}>
{body} {body}
</div> </div>
) );
} else {
return <div className={classes.ActionButton}>{body}</div>;
} }
} };
export default ActionButton;

View file

@ -1,21 +1,17 @@
import { ReactNode } from 'react';
import classes from './Button.module.css'; import classes from './Button.module.css';
interface ComponentProps { interface Props {
children: string; children: ReactNode;
click?: any; click?: any;
} }
const Button = (props: ComponentProps): JSX.Element => { export const Button = (props: Props): JSX.Element => {
const { const { children, click } = props;
children,
click
} = props;
return ( return (
<button className={classes.Button} onClick={click ? click : () => {}}> <button className={classes.Button} onClick={click ? click : () => {}}>
{children} {children}
</button> </button>
) );
} };
export default Button;

View file

@ -1,15 +1,10 @@
import { ReactNode } from 'react';
import classes from './InputGroup.module.css'; import classes from './InputGroup.module.css';
interface ComponentProps { interface Props {
children: JSX.Element | JSX.Element[]; children: ReactNode;
} }
const InputGroup = (props: ComponentProps): JSX.Element => { export const InputGroup = (props: Props): JSX.Element => {
return ( return <div className={classes.InputGroup}>{props.children}</div>;
<div className={classes.InputGroup}> };
{props.children}
</div>
)
}
export default InputGroup;

View file

@ -1,31 +1,27 @@
import { SyntheticEvent } from 'react'; import { ReactNode, SyntheticEvent } from 'react';
import classes from './ModalForm.module.css'; import classes from './ModalForm.module.css';
import Icon from '../../Icons/Icon/Icon'; import { Icon } from '../..';
interface ComponentProps { interface ComponentProps {
children: JSX.Element | JSX.Element[]; children: ReactNode;
modalHandler?: () => void; modalHandler?: () => void;
formHandler: (e: SyntheticEvent<HTMLFormElement>) => void; formHandler: (e: SyntheticEvent<HTMLFormElement>) => void;
} }
const ModalForm = (props: ComponentProps): JSX.Element => { export const ModalForm = (props: ComponentProps): JSX.Element => {
const _modalHandler = (): void => { const _modalHandler = (): void => {
if (props.modalHandler) { if (props.modalHandler) {
props.modalHandler(); props.modalHandler();
} }
} };
return ( return (
<div className={classes.ModalForm}> <div className={classes.ModalForm}>
<div className={classes.ModalFormIcon} onClick={_modalHandler}> <div className={classes.ModalFormIcon} onClick={_modalHandler}>
<Icon icon='mdiClose' /> <Icon icon="mdiClose" />
</div> </div>
<form onSubmit={(e) => props.formHandler(e)}> <form onSubmit={(e) => props.formHandler(e)}>{props.children}</form>
{props.children}
</form>
</div> </div>
) );
} };
export default ModalForm;

View file

@ -1,18 +1,18 @@
import { Fragment } from 'react'; import { Fragment, ReactNode } from 'react';
import classes from './Headline.module.css'; import classes from './Headline.module.css';
interface ComponentProps { interface Props {
title: string; title: string;
subtitle?: string | JSX.Element; subtitle?: ReactNode;
} }
const Headline = (props: ComponentProps): JSX.Element => { export const Headline = (props: Props): JSX.Element => {
return ( return (
<Fragment> <Fragment>
<h1 className={classes.HeadlineTitle}>{props.title}</h1> <h1 className={classes.HeadlineTitle}>{props.title}</h1>
{props.subtitle && <p className={classes.HeadlineSubtitle}>{props.subtitle}</p>} {props.subtitle && (
<p className={classes.HeadlineSubtitle}>{props.subtitle}</p>
)}
</Fragment> </Fragment>
) );
} };
export default Headline;

View file

@ -2,17 +2,15 @@ import { Link } from 'react-router-dom';
import classes from './SectionHeadline.module.css'; import classes from './SectionHeadline.module.css';
interface ComponentProps { interface Props {
title: string; title: string;
link: string link: string;
} }
const SectionHeadline = (props: ComponentProps): JSX.Element => { export const SectionHeadline = (props: Props): JSX.Element => {
return ( return (
<Link to={props.link}> <Link to={props.link}>
<h2 className={classes.SectionHeadline}>{props.title}</h2> <h2 className={classes.SectionHeadline}>{props.title}</h2>
</Link> </Link>
) );
} };
export default SectionHeadline;

View file

@ -4,8 +4,6 @@ interface Props {
text: string; text: string;
} }
const SettingsHeadline = (props: Props): JSX.Element => { export const SettingsHeadline = (props: Props): JSX.Element => {
return <h2 className={classes.SettingsHeadline}>{props.text}</h2>; return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
}; };
export default SettingsHeadline;

View file

@ -1,6 +1,4 @@
.Icon { .Icon {
color: var(--color-primary); color: var(--color-primary);
/* for settings */
/* color: var(--color-background); */
width: 90%; width: 90%;
} }

View file

@ -2,12 +2,12 @@ import classes from './Icon.module.css';
import { Icon as MDIcon } from '@mdi/react'; import { Icon as MDIcon } from '@mdi/react';
interface ComponentProps { interface Props {
icon: string; icon: string;
color?: string; color?: string;
} }
const Icon = (props: ComponentProps): JSX.Element => { export const Icon = (props: Props): JSX.Element => {
const MDIcons = require('@mdi/js'); const MDIcons = require('@mdi/js');
let iconPath = MDIcons[props.icon]; let iconPath = MDIcons[props.icon];
@ -22,7 +22,5 @@ const Icon = (props: ComponentProps): JSX.Element => {
path={iconPath} path={iconPath}
color={props.color ? props.color : 'var(--color-primary)'} color={props.color ? props.color : 'var(--color-primary)'}
/> />
) );
} };
export default Icon;

View file

@ -1,39 +1,32 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
import { Skycons } from 'skycons-ts'; import { Skycons } from 'skycons-ts';
import { GlobalState, Theme } from '../../../../interfaces'; import { State } from '../../../../store/reducers';
import { IconMapping, TimeOfDay } from './IconMapping'; import { IconMapping, TimeOfDay } from './IconMapping';
interface ComponentProps { interface Props {
theme: Theme;
weatherStatusCode: number; weatherStatusCode: number;
isDay: number; isDay: number;
} }
const WeatherIcon = (props: ComponentProps): JSX.Element => { export const WeatherIcon = (props: Props): JSX.Element => {
const { theme } = useSelector((state: State) => state.theme);
const icon = props.isDay const icon = props.isDay
? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day) ? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
: new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night); : new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night);
useEffect(() => { useEffect(() => {
const delay = setTimeout(() => { const delay = setTimeout(() => {
const skycons = new Skycons({'color': props.theme.colors.accent}); const skycons = new Skycons({ color: theme.colors.accent });
skycons.add(`weather-icon`, icon); skycons.add(`weather-icon`, icon);
skycons.play(); skycons.play();
}, 1); }, 1);
return () => { return () => {
clearTimeout(delay); clearTimeout(delay);
} };
}, [props.weatherStatusCode, icon, props.theme.colors.accent]); }, [props.weatherStatusCode, icon, theme.colors.accent]);
return <canvas id={`weather-icon`} width='50' height='50'></canvas> return <canvas id={`weather-icon`} width="50" height="50"></canvas>;
} };
const mapStateToProps = (state: GlobalState) => {
return {
theme: state.theme.theme
}
}
export default connect(mapStateToProps)(WeatherIcon);

View file

@ -1,13 +1,10 @@
import { ReactNode } from 'react';
import classes from './Layout.module.css'; import classes from './Layout.module.css';
interface ComponentProps { interface ComponentProps {
children: JSX.Element | JSX.Element[]; children: ReactNode;
} }
export const Container = (props: ComponentProps): JSX.Element => { export const Container = (props: ComponentProps): JSX.Element => {
return ( return <div className={classes.Container}>{props.children}</div>;
<div className={classes.Container}> };
{props.children}
</div>
)
}

View file

@ -1,28 +1,29 @@
import { MouseEvent, useRef } from 'react'; import { MouseEvent, ReactNode, useRef } from 'react';
import classes from './Modal.module.css'; import classes from './Modal.module.css';
interface ComponentProps { interface Props {
isOpen: boolean; isOpen: boolean;
setIsOpen: Function; setIsOpen: Function;
children: JSX.Element; children: ReactNode;
} }
const Modal = (props: ComponentProps): JSX.Element => { export const Modal = (props: Props): JSX.Element => {
const modalRef = useRef(null); const modalRef = useRef(null);
const modalClasses = [classes.Modal, props.isOpen ? classes.ModalOpen : classes.ModalClose].join(' '); const modalClasses = [
classes.Modal,
props.isOpen ? classes.ModalOpen : classes.ModalClose,
].join(' ');
const clickHandler = (e: MouseEvent) => { const clickHandler = (e: MouseEvent) => {
if (e.target === modalRef.current) { if (e.target === modalRef.current) {
props.setIsOpen(false); props.setIsOpen(false);
} }
} };
return ( return (
<div className={modalClasses} onClick={clickHandler} ref={modalRef}> <div className={modalClasses} onClick={clickHandler} ref={modalRef}>
{props.children} {props.children}
</div> </div>
) );
} };
export default Modal;

View file

@ -1,18 +1,21 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import { clearNotification } from '../../../store/actions'; import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import classes from './Notification.module.css'; import classes from './Notification.module.css';
interface ComponentProps { interface Props {
title: string; title: string;
message: string; message: string;
id: number; id: number;
url: string | null; url: string | null;
clearNotification: (id: number) => void;
} }
const Notification = (props: ComponentProps): JSX.Element => { export const Notification = (props: Props): JSX.Element => {
const dispatch = useDispatch();
const { clearNotification } = bindActionCreators(actionCreators, dispatch);
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const elementClasses = [ const elementClasses = [
classes.Notification, classes.Notification,
@ -24,13 +27,13 @@ const Notification = (props: ComponentProps): JSX.Element => {
setIsOpen(false); setIsOpen(false);
}, 3500); }, 3500);
const clearNotification = setTimeout(() => { const clearNotificationTimeout = setTimeout(() => {
props.clearNotification(props.id); clearNotification(props.id);
}, 3600); }, 3600);
return () => { return () => {
window.clearTimeout(closeNotification); window.clearTimeout(closeNotification);
window.clearTimeout(clearNotification); window.clearTimeout(clearNotificationTimeout);
}; };
}, []); }, []);
@ -48,5 +51,3 @@ const Notification = (props: ComponentProps): JSX.Element => {
</div> </div>
); );
}; };
export default connect(null, { clearNotification })(Notification);

View file

@ -1,11 +1,9 @@
import classes from './Spinner.module.css'; import classes from './Spinner.module.css';
const Spinner = (): JSX.Element => { export const Spinner = (): JSX.Element => {
return ( return (
<div className={classes.SpinnerWrapper}> <div className={classes.SpinnerWrapper}>
<div className={classes.Spinner}>Loading...</div> <div className={classes.Spinner}>Loading...</div>
</div> </div>
) );
} };
export default Spinner;

View file

@ -1,26 +1,26 @@
import classes from './Table.module.css'; import classes from './Table.module.css';
interface ComponentProps { interface Props {
children: JSX.Element | JSX.Element[]; children: React.ReactNode;
headers: string[]; headers: string[];
innerRef?: any; innerRef?: any;
} }
const Table = (props: ComponentProps): JSX.Element => { export const Table = (props: Props): JSX.Element => {
return ( return (
<div className={classes.TableContainer} ref={props.innerRef}> <div className={classes.TableContainer} ref={props.innerRef}>
<table className={classes.Table}> <table className={classes.Table}>
<thead className={classes.TableHead}> <thead className={classes.TableHead}>
<tr> <tr>
{props.headers.map((header: string, index: number): JSX.Element => (<th key={index}>{header}</th>))} {props.headers.map(
(header: string, index: number): JSX.Element => (
<th key={index}>{header}</th>
)
)}
</tr> </tr>
</thead> </thead>
<tbody className={classes.TableBody}> <tbody className={classes.TableBody}>{props.children}</tbody>
{props.children}
</tbody>
</table> </table>
</div> </div>
) );
} };
export default Table;

View file

@ -0,0 +1,14 @@
export * from './Table/Table';
export * from './Spinner/Spinner';
export * from './Notification/Notification';
export * from './Modal/Modal';
export * from './Layout/Layout';
export * from './Icons/Icon/Icon';
export * from './Icons/WeatherIcon/WeatherIcon';
export * from './Headlines/Headline/Headline';
export * from './Headlines/SectionHeadline/SectionHeadline';
export * from './Headlines/SettingsHeadline/SettingsHeadline';
export * from './Forms/InputGroup/InputGroup';
export * from './Forms/ModalForm/ModalForm';
export * from './Buttons/ActionButton/ActionButton';
export * from './Buttons/Button/Button';

View file

@ -2,23 +2,21 @@ import { useState, useEffect, Fragment } from 'react';
import axios from 'axios'; import axios from 'axios';
// Redux // Redux
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
// Typescript // Typescript
import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces'; import { Weather, ApiResponse } from '../../../interfaces';
// CSS // CSS
import classes from './WeatherWidget.module.css'; import classes from './WeatherWidget.module.css';
// UI // UI
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon'; import { WeatherIcon } from '../../UI';
import { State } from '../../../store/reducers';
interface ComponentProps { export const WeatherWidget = (): JSX.Element => {
configLoading: boolean; const { loading, config } = useSelector((state: State) => state.config);
config: Config;
}
const WeatherWidget = (props: ComponentProps): JSX.Element => {
const [weather, setWeather] = useState<Weather>({ const [weather, setWeather] = useState<Weather>({
externalLastUpdate: '', externalLastUpdate: '',
tempC: 0, tempC: 0,
@ -68,8 +66,8 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
return ( return (
<div className={classes.WeatherWidget}> <div className={classes.WeatherWidget}>
{isLoading || {isLoading ||
props.configLoading || loading ||
(props.config.WEATHER_API_KEY && weather.id > 0 && ( (config.WEATHER_API_KEY && weather.id > 0 && (
<Fragment> <Fragment>
<div className={classes.WeatherIcon}> <div className={classes.WeatherIcon}>
<WeatherIcon <WeatherIcon
@ -78,7 +76,7 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
/> />
</div> </div>
<div className={classes.WeatherDetails}> <div className={classes.WeatherDetails}>
{props.config.isCelsius ? ( {config.isCelsius ? (
<span>{weather.tempC}°C</span> <span>{weather.tempC}°C</span>
) : ( ) : (
<span>{weather.tempF}°F</span> <span>{weather.tempF}°F</span>
@ -90,12 +88,3 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
</div> </div>
); );
}; };
const mapStateToProps = (state: GlobalState) => {
return {
configLoading: state.config.loading,
config: state.config.config,
};
};
export default connect(mapStateToProps)(WeatherWidget);

View file

@ -1,11 +1,17 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import './index.css'; import './index.css';
import App from './App';
import { Provider } from 'react-redux';
import { store } from './store/store';
import { App } from './App';
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}>
<App /> <App />
</Provider>
</React.StrictMode>, </React.StrictMode>,
document.getElementById('root') document.getElementById('root')
); );

View file

@ -8,3 +8,9 @@ export interface ApiResponse<T> {
success: boolean; success: boolean;
data: T; data: T;
} }
export interface Token {
app: string;
exp: number;
iat: number;
}

View file

@ -1,15 +1,13 @@
import { Model } from '.'; import { Model } from '.';
export interface App extends Model {
name: string;
url: string;
icon: string;
isPinned: boolean;
orderId: number;
}
export interface NewApp { export interface NewApp {
name: string; name: string;
url: string; url: string;
icon: string; icon: string;
isPublic: boolean;
}
export interface App extends Model, NewApp {
orderId: number;
isPinned: boolean;
} }

View file

@ -1,15 +1,11 @@
import { Model } from '.'; import { Model } from '.';
export interface Bookmark extends Model {
name: string;
url: string;
categoryId: number;
icon: string;
}
export interface NewBookmark { export interface NewBookmark {
name: string; name: string;
url: string; url: string;
categoryId: number; categoryId: number;
icon: string; icon: string;
isPublic: boolean;
} }
export interface Bookmark extends Model, NewBookmark {}

View file

@ -1,12 +1,12 @@
import { Model, Bookmark } from '.'; import { Model, Bookmark } from '.';
export interface Category extends Model { export interface NewCategory {
name: string; name: string;
isPublic: boolean;
}
export interface Category extends Model, NewCategory {
isPinned: boolean; isPinned: boolean;
orderId: number; orderId: number;
bookmarks: Bookmark[]; bookmarks: Bookmark[];
} }
export interface NewCategory {
name: string;
}

View file

@ -24,4 +24,5 @@ export interface Config {
greetingsSchema: string; greetingsSchema: string;
daySchema: string; daySchema: string;
monthSchema: string; monthSchema: string;
showTime: boolean;
} }

View file

@ -22,12 +22,16 @@ export interface OtherSettingsForm {
useOrdering: string; useOrdering: string;
appsSameTab: boolean; appsSameTab: boolean;
bookmarksSameTab: boolean; bookmarksSameTab: boolean;
dockerApps: boolean;
dockerHost: string;
kubernetesApps: boolean;
unpinStoppedApps: boolean;
useAmericanDate: boolean; useAmericanDate: boolean;
greetingsSchema: string; greetingsSchema: string;
daySchema: string; daySchema: string;
monthSchema: string; monthSchema: string;
showTime: boolean;
}
export interface DockerSettingsForm {
dockerApps: boolean;
dockerHost: string;
kubernetesApps: boolean;
unpinStoppedApps: boolean;
} }

View file

@ -1,13 +0,0 @@
import { State as AppState } from '../store/reducers/app';
import { State as ThemeState } from '../store/reducers/theme';
import { State as BookmarkState } from '../store/reducers/bookmark';
import { State as NotificationState } from '../store/reducers/notification';
import { State as ConfigState } from '../store/reducers/config';
export interface GlobalState {
theme: ThemeState;
app: AppState;
bookmark: BookmarkState;
notification: NotificationState;
config: ConfigState;
}

View file

@ -1,4 +1,5 @@
export interface Route { export interface Route {
name: string; name: string;
dest: string; dest: string;
authRequired: boolean;
} }

View file

@ -1,6 +1,5 @@
export * from './App'; export * from './App';
export * from './Theme'; export * from './Theme';
export * from './GlobalState';
export * from './Api'; export * from './Api';
export * from './Weather'; export * from './Weather';
export * from './Bookmark'; export * from './Bookmark';

View file

@ -0,0 +1,198 @@
import { ActionType } from '../action-types';
import { Dispatch } from 'redux';
import { ApiResponse, App, Config, NewApp } from '../../interfaces';
import {
AddAppAction,
DeleteAppAction,
GetAppsAction,
PinAppAction,
ReorderAppsAction,
SortAppsAction,
UpdateAppAction,
} from '../actions/app';
import axios from 'axios';
import { applyAuth } from '../../utility';
export const getApps =
() => async (dispatch: Dispatch<GetAppsAction<undefined | App[]>>) => {
dispatch({
type: ActionType.getApps,
payload: undefined,
});
try {
const res = await axios.get<ApiResponse<App[]>>('/api/apps', {
headers: applyAuth(),
});
dispatch({
type: ActionType.getAppsSuccess,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const pinApp =
(app: App) => async (dispatch: Dispatch<PinAppAction>) => {
try {
const { id, isPinned, name } = app;
const res = await axios.put<ApiResponse<App>>(
`/api/apps/${id}`,
{
isPinned: !isPinned,
},
{
headers: applyAuth(),
}
);
const status = isPinned
? 'unpinned from Homescreen'
: 'pinned to Homescreen';
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `App ${name} ${status}`,
},
});
dispatch({
type: ActionType.pinApp,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const addApp =
(formData: NewApp | FormData) => async (dispatch: Dispatch<AddAppAction>) => {
try {
const res = await axios.post<ApiResponse<App>>('/api/apps', formData, {
headers: applyAuth(),
});
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `App added`,
},
});
await dispatch({
type: ActionType.addAppSuccess,
payload: res.data.data,
});
// Sort apps
dispatch<any>(sortApps());
} catch (err) {
console.log(err);
}
};
export const deleteApp =
(id: number) => async (dispatch: Dispatch<DeleteAppAction>) => {
try {
await axios.delete<ApiResponse<{}>>(`/api/apps/${id}`, {
headers: applyAuth(),
});
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: 'App deleted',
},
});
dispatch({
type: ActionType.deleteApp,
payload: id,
});
} catch (err) {
console.log(err);
}
};
export const updateApp =
(id: number, formData: NewApp | FormData) =>
async (dispatch: Dispatch<UpdateAppAction>) => {
try {
const res = await axios.put<ApiResponse<App>>(
`/api/apps/${id}`,
formData,
{
headers: applyAuth(),
}
);
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `App updated`,
},
});
await dispatch({
type: ActionType.updateApp,
payload: res.data.data,
});
// Sort apps
dispatch<any>(sortApps());
} catch (err) {
console.log(err);
}
};
export const reorderApps =
(apps: App[]) => async (dispatch: Dispatch<ReorderAppsAction>) => {
interface ReorderQuery {
apps: {
id: number;
orderId: number;
}[];
}
try {
const updateQuery: ReorderQuery = { apps: [] };
apps.forEach((app, index) =>
updateQuery.apps.push({
id: app.id,
orderId: index + 1,
})
);
await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery, {
headers: applyAuth(),
});
dispatch({
type: ActionType.reorderApps,
payload: apps,
});
} catch (err) {
console.log(err);
}
};
export const sortApps = () => async (dispatch: Dispatch<SortAppsAction>) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config');
dispatch({
type: ActionType.sortApps,
payload: res.data.data.useOrdering,
});
} catch (err) {
console.log(err);
}
};

View file

@ -0,0 +1,85 @@
import { Dispatch } from 'redux';
import { ApiResponse } from '../../interfaces';
import { ActionType } from '../action-types';
import {
AuthErrorAction,
AutoLoginAction,
LoginAction,
LogoutAction,
} from '../actions/auth';
import axios, { AxiosError } from 'axios';
import { getApps, getCategories } from '.';
export const login =
(formData: { password: string; duration: string }) =>
async (dispatch: Dispatch<LoginAction>) => {
try {
const res = await axios.post<ApiResponse<{ token: string }>>(
'/api/auth',
formData
);
localStorage.setItem('token', res.data.data.token);
dispatch({
type: ActionType.login,
payload: res.data.data.token,
});
dispatch<any>(getApps());
dispatch<any>(getCategories());
} catch (err) {
dispatch<any>(authError(err, true));
}
};
export const logout = () => (dispatch: Dispatch<LogoutAction>) => {
localStorage.removeItem('token');
dispatch({
type: ActionType.logout,
});
dispatch<any>(getApps());
dispatch<any>(getCategories());
};
export const autoLogin = () => async (dispatch: Dispatch<AutoLoginAction>) => {
const token: string = localStorage.token;
try {
await axios.post<ApiResponse<{ token: { isValid: boolean } }>>(
'/api/auth/validate',
{ token }
);
dispatch({
type: ActionType.autoLogin,
payload: token,
});
dispatch<any>(getApps());
dispatch<any>(getCategories());
} catch (err) {
dispatch<any>(authError(err, false));
}
};
export const authError =
(error: unknown, showNotification: boolean) =>
(dispatch: Dispatch<AuthErrorAction>) => {
const apiError = error as AxiosError;
if (showNotification) {
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Error',
message: apiError.response?.data.error,
},
});
}
dispatch<any>(getApps());
dispatch<any>(getCategories());
};

View file

@ -0,0 +1,321 @@
import axios from 'axios';
import { Dispatch } from 'redux';
import {
ApiResponse,
Bookmark,
Category,
Config,
NewBookmark,
NewCategory,
} from '../../interfaces';
import { applyAuth } from '../../utility';
import { ActionType } from '../action-types';
import {
AddBookmarkAction,
AddCategoryAction,
DeleteBookmarkAction,
DeleteCategoryAction,
GetCategoriesAction,
PinCategoryAction,
ReorderCategoriesAction,
SortCategoriesAction,
UpdateBookmarkAction,
UpdateCategoryAction,
} from '../actions/bookmark';
export const getCategories =
() =>
async (dispatch: Dispatch<GetCategoriesAction<undefined | Category[]>>) => {
dispatch({
type: ActionType.getCategories,
payload: undefined,
});
try {
const res = await axios.get<ApiResponse<Category[]>>('/api/categories', {
headers: applyAuth(),
});
dispatch({
type: ActionType.getCategoriesSuccess,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const addCategory =
(formData: NewCategory) => async (dispatch: Dispatch<AddCategoryAction>) => {
try {
const res = await axios.post<ApiResponse<Category>>(
'/api/categories',
formData,
{ headers: applyAuth() }
);
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `Category ${formData.name} created`,
},
});
dispatch({
type: ActionType.addCategory,
payload: res.data.data,
});
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
};
export const addBookmark =
(formData: NewBookmark | FormData) =>
async (dispatch: Dispatch<AddBookmarkAction>) => {
try {
const res = await axios.post<ApiResponse<Bookmark>>(
'/api/bookmarks',
formData,
{ headers: applyAuth() }
);
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `Bookmark created`,
},
});
dispatch({
type: ActionType.addBookmark,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const pinCategory =
(category: Category) => async (dispatch: Dispatch<PinCategoryAction>) => {
try {
const { id, isPinned, name } = category;
const res = await axios.put<ApiResponse<Category>>(
`/api/categories/${id}`,
{ isPinned: !isPinned },
{ headers: applyAuth() }
);
const status = isPinned
? 'unpinned from Homescreen'
: 'pinned to Homescreen';
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `Category ${name} ${status}`,
},
});
dispatch({
type: ActionType.pinCategory,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const deleteCategory =
(id: number) => async (dispatch: Dispatch<DeleteCategoryAction>) => {
try {
await axios.delete<ApiResponse<{}>>(`/api/categories/${id}`, {
headers: applyAuth(),
});
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `Category deleted`,
},
});
dispatch({
type: ActionType.deleteCategory,
payload: id,
});
} catch (err) {
console.log(err);
}
};
export const updateCategory =
(id: number, formData: NewCategory) =>
async (dispatch: Dispatch<UpdateCategoryAction>) => {
try {
const res = await axios.put<ApiResponse<Category>>(
`/api/categories/${id}`,
formData,
{ headers: applyAuth() }
);
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `Category ${formData.name} updated`,
},
});
dispatch({
type: ActionType.updateCategory,
payload: res.data.data,
});
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
};
export const deleteBookmark =
(bookmarkId: number, categoryId: number) =>
async (dispatch: Dispatch<DeleteBookmarkAction>) => {
try {
await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`, {
headers: applyAuth(),
});
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: 'Bookmark deleted',
},
});
dispatch({
type: ActionType.deleteBookmark,
payload: {
bookmarkId,
categoryId,
},
});
} catch (err) {
console.log(err);
}
};
export const updateBookmark =
(
bookmarkId: number,
formData: NewBookmark | FormData,
category: {
prev: number;
curr: number;
}
) =>
async (
dispatch: Dispatch<
DeleteBookmarkAction | AddBookmarkAction | UpdateBookmarkAction
>
) => {
try {
const res = await axios.put<ApiResponse<Bookmark>>(
`/api/bookmarks/${bookmarkId}`,
formData,
{ headers: applyAuth() }
);
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: `Bookmark updated`,
},
});
// Check if category was changed
const categoryWasChanged = category.curr !== category.prev;
if (categoryWasChanged) {
// Delete bookmark from old category
dispatch({
type: ActionType.deleteBookmark,
payload: {
bookmarkId,
categoryId: category.prev,
},
});
// Add bookmark to the new category
dispatch({
type: ActionType.addBookmark,
payload: res.data.data,
});
} else {
// Else update only name/url/icon
dispatch({
type: ActionType.updateBookmark,
payload: res.data.data,
});
}
} catch (err) {
console.log(err);
}
};
export const sortCategories =
() => async (dispatch: Dispatch<SortCategoriesAction>) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config');
dispatch({
type: ActionType.sortCategories,
payload: res.data.data.useOrdering,
});
} catch (err) {
console.log(err);
}
};
export const reorderCategories =
(categories: Category[]) =>
async (dispatch: Dispatch<ReorderCategoriesAction>) => {
interface ReorderQuery {
categories: {
id: number;
orderId: number;
}[];
}
try {
const updateQuery: ReorderQuery = { categories: [] };
categories.forEach((category, index) =>
updateQuery.categories.push({
id: category.id,
orderId: index + 1,
})
);
await axios.put<ApiResponse<{}>>(
'/api/categories/0/reorder',
updateQuery,
{ headers: applyAuth() }
);
dispatch({
type: ActionType.reorderCategories,
payload: categories,
});
} catch (err) {
console.log(err);
}
};

View file

@ -0,0 +1,156 @@
import { Dispatch } from 'redux';
import {
AddQueryAction,
DeleteQueryAction,
FetchQueriesAction,
GetConfigAction,
UpdateConfigAction,
UpdateQueryAction,
} from '../actions/config';
import axios from 'axios';
import {
ApiResponse,
Config,
DockerSettingsForm,
OtherSettingsForm,
Query,
SearchForm,
WeatherForm,
} from '../../interfaces';
import { ActionType } from '../action-types';
import { storeUIConfig, applyAuth } from '../../utility';
const keys: (keyof Config)[] = [
'useAmericanDate',
'greetingsSchema',
'daySchema',
'monthSchema',
'showTime',
];
export const getConfig = () => async (dispatch: Dispatch<GetConfigAction>) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config');
dispatch({
type: ActionType.getConfig,
payload: res.data.data,
});
// Set custom page title if set
document.title = res.data.data.customTitle;
// Store settings for priority UI elements
for (let key of keys) {
storeUIConfig(key, res.data.data);
}
} catch (err) {
console.log(err);
}
};
export const updateConfig =
(
formData: WeatherForm | OtherSettingsForm | SearchForm | DockerSettingsForm
) =>
async (dispatch: Dispatch<UpdateConfigAction>) => {
try {
const res = await axios.put<ApiResponse<Config>>(
'/api/config',
formData,
{
headers: applyAuth(),
}
);
dispatch<any>({
type: ActionType.createNotification,
payload: {
title: 'Success',
message: 'Settings updated',
},
});
dispatch({
type: ActionType.updateConfig,
payload: res.data.data,
});
// Store settings for priority UI elements
for (let key of keys) {
storeUIConfig(key, res.data.data);
}
} catch (err) {
console.log(err);
}
};
export const fetchQueries =
() => async (dispatch: Dispatch<FetchQueriesAction>) => {
try {
const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
dispatch({
type: ActionType.fetchQueries,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const addQuery =
(query: Query) => async (dispatch: Dispatch<AddQueryAction>) => {
try {
const res = await axios.post<ApiResponse<Query>>('/api/queries', query, {
headers: applyAuth(),
});
dispatch({
type: ActionType.addQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const deleteQuery =
(prefix: string) => async (dispatch: Dispatch<DeleteQueryAction>) => {
try {
const res = await axios.delete<ApiResponse<Query[]>>(
`/api/queries/${prefix}`,
{
headers: applyAuth(),
}
);
dispatch({
type: ActionType.deleteQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export const updateQuery =
(query: Query, oldPrefix: string) =>
async (dispatch: Dispatch<UpdateQueryAction>) => {
try {
const res = await axios.put<ApiResponse<Query[]>>(
`/api/queries/${oldPrefix}`,
query,
{
headers: applyAuth(),
}
);
dispatch({
type: ActionType.updateQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};

View file

@ -0,0 +1,6 @@
export * from './theme';
export * from './config';
export * from './notification';
export * from './app';
export * from './bookmark';
export * from './auth';

View file

@ -0,0 +1,24 @@
import { Dispatch } from 'redux';
import { NewNotification } from '../../interfaces';
import { ActionType } from '../action-types';
import {
CreateNotificationAction,
ClearNotificationAction,
} from '../actions/notification';
export const createNotification =
(notification: NewNotification) =>
(dispatch: Dispatch<CreateNotificationAction>) => {
dispatch({
type: ActionType.createNotification,
payload: notification,
});
};
export const clearNotification =
(id: number) => (dispatch: Dispatch<ClearNotificationAction>) => {
dispatch({
type: ActionType.clearNotification,
payload: id,
});
};

View file

@ -0,0 +1,26 @@
import { Dispatch } from 'redux';
import { SetThemeAction } from '../actions/theme';
import { ActionType } from '../action-types';
import { Theme } from '../../interfaces/Theme';
import { themes } from '../../components/Themer/themes.json';
export const setTheme =
(name: string) => (dispatch: Dispatch<SetThemeAction>) => {
const theme = themes.find((theme) => theme.name === name);
if (theme) {
localStorage.setItem('theme', name);
loadTheme(theme);
dispatch({
type: ActionType.setTheme,
payload: theme,
});
}
};
export const loadTheme = (theme: Theme): void => {
for (const [key, value] of Object.entries(theme.colors)) {
document.body.style.setProperty(`--color-${key}`, value);
}
};

View file

@ -0,0 +1,45 @@
export enum ActionType {
// THEME
setTheme = 'SET_THEME',
// CONFIG
getConfig = 'GET_CONFIG',
updateConfig = 'UPDATE_CONFIG',
// QUERIES
addQuery = 'ADD_QUERY',
deleteQuery = 'DELETE_QUERY',
fetchQueries = 'FETCH_QUERIES',
updateQuery = 'UPDATE_QUERY',
// NOTIFICATIONS
createNotification = 'CREATE_NOTIFICATION',
clearNotification = 'CLEAR_NOTIFICATION',
// APPS
getApps = 'GET_APPS',
getAppsSuccess = 'GET_APPS_SUCCESS',
getAppsError = 'GET_APPS_ERROR',
pinApp = 'PIN_APP',
addApp = 'ADD_APP',
addAppSuccess = 'ADD_APP_SUCCESS',
deleteApp = 'DELETE_APP',
updateApp = 'UPDATE_APP',
reorderApps = 'REORDER_APPS',
sortApps = 'SORT_APPS',
// CATEGORES
getCategories = 'GET_CATEGORIES',
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
getCategoriesError = 'GET_CATEGORIES_ERROR',
addCategory = 'ADD_CATEGORY',
pinCategory = 'PIN_CATEGORY',
deleteCategory = 'DELETE_CATEGORY',
updateCategory = 'UPDATE_CATEGORY',
sortCategories = 'SORT_CATEGORIES',
reorderCategories = 'REORDER_CATEGORIES',
// BOOKMARKS
addBookmark = 'ADD_BOOKMARK',
deleteBookmark = 'DELETE_BOOKMARK',
updateBookmark = 'UPDATE_BOOKMARK',
// AUTH
login = 'LOGIN',
logout = 'LOGOUT',
autoLogin = 'AUTO_LOGIN',
authError = 'AUTH_ERROR',
}

View file

@ -1,110 +0,0 @@
import {
// Theme
SetThemeAction,
// Apps
GetAppsAction,
PinAppAction,
AddAppAction,
DeleteAppAction,
UpdateAppAction,
ReorderAppsAction,
SortAppsAction,
// Categories
GetCategoriesAction,
AddCategoryAction,
PinCategoryAction,
DeleteCategoryAction,
UpdateCategoryAction,
SortCategoriesAction,
ReorderCategoriesAction,
// Bookmarks
AddBookmarkAction,
DeleteBookmarkAction,
UpdateBookmarkAction,
// Notifications
CreateNotificationAction,
ClearNotificationAction,
// Config
GetConfigAction,
UpdateConfigAction,
} from './';
import {
AddQueryAction,
DeleteQueryAction,
FetchQueriesAction,
UpdateQueryAction,
} from './config';
export enum ActionTypes {
// Theme
setTheme = 'SET_THEME',
// Apps
getApps = 'GET_APPS',
getAppsSuccess = 'GET_APPS_SUCCESS',
getAppsError = 'GET_APPS_ERROR',
pinApp = 'PIN_APP',
addApp = 'ADD_APP',
addAppSuccess = 'ADD_APP_SUCCESS',
deleteApp = 'DELETE_APP',
updateApp = 'UPDATE_APP',
reorderApps = 'REORDER_APPS',
sortApps = 'SORT_APPS',
// Categories
getCategories = 'GET_CATEGORIES',
getCategoriesSuccess = 'GET_CATEGORIES_SUCCESS',
getCategoriesError = 'GET_CATEGORIES_ERROR',
addCategory = 'ADD_CATEGORY',
pinCategory = 'PIN_CATEGORY',
deleteCategory = 'DELETE_CATEGORY',
updateCategory = 'UPDATE_CATEGORY',
sortCategories = 'SORT_CATEGORIES',
reorderCategories = 'REORDER_CATEGORIES',
// Bookmarks
addBookmark = 'ADD_BOOKMARK',
deleteBookmark = 'DELETE_BOOKMARK',
updateBookmark = 'UPDATE_BOOKMARK',
// Notifications
createNotification = 'CREATE_NOTIFICATION',
clearNotification = 'CLEAR_NOTIFICATION',
// Config
getConfig = 'GET_CONFIG',
updateConfig = 'UPDATE_CONFIG',
fetchQueries = 'FETCH_QUERIES',
addQuery = 'ADD_QUERY',
deleteQuery = 'DELETE_QUERY',
updateQuery = 'UPDATE_QUERY',
}
export type Action =
// Theme
| SetThemeAction
// Apps
| GetAppsAction<any>
| PinAppAction
| AddAppAction
| DeleteAppAction
| UpdateAppAction
| ReorderAppsAction
| SortAppsAction
// Categories
| GetCategoriesAction<any>
| AddCategoryAction
| PinCategoryAction
| DeleteCategoryAction
| UpdateCategoryAction
| SortCategoriesAction
| ReorderCategoriesAction
// Bookmarks
| AddBookmarkAction
| DeleteBookmarkAction
| UpdateBookmarkAction
// Notifications
| CreateNotificationAction
| ClearNotificationAction
// Config
| GetConfigAction
| UpdateConfigAction
| FetchQueriesAction
| AddQueryAction
| DeleteQueryAction
| UpdateQueryAction;

View file

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

View file

@ -0,0 +1,19 @@
import { ActionType } from '../action-types';
export interface LoginAction {
type: ActionType.login;
payload: string;
}
export interface LogoutAction {
type: ActionType.logout;
}
export interface AutoLoginAction {
type: ActionType.autoLogin;
payload: string;
}
export interface AuthErrorAction {
type: ActionType.authError;
}

View file

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

View file

@ -1,157 +1,32 @@
import axios from 'axios'; import { ActionType } from '../action-types';
import { Dispatch } from 'redux'; import { Config, Query } from '../../interfaces';
import { ActionTypes } from './actionTypes';
import { Config, ApiResponse, Query } from '../../interfaces';
import { CreateNotificationAction } from './notification';
import { storeUIConfig } from '../../utility';
export interface GetConfigAction { export interface GetConfigAction {
type: ActionTypes.getConfig; type: ActionType.getConfig;
payload: Config; payload: Config;
} }
export const getConfig = () => async (dispatch: Dispatch) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config');
dispatch<GetConfigAction>({
type: ActionTypes.getConfig,
payload: res.data.data,
});
// Set custom page title if set
document.title = res.data.data.customTitle;
// Store settings for priority UI elements
const keys: (keyof Config)[] = [
'useAmericanDate',
'greetingsSchema',
'daySchema',
'monthSchema',
];
for (let key of keys) {
storeUIConfig(key, res.data.data);
}
} catch (err) {
console.log(err);
}
};
export interface UpdateConfigAction { export interface UpdateConfigAction {
type: ActionTypes.updateConfig; type: ActionType.updateConfig;
payload: Config; payload: Config;
} }
export const updateConfig = (formData: any) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<Config>>('/api/config', formData);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: 'Settings updated',
},
});
dispatch<UpdateConfigAction>({
type: ActionTypes.updateConfig,
payload: res.data.data,
});
// Store settings for priority UI elements
const keys: (keyof Config)[] = [
'useAmericanDate',
'greetingsSchema',
'daySchema',
'monthSchema',
];
for (let key of keys) {
storeUIConfig(key, res.data.data);
}
} catch (err) {
console.log(err);
}
};
export interface FetchQueriesAction { export interface FetchQueriesAction {
type: ActionTypes.fetchQueries; type: ActionType.fetchQueries;
payload: Query[]; payload: Query[];
} }
export const fetchQueries =
() => async (dispatch: Dispatch<FetchQueriesAction>) => {
try {
const res = await axios.get<ApiResponse<Query[]>>('/api/queries');
dispatch<FetchQueriesAction>({
type: ActionTypes.fetchQueries,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export interface AddQueryAction { export interface AddQueryAction {
type: ActionTypes.addQuery; type: ActionType.addQuery;
payload: Query; payload: Query;
} }
export const addQuery =
(query: Query) => async (dispatch: Dispatch<AddQueryAction>) => {
try {
const res = await axios.post<ApiResponse<Query>>('/api/queries', query);
dispatch<AddQueryAction>({
type: ActionTypes.addQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export interface DeleteQueryAction { export interface DeleteQueryAction {
type: ActionTypes.deleteQuery; type: ActionType.deleteQuery;
payload: Query[]; payload: Query[];
} }
export const deleteQuery =
(prefix: string) => async (dispatch: Dispatch<DeleteQueryAction>) => {
try {
const res = await axios.delete<ApiResponse<Query[]>>(
`/api/queries/${prefix}`
);
dispatch<DeleteQueryAction>({
type: ActionTypes.deleteQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
export interface UpdateQueryAction { export interface UpdateQueryAction {
type: ActionTypes.updateQuery; type: ActionType.updateQuery;
payload: Query[]; payload: Query[];
} }
export const updateQuery =
(query: Query, oldPrefix: string) =>
async (dispatch: Dispatch<UpdateQueryAction>) => {
try {
const res = await axios.put<ApiResponse<Query[]>>(
`/api/queries/${oldPrefix}`,
query
);
dispatch<UpdateQueryAction>({
type: ActionTypes.updateQuery,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};

View file

@ -1,6 +1,86 @@
export * from './theme'; import { App } from '../../interfaces';
export * from './app';
export * from './actionTypes'; import { SetThemeAction } from './theme';
export * from './bookmark';
export * from './notification'; import {
export * from './config'; AddQueryAction,
DeleteQueryAction,
FetchQueriesAction,
GetConfigAction,
UpdateConfigAction,
UpdateQueryAction,
} from './config';
import {
ClearNotificationAction,
CreateNotificationAction,
} from './notification';
import {
GetAppsAction,
PinAppAction,
AddAppAction,
DeleteAppAction,
UpdateAppAction,
ReorderAppsAction,
SortAppsAction,
} from './app';
import {
GetCategoriesAction,
AddCategoryAction,
PinCategoryAction,
DeleteCategoryAction,
UpdateCategoryAction,
SortCategoriesAction,
ReorderCategoriesAction,
AddBookmarkAction,
DeleteBookmarkAction,
UpdateBookmarkAction,
} from './bookmark';
import {
AuthErrorAction,
AutoLoginAction,
LoginAction,
LogoutAction,
} from './auth';
export type Action =
// Theme
| SetThemeAction
// Config
| GetConfigAction
| UpdateConfigAction
| AddQueryAction
| DeleteQueryAction
| FetchQueriesAction
| UpdateQueryAction
// Notifications
| CreateNotificationAction
| ClearNotificationAction
// Apps
| GetAppsAction<undefined | App[]>
| PinAppAction
| AddAppAction
| DeleteAppAction
| UpdateAppAction
| ReorderAppsAction
| SortAppsAction
// Categories
| GetCategoriesAction<any>
| AddCategoryAction
| PinCategoryAction
| DeleteCategoryAction
| UpdateCategoryAction
| SortCategoriesAction
| ReorderCategoriesAction
// Bookmarks
| AddBookmarkAction
| DeleteBookmarkAction
| UpdateBookmarkAction
// Auth
| LoginAction
| LogoutAction
| AutoLoginAction
| AuthErrorAction;

View file

@ -1,27 +1,12 @@
import { Dispatch } from 'redux'; import { ActionType } from '../action-types';
import { ActionTypes } from '.';
import { NewNotification } from '../../interfaces'; import { NewNotification } from '../../interfaces';
export interface CreateNotificationAction { export interface CreateNotificationAction {
type: ActionTypes.createNotification, type: ActionType.createNotification;
payload: NewNotification payload: NewNotification;
}
export const createNotification = (notification: NewNotification) => (dispatch: Dispatch) => {
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: notification
})
} }
export interface ClearNotificationAction { export interface ClearNotificationAction {
type: ActionTypes.clearNotification, type: ActionType.clearNotification;
payload: number payload: number;
}
export const clearNotification = (id: number) => (dispatch: Dispatch) => {
dispatch<ClearNotificationAction>({
type: ActionTypes.clearNotification,
payload: id
})
} }

View file

@ -1,29 +1,7 @@
import { Dispatch } from 'redux'; import { ActionType } from '../action-types';
import { themes } from '../../components/Themer/themes.json'; import { Theme } from '../../interfaces';
import { Theme } from '../../interfaces/Theme';
import { ActionTypes } from './actionTypes';
export interface SetThemeAction { export interface SetThemeAction {
type: ActionTypes.setTheme, type: ActionType.setTheme;
payload: Theme payload: Theme;
}
export const setTheme = (themeName: string) => (dispatch: Dispatch) => {
const theme = themes.find((theme: Theme) => theme.name === themeName);
if (theme) {
localStorage.setItem('theme', themeName);
loadTheme(theme);
dispatch<SetThemeAction>({
type: ActionTypes.setTheme,
payload: theme
})
}
}
export const loadTheme = (theme: Theme): void => {
for (const [key, value] of Object.entries(theme.colors)) {
document.body.style.setProperty(`--color-${key}`, value);
}
} }

View file

@ -0,0 +1,2 @@
export * from './store';
export * as actionCreators from './action-creators';

View file

@ -1,118 +1,92 @@
import { ActionTypes, Action } from '../actions'; import { ActionType } from '../action-types';
import { App } from '../../interfaces/App'; import { Action } from '../actions/index';
import { App } from '../../interfaces';
import { sortData } from '../../utility'; import { sortData } from '../../utility';
export interface State { interface AppsState {
loading: boolean; loading: boolean;
apps: App[]; apps: App[];
errors: string | undefined; errors: string | undefined;
} }
const initialState: State = { const initialState: AppsState = {
loading: true, loading: true,
apps: [], apps: [],
errors: undefined errors: undefined,
} };
const getApps = (state: State, action: Action): State => { export const appsReducer = (
state: AppsState = initialState,
action: Action
): AppsState => {
switch (action.type) {
case ActionType.getApps:
return { return {
...state, ...state,
loading: true, loading: true,
errors: undefined errors: undefined,
} };
}
const getAppsSuccess = (state: State, action: Action): State => { case ActionType.getAppsSuccess:
return { return {
...state, ...state,
loading: false, loading: false,
apps: action.payload apps: action.payload || [],
} };
}
const getAppsError = (state: State, action: Action): State => { case ActionType.pinApp:
return { const pinnedAppIdx = state.apps.findIndex(
...state, (app) => app.id === action.payload.id
loading: false, );
errors: action.payload
}
}
const pinApp = (state: State, action: Action): State => {
const tmpApps = [...state.apps];
const changedApp = tmpApps.find((app: App) => app.id === action.payload.id);
if (changedApp) {
changedApp.isPinned = action.payload.isPinned;
}
return { return {
...state, ...state,
apps: tmpApps apps: [
} ...state.apps.slice(0, pinnedAppIdx),
} action.payload,
...state.apps.slice(pinnedAppIdx + 1),
],
};
const addAppSuccess = (state: State, action: Action): State => { case ActionType.addAppSuccess:
return { return {
...state, ...state,
apps: [...state.apps, action.payload] apps: [...state.apps, action.payload],
} };
}
const deleteApp = (state: State, action: Action): State => { case ActionType.deleteApp:
const tmpApps = [...state.apps].filter((app: App) => app.id !== action.payload); return {
...state,
apps: [...state.apps].filter((app) => app.id !== action.payload),
};
case ActionType.updateApp:
const updatedAppIdx = state.apps.findIndex(
(app) => app.id === action.payload.id
);
return { return {
...state, ...state,
apps: tmpApps apps: [
} ...state.apps.slice(0, updatedAppIdx),
} action.payload,
...state.apps.slice(updatedAppIdx + 1),
const updateApp = (state: State, action: Action): State => { ],
const tmpApps = [...state.apps]; };
const appInUpdate = tmpApps.find((app: App) => app.id === action.payload.id);
if (appInUpdate) {
appInUpdate.name = action.payload.name;
appInUpdate.url = action.payload.url;
appInUpdate.icon = action.payload.icon;
}
case ActionType.reorderApps:
return { return {
...state, ...state,
apps: tmpApps apps: action.payload,
} };
}
const reorderApps = (state: State, action: Action): State => { case ActionType.sortApps:
return { return {
...state, ...state,
apps: action.payload apps: sortData<App>(state.apps, action.payload),
} };
}
const sortApps = (state: State, action: Action): State => { default:
const sortedApps = sortData<App>(state.apps, action.payload); return state;
return {
...state,
apps: sortedApps
} }
} };
const appReducer = (state = initialState, action: Action) => {
switch (action.type) {
case ActionTypes.getApps: return getApps(state, action);
case ActionTypes.getAppsSuccess: return getAppsSuccess(state, action);
case ActionTypes.getAppsError: return getAppsError(state, action);
case ActionTypes.pinApp: return pinApp(state, action);
case ActionTypes.addAppSuccess: return addAppSuccess(state, action);
case ActionTypes.deleteApp: return deleteApp(state, action);
case ActionTypes.updateApp: return updateApp(state, action);
case ActionTypes.reorderApps: return reorderApps(state, action);
case ActionTypes.sortApps: return sortApps(state, action);
default: return state;
}
}
export default appReducer;

Some files were not shown because too many files have changed in this diff Show more