Merge pull request #169 from pawelmalak/v2

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

View File

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

View File

@ -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"]

View File

@ -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"]

View File

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

View File

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

4
.env
View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/_bookmarks.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

BIN
.github/_home.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

BIN
.github/apps.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
.github/bookmarks.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
.github/home.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
.github/settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

View File

@ -1,3 +1,18 @@
### v2.0.0 (2021-11-15)
- Added authentication system:
- Only logged in user can access settings ([#33](https://github.com/pawelmalak/flame/issues/33))
- User can set which apps, categories and bookmarks should be available for guest users ([#45](https://github.com/pawelmalak/flame/issues/45))
- Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about this feature
- Docker images will now be versioned ([#110](https://github.com/pawelmalak/flame/issues/110))
- Icons can now be set via URL ([#138](https://github.com/pawelmalak/flame/issues/138))
- Added current time to the header ([#157](https://github.com/pawelmalak/flame/issues/157))
- Fixed bug where typing certain characters in the search bar would result in a blank page ([#158](https://github.com/pawelmalak/flame/issues/158))
- Fixed bug with MDI icon name not being properly parsed if there was leading or trailing whitespace ([#164](https://github.com/pawelmalak/flame/issues/164))
- Added new shortcut to clear search bar and focus on it ([#170](https://github.com/pawelmalak/flame/issues/170))
- Added Wikipedia to search queries
- Updated project wiki
- Lots of changes and refactors under the hood to make future development easier
### v1.7.4 (2021-11-08)
- 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
View File

@ -1,37 +1,19 @@
# Flame
![Homescreen screenshot](./.github/_home.png)
![Homescreen screenshot](.github/home.png)
## Description
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary.
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors, it allows you to setup your very own application hub in no time - no file editing necessary.
## Technology
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- Redux
- TypeScript
- Deployment
- Docker
- Kubernetes
## Development
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
cd flame
# run only once
npm run dev-init
# start backend and frontend development servers
npm run dev
```
## Functionality
- 📝 Create, update, delete your applications and bookmarks directly from the app using built-in GUI editors
- 📌 Pin your favourite items to the homescreen for quick and easy access
- 🔍 Integrated search bar with local filtering, 11 web search providers and ability to add your own
- 🔑 Authentication system to protect your settings, apps and bookmarks
- 🔨 Dozens of option to customize Flame interface to your needs, including support for custom CSS and 15 built-in color themes
- ☀️ Weather widget with current temperature, cloud coverage and animated weather status
- 🐳 Docker integration to automatically pick and add apps based on their labels
## Installation
@ -40,32 +22,34 @@ npm run dev
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
```sh
docker pull pawelmalak/flame:latest
docker pull pawelmalak/flame
# for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch
```
#### Building images
```sh
# build image for amd64 only
docker build -t flame .
# build multiarch image for amd64, armv7 and arm64
# building failed multiple times with 2GB memory usage limit so you might want to increase it
docker buildx build \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-f Dockerfile.multiarch \
-t flame:multiarch .
# installing specific version
docker pull pawelmalak/flame:2.0.0
```
#### Deployment
```sh
# run container
docker run -p 5005:5005 -v /path/to/data:/app/data flame
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password flame
```
#### Building images
```sh
# build image for amd64 only
docker build -t flame -f .docker/Dockerfile .
# build multiarch image for amd64, armv7 and arm64
# building failed multiple times with 2GB memory usage limit so you might want to increase it
docker buildx build \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-f .docker/Dockerfile.multiarch \
-t flame:multiarch .
```
#### Docker-Compose
@ -81,6 +65,8 @@ services:
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
ports:
- 5005:5005
environment:
- PASSWORD=flame_password
restart: unless-stopped
```
@ -95,39 +81,56 @@ skaffold dev
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
## Functionality
## Development
- Applications
- Create, update, delete and organize applications using GUI
- Pin your favourite apps to the homescreen
### Technology
![Homescreen screenshot](./.github/_apps.png)
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- Redux
- TypeScript
- Deployment
- Docker
- Kubernetes
- Bookmarks
- Create, update, delete and organize bookmarks and categories using GUI
- Pin your favourite categories to the homescreen
- Import html bookmarks (experimental)
### Creating dev environment
![Homescreen screenshot](./.github/_bookmarks.png)
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
cd flame
- Weather
# run only once
npm run dev-init
- Get current temperature, cloud coverage and weather status with animated icons
# start backend and frontend development servers
npm run dev
```
- Themes
- Customize your page by choosing from 15 color themes
## Screenshots
![Homescreen screenshot](./.github/_themes.png)
![Apps screenshot](.github/apps.png)
![Bookmarks screenshot](.github/bookmarks.png)
![Settings screenshot](.github/settings.png)
![Themes screenshot](.github/themes.png)
## Usage
### Authentication
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
### Search bar
#### Searching
To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
> You can change where to open search results (same/new tab) in the settings
The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
@ -151,7 +154,7 @@ labels:
# - flame.icon=custom to make changes in app. ie: custom icon upload
```
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker
You can also set up different apps in the same label adding `;` between each one.
@ -199,7 +202,7 @@ metadata:
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
```
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
### Import HTML Bookmarks (Experimental)

3
api.js
View File

@ -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);

View File

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

View File

@ -9876,6 +9876,11 @@
"object.assign": "^4.1.2"
}
},
"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",

View File

@ -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",

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -1,17 +1,23 @@
import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
import { Fragment } from 'react';
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { Bookmark, Category } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import 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);

View File

@ -1,363 +0,0 @@
// React
import {
useState,
SyntheticEvent,
Fragment,
ChangeEvent,
useEffect,
} from 'react';
// Redux
import { connect } from 'react-redux';
import {
getCategories,
addCategory,
addBookmark,
updateCategory,
updateBookmark,
createNotification,
} from '../../../store/actions';
// Typescript
import {
Bookmark,
Category,
GlobalState,
NewBookmark,
NewCategory,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// UI
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// CSS
import classes from './BookmarkForm.module.css';
interface ComponentProps {
modalHandler: () => void;
contentType: ContentType;
categories: Category[];
category?: Category;
bookmark?: Bookmark;
addCategory: (formData: NewCategory) => void;
addBookmark: (formData: NewBookmark | FormData) => void;
updateCategory: (id: number, formData: NewCategory) => void;
updateBookmark: (
id: number,
formData: NewBookmark | FormData,
category: {
prev: number;
curr: number;
}
) => void;
createNotification: (notification: NewNotification) => void;
}
const BookmarkForm = (props: ComponentProps): JSX.Element => {
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [categoryName, setCategoryName] = useState<NewCategory>({
name: '',
});
const [formData, setFormData] = useState<NewBookmark>({
name: '',
url: '',
categoryId: -1,
icon: '',
});
// Load category data if provided for editing
useEffect(() => {
if (props.category) {
setCategoryName({ name: props.category.name });
} else {
setCategoryName({ name: '' });
}
}, [props.category]);
// Load bookmark data if provided for editing
useEffect(() => {
if (props.bookmark) {
setFormData({
name: props.bookmark.name,
url: props.bookmark.url,
categoryId: props.bookmark.categoryId,
icon: props.bookmark.icon,
});
} else {
setFormData({
name: '',
url: '',
categoryId: -1,
icon: '',
});
}
}, [props.bookmark]);
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
const createFormData = (): FormData => {
const data = new FormData();
if (customIcon) {
data.append('icon', customIcon);
}
data.append('name', formData.name);
data.append('url', formData.url);
data.append('categoryId', `${formData.categoryId}`);
return data;
};
if (!props.category && !props.bookmark) {
// Add new
if (props.contentType === ContentType.category) {
// Add category
props.addCategory(categoryName);
setCategoryName({ name: '' });
} else if (props.contentType === ContentType.bookmark) {
// Add bookmark
if (formData.categoryId === -1) {
props.createNotification({
title: 'Error',
message: 'Please select category',
});
return;
}
if (customIcon) {
const data = createFormData();
props.addBookmark(data);
} else {
props.addBookmark(formData);
}
setFormData({
name: '',
url: '',
categoryId: formData.categoryId,
icon: '',
});
// setCustomIcon(null);
}
} else {
// Update
if (props.contentType === ContentType.category && props.category) {
// Update category
props.updateCategory(props.category.id, categoryName);
setCategoryName({ name: '' });
} else if (props.contentType === ContentType.bookmark && props.bookmark) {
// Update bookmark
if (customIcon) {
const data = createFormData();
props.updateBookmark(props.bookmark.id, data, {
prev: props.bookmark.categoryId,
curr: formData.categoryId,
});
} else {
props.updateBookmark(props.bookmark.id, formData, {
prev: props.bookmark.categoryId,
curr: formData.categoryId,
});
}
setFormData({
name: '',
url: '',
categoryId: -1,
icon: '',
});
setCustomIcon(null);
}
props.modalHandler();
}
};
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
setFormData({
...formData,
categoryId: parseInt(e.target.value),
});
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
if (e.target.files) {
setCustomIcon(e.target.files[0]);
}
};
let button = <Button>Submit</Button>;
if (!props.category && !props.bookmark) {
if (props.contentType === ContentType.category) {
button = <Button>Add new category</Button>;
} else {
button = <Button>Add new bookmark</Button>;
}
} else if (props.category) {
button = <Button>Update category</Button>;
} else if (props.bookmark) {
button = <Button>Update bookmark</Button>;
}
return (
<ModalForm
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
{props.contentType === ContentType.category ? (
<Fragment>
<InputGroup>
<label htmlFor="categoryName">Category Name</label>
<input
type="text"
name="categoryName"
id="categoryName"
placeholder="Social Media"
required
value={categoryName.name}
onChange={(e) => setCategoryName({ name: e.target.value })}
/>
</InputGroup>
</Fragment>
) : (
<Fragment>
<InputGroup>
<label htmlFor="name">Bookmark Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Reddit"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="url">Bookmark URL</label>
<input
type="text"
name="url"
id="url"
placeholder="reddit.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
{' '}
Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor="categoryId">Bookmark Category</label>
<select
name="categoryId"
id="categoryId"
required
onChange={(e) => selectChangeHandler(e)}
value={formData.categoryId}
>
<option value={-1}>Select category</option>
{props.categories.map((category: Category): JSX.Element => {
return (
<option key={category.id} value={category.id}>
{category.name}
</option>
);
})}
</select>
</InputGroup>
{!useCustomIcon ? (
// mdi
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a href="https://materialdesignicons.com/" target="blank">
{' '}
Click here for reference
</a>
</span>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}
>
Switch to custom icon upload
</span>
</InputGroup>
) : (
// custom
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="file"
name="icon"
id="icon"
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
</Fragment>
)}
{button}
</ModalForm>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
categories: state.bookmark.categories,
};
};
const dispatchMap = {
getCategories,
addCategory,
addBookmark,
updateCategory,
updateBookmark,
createNotification,
};
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

View File

@ -4,19 +4,19 @@ import classes from './BookmarkGrid.module.css';
import { Category } from '../../../interfaces';
import 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;

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,260 @@
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { Bookmark, Category, NewBookmark } from '../../../interfaces';
// UI
import { ModalForm, InputGroup, Button } from '../../UI';
// CSS
import classes from './Form.module.css';
// Utils
import { inputHandler, newBookmarkTemplate } from '../../../utility';
interface Props {
modalHandler: () => void;
bookmark?: Bookmark;
}
export const BookmarksForm = ({
bookmark,
modalHandler,
}: Props): JSX.Element => {
const { categories } = useSelector((state: State) => state.bookmarks);
const dispatch = useDispatch();
const { addBookmark, updateBookmark, createNotification } =
bindActionCreators(actionCreators, dispatch);
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [formData, setFormData] = useState<NewBookmark>(newBookmarkTemplate);
// Load bookmark data if provided for editing
useEffect(() => {
if (bookmark) {
setFormData({ ...bookmark });
} else {
setFormData(newBookmarkTemplate);
}
}, [bookmark]);
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<NewBookmark>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
if (e.target.files) {
setCustomIcon(e.target.files[0]);
}
};
// Bookmarks form handler
const formSubmitHandler = (e: FormEvent): void => {
e.preventDefault();
const createFormData = (): FormData => {
const data = new FormData();
if (customIcon) {
data.append('icon', customIcon);
}
data.append('name', formData.name);
data.append('url', formData.url);
data.append('categoryId', `${formData.categoryId}`);
data.append('isPublic', `${formData.isPublic}`);
return data;
};
const checkCategory = (): boolean => {
if (formData.categoryId < 0) {
createNotification({
title: 'Error',
message: 'Please select category',
});
return false;
}
return true;
};
if (!bookmark) {
// add new bookmark
if (!checkCategory()) return;
if (formData.categoryId < 0) {
createNotification({
title: 'Error',
message: 'Please select category',
});
return;
}
if (customIcon) {
const data = createFormData();
addBookmark(data);
} else {
addBookmark(formData);
}
setFormData({
...newBookmarkTemplate,
categoryId: formData.categoryId,
isPublic: formData.isPublic,
});
} else {
// update
if (!checkCategory()) return;
if (customIcon) {
const data = createFormData();
updateBookmark(bookmark.id, data, {
prev: bookmark.categoryId,
curr: formData.categoryId,
});
} else {
updateBookmark(bookmark.id, formData, {
prev: bookmark.categoryId,
curr: formData.categoryId,
});
}
modalHandler();
setFormData(newBookmarkTemplate);
setCustomIcon(null);
}
};
return (
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
<InputGroup>
<label htmlFor="name">Bookmark Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Reddit"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="url">Bookmark URL</label>
<input
type="text"
name="url"
id="url"
placeholder="reddit.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="categoryId">Bookmark Category</label>
<select
name="categoryId"
id="categoryId"
required
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
value={formData.categoryId}
>
<option value={-1}>Select category</option>
{categories.map((category: Category): JSX.Element => {
return (
<option key={category.id} value={category.id}>
{category.name}
</option>
);
})}
</select>
</InputGroup>
{!useCustomIcon ? (
// mdi
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI or pass a valid URL.
<a href="https://materialdesignicons.com/" target="blank">
{' '}
Click here for reference
</a>
</span>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}
>
Switch to custom icon upload
</span>
</InputGroup>
) : (
// custom
<InputGroup>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type="file"
name="icon"
id="icon"
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
<InputGroup>
<label htmlFor="isPublic">Bookmark visibility</label>
<select
id="isPublic"
name="isPublic"
value={formData.isPublic ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Visible (anyone can access it)</option>
<option value={0}>Hidden (authentication required)</option>
</select>
</InputGroup>
<Button>{bookmark ? 'Update bookmark' : 'Add new bookmark'}</Button>
</ModalForm>
);
};

View File

@ -0,0 +1,100 @@
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
// Redux
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { Category, NewCategory } from '../../../interfaces';
// UI
import { ModalForm, InputGroup, Button } from '../../UI';
// Utils
import { inputHandler, newCategoryTemplate } from '../../../utility';
interface Props {
modalHandler: () => void;
category?: Category;
}
export const CategoryForm = ({
category,
modalHandler,
}: Props): JSX.Element => {
const dispatch = useDispatch();
const { addCategory, updateCategory } = bindActionCreators(
actionCreators,
dispatch
);
const [formData, setFormData] = useState<NewCategory>(newCategoryTemplate);
// Load category data if provided for editing
useEffect(() => {
if (category) {
setFormData({ ...category });
} else {
setFormData(newCategoryTemplate);
}
}, [category]);
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<NewCategory>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
// Category form handler
const formSubmitHandler = (e: FormEvent): void => {
e.preventDefault();
if (!category) {
addCategory(formData);
} else {
updateCategory(category.id, formData);
}
setFormData(newCategoryTemplate);
modalHandler();
};
return (
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
<InputGroup>
<label htmlFor="name">Category Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Social Media"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="isPublic">Category visibility</label>
<select
id="isPublic"
name="isPublic"
value={formData.isPublic ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Visible (anyone can access it)</option>
<option value={0}>Hidden (authentication required)</option>
</select>
</InputGroup>
<Button>{category ? 'Update category' : 'Add new category'}</Button>
</ModalForm>
);
};

View File

@ -0,0 +1,44 @@
// Typescript
import { Bookmark, Category } from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// Utils
import { CategoryForm } from './CategoryForm';
import { BookmarksForm } from './BookmarksForm';
import { Fragment } from 'react';
interface Props {
modalHandler: () => void;
contentType: ContentType;
inUpdate?: boolean;
category?: Category;
bookmark?: Bookmark;
}
export const Form = (props: Props): JSX.Element => {
const { modalHandler, contentType, inUpdate, category, bookmark } = props;
return (
<Fragment>
{!inUpdate ? (
// form: add new
<Fragment>
{contentType === ContentType.category ? (
<CategoryForm modalHandler={modalHandler} />
) : (
<BookmarksForm modalHandler={modalHandler} />
)}
</Fragment>
) : (
// form: update
<Fragment>
{contentType === ContentType.category ? (
<CategoryForm modalHandler={modalHandler} category={category} />
) : (
<BookmarksForm modalHandler={modalHandler} bookmark={bookmark} />
)}
</Fragment>
)}
</Fragment>
);
};

View File

@ -1,17 +1,17 @@
import { useEffect, useState } from 'react';
import { 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);

View File

@ -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}`;
}
};

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,13 @@
import { useSelector } from 'react-redux';
import { Redirect, Route, RouteProps } from 'react-router';
import { State } from '../../store/reducers';
export const ProtectedRoute = ({ ...rest }: RouteProps) => {
const { isAuthenticated } = useSelector((state: State) => state.auth);
if (isAuthenticated) {
return <Route {...rest} />;
} else {
return <Redirect to="/settings/app" />;
}
};

View File

@ -1,42 +1,33 @@
import { useRef, useEffect, KeyboardEvent } from 'react';
// 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);

View File

@ -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);
}

View File

@ -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>
);
};

View File

@ -0,0 +1,103 @@
import { FormEvent, Fragment, useEffect, useState } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
import { decodeToken, parseTokenExpire } from '../../../../utility';
// Other
import { InputGroup, Button } from '../../../UI';
import classes from '../AppDetails.module.css';
export const AuthForm = (): JSX.Element => {
const { isAuthenticated, token } = useSelector((state: State) => state.auth);
const dispatch = useDispatch();
const { login, logout } = bindActionCreators(actionCreators, dispatch);
const [tokenExpires, setTokenExpires] = useState('');
const [formData, setFormData] = useState({
password: '',
duration: '14d',
});
useEffect(() => {
if (token) {
const decoded = decodeToken(token);
const expiresIn = parseTokenExpire(decoded.exp);
setTokenExpires(expiresIn);
}
}, [token]);
const formHandler = (e: FormEvent) => {
e.preventDefault();
login(formData);
setFormData({
password: '',
duration: '14d',
});
};
return (
<Fragment>
{!isAuthenticated ? (
<form onSubmit={formHandler}>
<InputGroup>
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="••••••"
autoComplete="current-password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
<span>
See
<a
href="https://github.com/pawelmalak/flame/wiki/Authentication"
target="blank"
>
{` project wiki `}
</a>
to read more about authentication
</span>
</InputGroup>
<InputGroup>
<label htmlFor="duration">Session duration</label>
<select
id="duration"
name="duration"
value={formData.duration}
onChange={(e) =>
setFormData({ ...formData, duration: e.target.value })
}
>
<option value="1h">1 hour</option>
<option value="1d">1 day</option>
<option value="14d">2 weeks</option>
<option value="30d">1 month</option>
<option value="1y">1 year</option>
</select>
</InputGroup>
<Button>Login</Button>
</form>
) : (
<div>
<p className={classes.text}>
You are logged in. Your session will expire{' '}
<span>{tokenExpires}</span>
</p>
<Button click={logout}>Logout</Button>
</div>
)}
</Fragment>
);
};

View File

@ -0,0 +1,122 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { DockerSettingsForm } from '../../../interfaces';
// UI
import { InputGroup, Button, SettingsHeadline } from '../../UI';
// Utils
import { inputHandler, dockerSettingsTemplate } from '../../../utility';
export const DockerSettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state
const [formData, setFormData] = useState<DockerSettingsForm>(
dockerSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await updateConfig(formData);
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<DockerSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<SettingsHeadline text="Docker" />
{/* CUSTOM DOCKER SOCKET HOST */}
<InputGroup>
<label htmlFor="dockerHost">Docker Host</label>
<input
type="text"
id="dockerHost"
name="dockerHost"
placeholder="dockerHost:port"
value={formData.dockerHost}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* USE DOCKER API */}
<InputGroup>
<label htmlFor="dockerApps">Use Docker API</label>
<select
id="dockerApps"
name="dockerApps"
value={formData.dockerApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* UNPIN DOCKER APPS */}
<InputGroup>
<label htmlFor="unpinStoppedApps">
Unpin stopped containers / other apps
</label>
<select
id="unpinStoppedApps"
name="unpinStoppedApps"
value={formData.unpinStoppedApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* KUBERNETES SETTINGS */}
<SettingsHeadline text="Kubernetes" />
{/* USE KUBERNETES */}
<InputGroup>
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
<select
id="kubernetesApps"
name="kubernetesApps"
value={formData.kubernetesApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
);
};

View File

@ -1,29 +1,31 @@
import { Fragment, useState } from 'react';
import { 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
);

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);
);
};

View File

@ -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);

View File

@ -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
);

View File

@ -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
}
]
}

View File

@ -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;
);
};

View File

@ -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);
);
};

View File

@ -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;
};

View File

@ -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;
);
};

View File

@ -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>;
};

View File

@ -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;
);
};

View File

@ -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;
);
};

View File

@ -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;
);
};

View File

@ -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;

View File

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

View File

@ -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;
);
};

View File

@ -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>;
};

View File

@ -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>;
};

View File

@ -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;
);
};

View File

@ -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);

View File

@ -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;
);
};

View File

@ -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;
);
};

View File

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

View File

@ -2,23 +2,21 @@ import { useState, useEffect, Fragment } from 'react';
import axios from 'axios';
// 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);

View File

@ -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')
);
);

View File

@ -7,4 +7,10 @@ export interface Model {
export interface ApiResponse<T> {
success: boolean;
data: T;
}
}
export interface Token {
app: string;
exp: number;
iat: number;
}

View File

@ -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;
}

View File

@ -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 {}

View File

@ -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;
}

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,205 +1,38 @@
import axios from 'axios';
import { 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);
}
};

View File

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

View File

@ -1,371 +1,58 @@
import axios from 'axios';
import { 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);
}
};

View File

@ -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);
}
};

View File

@ -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;

View File

@ -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
})
}

View File

@ -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);
}
}

View File

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

View File

@ -1,118 +1,92 @@
import { ActionTypes, Action } from '../actions';
import { 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;
}
};

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