|
@ -25,5 +25,6 @@ WORKDIR /app
|
|||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PASSWORD=flame_password
|
||||
|
||||
CMD ["node", "server.js"]
|
|
@ -1,16 +1,26 @@
|
|||
FROM node:lts-alpine as build-front
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./client .
|
||||
|
||||
RUN npm install --production \
|
||||
&& npm run build
|
||||
|
||||
FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p ./public
|
||||
|
||||
COPY --from=build-front /app/build/ ./public
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
CMD ["npm", "run", "skaffold"]
|
||||
|
||||
CMD ["npm", "run", "skaffold"]
|
|
@ -1,10 +1,11 @@
|
|||
FROM node:14 as builder
|
||||
FROM node:14-alpine3.11 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install --production
|
||||
RUN apk --no-cache --virtual build-dependencies add python make g++ \
|
||||
&& npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
|
@ -16,7 +17,7 @@ RUN mkdir -p ./public ./data \
|
|||
&& mv ./client/build/* ./public \
|
||||
&& rm -rf ./client
|
||||
|
||||
FROM node:14-alpine
|
||||
FROM node:14-alpine3.11
|
||||
|
||||
COPY --from=builder /app /app
|
||||
|
||||
|
@ -25,5 +26,6 @@ WORKDIR /app
|
|||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PASSWORD=flame_password
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "server.js"]
|
|
@ -7,4 +7,6 @@ services:
|
|||
- /path/to/data:/app/data
|
||||
ports:
|
||||
- 5005:5005
|
||||
environment:
|
||||
- PASSWORD=flame_password
|
||||
restart: unless-stopped
|
|
@ -1,6 +1,5 @@
|
|||
node_modules
|
||||
github
|
||||
.github
|
||||
public
|
||||
build.sh
|
||||
k8s
|
||||
skaffold.yaml
|
||||
skaffold.yaml
|
4
.env
|
@ -1,3 +1,5 @@
|
|||
PORT=5005
|
||||
NODE_ENV=development
|
||||
VERSION=1.7.4
|
||||
VERSION=2.0.0
|
||||
PASSWORD=flame_password
|
||||
SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b
|
BIN
.github/_apps.png
vendored
Before Width: | Height: | Size: 90 KiB |
BIN
.github/_bookmarks.png
vendored
Before Width: | Height: | Size: 93 KiB |
BIN
.github/_home.png
vendored
Before Width: | Height: | Size: 75 KiB |
BIN
.github/apps.png
vendored
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
.github/bookmarks.png
vendored
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
.github/home.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
.github/settings.png
vendored
Normal file
After Width: | Height: | Size: 62 KiB |
0
.github/_themes.png → .github/themes.png
vendored
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
29
CHANGELOG.md
|
@ -1,3 +1,18 @@
|
|||
### v2.0.0 (2021-11-15)
|
||||
- Added authentication system:
|
||||
- Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33))
|
||||
- User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45))
|
||||
- Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature
|
||||
- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110))
|
||||
- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138))
|
||||
- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157))
|
||||
- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158))
|
||||
- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164))
|
||||
- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170))
|
||||
- Added Wikipedia to search queries
|
||||
- Updated project wiki
|
||||
- Lots of changes and refactors under the hood to make future development easier
|
||||
|
||||
### v1.7.4 (2021-11-08)
|
||||
- Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103))
|
||||
- Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129))
|
||||
|
@ -62,12 +77,12 @@
|
|||
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
|
||||
- Added changelog file
|
||||
|
||||
### v1.6 (2021-07-17)
|
||||
### v1.6.0 (2021-07-17)
|
||||
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
|
||||
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
|
||||
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
|
||||
|
||||
### v1.5 (2021-06-24)
|
||||
### v1.5.0 (2021-06-24)
|
||||
- Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
|
||||
- Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
|
||||
- Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
|
||||
|
@ -75,7 +90,7 @@
|
|||
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
|
||||
- Improved Logger
|
||||
|
||||
### v1.4 (2021-06-18)
|
||||
### v1.4.0 (2021-06-18)
|
||||
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
|
||||
|
@ -84,14 +99,14 @@
|
|||
- Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
|
||||
- Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
|
||||
|
||||
### v1.3 (2021-06-14)
|
||||
### v1.3.0 (2021-06-14)
|
||||
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
|
||||
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
|
||||
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
|
||||
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
|
||||
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
|
||||
|
||||
### v1.2 (2021-06-10)
|
||||
### v1.2.0 (2021-06-10)
|
||||
- Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
|
||||
- Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
|
||||
- Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
|
||||
|
@ -100,11 +115,11 @@
|
|||
- Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18))
|
||||
- Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20))
|
||||
|
||||
### v1.1 (2021-06-09)
|
||||
### v1.1.0 (2021-06-09)
|
||||
- Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
|
||||
- Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
|
||||
|
||||
### v1.0 (2021-06-08)
|
||||
### v1.0.0 (2021-06-08)
|
||||
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.
|
||||
|
|
131
README.md
|
@ -1,37 +1,19 @@
|
|||
# Flame
|
||||
|
||||

|
||||

|
||||
|
||||
## Description
|
||||
|
||||
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary.
|
||||
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
|
||||
|
||||
## Technology
|
||||
|
||||
- Backend
|
||||
- Node.js + Express
|
||||
- Sequelize ORM + SQLite
|
||||
- Frontend
|
||||
- React
|
||||
- Redux
|
||||
- TypeScript
|
||||
- Deployment
|
||||
- Docker
|
||||
- Kubernetes
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
# clone repository
|
||||
git clone https://github.com/pawelmalak/flame
|
||||
cd flame
|
||||
|
||||
# run only once
|
||||
npm run dev-init
|
||||
|
||||
# start backend and frontend development servers
|
||||
npm run dev
|
||||
```
|
||||
## Functionality
|
||||
- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
|
||||
- 📌 Pin your favourite items to the homescreen for quick and easy access
|
||||
- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
|
||||
- 🔑 Authentication system to protect your settings, apps and bookmarks
|
||||
- 🔨 Dozens of option to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes
|
||||
- ☀️ Weather widget with current temperature, cloud coverage and animated weather status
|
||||
- 🐳 Docker integration to automatically pick and add apps based on their labels
|
||||
|
||||
## Installation
|
||||
|
||||
|
@ -40,32 +22,34 @@ npm run dev
|
|||
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
|
||||
|
||||
```sh
|
||||
docker pull pawelmalak/flame:latest
|
||||
docker pull pawelmalak/flame
|
||||
|
||||
# for ARM architecture (e.g. RaspberryPi)
|
||||
docker pull pawelmalak/flame:multiarch
|
||||
```
|
||||
|
||||
|
||||
#### Building images
|
||||
|
||||
```sh
|
||||
# build image for amd64 only
|
||||
docker build -t flame .
|
||||
|
||||
# build multiarch image for amd64, armv7 and arm64
|
||||
# building failed multiple times with 2GB memory usage limit so you might want to increase it
|
||||
docker buildx build \
|
||||
--platform linux/arm/v7,linux/arm64,linux/amd64 \
|
||||
-f Dockerfile.multiarch \
|
||||
-t flame:multiarch .
|
||||
# installing specific version
|
||||
docker pull pawelmalak/flame:2.0.0
|
||||
```
|
||||
|
||||
#### Deployment
|
||||
|
||||
```sh
|
||||
# run container
|
||||
docker run -p 5005:5005 -v /path/to/data:/app/data flame
|
||||
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password flame
|
||||
```
|
||||
|
||||
#### Building images
|
||||
|
||||
```sh
|
||||
# build image for amd64 only
|
||||
docker build -t flame -f .docker/Dockerfile .
|
||||
|
||||
# build multiarch image for amd64, armv7 and arm64
|
||||
# building failed multiple times with 2GB memory usage limit so you might want to increase it
|
||||
docker buildx build \
|
||||
--platform linux/arm/v7,linux/arm64,linux/amd64 \
|
||||
-f .docker/Dockerfile.multiarch \
|
||||
-t flame:multiarch .
|
||||
```
|
||||
|
||||
#### Docker-Compose
|
||||
|
@ -81,6 +65,8 @@ services:
|
|||
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
|
||||
ports:
|
||||
- 5005:5005
|
||||
environment:
|
||||
- PASSWORD=flame_password
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
|
@ -95,39 +81,56 @@ skaffold dev
|
|||
|
||||
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
|
||||
|
||||
## Functionality
|
||||
## Development
|
||||
|
||||
- Applications
|
||||
- Create, update, delete and organize applications using GUI
|
||||
- Pin your favourite apps to the homescreen
|
||||
### Technology
|
||||
|
||||

|
||||
- Backend
|
||||
- Node.js + Express
|
||||
- Sequelize ORM + SQLite
|
||||
- Frontend
|
||||
- React
|
||||
- Redux
|
||||
- TypeScript
|
||||
- Deployment
|
||||
- Docker
|
||||
- Kubernetes
|
||||
|
||||
- Bookmarks
|
||||
- Create, update, delete and organize bookmarks and categories using GUI
|
||||
- Pin your favourite categories to the homescreen
|
||||
- Import html bookmarks (experimental)
|
||||
### Creating dev environment
|
||||
|
||||

|
||||
```sh
|
||||
# clone repository
|
||||
git clone https://github.com/pawelmalak/flame
|
||||
cd flame
|
||||
|
||||
- Weather
|
||||
# run only once
|
||||
npm run dev-init
|
||||
|
||||
- Get current temperature, cloud coverage and weather status with animated icons
|
||||
# start backend and frontend development servers
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- Themes
|
||||
- Customize your page by choosing from 15 color themes
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
### Authentication
|
||||
|
||||
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
|
||||
|
||||
### Search bar
|
||||
|
||||
#### Searching
|
||||
|
||||
To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
|
||||
|
||||
> You can change where to open search results (same/new tab) in the settings
|
||||
The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
|
||||
|
||||
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
|
||||
|
||||
|
@ -151,7 +154,7 @@ labels:
|
|||
# - flame.icon=custom to make changes in app. ie: custom icon upload
|
||||
```
|
||||
|
||||
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section
|
||||
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker
|
||||
|
||||
You can also set up different apps in the same label adding `;` between each one.
|
||||
|
||||
|
@ -199,7 +202,7 @@ metadata:
|
|||
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
|
||||
```
|
||||
|
||||
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section
|
||||
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
|
||||
|
||||
### Import HTML Bookmarks (Experimental)
|
||||
|
||||
|
|
3
api.js
|
@ -1,6 +1,6 @@
|
|||
const { join } = require('path');
|
||||
const express = require('express');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const { errorHandler } = require('./middleware');
|
||||
|
||||
const api = express();
|
||||
|
||||
|
@ -21,6 +21,7 @@ api.use('/api/weather', require('./routes/weather'));
|
|||
api.use('/api/categories', require('./routes/category'));
|
||||
api.use('/api/bookmarks', require('./routes/bookmark'));
|
||||
api.use('/api/queries', require('./routes/queries'));
|
||||
api.use('/api/auth', require('./routes/auth'));
|
||||
|
||||
// Custom error handler
|
||||
api.use(errorHandler);
|
||||
|
|
|
@ -1 +1 @@
|
|||
REACT_APP_VERSION=1.7.4
|
||||
REACT_APP_VERSION=2.0.0
|
23
client/package-lock.json
generated
|
@ -9876,6 +9876,11 @@
|
|||
"object.assign": "^4.1.2"
|
||||
}
|
||||
},
|
||||
"jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"killable": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
|
||||
|
@ -11123,9 +11128,9 @@
|
|||
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
|
@ -14729,9 +14734,9 @@
|
|||
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
|
||||
},
|
||||
"tar": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
|
||||
"integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
|
||||
"version": "6.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
|
||||
"integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
|
||||
"requires": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
|
@ -14977,9 +14982,9 @@
|
|||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
|
||||
},
|
||||
"tmpl": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
|
||||
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
|
||||
},
|
||||
"to-arraybuffer": {
|
||||
"version": "1.0.1",
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
"axios": "^0.24.0",
|
||||
"external-svg-loader": "^1.3.4",
|
||||
"http-proxy-middleware": "^2.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-autosuggest": "^10.1.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
|
|
|
@ -1,38 +1,67 @@
|
|||
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
||||
import { fetchQueries, getConfig, setTheme } from './store/actions';
|
||||
import { autoLogin, getConfig } from './store/action-creators';
|
||||
import { actionCreators, store } from './store';
|
||||
import 'external-svg-loader';
|
||||
|
||||
// Redux
|
||||
import { store } from './store/store';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// Utils
|
||||
import { checkVersion } from './utility';
|
||||
import { checkVersion, decodeToken } from './utility';
|
||||
|
||||
// Routes
|
||||
import Home from './components/Home/Home';
|
||||
import Apps from './components/Apps/Apps';
|
||||
import Settings from './components/Settings/Settings';
|
||||
import Bookmarks from './components/Bookmarks/Bookmarks';
|
||||
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
||||
import { Home } from './components/Home/Home';
|
||||
import { Apps } from './components/Apps/Apps';
|
||||
import { Settings } from './components/Settings/Settings';
|
||||
import { Bookmarks } from './components/Bookmarks/Bookmarks';
|
||||
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Load config
|
||||
// Get config
|
||||
store.dispatch<any>(getConfig());
|
||||
|
||||
// Set theme
|
||||
if (localStorage.theme) {
|
||||
store.dispatch<any>(setTheme(localStorage.theme));
|
||||
// Validate token
|
||||
if (localStorage.token) {
|
||||
store.dispatch<any>(autoLogin());
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
checkVersion();
|
||||
export const App = (): JSX.Element => {
|
||||
const dispath = useDispatch();
|
||||
const { fetchQueries, setTheme, logout, createNotification } =
|
||||
bindActionCreators(actionCreators, dispath);
|
||||
|
||||
// fetch queries
|
||||
store.dispatch<any>(fetchQueries());
|
||||
useEffect(() => {
|
||||
// check if token is valid
|
||||
const tokenIsValid = setInterval(() => {
|
||||
if (localStorage.token) {
|
||||
const expiresIn = decodeToken(localStorage.token).exp * 1000;
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (now > expiresIn) {
|
||||
logout();
|
||||
createNotification({
|
||||
title: 'Info',
|
||||
message: 'Session expired. You have been logged out',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// set theme
|
||||
if (localStorage.theme) {
|
||||
setTheme(localStorage.theme);
|
||||
}
|
||||
|
||||
// check for updated
|
||||
checkVersion();
|
||||
|
||||
// load custom search queries
|
||||
fetchQueries();
|
||||
|
||||
return () => window.clearInterval(tokenIsValid);
|
||||
}, []);
|
||||
|
||||
const App = (): JSX.Element => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<>
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
|
@ -42,8 +71,6 @@ const App = (): JSX.Element => {
|
|||
</Switch>
|
||||
</BrowserRouter>
|
||||
<NotificationCenter />
|
||||
</Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
|
@ -1,35 +1,41 @@
|
|||
import classes from './AppCard.module.css';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import { iconParser, urlParser } from '../../../utility';
|
||||
import { Icon } from '../../UI';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
|
||||
import { App, Config, GlobalState } from '../../../interfaces';
|
||||
import { connect } from 'react-redux';
|
||||
import { App } from '../../../interfaces';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
app: App;
|
||||
pinHandler?: Function;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const AppCard = (props: ComponentProps): JSX.Element => {
|
||||
export const AppCard = (props: Props): JSX.Element => {
|
||||
const { config } = useSelector((state: State) => state.config);
|
||||
|
||||
const [displayUrl, redirectUrl] = urlParser(props.app.url);
|
||||
|
||||
let iconEl: JSX.Element;
|
||||
const { icon } = props.app;
|
||||
|
||||
if (/.(jpeg|jpg|png)$/i.test(icon)) {
|
||||
if (isImage(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<img
|
||||
src={`/uploads/${icon}`}
|
||||
src={source}
|
||||
alt={`${props.app.name} icon`}
|
||||
className={classes.CustomIcon}
|
||||
/>
|
||||
);
|
||||
} else if (/.(svg)$/i.test(icon)) {
|
||||
} else if (isSvg(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.CustomIcon}>
|
||||
<svg
|
||||
data-src={`/uploads/${icon}`}
|
||||
data-src={source}
|
||||
fill="var(--color-primary)"
|
||||
className={classes.CustomIcon}
|
||||
></svg>
|
||||
|
@ -42,7 +48,7 @@ const AppCard = (props: ComponentProps): JSX.Element => {
|
|||
return (
|
||||
<a
|
||||
href={redirectUrl}
|
||||
target={props.config.appsSameTab ? '' : '_blank'}
|
||||
target={config.appsSameTab ? '' : '_blank'}
|
||||
rel="noreferrer"
|
||||
className={classes.AppCard}
|
||||
>
|
||||
|
@ -54,11 +60,3 @@ const AppCard = (props: ComponentProps): JSX.Element => {
|
|||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(AppCard);
|
||||
|
|
|
@ -1,50 +1,46 @@
|
|||
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { addApp, updateApp } from '../../../store/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { App, NewApp } from '../../../interfaces';
|
||||
|
||||
import classes from './AppForm.module.css';
|
||||
|
||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
import { inputHandler, newAppTemplate } from '../../../utility';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
addApp: (formData: NewApp | FormData) => any;
|
||||
updateApp: (id: number, formData: NewApp | FormData) => any;
|
||||
app?: App;
|
||||
}
|
||||
|
||||
const AppForm = (props: ComponentProps): JSX.Element => {
|
||||
export const AppForm = ({ app, modalHandler }: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addApp, updateApp } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
const [formData, setFormData] = useState<NewApp>({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
});
|
||||
const [formData, setFormData] = useState<NewApp>(newAppTemplate);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.app) {
|
||||
if (app) {
|
||||
setFormData({
|
||||
name: props.app.name,
|
||||
url: props.app.url,
|
||||
icon: props.app.icon,
|
||||
...app,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
});
|
||||
setFormData(newAppTemplate);
|
||||
}
|
||||
}, [props.app]);
|
||||
}, [app]);
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
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('url', formData.url);
|
||||
data.append('isPublic', `${formData.isPublic}`);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
if (!props.app) {
|
||||
if (!app) {
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
props.addApp(data);
|
||||
addApp(data);
|
||||
} else {
|
||||
props.addApp(formData);
|
||||
addApp(formData);
|
||||
}
|
||||
} else {
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
props.updateApp(props.app.id, data);
|
||||
updateApp(app.id, data);
|
||||
} else {
|
||||
props.updateApp(props.app.id, formData);
|
||||
props.modalHandler();
|
||||
updateApp(app.id, formData);
|
||||
modalHandler();
|
||||
}
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
});
|
||||
setFormData(newAppTemplate);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
modalHandler={props.modalHandler}
|
||||
formHandler={formSubmitHandler}
|
||||
>
|
||||
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
|
||||
{/* NAME */}
|
||||
<InputGroup>
|
||||
<label htmlFor="name">App Name</label>
|
||||
<input
|
||||
|
@ -110,6 +101,8 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* URL */}
|
||||
<InputGroup>
|
||||
<label htmlFor="url">App URL</label>
|
||||
<input
|
||||
|
@ -121,17 +114,9 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||
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>
|
||||
|
||||
{/* ICON */}
|
||||
{!useCustomIcon ? (
|
||||
// use mdi icon
|
||||
<InputGroup>
|
||||
|
@ -146,7 +131,7 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI.
|
||||
Use icon name from MDI or pass a valid URL.
|
||||
<a href="https://materialdesignicons.com/" target="blank">
|
||||
{' '}
|
||||
Click here for reference
|
||||
|
@ -182,7 +167,22 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||
</span>
|
||||
</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>Update application</Button>
|
||||
|
@ -190,5 +190,3 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(null, { addApp, updateApp })(AppForm);
|
||||
|
|
|
@ -2,15 +2,15 @@ import classes from './AppGrid.module.css';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { App } from '../../../interfaces/App';
|
||||
|
||||
import AppCard from '../AppCard/AppCard';
|
||||
import { AppCard } from '../AppCard/AppCard';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
apps: App[];
|
||||
totalApps?: number;
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
const AppGrid = (props: ComponentProps): JSX.Element => {
|
||||
export const AppGrid = (props: Props): JSX.Element => {
|
||||
let apps: JSX.Element;
|
||||
|
||||
if (props.apps.length > 0) {
|
||||
|
@ -49,5 +49,3 @@ const AppGrid = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
return apps;
|
||||
};
|
||||
|
||||
export default AppGrid;
|
||||
|
|
|
@ -8,48 +8,45 @@ import {
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
pinApp,
|
||||
deleteApp,
|
||||
reorderApps,
|
||||
updateConfig,
|
||||
createNotification,
|
||||
} from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
|
||||
import { App } from '../../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './AppTable.module.css';
|
||||
|
||||
// UI
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import Table from '../../UI/Table/Table';
|
||||
import { Icon, Table } from '../../UI';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
interface ComponentProps {
|
||||
apps: App[];
|
||||
config: Config;
|
||||
pinApp: (app: App) => void;
|
||||
deleteApp: (id: number) => void;
|
||||
interface Props {
|
||||
updateAppHandler: (app: App) => void;
|
||||
reorderApps: (apps: App[]) => void;
|
||||
updateConfig: (formData: any) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
|
||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
||||
export const AppTable = (props: Props): JSX.Element => {
|
||||
const {
|
||||
apps: { apps },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { pinApp, deleteApp, reorderApps, updateConfig, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||
|
||||
// Copy apps array
|
||||
useEffect(() => {
|
||||
setLocalApps([...props.apps]);
|
||||
}, [props.apps]);
|
||||
setLocalApps([...apps]);
|
||||
}, [apps]);
|
||||
|
||||
// Check ordering
|
||||
useEffect(() => {
|
||||
const order = props.config.useOrdering;
|
||||
const order = config.useOrdering;
|
||||
|
||||
if (order === 'orderId') {
|
||||
setIsCustomOrder(true);
|
||||
|
@ -62,7 +59,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteApp(app.id);
|
||||
deleteApp(app.id);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -79,7 +76,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (!isCustomOrder) {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled',
|
||||
});
|
||||
|
@ -95,7 +92,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
tmpApps.splice(result.destination.index, 0, movedApp);
|
||||
|
||||
setLocalApps(tmpApps);
|
||||
props.reorderApps(tmpApps);
|
||||
reorderApps(tmpApps);
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -114,7 +111,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
<Droppable droppableId="apps">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={['Name', 'URL', 'Icon', 'Actions']}
|
||||
headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{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.url}</td>
|
||||
<td style={{ width: '200px' }}>{app.icon}</td>
|
||||
<td style={{ width: '200px' }}>
|
||||
{app.isPublic ? 'Visible' : 'Hidden'}
|
||||
</td>
|
||||
{!snapshot.isDragging && (
|
||||
<td className={classes.TableActions}>
|
||||
<div
|
||||
|
@ -175,9 +175,9 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.pinApp(app)}
|
||||
onClick={() => pinApp(app)}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(e, app, props.pinApp)
|
||||
keyboardActionHandler(e, app, pinApp)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
|
@ -205,20 +205,3 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
</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);
|
||||
|
|
|
@ -2,56 +2,59 @@ import { useEffect, useState } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { getApps } from '../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { App, GlobalState } from '../../interfaces';
|
||||
import { App } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Apps.module.css';
|
||||
|
||||
// UI
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
|
||||
import Modal from '../UI/Modal/Modal';
|
||||
import { Headline, Spinner, ActionButton, Modal, Container } from '../UI';
|
||||
|
||||
// Subcomponents
|
||||
import AppGrid from './AppGrid/AppGrid';
|
||||
import AppForm from './AppForm/AppForm';
|
||||
import AppTable from './AppTable/AppTable';
|
||||
import { AppGrid } from './AppGrid/AppGrid';
|
||||
import { AppForm } from './AppForm/AppForm';
|
||||
import { AppTable } from './AppTable/AppTable';
|
||||
|
||||
interface ComponentProps {
|
||||
getApps: Function;
|
||||
apps: App[];
|
||||
loading: boolean;
|
||||
// Utils
|
||||
import { appTemplate } from '../../utility';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
const Apps = (props: ComponentProps): JSX.Element => {
|
||||
const { getApps, apps, loading, searching = false } = props;
|
||||
export const Apps = (props: Props): JSX.Element => {
|
||||
const {
|
||||
apps: { apps, loading },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { getApps } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [isInEdit, setIsInEdit] = useState(false);
|
||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||
const [appInUpdate, setAppInUpdate] = useState<App>({
|
||||
name: 'string',
|
||||
url: 'string',
|
||||
icon: 'string',
|
||||
isPinned: false,
|
||||
orderId: 0,
|
||||
id: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const [appInUpdate, setAppInUpdate] = useState<App>(appTemplate);
|
||||
|
||||
useEffect(() => {
|
||||
if (apps.length === 0) {
|
||||
if (!apps.length) {
|
||||
getApps();
|
||||
}
|
||||
}, [getApps]);
|
||||
}, []);
|
||||
|
||||
// observe if user is authenticated -> set default view if not
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setIsInEdit(false);
|
||||
setModalIsOpen(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const toggleModal = (): void => {
|
||||
setModalIsOpen(!modalIsOpen);
|
||||
|
@ -84,16 +87,18 @@ const Apps = (props: ComponentProps): JSX.Element => {
|
|||
subtitle={<Link to="/">Go back</Link>}
|
||||
/>
|
||||
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
|
||||
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
|
||||
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={classes.Apps}>
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !isInEdit ? (
|
||||
<AppGrid apps={apps} searching />
|
||||
<AppGrid apps={apps} searching={props.searching} />
|
||||
) : (
|
||||
<AppTable updateAppHandler={toggleUpdate} />
|
||||
)}
|
||||
|
@ -101,12 +106,3 @@ const Apps = (props: ComponentProps): JSX.Element => {
|
|||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
apps: state.app.apps,
|
||||
loading: state.app.loading,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { getApps })(Apps);
|
||||
|
|
|
@ -1,17 +1,23 @@
|
|||
import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
|
||||
import classes from './BookmarkCard.module.css';
|
||||
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import { iconParser, urlParser } from '../../../utility';
|
||||
import { Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Icon } from '../../UI';
|
||||
|
||||
interface ComponentProps {
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
category: Category;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
||||
export const BookmarkCard = (props: Props): JSX.Element => {
|
||||
const { config } = useSelector((state: State) => state.config);
|
||||
|
||||
return (
|
||||
<div className={classes.BookmarkCard}>
|
||||
<h3>{props.category.name}</h3>
|
||||
|
@ -24,21 +30,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||
if (bookmark.icon) {
|
||||
const { icon, name } = bookmark;
|
||||
|
||||
if (/.(jpeg|jpg|png)$/i.test(icon)) {
|
||||
if (isImage(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<img
|
||||
src={`/uploads/${icon}`}
|
||||
src={source}
|
||||
alt={`${name} icon`}
|
||||
className={classes.CustomIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (/.(svg)$/i.test(icon)) {
|
||||
} else if (isSvg(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<svg
|
||||
data-src={`/uploads/${icon}`}
|
||||
data-src={source}
|
||||
fill="var(--color-primary)"
|
||||
className={classes.BookmarkIconSvg}
|
||||
></svg>
|
||||
|
@ -56,7 +66,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||
return (
|
||||
<a
|
||||
href={redirectUrl}
|
||||
target={props.config.bookmarksSameTab ? '' : '_blank'}
|
||||
target={config.bookmarksSameTab ? '' : '_blank'}
|
||||
rel="noreferrer"
|
||||
key={`bookmark-${bookmark.id}`}
|
||||
>
|
||||
|
@ -69,11 +79,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(BookmarkCard);
|
||||
|
|
|
@ -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);
|
|
@ -4,19 +4,19 @@ import classes from './BookmarkGrid.module.css';
|
|||
|
||||
import { Category } from '../../../interfaces';
|
||||
|
||||
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
||||
import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
totalCategories?: number;
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
|
||||
export const BookmarkGrid = (props: Props): JSX.Element => {
|
||||
let bookmarks: JSX.Element;
|
||||
|
||||
if (props.categories.length > 0) {
|
||||
if (props.searching && props.categories[0].bookmarks.length === 0) {
|
||||
if (props.categories.length) {
|
||||
if (props.searching && !props.categories[0].bookmarks.length) {
|
||||
bookmarks = (
|
||||
<p className={classes.BookmarksMessage}>
|
||||
No bookmarks match your search criteria
|
||||
|
@ -53,5 +53,3 @@ const BookmarkGrid = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
return bookmarks;
|
||||
};
|
||||
|
||||
export default BookmarkGrid;
|
||||
|
|
|
@ -8,45 +8,39 @@ import {
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
pinCategory,
|
||||
deleteCategory,
|
||||
deleteBookmark,
|
||||
createNotification,
|
||||
reorderCategories,
|
||||
} from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
Bookmark,
|
||||
Category,
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
} from '../../../interfaces';
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
|
||||
// CSS
|
||||
import classes from './BookmarkTable.module.css';
|
||||
|
||||
// UI
|
||||
import Table from '../../UI/Table/Table';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import { Table, Icon } from '../../UI';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
config: Config;
|
||||
pinCategory: (category: Category) => void;
|
||||
deleteCategory: (id: number) => void;
|
||||
updateHandler: (data: Category | Bookmark) => void;
|
||||
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
reorderCategories: (categories: Category[]) => void;
|
||||
}
|
||||
|
||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
||||
export const BookmarkTable = (props: Props): JSX.Element => {
|
||||
const { config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
pinCategory,
|
||||
deleteCategory,
|
||||
deleteBookmark,
|
||||
createNotification,
|
||||
reorderCategories,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localCategories, setLocalCategories] = useState<Category[]>([]);
|
||||
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||
|
||||
|
@ -57,7 +51,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
// Check ordering
|
||||
useEffect(() => {
|
||||
const order = props.config.useOrdering;
|
||||
const order = config.useOrdering;
|
||||
|
||||
if (order === 'orderId') {
|
||||
setIsCustomOrder(true);
|
||||
|
@ -70,7 +64,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteCategory(category.id);
|
||||
deleteCategory(category.id);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -80,7 +74,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteBookmark(bookmark.id, bookmark.categoryId);
|
||||
deleteBookmark(bookmark.id, bookmark.categoryId);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -96,7 +90,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (!isCustomOrder) {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled',
|
||||
});
|
||||
|
@ -112,7 +106,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
tmpCategories.splice(result.destination.index, 0, movedApp);
|
||||
|
||||
setLocalCategories(tmpCategories);
|
||||
props.reorderCategories(tmpCategories);
|
||||
reorderCategories(tmpCategories);
|
||||
};
|
||||
|
||||
if (props.contentType === ContentType.category) {
|
||||
|
@ -131,7 +125,10 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId="categories">
|
||||
{(provided) => (
|
||||
<Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
|
||||
<Table
|
||||
headers={['Name', 'Visibility', 'Actions']}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localCategories.map(
|
||||
(category: Category, index): JSX.Element => {
|
||||
return (
|
||||
|
@ -156,7 +153,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
ref={provided.innerRef}
|
||||
style={style}
|
||||
>
|
||||
<td>{category.name}</td>
|
||||
<td style={{ width: '300px' }}>
|
||||
{category.name}
|
||||
</td>
|
||||
<td style={{ width: '300px' }}>
|
||||
{category.isPublic ? 'Visible' : 'Hidden'}
|
||||
</td>
|
||||
{!snapshot.isDragging && (
|
||||
<td className={classes.TableActions}>
|
||||
<div
|
||||
|
@ -186,12 +188,12 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.pinCategory(category)}
|
||||
onClick={() => pinCategory(category)}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
category,
|
||||
props.pinCategory
|
||||
pinCategory
|
||||
)
|
||||
}
|
||||
tabIndex={0}
|
||||
|
@ -232,7 +234,9 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
});
|
||||
|
||||
return (
|
||||
<Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
|
||||
<Table
|
||||
headers={['Name', 'URL', 'Icon', 'Visibility', 'Category', 'Actions']}
|
||||
>
|
||||
{bookmarks.map(
|
||||
(bookmark: { bookmark: Bookmark; categoryName: string }) => {
|
||||
return (
|
||||
|
@ -240,6 +244,7 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
|||
<td>{bookmark.bookmark.name}</td>
|
||||
<td>{bookmark.bookmark.url}</td>
|
||||
<td>{bookmark.bookmark.icon}</td>
|
||||
<td>{bookmark.bookmark.isPublic ? 'Visible' : 'Hidden'}</td>
|
||||
<td>{bookmark.categoryName}</td>
|
||||
<td className={classes.TableActions}>
|
||||
<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);
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { getCategories } from '../../store/actions';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
// Typescript
|
||||
import { Category, Bookmark } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './Bookmarks.module.css';
|
||||
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
|
||||
// UI
|
||||
import { Container, Headline, ActionButton, Spinner, Modal } from '../UI';
|
||||
|
||||
import BookmarkGrid from './BookmarkGrid/BookmarkGrid';
|
||||
import { Category, GlobalState, Bookmark } from '../../interfaces';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import Modal from '../UI/Modal/Modal';
|
||||
import BookmarkForm from './BookmarkForm/BookmarkForm';
|
||||
import BookmarkTable from './BookmarkTable/BookmarkTable';
|
||||
// Components
|
||||
import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
|
||||
import { BookmarkTable } from './BookmarkTable/BookmarkTable';
|
||||
import { Form } from './Form/Form';
|
||||
|
||||
interface ComponentProps {
|
||||
loading: boolean;
|
||||
categories: Category[];
|
||||
getCategories: () => void;
|
||||
// Utils
|
||||
import { bookmarkTemplate, categoryTemplate } from '../../utility';
|
||||
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
|
@ -28,8 +33,14 @@ export enum ContentType {
|
|||
bookmark,
|
||||
}
|
||||
|
||||
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
||||
const { getCategories, categories, loading, searching = false } = props;
|
||||
export const Bookmarks = (props: Props): JSX.Element => {
|
||||
const {
|
||||
bookmarks: { loading, categories },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { getCategories } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [formContentType, setFormContentType] = useState(ContentType.category);
|
||||
|
@ -38,30 +49,24 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||
ContentType.category
|
||||
);
|
||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
|
||||
name: '',
|
||||
id: -1,
|
||||
isPinned: false,
|
||||
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(),
|
||||
});
|
||||
const [categoryInUpdate, setCategoryInUpdate] =
|
||||
useState<Category>(categoryTemplate);
|
||||
const [bookmarkInUpdate, setBookmarkInUpdate] =
|
||||
useState<Bookmark>(bookmarkTemplate);
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) {
|
||||
if (!categories.length) {
|
||||
getCategories();
|
||||
}
|
||||
}, [getCategories]);
|
||||
}, []);
|
||||
|
||||
// observe if user is authenticated -> set default view if not
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setIsInEdit(false);
|
||||
setModalIsOpen(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const toggleModal = (): void => {
|
||||
setModalIsOpen(!modalIsOpen);
|
||||
|
@ -102,55 +107,46 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||
return (
|
||||
<Container>
|
||||
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
|
||||
{!isInUpdate ? (
|
||||
<BookmarkForm
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
/>
|
||||
) : formContentType === ContentType.category ? (
|
||||
<BookmarkForm
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
category={categoryInUpdate}
|
||||
/>
|
||||
) : (
|
||||
<BookmarkForm
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
bookmark={bookmarkInUpdate}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
inUpdate={isInUpdate}
|
||||
category={categoryInUpdate}
|
||||
bookmark={bookmarkInUpdate}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
|
||||
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton
|
||||
name="Add Category"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => addActionHandler(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Add Bookmark"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => addActionHandler(ContentType.bookmark)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Categories"
|
||||
icon="mdiPencil"
|
||||
handler={() => editActionHandler(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Bookmarks"
|
||||
icon="mdiPencil"
|
||||
handler={() => editActionHandler(ContentType.bookmark)}
|
||||
/>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton
|
||||
name="Add Category"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => addActionHandler(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Add Bookmark"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => addActionHandler(ContentType.bookmark)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Categories"
|
||||
icon="mdiPencil"
|
||||
handler={() => editActionHandler(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Bookmarks"
|
||||
icon="mdiPencil"
|
||||
handler={() => editActionHandler(ContentType.bookmark)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !isInEdit ? (
|
||||
<BookmarkGrid categories={categories} searching />
|
||||
<BookmarkGrid categories={categories} searching={props.searching} />
|
||||
) : (
|
||||
<BookmarkTable
|
||||
contentType={tableContentType}
|
||||
|
@ -161,12 +157,3 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.bookmark.loading,
|
||||
categories: state.bookmark.categories,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { getCategories })(Bookmarks);
|
||||
|
|
260
client/src/components/Bookmarks/Form/BookmarksForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
100
client/src/components/Bookmarks/Form/CategoryForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
44
client/src/components/Bookmarks/Form/Form.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -1,17 +1,17 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Config, GlobalState } from '../../../interfaces';
|
||||
import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget';
|
||||
import { getDateTime } from './functions/getDateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
|
||||
// CSS
|
||||
import classes from './Header.module.css';
|
||||
|
||||
interface Props {
|
||||
config: Config;
|
||||
}
|
||||
// Components
|
||||
import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
|
||||
|
||||
const Header = (props: Props): JSX.Element => {
|
||||
// Utils
|
||||
import { getDateTime } from './functions/getDateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
const [dateTime, setDateTime] = useState<string>(getDateTime());
|
||||
const [greeting, setGreeting] = useState<string>(greeter());
|
||||
|
||||
|
@ -39,11 +39,3 @@ const Header = (props: Props): JSX.Element => {
|
|||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(Header);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { parseTime } from '../../../../utility';
|
||||
|
||||
export const getDateTime = (): string => {
|
||||
const days = localStorage.getItem('daySchema')?.split(';') || [
|
||||
'Sunday',
|
||||
|
@ -27,14 +29,23 @@ export const getDateTime = (): string => {
|
|||
const now = new Date();
|
||||
|
||||
const useAmericanDate = localStorage.useAmericanDate === 'true';
|
||||
const showTime = localStorage.showTime === 'true';
|
||||
|
||||
const p = parseTime;
|
||||
|
||||
const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
|
||||
now.getSeconds()
|
||||
)}`;
|
||||
|
||||
const timeEl = showTime ? ` - ${time}` : '';
|
||||
|
||||
if (!useAmericanDate) {
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${
|
||||
months[now.getMonth()]
|
||||
} ${now.getFullYear()}`;
|
||||
} ${now.getFullYear()}${timeEl}`;
|
||||
} else {
|
||||
return `${days[now.getDay()]}, ${
|
||||
months[now.getMonth()]
|
||||
} ${now.getDate()} ${now.getFullYear()}`;
|
||||
} ${now.getDate()} ${now.getFullYear()}${timeEl}`;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -2,47 +2,41 @@ import { useState, useEffect, Fragment } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { getApps, getCategories } from '../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
// Typescript
|
||||
import { GlobalState } from '../../interfaces/GlobalState';
|
||||
import { App, Category, Config } from '../../interfaces';
|
||||
import { App, Category } from '../../interfaces';
|
||||
|
||||
// UI
|
||||
import Icon from '../UI/Icons/Icon/Icon';
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import { Icon, Container, SectionHeadline, Spinner } from '../UI';
|
||||
|
||||
// CSS
|
||||
import classes from './Home.module.css';
|
||||
|
||||
// Components
|
||||
import AppGrid from '../Apps/AppGrid/AppGrid';
|
||||
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import Header from './Header/Header';
|
||||
import { AppGrid } from '../Apps/AppGrid/AppGrid';
|
||||
import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||
import { SearchBar } from '../SearchBar/SearchBar';
|
||||
import { Header } from './Header/Header';
|
||||
|
||||
interface ComponentProps {
|
||||
getApps: Function;
|
||||
getCategories: Function;
|
||||
appsLoading: boolean;
|
||||
apps: App[];
|
||||
categoriesLoading: boolean;
|
||||
categories: Category[];
|
||||
config: Config;
|
||||
}
|
||||
// Utils
|
||||
import { escapeRegex } from '../../utility';
|
||||
|
||||
const Home = (props: ComponentProps): JSX.Element => {
|
||||
export const Home = (): JSX.Element => {
|
||||
const {
|
||||
getApps,
|
||||
apps,
|
||||
appsLoading,
|
||||
getCategories,
|
||||
categories,
|
||||
categoriesLoading,
|
||||
} = props;
|
||||
apps: { apps, loading: appsLoading },
|
||||
bookmarks: { categories, loading: bookmarksLoading },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { getApps, getCategories } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
// Local search query
|
||||
const [localSearch, setLocalSearch] = useState<null | string>(null);
|
||||
|
@ -56,20 +50,22 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
if (!apps.length) {
|
||||
getApps();
|
||||
}
|
||||
}, [getApps]);
|
||||
}, []);
|
||||
|
||||
// Load bookmark categories
|
||||
useEffect(() => {
|
||||
if (!categories.length) {
|
||||
getCategories();
|
||||
}
|
||||
}, [getCategories]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSearch) {
|
||||
// Search through apps
|
||||
setAppSearchResult([
|
||||
...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)),
|
||||
...apps.filter(({ name }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(name)
|
||||
),
|
||||
]);
|
||||
|
||||
// Search through bookmarks
|
||||
|
@ -79,7 +75,9 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
category.bookmarks = categories
|
||||
.map(({ bookmarks }) => bookmarks)
|
||||
.flat()
|
||||
.filter(({ name }) => new RegExp(localSearch, 'i').test(name));
|
||||
.filter(({ name }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(name)
|
||||
);
|
||||
|
||||
setBookmarkSearchResult([category]);
|
||||
} else {
|
||||
|
@ -90,7 +88,7 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
return (
|
||||
<Container>
|
||||
{!props.config.hideSearch ? (
|
||||
{!config.hideSearch ? (
|
||||
<SearchBar
|
||||
setLocalSearch={setLocalSearch}
|
||||
appSearchResult={appSearchResult}
|
||||
|
@ -100,9 +98,9 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
<div></div>
|
||||
)}
|
||||
|
||||
{!props.config.hideHeader ? <Header /> : <div></div>}
|
||||
{!config.hideHeader ? <Header /> : <div></div>}
|
||||
|
||||
{!props.config.hideApps ? (
|
||||
{!config.hideApps ? (
|
||||
<Fragment>
|
||||
<SectionHeadline title="Applications" link="/applications" />
|
||||
{appsLoading ? (
|
||||
|
@ -124,16 +122,18 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
<div></div>
|
||||
)}
|
||||
|
||||
{!props.config.hideCategories ? (
|
||||
{!config.hideCategories ? (
|
||||
<Fragment>
|
||||
<SectionHeadline title="Bookmarks" link="/bookmarks" />
|
||||
{categoriesLoading ? (
|
||||
{bookmarksLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<BookmarkGrid
|
||||
categories={
|
||||
!bookmarkSearchResult
|
||||
? categories.filter(({ isPinned }) => isPinned)
|
||||
? categories.filter(
|
||||
({ isPinned, bookmarks }) => isPinned && bookmarks.length
|
||||
)
|
||||
: bookmarkSearchResult
|
||||
}
|
||||
totalCategories={categories.length}
|
||||
|
@ -151,15 +151,3 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
</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);
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { GlobalState, Notification as _Notification } from '../../interfaces';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Notification as NotificationInterface } from '../../interfaces';
|
||||
|
||||
import classes from './NotificationCenter.module.css';
|
||||
|
||||
import Notification from '../UI/Notification/Notification';
|
||||
import { Notification } from '../UI';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
interface ComponentProps {
|
||||
notifications: _Notification[];
|
||||
}
|
||||
export const NotificationCenter = (): JSX.Element => {
|
||||
const { notifications } = useSelector((state: State) => state.notification);
|
||||
|
||||
const NotificationCenter = (props: ComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
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 (
|
||||
<Notification
|
||||
title={notification.title}
|
||||
|
@ -29,11 +28,3 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
notifications: state.notification.notifications,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NotificationCenter);
|
||||
|
|
13
client/src/components/Routing/ProtectedRoute.tsx
Normal 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" />;
|
||||
}
|
||||
};
|
|
@ -1,42 +1,33 @@
|
|||
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification } from '../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
App,
|
||||
Category,
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
} from '../../interfaces';
|
||||
import { App, Category } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './SearchBar.module.css';
|
||||
|
||||
// Utils
|
||||
import { searchParser, urlParser, redirectUrl } from '../../utility';
|
||||
import { State } from '../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../store';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
interface Props {
|
||||
setLocalSearch: (query: string) => void;
|
||||
appSearchResult: App[] | null;
|
||||
bookmarkSearchResult: Category[] | null;
|
||||
config: Config;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const SearchBar = (props: ComponentProps): JSX.Element => {
|
||||
const {
|
||||
setLocalSearch,
|
||||
createNotification,
|
||||
config,
|
||||
loading,
|
||||
appSearchResult,
|
||||
bookmarkSearchResult,
|
||||
} = props;
|
||||
export const SearchBar = (props: Props): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
|
||||
|
||||
|
@ -54,12 +45,17 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
if (key === 'Escape') {
|
||||
clearSearch();
|
||||
} else if (document.activeElement !== inputRef.current) {
|
||||
if (key === '`') {
|
||||
inputRef.current.focus();
|
||||
clearSearch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', keyOutsideFocus);
|
||||
window.addEventListener('keyup', keyOutsideFocus);
|
||||
|
||||
return () => window.removeEventListener('keydown', keyOutsideFocus);
|
||||
return () => window.removeEventListener('keyup', keyOutsideFocus);
|
||||
}, []);
|
||||
|
||||
const clearSearch = () => {
|
||||
|
@ -126,12 +122,3 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
config: state.config.config,
|
||||
loading: state.config.loading,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { createNotification })(SearchBar);
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
.AppVersion {
|
||||
.text {
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.AppVersion a {
|
||||
.text a,
|
||||
.text span {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 30px 0;
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
|
|
@ -1,34 +1,43 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
import { Button, SettingsHeadline } from '../../UI';
|
||||
import classes from './AppDetails.module.css';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import { checkVersion } from '../../../utility';
|
||||
import { AuthForm } from './AuthForm/AuthForm';
|
||||
|
||||
const AppDetails = (): JSX.Element => {
|
||||
export const AppDetails = (): JSX.Element => {
|
||||
return (
|
||||
<Fragment>
|
||||
<p className={classes.AppVersion}>
|
||||
<a
|
||||
href='https://github.com/pawelmalak/flame'
|
||||
target='_blank'
|
||||
rel='noreferrer'>
|
||||
Flame
|
||||
</a>
|
||||
{' '}
|
||||
version {process.env.REACT_APP_VERSION}
|
||||
</p>
|
||||
<p className={classes.AppVersion}>
|
||||
See changelog {' '}
|
||||
<a
|
||||
href='https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md'
|
||||
target='_blank'
|
||||
rel='noreferrer'>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
<SettingsHeadline text="Authentication" />
|
||||
<AuthForm />
|
||||
|
||||
export default AppDetails;
|
||||
<hr className={classes.separator} />
|
||||
|
||||
<div>
|
||||
<SettingsHeadline text="App version" />
|
||||
<p className={classes.text}>
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Flame
|
||||
</a>{' '}
|
||||
version {process.env.REACT_APP_VERSION}
|
||||
</p>
|
||||
|
||||
<p className={classes.text}>
|
||||
See changelog{' '}
|
||||
<a
|
||||
href="https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
|
103
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
122
client/src/components/Settings/DockerSettings/DockerSettings.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -1,29 +1,31 @@
|
|||
import { Fragment, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Query } from '../../../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './CustomQueries.module.css';
|
||||
|
||||
import Modal from '../../../UI/Modal/Modal';
|
||||
import Icon from '../../../UI/Icons/Icon/Icon';
|
||||
import {
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
Query,
|
||||
} from '../../../../interfaces';
|
||||
import QueriesForm from './QueriesForm';
|
||||
import { deleteQuery, createNotification } from '../../../../store/actions';
|
||||
import Button from '../../../UI/Buttons/Button/Button';
|
||||
// UI
|
||||
import { Modal, Icon, Button } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
customQueries: Query[];
|
||||
deleteQuery: (prefix: string) => {};
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
config: Config;
|
||||
}
|
||||
// Components
|
||||
import { QueriesForm } from './QueriesForm';
|
||||
|
||||
const CustomQueries = (props: Props): JSX.Element => {
|
||||
const { customQueries, deleteQuery, createNotification } = props;
|
||||
export const CustomQueries = (): JSX.Element => {
|
||||
const { customQueries, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { deleteQuery, createNotification } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
|
||||
|
@ -34,7 +36,7 @@ const CustomQueries = (props: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
const deleteHandler = (query: Query) => {
|
||||
const currentProvider = props.config.defaultSearchProvider;
|
||||
const currentProvider = config.defaultSearchProvider;
|
||||
const isCurrent = currentProvider === query.prefix;
|
||||
|
||||
if (isCurrent) {
|
||||
|
@ -105,14 +107,3 @@ const CustomQueries = (props: Props): JSX.Element => {
|
|||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
customQueries: state.config.customQueries,
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { deleteQuery, createNotification })(
|
||||
CustomQueries
|
||||
);
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
import { Query } from '../../../../interfaces';
|
||||
import Button from '../../../UI/Buttons/Button/Button';
|
||||
import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
|
||||
import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
|
||||
import { connect } from 'react-redux';
|
||||
import { addQuery, updateQuery } from '../../../../store/actions';
|
||||
|
||||
import { Button, InputGroup, ModalForm } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
addQuery: (query: Query) => {};
|
||||
updateQuery: (query: Query, Oldprefix: string) => {};
|
||||
query?: Query;
|
||||
}
|
||||
|
||||
const QueriesForm = (props: Props): JSX.Element => {
|
||||
const { modalHandler, addQuery, updateQuery, query } = props;
|
||||
export const QueriesForm = (props: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addQuery, updateQuery } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const { modalHandler, query } = props;
|
||||
|
||||
const [formData, setFormData] = useState<Query>({
|
||||
name: '',
|
||||
|
@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Prefix</label>
|
||||
<input
|
||||
|
@ -89,6 +96,7 @@ const QueriesForm = (props: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Query Template</label>
|
||||
<input
|
||||
|
@ -101,9 +109,8 @@ const QueriesForm = (props: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(null, { addQuery, updateQuery })(QueriesForm);
|
||||
|
|
|
@ -1,26 +1,15 @@
|
|||
// React
|
||||
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// State
|
||||
import { createNotification, updateConfig } from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
Query,
|
||||
SearchForm,
|
||||
} from '../../../interfaces';
|
||||
import { Query, SearchForm } from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import CustomQueries from './CustomQueries/CustomQueries';
|
||||
import { CustomQueries } from './CustomQueries/CustomQueries';
|
||||
|
||||
// UI
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import { Button, SettingsHeadline, InputGroup } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, searchSettingsTemplate } from '../../../utility';
|
||||
|
@ -28,31 +17,35 @@ import { inputHandler, searchSettingsTemplate } from '../../../utility';
|
|||
// Data
|
||||
import { queries } from '../../../utility/searchQueries.json';
|
||||
|
||||
interface Props {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: SearchForm) => void;
|
||||
loading: boolean;
|
||||
customQueries: Query[];
|
||||
config: Config;
|
||||
}
|
||||
// Redux
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
export const SearchSettings = (): JSX.Element => {
|
||||
const { loading, customQueries, config } = useSelector(
|
||||
(state: State) => state.config
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const SearchSettings = (props: Props): JSX.Element => {
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...props.config,
|
||||
...config,
|
||||
});
|
||||
}, [props.loading]);
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
await updateConfig(formData);
|
||||
};
|
||||
|
||||
// Input handler
|
||||
|
@ -84,7 +77,7 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
value={formData.defaultSearchProvider}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
>
|
||||
{[...queries, ...props.customQueries].map((query: Query, idx) => {
|
||||
{[...queries, ...customQueries].map((query: Query, idx) => {
|
||||
const isCustom = idx >= queries.length;
|
||||
|
||||
return (
|
||||
|
@ -95,6 +88,7 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
})}
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="searchSameTab">
|
||||
Open search results in the same tab
|
||||
|
@ -109,6 +103,7 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="hideSearch">Hide search bar</label>
|
||||
<select
|
||||
|
@ -121,6 +116,7 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
|
||||
<select
|
||||
|
@ -133,6 +129,7 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
|
||||
|
@ -142,18 +139,3 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
</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);
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
//
|
||||
import { NavLink, Link, Switch, Route } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import { Route as SettingsRoute } from '../../interfaces';
|
||||
|
||||
|
@ -8,28 +11,33 @@ import { Route as SettingsRoute } from '../../interfaces';
|
|||
import classes from './Settings.module.css';
|
||||
|
||||
// Components
|
||||
import Themer from '../Themer/Themer';
|
||||
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
||||
import OtherSettings from './OtherSettings/OtherSettings';
|
||||
import AppDetails from './AppDetails/AppDetails';
|
||||
import StyleSettings from './StyleSettings/StyleSettings';
|
||||
import SearchSettings from './SearchSettings/SearchSettings';
|
||||
import { Themer } from '../Themer/Themer';
|
||||
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
|
||||
import { UISettings } from './UISettings/UISettings';
|
||||
import { AppDetails } from './AppDetails/AppDetails';
|
||||
import { StyleSettings } from './StyleSettings/StyleSettings';
|
||||
import { SearchSettings } from './SearchSettings/SearchSettings';
|
||||
import { DockerSettings } from './DockerSettings/DockerSettings';
|
||||
import { ProtectedRoute } from '../Routing/ProtectedRoute';
|
||||
|
||||
// UI
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import { Container, Headline } from '../UI';
|
||||
|
||||
// Data
|
||||
import { routes } from './settings.json';
|
||||
|
||||
const Settings = (): JSX.Element => {
|
||||
export const Settings = (): JSX.Element => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
|
||||
const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
|
||||
<div className={classes.Settings}>
|
||||
{/* NAVIGATION MENU */}
|
||||
<nav className={classes.SettingsNav}>
|
||||
{routes.map(({ name, dest }: SettingsRoute, idx) => (
|
||||
{tabs.map(({ name, dest }: SettingsRoute, idx) => (
|
||||
<NavLink
|
||||
className={classes.SettingsNavLink}
|
||||
activeClassName={classes.SettingsNavLinkActive}
|
||||
|
@ -46,10 +54,20 @@ const Settings = (): JSX.Element => {
|
|||
<section className={classes.SettingsContent}>
|
||||
<Switch>
|
||||
<Route exact path="/settings" component={Themer} />
|
||||
<Route path="/settings/weather" component={WeatherSettings} />
|
||||
<Route path="/settings/search" component={SearchSettings} />
|
||||
<Route path="/settings/other" component={OtherSettings} />
|
||||
<Route path="/settings/css" component={StyleSettings} />
|
||||
<ProtectedRoute
|
||||
path="/settings/weather"
|
||||
component={WeatherSettings}
|
||||
/>
|
||||
<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} />
|
||||
</Switch>
|
||||
</section>
|
||||
|
@ -57,5 +75,3 @@ const Settings = (): JSX.Element => {
|
|||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
|
|
@ -2,54 +2,60 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
|||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification } from '../../../store/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { ApiResponse, NewNotification } from '../../../interfaces';
|
||||
import { ApiResponse } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
// Other
|
||||
import { InputGroup, Button } from '../../UI';
|
||||
import { applyAuth } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
export const StyleSettings = (): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const StyleSettings = (props: ComponentProps): JSX.Element => {
|
||||
const [customStyles, setCustomStyles] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
axios.get<ApiResponse<string>>('/api/config/0/css')
|
||||
.then(data => setCustomStyles(data.data.data))
|
||||
.catch(err => console.log(err.response));
|
||||
}, [])
|
||||
axios
|
||||
.get<ApiResponse<string>>('/api/config/0/css')
|
||||
.then((data) => setCustomStyles(data.data.data))
|
||||
.catch((err) => console.log(err.response));
|
||||
}, []);
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
setCustomStyles(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const formSubmitHandler = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles })
|
||||
axios
|
||||
.put<ApiResponse<{}>>(
|
||||
'/api/config/0/css',
|
||||
{ styles: customStyles },
|
||||
{ headers: applyAuth() }
|
||||
)
|
||||
.then(() => {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
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 (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
<InputGroup>
|
||||
<label htmlFor='customStyles'>Custom CSS</label>
|
||||
<label htmlFor="customStyles">Custom CSS</label>
|
||||
<textarea
|
||||
id='customStyles'
|
||||
name='customStyles'
|
||||
id="customStyles"
|
||||
name="customStyles"
|
||||
value={customStyles}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
spellCheck={false}
|
||||
|
@ -57,7 +63,5 @@ const StyleSettings = (props: ComponentProps): JSX.Element => {
|
|||
</InputGroup>
|
||||
<Button>Save CSS</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(null, { createNotification })(StyleSettings);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,41 +1,28 @@
|
|||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
createNotification,
|
||||
updateConfig,
|
||||
sortApps,
|
||||
sortCategories,
|
||||
} from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
OtherSettingsForm,
|
||||
} from '../../../interfaces';
|
||||
import { OtherSettingsForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { otherSettingsTemplate, inputHandler } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: OtherSettingsForm) => void;
|
||||
sortApps: () => void;
|
||||
sortCategories: () => void;
|
||||
loading: boolean;
|
||||
config: Config;
|
||||
}
|
||||
export const UISettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||
const { config } = props;
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig, sortApps, sortCategories } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<OtherSettingsForm>(
|
||||
|
@ -47,21 +34,21 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
setFormData({
|
||||
...config,
|
||||
});
|
||||
}, [props.loading]);
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
await updateConfig(formData);
|
||||
|
||||
// Update local page title
|
||||
document.title = formData.customTitle;
|
||||
|
||||
// Sort apps and categories with new settings
|
||||
props.sortApps();
|
||||
props.sortCategories();
|
||||
sortApps();
|
||||
sortCategories();
|
||||
};
|
||||
|
||||
// Input handler
|
||||
|
@ -185,8 +172,8 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* MODULES OPTIONS */}
|
||||
<SettingsHeadline text="Modules" />
|
||||
{/* HEADER OPTIONS */}
|
||||
<SettingsHeadline text="Header" />
|
||||
{/* HIDE HEADER */}
|
||||
<InputGroup>
|
||||
<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>
|
||||
</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 */}
|
||||
<InputGroup>
|
||||
<label htmlFor="hideApps">Hide applications</label>
|
||||
|
@ -274,83 +277,7 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
|
|||
</select>
|
||||
</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>
|
||||
</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);
|
|
@ -2,34 +2,29 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
|||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification, updateConfig } from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
ApiResponse,
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
Weather,
|
||||
WeatherForm,
|
||||
} from '../../../interfaces';
|
||||
import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import { InputGroup, Button } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: WeatherForm) => void;
|
||||
loading: boolean;
|
||||
config: Config;
|
||||
}
|
||||
export const WeatherSettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification, updateConfig } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<WeatherForm>(
|
||||
weatherSettingsTemplate
|
||||
|
@ -38,9 +33,9 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...props.config,
|
||||
...config,
|
||||
});
|
||||
}, [props.loading]);
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
|
@ -48,26 +43,26 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
// Check for api key input
|
||||
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Warning',
|
||||
message: 'API key is missing. Weather Module will NOT work',
|
||||
});
|
||||
}
|
||||
|
||||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
await updateConfig(formData);
|
||||
|
||||
// Update weather
|
||||
axios
|
||||
.get<ApiResponse<Weather>>('/api/weather/update')
|
||||
.then(() => {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Success',
|
||||
message: 'Weather updated',
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: err.response.data.error,
|
||||
});
|
||||
|
@ -108,6 +103,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
. Key is required for weather module to work.
|
||||
</span>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="lat">Location latitude</label>
|
||||
<input
|
||||
|
@ -131,6 +127,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
</a>
|
||||
</span>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="long">Location longitude</label>
|
||||
<input
|
||||
|
@ -144,6 +141,7 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
lang="en-150"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="isCelsius">Temperature unit</label>
|
||||
<select
|
||||
|
@ -156,18 +154,8 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
<option value={0}>Fahrenheit</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.config.loading,
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { createNotification, updateConfig })(
|
||||
WeatherSettings
|
||||
);
|
||||
|
|
|
@ -2,27 +2,38 @@
|
|||
"routes": [
|
||||
{
|
||||
"name": "Theme",
|
||||
"dest": "/settings"
|
||||
"dest": "/settings",
|
||||
"authRequired": false
|
||||
},
|
||||
{
|
||||
"name": "Weather",
|
||||
"dest": "/settings/weather"
|
||||
"dest": "/settings/weather",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "Search",
|
||||
"dest": "/settings/search"
|
||||
"dest": "/settings/search",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "Other",
|
||||
"dest": "/settings/other"
|
||||
"name": "Interface",
|
||||
"dest": "/settings/interface",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "Docker",
|
||||
"dest": "/settings/docker",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "CSS",
|
||||
"dest": "/settings/css"
|
||||
"dest": "/settings/css",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "App",
|
||||
"dest": "/settings/app"
|
||||
"dest": "/settings/app",
|
||||
"authRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,14 +1,17 @@
|
|||
import { Theme } from '../../interfaces/Theme';
|
||||
import classes from './ThemePreview.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
applyTheme: Function;
|
||||
}
|
||||
|
||||
const ThemePreview = (props: ComponentProps): JSX.Element => {
|
||||
export const ThemePreview = (props: Props): JSX.Element => {
|
||||
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.ColorPreview}
|
||||
|
@ -25,7 +28,5 @@ const ThemePreview = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
<p>{props.theme.name}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemePreview;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,37 +1,29 @@
|
|||
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 { themes } from './themes.json';
|
||||
import { Theme } from '../../interfaces/Theme';
|
||||
import ThemePreview from './ThemePreview';
|
||||
import { ThemePreview } from './ThemePreview';
|
||||
|
||||
import { setTheme } from '../../store/actions';
|
||||
|
||||
interface ComponentProps {
|
||||
setTheme: Function;
|
||||
}
|
||||
|
||||
const Themer = (props: ComponentProps): JSX.Element => {
|
||||
export const Themer = (): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { setTheme } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div>
|
||||
<div className={classes.ThemerGrid}>
|
||||
{themes.map((theme: Theme, idx: number): JSX.Element => (
|
||||
<ThemePreview
|
||||
key={idx}
|
||||
theme={theme}
|
||||
applyTheme={props.setTheme}
|
||||
/>
|
||||
))}
|
||||
{themes.map(
|
||||
(theme: Theme, idx: number): JSX.Element => (
|
||||
<ThemePreview key={idx} theme={theme} applyTheme={setTheme} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default connect(null, { setTheme })(Themer);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,55 +2,45 @@ import { Fragment } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classes from './ActionButton.module.css';
|
||||
import Icon from '../../Icons/Icon/Icon';
|
||||
import { Icon } from '../..';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
name: string;
|
||||
icon: string;
|
||||
link?: string;
|
||||
handler?: () => void;
|
||||
}
|
||||
|
||||
const ActionButton = (props: ComponentProps): JSX.Element => {
|
||||
export const ActionButton = (props: Props): JSX.Element => {
|
||||
const body = (
|
||||
<Fragment>
|
||||
<div className={classes.ActionButtonIcon}>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
<div className={classes.ActionButtonName}>
|
||||
{props.name}
|
||||
</div>
|
||||
<div className={classes.ActionButtonName}>{props.name}</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (props.link) {
|
||||
return (
|
||||
<Link
|
||||
to={props.link}
|
||||
tabIndex={0}>
|
||||
<Link to={props.link} tabIndex={0}>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
} else if (props.handler) {
|
||||
return (
|
||||
<div
|
||||
className={classes.ActionButton}
|
||||
onClick={props.handler}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && props.handler) props.handler()
|
||||
if (e.key === 'Enter' && props.handler) props.handler();
|
||||
}}
|
||||
tabIndex={0}
|
||||
>{body}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className={classes.ActionButton}>
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return <div className={classes.ActionButton}>{body}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ActionButton;
|
||||
};
|
||||
|
|
|
@ -1,21 +1,17 @@
|
|||
import { ReactNode } from 'react';
|
||||
import classes from './Button.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
children: string;
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
click?: any;
|
||||
}
|
||||
|
||||
const Button = (props: ComponentProps): JSX.Element => {
|
||||
const {
|
||||
children,
|
||||
click
|
||||
} = props;
|
||||
export const Button = (props: Props): JSX.Element => {
|
||||
const { children, click } = props;
|
||||
|
||||
return (
|
||||
<button className={classes.Button} onClick={click ? click : () => {}} >
|
||||
<button className={classes.Button} onClick={click ? click : () => {}}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,15 +1,10 @@
|
|||
import { ReactNode } from 'react';
|
||||
import classes from './InputGroup.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const InputGroup = (props: ComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classes.InputGroup}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputGroup;
|
||||
export const InputGroup = (props: Props): JSX.Element => {
|
||||
return <div className={classes.InputGroup}>{props.children}</div>;
|
||||
};
|
||||
|
|
|
@ -1,31 +1,27 @@
|
|||
import { SyntheticEvent } from 'react';
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
|
||||
import classes from './ModalForm.module.css';
|
||||
import Icon from '../../Icons/Icon/Icon';
|
||||
import { Icon } from '../..';
|
||||
|
||||
interface ComponentProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
children: ReactNode;
|
||||
modalHandler?: () => void;
|
||||
formHandler: (e: SyntheticEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
const ModalForm = (props: ComponentProps): JSX.Element => {
|
||||
export const ModalForm = (props: ComponentProps): JSX.Element => {
|
||||
const _modalHandler = (): void => {
|
||||
if (props.modalHandler) {
|
||||
props.modalHandler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.ModalForm}>
|
||||
<div className={classes.ModalFormIcon} onClick={_modalHandler}>
|
||||
<Icon icon='mdiClose' />
|
||||
<Icon icon="mdiClose" />
|
||||
</div>
|
||||
<form onSubmit={(e) => props.formHandler(e)}>
|
||||
{props.children}
|
||||
</form>
|
||||
<form onSubmit={(e) => props.formHandler(e)}>{props.children}</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ModalForm;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { Fragment } from 'react';
|
||||
import { Fragment, ReactNode } from 'react';
|
||||
import classes from './Headline.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string | JSX.Element;
|
||||
subtitle?: ReactNode;
|
||||
}
|
||||
|
||||
const Headline = (props: ComponentProps): JSX.Element => {
|
||||
export const Headline = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<Fragment>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default Headline;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,17 +2,15 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import classes from './SectionHeadline.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
title: string;
|
||||
link: string
|
||||
link: string;
|
||||
}
|
||||
|
||||
const SectionHeadline = (props: ComponentProps): JSX.Element => {
|
||||
export const SectionHeadline = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<Link to={props.link}>
|
||||
<h2 className={classes.SectionHeadline}>{props.title}</h2>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionHeadline;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,8 +4,6 @@ interface Props {
|
|||
text: string;
|
||||
}
|
||||
|
||||
const SettingsHeadline = (props: Props): JSX.Element => {
|
||||
export const SettingsHeadline = (props: Props): JSX.Element => {
|
||||
return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
|
||||
};
|
||||
|
||||
export default SettingsHeadline;
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
.Icon {
|
||||
color: var(--color-primary);
|
||||
/* for settings */
|
||||
/* color: var(--color-background); */
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,12 @@ import classes from './Icon.module.css';
|
|||
|
||||
import { Icon as MDIcon } from '@mdi/react';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
icon: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const Icon = (props: ComponentProps): JSX.Element => {
|
||||
export const Icon = (props: Props): JSX.Element => {
|
||||
const MDIcons = require('@mdi/js');
|
||||
let iconPath = MDIcons[props.icon];
|
||||
|
||||
|
@ -22,7 +22,5 @@ const Icon = (props: ComponentProps): JSX.Element => {
|
|||
path={iconPath}
|
||||
color={props.color ? props.color : 'var(--color-primary)'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,39 +1,32 @@
|
|||
import { useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Skycons } from 'skycons-ts';
|
||||
import { GlobalState, Theme } from '../../../../interfaces';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { IconMapping, TimeOfDay } from './IconMapping';
|
||||
|
||||
interface ComponentProps {
|
||||
theme: Theme;
|
||||
interface Props {
|
||||
weatherStatusCode: 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
|
||||
? new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.day)
|
||||
: new IconMapping().mapIcon(props.weatherStatusCode, TimeOfDay.night);
|
||||
|
||||
useEffect(() => {
|
||||
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.play();
|
||||
}, 1);
|
||||
|
||||
return () => {
|
||||
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>
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
theme: state.theme.theme
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(WeatherIcon);
|
||||
return <canvas id={`weather-icon`} width="50" height="50"></canvas>;
|
||||
};
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import { ReactNode } from 'react';
|
||||
import classes from './Layout.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Container = (props: ComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div className={classes.Container}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className={classes.Container}>{props.children}</div>;
|
||||
};
|
||||
|
|
|
@ -1,28 +1,29 @@
|
|||
import { MouseEvent, useRef } from 'react';
|
||||
import { MouseEvent, ReactNode, useRef } from 'react';
|
||||
|
||||
import classes from './Modal.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
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 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) => {
|
||||
if (e.target === modalRef.current) {
|
||||
props.setIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={modalClasses} onClick={clickHandler} ref={modalRef}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearNotification } from '../../../store/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
import classes from './Notification.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
title: string;
|
||||
message: string;
|
||||
id: number;
|
||||
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 elementClasses = [
|
||||
classes.Notification,
|
||||
|
@ -24,13 +27,13 @@ const Notification = (props: ComponentProps): JSX.Element => {
|
|||
setIsOpen(false);
|
||||
}, 3500);
|
||||
|
||||
const clearNotification = setTimeout(() => {
|
||||
props.clearNotification(props.id);
|
||||
const clearNotificationTimeout = setTimeout(() => {
|
||||
clearNotification(props.id);
|
||||
}, 3600);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(closeNotification);
|
||||
window.clearTimeout(clearNotification);
|
||||
window.clearTimeout(clearNotificationTimeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
@ -48,5 +51,3 @@ const Notification = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(null, { clearNotification })(Notification);
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import classes from './Spinner.module.css';
|
||||
|
||||
const Spinner = (): JSX.Element => {
|
||||
export const Spinner = (): JSX.Element => {
|
||||
return (
|
||||
<div className={classes.SpinnerWrapper}>
|
||||
<div className={classes.Spinner}>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,26 +1,26 @@
|
|||
import classes from './Table.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
children: JSX.Element | JSX.Element[];
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
headers: string[];
|
||||
innerRef?: any;
|
||||
}
|
||||
|
||||
const Table = (props: ComponentProps): JSX.Element => {
|
||||
export const Table = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<div className={classes.TableContainer} ref={props.innerRef}>
|
||||
<table className={classes.Table}>
|
||||
<thead className={classes.TableHead}>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className={classes.TableBody}>
|
||||
{props.children}
|
||||
</tbody>
|
||||
<tbody className={classes.TableBody}>{props.children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Table;
|
||||
);
|
||||
};
|
||||
|
|
14
client/src/components/UI/index.ts
Normal 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';
|
|
@ -2,23 +2,21 @@ import { useState, useEffect, Fragment } from 'react';
|
|||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces';
|
||||
import { Weather, ApiResponse } from '../../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './WeatherWidget.module.css';
|
||||
|
||||
// UI
|
||||
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
|
||||
import { WeatherIcon } from '../../UI';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
interface ComponentProps {
|
||||
configLoading: boolean;
|
||||
config: Config;
|
||||
}
|
||||
export const WeatherWidget = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const WeatherWidget = (props: ComponentProps): JSX.Element => {
|
||||
const [weather, setWeather] = useState<Weather>({
|
||||
externalLastUpdate: '',
|
||||
tempC: 0,
|
||||
|
@ -68,8 +66,8 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
|
|||
return (
|
||||
<div className={classes.WeatherWidget}>
|
||||
{isLoading ||
|
||||
props.configLoading ||
|
||||
(props.config.WEATHER_API_KEY && weather.id > 0 && (
|
||||
loading ||
|
||||
(config.WEATHER_API_KEY && weather.id > 0 && (
|
||||
<Fragment>
|
||||
<div className={classes.WeatherIcon}>
|
||||
<WeatherIcon
|
||||
|
@ -78,7 +76,7 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
|
|||
/>
|
||||
</div>
|
||||
<div className={classes.WeatherDetails}>
|
||||
{props.config.isCelsius ? (
|
||||
{config.isCelsius ? (
|
||||
<span>{weather.tempC}°C</span>
|
||||
) : (
|
||||
<span>{weather.tempF}°F</span>
|
||||
|
@ -90,12 +88,3 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
configLoading: state.config.loading,
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(WeatherWidget);
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store/store';
|
||||
|
||||
import { App } from './App';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
);
|
||||
|
|
|
@ -7,4 +7,10 @@ export interface Model {
|
|||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Token {
|
||||
app: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import { Model } from '.';
|
||||
|
||||
export interface App extends Model {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
isPinned: boolean;
|
||||
orderId: number;
|
||||
}
|
||||
|
||||
export interface NewApp {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: string;
|
||||
}
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface App extends Model, NewApp {
|
||||
orderId: number;
|
||||
isPinned: boolean;
|
||||
}
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
import { Model } from '.';
|
||||
|
||||
export interface Bookmark extends Model {
|
||||
name: string;
|
||||
url: string;
|
||||
categoryId: number;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface NewBookmark {
|
||||
name: string;
|
||||
url: string;
|
||||
categoryId: number;
|
||||
icon: string;
|
||||
}
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface Bookmark extends Model, NewBookmark {}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import { Model, Bookmark } from '.';
|
||||
|
||||
export interface Category extends Model {
|
||||
export interface NewCategory {
|
||||
name: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface Category extends Model, NewCategory {
|
||||
isPinned: boolean;
|
||||
orderId: number;
|
||||
bookmarks: Bookmark[];
|
||||
}
|
||||
|
||||
export interface NewCategory {
|
||||
name: string;
|
||||
}
|
|
@ -24,4 +24,5 @@ export interface Config {
|
|||
greetingsSchema: string;
|
||||
daySchema: string;
|
||||
monthSchema: string;
|
||||
showTime: boolean;
|
||||
}
|
||||
|
|
|
@ -22,12 +22,16 @@ export interface OtherSettingsForm {
|
|||
useOrdering: string;
|
||||
appsSameTab: boolean;
|
||||
bookmarksSameTab: boolean;
|
||||
dockerApps: boolean;
|
||||
dockerHost: string;
|
||||
kubernetesApps: boolean;
|
||||
unpinStoppedApps: boolean;
|
||||
useAmericanDate: boolean;
|
||||
greetingsSchema: string;
|
||||
daySchema: string;
|
||||
monthSchema: string;
|
||||
showTime: boolean;
|
||||
}
|
||||
|
||||
export interface DockerSettingsForm {
|
||||
dockerApps: boolean;
|
||||
dockerHost: string;
|
||||
kubernetesApps: boolean;
|
||||
unpinStoppedApps: boolean;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
export interface Route {
|
||||
name: string;
|
||||
dest: string;
|
||||
authRequired: boolean;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
export * from './App';
|
||||
export * from './Theme';
|
||||
export * from './GlobalState';
|
||||
export * from './Api';
|
||||
export * from './Weather';
|
||||
export * from './Bookmark';
|
||||
|
|
198
client/src/store/action-creators/app.ts
Normal 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);
|
||||
}
|
||||
};
|
85
client/src/store/action-creators/auth.ts
Normal 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());
|
||||
};
|
321
client/src/store/action-creators/bookmark.ts
Normal 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);
|
||||
}
|
||||
};
|
156
client/src/store/action-creators/config.ts
Normal 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);
|
||||
}
|
||||
};
|
6
client/src/store/action-creators/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from './theme';
|
||||
export * from './config';
|
||||
export * from './notification';
|
||||
export * from './app';
|
||||
export * from './bookmark';
|
||||
export * from './auth';
|
24
client/src/store/action-creators/notification.ts
Normal 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,
|
||||
});
|
||||
};
|
26
client/src/store/action-creators/theme.ts
Normal 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);
|
||||
}
|
||||
};
|
45
client/src/store/action-types/index.ts
Normal 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',
|
||||
}
|
|
@ -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;
|
|
@ -1,205 +1,38 @@
|
|||
import axios from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ActionTypes } from './actionTypes';
|
||||
import { App, ApiResponse, NewApp, Config } from '../../interfaces';
|
||||
import { CreateNotificationAction } from './notification';
|
||||
import { ActionType } from '../action-types';
|
||||
import { App } from '../../interfaces';
|
||||
|
||||
export interface GetAppsAction<T> {
|
||||
type:
|
||||
| ActionTypes.getApps
|
||||
| ActionTypes.getAppsSuccess
|
||||
| ActionTypes.getAppsError;
|
||||
| ActionType.getApps
|
||||
| ActionType.getAppsSuccess
|
||||
| ActionType.getAppsError;
|
||||
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 {
|
||||
type: ActionTypes.pinApp;
|
||||
type: ActionType.pinApp;
|
||||
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 {
|
||||
type: ActionTypes.addAppSuccess;
|
||||
type: ActionType.addAppSuccess;
|
||||
payload: App;
|
||||
}
|
||||
|
||||
export const addApp =
|
||||
(formData: NewApp | FormData) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<App>>('/api/apps', formData);
|
||||
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: {
|
||||
title: 'Success',
|
||||
message: `App added`,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatch<AddAppAction>({
|
||||
type: ActionTypes.addAppSuccess,
|
||||
payload: res.data.data,
|
||||
});
|
||||
|
||||
// Sort apps
|
||||
dispatch<any>(sortApps());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
export interface DeleteAppAction {
|
||||
type: ActionTypes.deleteApp;
|
||||
type: ActionType.deleteApp;
|
||||
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 {
|
||||
type: ActionTypes.updateApp;
|
||||
type: ActionType.updateApp;
|
||||
payload: App;
|
||||
}
|
||||
|
||||
export const updateApp =
|
||||
(id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<App>>(
|
||||
`/api/apps/${id}`,
|
||||
formData
|
||||
);
|
||||
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: {
|
||||
title: 'Success',
|
||||
message: `App updated`,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatch<UpdateAppAction>({
|
||||
type: ActionTypes.updateApp,
|
||||
payload: res.data.data,
|
||||
});
|
||||
|
||||
// Sort apps
|
||||
dispatch<any>(sortApps());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
export interface ReorderAppsAction {
|
||||
type: ActionTypes.reorderApps;
|
||||
type: ActionType.reorderApps;
|
||||
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 {
|
||||
type: ActionTypes.sortApps;
|
||||
type: ActionType.sortApps;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
19
client/src/store/actions/auth.ts
Normal 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;
|
||||
}
|
|
@ -1,371 +1,58 @@
|
|||
import axios from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ActionTypes } from './actionTypes';
|
||||
import {
|
||||
Category,
|
||||
ApiResponse,
|
||||
NewCategory,
|
||||
Bookmark,
|
||||
NewBookmark,
|
||||
Config,
|
||||
} from '../../interfaces';
|
||||
import { CreateNotificationAction } from './notification';
|
||||
import { Bookmark, Category } from '../../interfaces';
|
||||
import { ActionType } from '../action-types';
|
||||
|
||||
/**
|
||||
* GET CATEGORIES
|
||||
*/
|
||||
export interface GetCategoriesAction<T> {
|
||||
type:
|
||||
| ActionTypes.getCategories
|
||||
| ActionTypes.getCategoriesSuccess
|
||||
| ActionTypes.getCategoriesError;
|
||||
| ActionType.getCategories
|
||||
| ActionType.getCategoriesSuccess
|
||||
| ActionType.getCategoriesError;
|
||||
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 {
|
||||
type: ActionTypes.addCategory;
|
||||
type: ActionType.addCategory;
|
||||
payload: Category;
|
||||
}
|
||||
|
||||
export const addCategory =
|
||||
(formData: NewCategory) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<Category>>(
|
||||
'/api/categories',
|
||||
formData
|
||||
);
|
||||
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: {
|
||||
title: 'Success',
|
||||
message: `Category ${formData.name} created`,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch<AddCategoryAction>({
|
||||
type: ActionTypes.addCategory,
|
||||
payload: res.data.data,
|
||||
});
|
||||
|
||||
dispatch<any>(sortCategories());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ADD BOOKMARK
|
||||
*/
|
||||
export interface AddBookmarkAction {
|
||||
type: ActionTypes.addBookmark;
|
||||
type: ActionType.addBookmark;
|
||||
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 {
|
||||
type: ActionTypes.pinCategory;
|
||||
type: ActionType.pinCategory;
|
||||
payload: Category;
|
||||
}
|
||||
|
||||
export const pinCategory =
|
||||
(category: Category) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const { id, isPinned, name } = category;
|
||||
const res = await axios.put<ApiResponse<Category>>(
|
||||
`/api/categories/${id}`,
|
||||
{ isPinned: !isPinned }
|
||||
);
|
||||
|
||||
const status = isPinned
|
||||
? 'unpinned from Homescreen'
|
||||
: 'pinned to Homescreen';
|
||||
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: {
|
||||
title: 'Success',
|
||||
message: `Category ${name} ${status}`,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch<PinCategoryAction>({
|
||||
type: ActionTypes.pinCategory,
|
||||
payload: res.data.data,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE CATEGORY
|
||||
*/
|
||||
export interface DeleteCategoryAction {
|
||||
type: ActionTypes.deleteCategory;
|
||||
type: ActionType.deleteCategory;
|
||||
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 {
|
||||
type: ActionTypes.updateCategory;
|
||||
type: ActionType.updateCategory;
|
||||
payload: Category;
|
||||
}
|
||||
|
||||
export const updateCategory =
|
||||
(id: number, formData: NewCategory) => async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Category>>(
|
||||
`/api/categories/${id}`,
|
||||
formData
|
||||
);
|
||||
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: {
|
||||
title: 'Success',
|
||||
message: `Category ${formData.name} updated`,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch<UpdateCategoryAction>({
|
||||
type: ActionTypes.updateCategory,
|
||||
payload: res.data.data,
|
||||
});
|
||||
|
||||
dispatch<any>(sortCategories());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE BOOKMARK
|
||||
*/
|
||||
export interface DeleteBookmarkAction {
|
||||
type: ActionTypes.deleteBookmark;
|
||||
type: ActionType.deleteBookmark;
|
||||
payload: {
|
||||
bookmarkId: 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 {
|
||||
type: ActionTypes.updateBookmark;
|
||||
type: ActionType.updateBookmark;
|
||||
payload: Bookmark;
|
||||
}
|
||||
|
||||
export const updateBookmark =
|
||||
(
|
||||
bookmarkId: number,
|
||||
formData: NewBookmark | FormData,
|
||||
category: {
|
||||
prev: number;
|
||||
curr: number;
|
||||
}
|
||||
) =>
|
||||
async (dispatch: Dispatch) => {
|
||||
try {
|
||||
const res = await axios.put<ApiResponse<Bookmark>>(
|
||||
`/api/bookmarks/${bookmarkId}`,
|
||||
formData
|
||||
);
|
||||
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: {
|
||||
title: 'Success',
|
||||
message: `Bookmark updated`,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if category was changed
|
||||
const categoryWasChanged = category.curr !== category.prev;
|
||||
|
||||
if (categoryWasChanged) {
|
||||
// Delete bookmark from old category
|
||||
dispatch<DeleteBookmarkAction>({
|
||||
type: ActionTypes.deleteBookmark,
|
||||
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 {
|
||||
type: ActionTypes.sortCategories;
|
||||
type: ActionType.sortCategories;
|
||||
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 {
|
||||
type: ActionTypes.reorderCategories;
|
||||
type: ActionType.reorderCategories;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,157 +1,32 @@
|
|||
import axios from 'axios';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ActionTypes } from './actionTypes';
|
||||
import { Config, ApiResponse, Query } from '../../interfaces';
|
||||
import { CreateNotificationAction } from './notification';
|
||||
import { storeUIConfig } from '../../utility';
|
||||
import { ActionType } from '../action-types';
|
||||
import { Config, Query } from '../../interfaces';
|
||||
|
||||
export interface GetConfigAction {
|
||||
type: ActionTypes.getConfig;
|
||||
type: ActionType.getConfig;
|
||||
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 {
|
||||
type: ActionTypes.updateConfig;
|
||||
type: ActionType.updateConfig;
|
||||
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 {
|
||||
type: ActionTypes.fetchQueries;
|
||||
type: ActionType.fetchQueries;
|
||||
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 {
|
||||
type: ActionTypes.addQuery;
|
||||
type: ActionType.addQuery;
|
||||
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 {
|
||||
type: ActionTypes.deleteQuery;
|
||||
type: ActionType.deleteQuery;
|
||||
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 {
|
||||
type: ActionTypes.updateQuery;
|
||||
type: ActionType.updateQuery;
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,86 @@
|
|||
export * from './theme';
|
||||
export * from './app';
|
||||
export * from './actionTypes';
|
||||
export * from './bookmark';
|
||||
export * from './notification';
|
||||
export * from './config';
|
||||
import { App } from '../../interfaces';
|
||||
|
||||
import { SetThemeAction } from './theme';
|
||||
|
||||
import {
|
||||
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;
|
||||
|
|
|
@ -1,27 +1,12 @@
|
|||
import { Dispatch } from 'redux';
|
||||
import { ActionTypes } from '.';
|
||||
import { ActionType } from '../action-types';
|
||||
import { NewNotification } from '../../interfaces';
|
||||
|
||||
export interface CreateNotificationAction {
|
||||
type: ActionTypes.createNotification,
|
||||
payload: NewNotification
|
||||
}
|
||||
|
||||
export const createNotification = (notification: NewNotification) => (dispatch: Dispatch) => {
|
||||
dispatch<CreateNotificationAction>({
|
||||
type: ActionTypes.createNotification,
|
||||
payload: notification
|
||||
})
|
||||
type: ActionType.createNotification;
|
||||
payload: NewNotification;
|
||||
}
|
||||
|
||||
export interface ClearNotificationAction {
|
||||
type: ActionTypes.clearNotification,
|
||||
payload: number
|
||||
type: ActionType.clearNotification;
|
||||
payload: number;
|
||||
}
|
||||
|
||||
export const clearNotification = (id: number) => (dispatch: Dispatch) => {
|
||||
dispatch<ClearNotificationAction>({
|
||||
type: ActionTypes.clearNotification,
|
||||
payload: id
|
||||
})
|
||||
}
|
|
@ -1,29 +1,7 @@
|
|||
import { Dispatch } from 'redux';
|
||||
import { themes } from '../../components/Themer/themes.json';
|
||||
import { Theme } from '../../interfaces/Theme';
|
||||
import { ActionTypes } from './actionTypes';
|
||||
import { ActionType } from '../action-types';
|
||||
import { Theme } from '../../interfaces';
|
||||
|
||||
export interface SetThemeAction {
|
||||
type: ActionTypes.setTheme,
|
||||
payload: Theme
|
||||
type: ActionType.setTheme;
|
||||
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);
|
||||
}
|
||||
}
|
2
client/src/store/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './store';
|
||||
export * as actionCreators from './action-creators';
|
|
@ -1,118 +1,92 @@
|
|||
import { ActionTypes, Action } from '../actions';
|
||||
import { App } from '../../interfaces/App';
|
||||
import { ActionType } from '../action-types';
|
||||
import { Action } from '../actions/index';
|
||||
import { App } from '../../interfaces';
|
||||
import { sortData } from '../../utility';
|
||||
|
||||
export interface State {
|
||||
interface AppsState {
|
||||
loading: boolean;
|
||||
apps: App[];
|
||||
errors: string | undefined;
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
const initialState: AppsState = {
|
||||
loading: true,
|
||||
apps: [],
|
||||
errors: undefined
|
||||
}
|
||||
errors: undefined,
|
||||
};
|
||||
|
||||
const getApps = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
errors: undefined
|
||||
}
|
||||
}
|
||||
|
||||
const getAppsSuccess = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
apps: action.payload
|
||||
}
|
||||
}
|
||||
|
||||
const getAppsError = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
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 {
|
||||
...state,
|
||||
apps: tmpApps
|
||||
}
|
||||
}
|
||||
|
||||
const addAppSuccess = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
apps: [...state.apps, action.payload]
|
||||
}
|
||||
}
|
||||
|
||||
const deleteApp = (state: State, action: Action): State => {
|
||||
const tmpApps = [...state.apps].filter((app: App) => app.id !== action.payload);
|
||||
|
||||
return {
|
||||
...state,
|
||||
apps: tmpApps
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
apps: tmpApps
|
||||
}
|
||||
}
|
||||
|
||||
const reorderApps = (state: State, action: Action): State => {
|
||||
return {
|
||||
...state,
|
||||
apps: action.payload
|
||||
}
|
||||
}
|
||||
|
||||
const sortApps = (state: State, action: Action): State => {
|
||||
const sortedApps = sortData<App>(state.apps, action.payload);
|
||||
|
||||
return {
|
||||
...state,
|
||||
apps: sortedApps
|
||||
}
|
||||
}
|
||||
|
||||
const appReducer = (state = initialState, action: Action) => {
|
||||
export const appsReducer = (
|
||||
state: AppsState = initialState,
|
||||
action: Action
|
||||
): AppsState => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
case ActionType.getApps:
|
||||
return {
|
||||
...state,
|
||||
loading: true,
|
||||
errors: undefined,
|
||||
};
|
||||
|
||||
export default appReducer;
|
||||
case ActionType.getAppsSuccess:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
apps: action.payload || [],
|
||||
};
|
||||
|
||||
case ActionType.pinApp:
|
||||
const pinnedAppIdx = state.apps.findIndex(
|
||||
(app) => app.id === action.payload.id
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
apps: [
|
||||
...state.apps.slice(0, pinnedAppIdx),
|
||||
action.payload,
|
||||
...state.apps.slice(pinnedAppIdx + 1),
|
||||
],
|
||||
};
|
||||
|
||||
case ActionType.addAppSuccess:
|
||||
return {
|
||||
...state,
|
||||
apps: [...state.apps, action.payload],
|
||||
};
|
||||
|
||||
case ActionType.deleteApp:
|
||||
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 {
|
||||
...state,
|
||||
apps: [
|
||||
...state.apps.slice(0, updatedAppIdx),
|
||||
action.payload,
|
||||
...state.apps.slice(updatedAppIdx + 1),
|
||||
],
|
||||
};
|
||||
|
||||
case ActionType.reorderApps:
|
||||
return {
|
||||
...state,
|
||||
apps: action.payload,
|
||||
};
|
||||
|
||||
case ActionType.sortApps:
|
||||
return {
|
||||
...state,
|
||||
apps: sortData<App>(state.apps, action.payload),
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
|