1
0
Fork 0
mirror of https://github.com/pawelmalak/flame.git synced 2025-07-23 13:29:35 +02:00

Compare commits

..

No commits in common. "master" and "v1.7.4" have entirely different histories.

233 changed files with 14866 additions and 35157 deletions

View file

@ -1 +0,0 @@
docker build -t flame:dev -f .docker/Dockerfile .

View file

@ -1,2 +0,0 @@
docker build -t pawelmalak/flame -t "pawelmalak/flame:$1" -f .docker/Dockerfile . \
&& docker push pawelmalak/flame && docker push "pawelmalak/flame:$1"

View file

@ -1,6 +0,0 @@
docker buildx build \
--platform linux/arm/v7,linux/arm64,linux/amd64 \
-f .docker/Dockerfile.multiarch \
-t pawelmalak/flame:multiarch \
-t "pawelmalak/flame:multiarch$1" \
--push .

View file

@ -1,22 +0,0 @@
version: '3.6'
services:
flame:
image: pawelmalak/flame
container_name: flame
volumes:
- /path/to/host/data:/app/data
# - /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration
ports:
- 5005:5005
# secrets:
# - password # optional but required for (1)
environment:
- PASSWORD=flame_password
# - PASSWORD_FILE=/run/secrets/password # optional but required for (1)
restart: unless-stopped
# optional but required for Docker secrets (1)
# secrets:
# password:
# file: /path/to/secrets/password

View file

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

4
.env
View file

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

View file

@ -1,27 +0,0 @@
---
name: Bug report
about: Create a bug report
title: "[BUG] "
labels: ''
assignees: ''
---
**Deployment details:**
- App version [e.g. v1.7.4]:
- Platform [e.g. amd64, arm64, arm/v7]:
- Docker image tag [e.g. latest, multiarch]:
---
**Bug description:**
A clear and concise description of what the bug is.
---
**Steps to reproduce:**
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'

BIN
.github/_apps.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/_bookmarks.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
.github/_home.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Before After
Before After

BIN
.github/apps.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

BIN
.github/bookmarks.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

BIN
.github/home.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

BIN
.github/settings.png vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules
data
public
!client/public
!client/public
build.sh

View file

@ -1,2 +1 @@
*.md
docker-compose.yml
*.md

View file

@ -1,69 +1,3 @@
### v2.3.1 (2023-07-23)
- Fixed bug where "Open search results in the same tab" setting was not respected if "Local search" was set as primary search provider ([#270](https://github.com/pawelmalak/flame/issues/270))
- Fixed bug where search bar had rounded input field on iOS ([#394](https://github.com/pawelmalak/flame/issues/394))
- Updated link to Material Design Icons reference page ([#414](https://github.com/pawelmalak/flame/issues/414))
- Fixed bug where color inputs in theme creator/editor were too small ([#429](https://github.com/pawelmalak/flame/issues/429))
- Changed input labels in settings for more consistent naming ([#430](https://github.com/pawelmalak/flame/issues/430))
### v2.3.0 (2022-03-25)
- Added custom theme editor ([#246](https://github.com/pawelmalak/flame/issues/246))
- Added option to set secondary search provider ([#295](https://github.com/pawelmalak/flame/issues/295))
- Fixed bug where pressing Enter with empty search bar would redirect to search results ([#325](https://github.com/pawelmalak/flame/issues/325))
- Fixed bug where user could create empty app or bookmark which was causing page to go blank ([#332](https://github.com/pawelmalak/flame/issues/332))
- Added new theme: Mint
### v2.2.2 (2022-03-21)
- Added option to get user location directly from the app ([#287](https://github.com/pawelmalak/flame/issues/287))
- Fixed bug with local search not working when using prefix ([#289](https://github.com/pawelmalak/flame/issues/289))
- Fixed bug with app description not updating when using custom icon ([#310](https://github.com/pawelmalak/flame/issues/310))
- Changed permissions to some files and directories created by Flame
- Changed some of the settings tabs
### v2.2.1 (2022-01-08)
- Local search will now include app descriptions ([#266](https://github.com/pawelmalak/flame/issues/266))
- Fixed bug with unsupported characters in local search [#279](https://github.com/pawelmalak/flame/issues/279))
- Background tasks optimization ([#283](https://github.com/pawelmalak/flame/issues/283))
### v2.2.0 (2021-12-17)
- Added option to set custom description for apps ([#201](https://github.com/pawelmalak/flame/issues/201))
- Fixed fatal error while deploying Flame to cluster ([#242](https://github.com/pawelmalak/flame/issues/242))
### v2.1.1 (2021-12-02)
- Added support for Docker secrets ([#189](https://github.com/pawelmalak/flame/issues/189))
- Changed some messages and buttons to make it easier to open bookmarks editor ([#239](https://github.com/pawelmalak/flame/issues/239))
### v2.1.0 (2021-11-26)
- Added option to set custom order for bookmarks ([#43](https://github.com/pawelmalak/flame/issues/43)) and ([#187](https://github.com/pawelmalak/flame/issues/187))
- Added support for .ico files for custom icons ([#209](https://github.com/pawelmalak/flame/issues/209))
- Empty apps and categories sections will now be hidden from guests ([#210](https://github.com/pawelmalak/flame/issues/210))
- Fixed bug with fahrenheit degrees being displayed as float ([#221](https://github.com/pawelmalak/flame/issues/221))
- Fixed bug with alphabetical order not working for bookmarks until the page was refreshed ([#224](https://github.com/pawelmalak/flame/issues/224))
- Added option to change visibilty of apps, categories and bookmarks directly from table view
- Password input will now autofocus when visiting /settings/app
### v2.0.1 (2021-11-19)
- Added option to display humidity in the weather widget ([#136](https://github.com/pawelmalak/flame/issues/136))
- Added option to set default theme for all new users ([#165](https://github.com/pawelmalak/flame/issues/165))
- Added option to hide header greetings and date separately ([#200](https://github.com/pawelmalak/flame/issues/200))
- Fixed bug with broken basic auth ([#202](https://github.com/pawelmalak/flame/issues/202))
- Fixed bug with parsing visibility value for apps and bookmarks when custom icon was used ([#203](https://github.com/pawelmalak/flame/issues/203))
- Fixed bug with custom icons not working with apps when "pin by default" was disabled
### 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))
@ -128,12 +62,12 @@
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
- Added changelog file
### v1.6.0 (2021-07-17)
### v1.6 (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.0 (2021-06-24)
### v1.5 (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))
@ -141,7 +75,7 @@
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
- Improved Logger
### v1.4.0 (2021-06-18)
### v1.4 (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))
@ -150,14 +84,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.0 (2021-06-14)
### v1.3 (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.0 (2021-06-10)
### v1.2 (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))
@ -166,11 +100,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.0 (2021-06-09)
### v1.1 (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.0 (2021-06-08)
### v1.0 (2021-06-08)
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.

View file

@ -1,4 +1,4 @@
FROM node:16 as builder
FROM node:14 as builder
WORKDIR /app
@ -16,7 +16,7 @@ RUN mkdir -p ./public ./data \
&& mv ./client/build/* ./public \
&& rm -rf ./client
FROM node:16-alpine
FROM node:14-alpine
COPY --from=builder /app /app
@ -25,6 +25,5 @@ WORKDIR /app
EXPOSE 5005
ENV NODE_ENV=production
ENV PASSWORD=flame_password
CMD ["sh", "-c", "chown -R node /app/data && node server.js"]
CMD ["node", "server.js"]

View file

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

208
README.md
View file

@ -1,112 +1,12 @@
# 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.
## 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 options to customize Flame interface to your needs, including support for custom CSS, 15 built-in color themes and custom theme builder
- ☀️ Weather widget with current temperature, cloud coverage and animated weather status
- 🐳 Docker integration to automatically pick and add apps based on their labels
## Installation
### With Docker (recommended)
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
```sh
docker pull pawelmalak/flame
# for ARM architecture (e.g. RaspberryPi)
docker pull pawelmalak/flame:multiarch
# installing specific version
docker pull pawelmalak/flame:2.0.0
```
#### Deployment
```sh
# run container
docker run -p 5005:5005 -v /path/to/data:/app/data -e PASSWORD=flame_password pawelmalak/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
```yaml
version: '3.6'
services:
flame:
image: pawelmalak/flame
container_name: flame
volumes:
- /path/to/host/data:/app/data
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration
ports:
- 5005:5005
secrets:
- password # optional but required for (1)
environment:
- PASSWORD=flame_password
- PASSWORD_FILE=/run/secrets/password # optional but required for (1)
restart: unless-stopped
# optional but required for Docker secrets (1)
secrets:
password:
file: /path/to/secrets/password
```
##### Docker Secrets
All environment variables can be overwritten by appending `_FILE` to the variable value. For example, you can use `PASSWORD_FILE` to pass through a docker secret instead of `PASSWORD`. If both `PASSWORD` and `PASSWORD_FILE` are set, the docker secret will take precedent.
```bash
# ./secrets/flame_password
my_custom_secret_password_123
# ./docker-compose.yml
secrets:
password:
file: ./secrets/flame_password
```
#### Skaffold
```sh
# use skaffold
skaffold dev
```
### Without Docker
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
## Development
### Technology
## Technology
- Backend
- Node.js + Express
@ -119,7 +19,7 @@ Follow instructions from wiki: [Installation without Docker](https://github.com/
- Docker
- Kubernetes
### Creating dev environment
## Development
```sh
# clone repository
@ -133,27 +33,101 @@ npm run dev-init
npm run dev
```
## Screenshots
## Installation
![Apps screenshot](.github/apps.png)
### With Docker (recommended)
![Bookmarks screenshot](.github/bookmarks.png)
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
![Settings screenshot](.github/settings.png)
```sh
docker pull pawelmalak/flame:latest
![Themes screenshot](.github/themes.png)
# 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 .
```
#### Deployment
```sh
# run container
docker run -p 5005:5005 -v /path/to/data:/app/data flame
```
#### Docker-Compose
```yaml
version: '2.1'
services:
flame:
image: pawelmalak/flame:latest
container_name: flame
volumes:
- <host_dir>:/app/data
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
ports:
- 5005:5005
restart: unless-stopped
```
#### Skaffold
```sh
# use skaffold
skaffold dev
```
### Without Docker
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
## Functionality
- Applications
- Create, update, delete and organize applications using GUI
- Pin your favourite apps to the homescreen
![Homescreen screenshot](./.github/_apps.png)
- Bookmarks
- Create, update, delete and organize bookmarks and categories using GUI
- Pin your favourite categories to the homescreen
- Import html bookmarks (experimental)
![Homescreen screenshot](./.github/_bookmarks.png)
- Weather
- Get current temperature, cloud coverage and weather status with animated icons
- Themes
- Customize your page by choosing from 15 color themes
![Homescreen screenshot](./.github/_themes.png)
## Usage
### Authentication
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
### Search bar
#### Searching
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`.
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
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
@ -177,7 +151,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 > Docker
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section
You can also set up different apps in the same label adding `;` between each one.
@ -225,7 +199,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 > Docker
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section
### Import HTML Bookmarks (Experimental)
@ -235,7 +209,7 @@ metadata:
- Backup your `db.sqlite` before running script!
- Known Issues:
- generated icons are sometimes incorrect
```bash
pip3 install Pillow, beautifulsoup4

4
api.js
View file

@ -1,6 +1,6 @@
const { join } = require('path');
const express = require('express');
const { errorHandler } = require('./middleware');
const errorHandler = require('./middleware/errorHandler');
const api = express();
@ -21,8 +21,6 @@ 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'));
api.use('/api/themes', require('./routes/themes'));
// Custom error handler
api.use(errorHandler);

View file

@ -1 +1 @@
REACT_APP_VERSION=2.3.1
REACT_APP_VERSION=1.7.4

33929
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@
"@types/jest": "^27.0.2",
"@types/node": "^16.11.6",
"@types/react": "^17.0.34",
"@types/react-autosuggest": "^10.1.5",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.11",
"@types/react-redux": "^7.1.20",
@ -18,13 +19,13 @@
"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",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "^5.0.1",
"react-scripts": "4.0.3",
"redux": "^4.1.2",
"redux-devtools-extension": "^2.13.9",
"redux-thunk": "^2.4.0",

View file

@ -1,82 +1,38 @@
import { useEffect } from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { fetchQueries, getConfig, setTheme } from './store/actions';
import 'external-svg-loader';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { autoLogin, getConfig } from './store/action-creators';
import { actionCreators, store } from './store';
import { State } from './store/reducers';
import { store } from './store/store';
import { Provider } from 'react-redux';
// Utils
import { checkVersion, decodeToken, parsePABToTheme } from './utility';
import { checkVersion } 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';
// Get config
// Load config
store.dispatch<any>(getConfig());
// Validate token
if (localStorage.token) {
store.dispatch<any>(autoLogin());
// Set theme
if (localStorage.theme) {
store.dispatch<any>(setTheme(localStorage.theme));
}
export const App = (): JSX.Element => {
const { config, loading } = useSelector((state: State) => state.config);
// Check for updates
checkVersion();
const dispath = useDispatch();
const { fetchQueries, setTheme, logout, createNotification, fetchThemes } =
bindActionCreators(actionCreators, dispath);
useEffect(() => {
// check if token is valid
const tokenIsValid = setInterval(() => {
if (localStorage.token) {
const expiresIn = decodeToken(localStorage.token).exp * 1000;
const now = new Date().getTime();
if (now > expiresIn) {
logout();
createNotification({
title: 'Info',
message: 'Session expired. You have been logged out',
});
}
}
}, 1000);
// load themes
fetchThemes();
// set user theme if present
if (localStorage.theme) {
setTheme(parsePABToTheme(localStorage.theme));
}
// check for updated
checkVersion();
// load custom search queries
fetchQueries();
return () => window.clearInterval(tokenIsValid);
}, []);
// If there is no user theme, set the default one
useEffect(() => {
if (!loading && !localStorage.theme) {
setTheme(parsePABToTheme(config.defaultTheme), false);
}
}, [loading]);
// fetch queries
store.dispatch<any>(fetchQueries());
const App = (): JSX.Element => {
return (
<>
<Provider store={store}>
<BrowserRouter>
<Switch>
<Route exact path="/" component={Home} />
@ -86,6 +42,8 @@ export const App = (): JSX.Element => {
</Switch>
</BrowserRouter>
<NotificationCenter />
</>
</Provider>
);
};
export default App;

View file

@ -1,12 +0,0 @@
.TableActions {
display: flex;
align-items: center;
}
.TableAction {
width: 22px;
}
.TableAction:hover {
cursor: pointer;
}

View file

@ -1,81 +0,0 @@
import { Icon } from '../UI';
import classes from './TableActions.module.css';
interface Entity {
id: number;
name: string;
isPinned?: boolean;
isPublic: boolean;
}
interface Props {
entity: Entity;
deleteHandler: (id: number, name: string) => void;
updateHandler: (id: number) => void;
pinHanlder?: (id: number) => void;
changeVisibilty: (id: number) => void;
showPin?: boolean;
}
export const TableActions = (props: Props): JSX.Element => {
const {
entity,
deleteHandler,
updateHandler,
pinHanlder,
changeVisibilty,
showPin = true,
} = props;
const _pinHandler = pinHanlder || function () {};
return (
<td className={classes.TableActions}>
{/* DELETE */}
<div
className={classes.TableAction}
onClick={() => deleteHandler(entity.id, entity.name)}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
{/* UPDATE */}
<div
className={classes.TableAction}
onClick={() => updateHandler(entity.id)}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
{/* PIN */}
{showPin && (
<div
className={classes.TableAction}
onClick={() => _pinHandler(entity.id)}
tabIndex={0}
>
{entity.isPinned ? (
<Icon icon="mdiPinOff" color="var(--color-accent)" />
) : (
<Icon icon="mdiPin" />
)}
</div>
)}
{/* VISIBILITY */}
<div
className={classes.TableAction}
onClick={() => changeVisibilty(entity.id)}
tabIndex={0}
>
{entity.isPublic ? (
<Icon icon="mdiEyeOff" color="var(--color-accent)" />
) : (
<Icon icon="mdiEye" />
)}
</div>
</td>
);
};

View file

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

View file

@ -1,49 +1,50 @@
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { NewApp } from '../../../interfaces';
import { connect } from 'react-redux';
import { addApp, updateApp } from '../../../store/actions';
import { App, NewApp } from '../../../interfaces';
import classes from './AppForm.module.css';
import { ModalForm, InputGroup, Button } from '../../UI';
import { inputHandler, newAppTemplate } from '../../../utility';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
interface Props {
interface ComponentProps {
modalHandler: () => void;
addApp: (formData: NewApp | FormData) => any;
updateApp: (id: number, formData: NewApp | FormData) => any;
app?: App;
}
export const AppForm = ({ modalHandler }: Props): JSX.Element => {
const { appInUpdate } = useSelector((state: State) => state.apps);
const dispatch = useDispatch();
const { addApp, updateApp, setEditApp, createNotification } =
bindActionCreators(actionCreators, dispatch);
const AppForm = (props: ComponentProps): JSX.Element => {
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [formData, setFormData] = useState<NewApp>(newAppTemplate);
const [formData, setFormData] = useState<NewApp>({
name: '',
url: '',
icon: '',
});
useEffect(() => {
if (appInUpdate) {
if (props.app) {
setFormData({
...appInUpdate,
name: props.app.name,
url: props.app.url,
icon: props.app.icon,
});
} else {
setFormData(newAppTemplate);
setFormData({
name: '',
url: '',
icon: '',
});
}
}, [appInUpdate]);
}, [props.app]);
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<NewApp>({
e,
options,
setStateHandler: setFormData,
state: formData,
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
@ -56,58 +57,49 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
for (let field of ['name', 'url', 'icon'] as const) {
if (/^ +$/.test(formData[field])) {
createNotification({
title: 'Error',
message: `Field cannot be empty: ${field}`,
});
return;
}
}
const createFormData = (): FormData => {
const data = new FormData();
if (customIcon) {
data.append('icon', customIcon);
}
data.append('name', formData.name);
data.append('description', formData.description);
data.append('url', formData.url);
data.append('isPublic', `${formData.isPublic ? 1 : 0}`);
return data;
};
if (!appInUpdate) {
if (!props.app) {
if (customIcon) {
const data = createFormData();
addApp(data);
props.addApp(data);
} else {
addApp(formData);
props.addApp(formData);
}
} else {
if (customIcon) {
const data = createFormData();
updateApp(appInUpdate.id, data);
props.updateApp(props.app.id, data);
} else {
updateApp(appInUpdate.id, formData);
modalHandler();
props.updateApp(props.app.id, formData);
props.modalHandler();
}
}
setFormData(newAppTemplate);
setEditApp(null);
setFormData({
name: '',
url: '',
icon: '',
});
};
return (
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
{/* NAME */}
<ModalForm
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
<InputGroup>
<label htmlFor="name">App name</label>
<label htmlFor="name">App Name</label>
<input
type="text"
name="name"
@ -118,8 +110,6 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* URL */}
<InputGroup>
<label htmlFor="url">App URL</label>
<input
@ -131,29 +121,21 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* DESCRIPTION */}
<InputGroup>
<label htmlFor="description">App description</label>
<input
type="text"
name="description"
id="description"
placeholder="My self-hosted app"
value={formData.description}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Optional - If description is not set, app URL will be displayed
<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>
<label htmlFor="icon">App icon</label>
<label htmlFor="icon">App Icon</label>
<input
type="text"
name="icon"
@ -164,8 +146,8 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI or pass a valid URL.
<a href="https://pictogrammers.com/library/mdi/" target="blank">
Use icon name from MDI.
<a href="https://materialdesignicons.com/" target="blank">
{' '}
Click here for reference
</a>
@ -187,7 +169,7 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
id="icon"
required
onChange={(e) => fileChangeHandler(e)}
accept=".jpg,.jpeg,.png,.svg,.ico"
accept=".jpg,.jpeg,.png,.svg"
/>
<span
onClick={() => {
@ -200,22 +182,7 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
</span>
</InputGroup>
)}
{/* 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>
{!appInUpdate ? (
{!props.app ? (
<Button>Add new application</Button>
) : (
<Button>Update application</Button>
@ -223,3 +190,5 @@ export const AppForm = ({ modalHandler }: Props): JSX.Element => {
</ModalForm>
);
};
export default connect(null, { addApp, updateApp })(AppForm);

View file

@ -20,3 +20,21 @@
grid-template-columns: repeat(4, 1fr);
}
}
.GridMessage {
color: var(--color-primary);
}
.GridMessage a {
color: var(--color-accent);
font-weight: 600;
}
.AppsMessage {
color: var(--color-primary);
}
.AppsMessage a {
color: var(--color-accent);
font-weight: 600;
}

View file

@ -2,47 +2,52 @@ import classes from './AppGrid.module.css';
import { Link } from 'react-router-dom';
import { App } from '../../../interfaces/App';
import { AppCard } from '../AppCard/AppCard';
import { Message } from '../../UI';
import AppCard from '../AppCard/AppCard';
interface Props {
interface ComponentProps {
apps: App[];
totalApps?: number;
searching: boolean;
}
export const AppGrid = (props: Props): JSX.Element => {
const AppGrid = (props: ComponentProps): JSX.Element => {
let apps: JSX.Element;
if (props.searching || props.apps.length) {
if (!props.apps.length) {
apps = <Message>No apps match your search criteria</Message>;
} else {
apps = (
<div className={classes.AppGrid}>
{props.apps.map((app: App): JSX.Element => {
return <AppCard key={app.id} app={app} />;
})}
</div>
);
}
if (props.apps.length > 0) {
apps = (
<div className={classes.AppGrid}>
{props.apps.map((app: App): JSX.Element => {
return <AppCard key={app.id} app={app} />;
})}
</div>
);
} else {
if (props.totalApps) {
apps = (
<Message>
There are no pinned applications. You can pin them from the{' '}
<Link to="/applications">/applications</Link> menu
</Message>
);
if (props.searching) {
apps = (
<p className={classes.AppsMessage}>
No apps match your search criteria
</p>
);
} else {
apps = (
<p className={classes.AppsMessage}>
There are no pinned applications. You can pin them from the{' '}
<Link to="/applications">/applications</Link> menu
</p>
);
}
} else {
apps = (
<Message>
<p className={classes.AppsMessage}>
You don't have any applications. You can add a new one from{' '}
<Link to="/applications">/applications</Link> menu
</Message>
</p>
);
}
}
return apps;
};
export default AppGrid;

View file

@ -1,13 +1,17 @@
.message {
color: var(--color-primary);
.TableActions {
display: flex;
align-items: center;
}
.message a {
color: var(--color-accent);
font-weight: 600;
.TableAction {
width: 22px;
}
.messageCenter {
.TableAction:hover {
cursor: pointer;
}
.Message {
width: 100%;
display: flex;
justify-content: center;
@ -16,11 +20,10 @@
margin-bottom: 20px;
}
.messageCenter a,
.messageCenter span {
.Message a {
color: var(--color-accent);
}
.messageCenter a:hover {
.Message a:hover {
cursor: pointer;
}
}

View file

@ -1,4 +1,4 @@
import { Fragment, useState, useEffect } from 'react';
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
import {
DragDropContext,
Droppable,
@ -8,42 +8,78 @@ import {
import { Link } from 'react-router-dom';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { connect } from 'react-redux';
import {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification,
} from '../../../store/actions';
// Typescript
import { App } from '../../../interfaces';
import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
// Other
import { Message, Table } from '../../UI';
import { TableActions } from '../../Actions/TableActions';
// CSS
import classes from './AppTable.module.css';
interface Props {
openFormForUpdating: (app: App) => void;
// UI
import Icon from '../../UI/Icons/Icon/Icon';
import Table from '../../UI/Table/Table';
interface ComponentProps {
apps: App[];
config: Config;
pinApp: (app: App) => void;
deleteApp: (id: number) => void;
updateAppHandler: (app: App) => void;
reorderApps: (apps: App[]) => void;
updateConfig: (formData: any) => void;
createNotification: (notification: NewNotification) => void;
}
export const AppTable = (props: Props): JSX.Element => {
const {
apps: { apps },
config: { config },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { pinApp, deleteApp, reorderApps, createNotification, updateApp } =
bindActionCreators(actionCreators, dispatch);
const AppTable = (props: ComponentProps): JSX.Element => {
const [localApps, setLocalApps] = useState<App[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
// Copy apps array
useEffect(() => {
setLocalApps([...apps]);
}, [apps]);
setLocalApps([...props.apps]);
}, [props.apps]);
// Check ordering
useEffect(() => {
const order = props.config.useOrdering;
if (order === 'orderId') {
setIsCustomOrder(true);
}
}, []);
const deleteAppHandler = (app: App): void => {
const proceed = window.confirm(
`Are you sure you want to delete ${app.name} at ${app.url} ?`
);
if (proceed) {
props.deleteApp(app.id);
}
};
// Support keyboard navigation for actions
const keyboardActionHandler = (
e: KeyboardEvent,
app: App,
handler: Function
) => {
if (e.key === 'Enter') {
handler(app);
}
};
const dragEndHanlder = (result: DropResult): void => {
if (config.useOrdering !== 'orderId') {
createNotification({
if (!isCustomOrder) {
props.createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
@ -59,51 +95,26 @@ export const AppTable = (props: Props): JSX.Element => {
tmpApps.splice(result.destination.index, 0, movedApp);
setLocalApps(tmpApps);
reorderApps(tmpApps);
};
// Action handlers
const deleteAppHandler = (id: number, name: string) => {
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
if (proceed) {
deleteApp(id);
}
};
const updateAppHandler = (id: number) => {
const app = apps.find((a) => a.id === id) as App;
props.openFormForUpdating(app);
};
const pinAppHandler = (id: number) => {
const app = apps.find((a) => a.id === id) as App;
pinApp(app);
};
const changeAppVisibiltyHandler = (id: number) => {
const app = apps.find((a) => a.id === id) as App;
updateApp(id, { ...app, isPublic: !app.isPublic });
props.reorderApps(tmpApps);
};
return (
<Fragment>
<Message isPrimary={false}>
{config.useOrdering === 'orderId' ? (
<div className={classes.Message}>
{isCustomOrder ? (
<p>You can drag and drop single rows to reorder application</p>
) : (
<p>
Custom order is disabled. You can change it in the{' '}
<Link to="/settings/general">settings</Link>
Custom order is disabled. You can change it in{' '}
<Link to="/settings/other">settings</Link>
</p>
)}
</Message>
</div>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="apps">
{(provided) => (
<Table
headers={['Name', 'URL', 'Icon', 'Visibility', 'Actions']}
headers={['Name', 'URL', 'Icon', 'Actions']}
innerRef={provided.innerRef}
>
{localApps.map((app: App, index): JSX.Element => {
@ -132,18 +143,54 @@ export const AppTable = (props: Props): 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 && (
<TableActions
entity={app}
deleteHandler={deleteAppHandler}
updateHandler={updateAppHandler}
pinHanlder={pinAppHandler}
changeVisibilty={changeAppVisibiltyHandler}
/>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteAppHandler(app)}
onKeyDown={(e) =>
keyboardActionHandler(
e,
app,
deleteAppHandler
)
}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateAppHandler(app)}
onKeyDown={(e) =>
keyboardActionHandler(
e,
app,
props.updateAppHandler
)
}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinApp(app)}
onKeyDown={(e) =>
keyboardActionHandler(e, app, props.pinApp)
}
tabIndex={0}
>
{app.isPinned ? (
<Icon
icon="mdiPinOff"
color="var(--color-accent)"
/>
) : (
<Icon icon="mdiPin" />
)}
</div>
</td>
)}
</tr>
);
@ -158,3 +205,20 @@ export const AppTable = (props: Props): 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,79 +2,81 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { connect } from 'react-redux';
import { getApps } from '../../store/actions';
// Typescript
import { App } from '../../interfaces';
import { App, GlobalState } from '../../interfaces';
// CSS
import classes from './Apps.module.css';
// UI
import { Headline, Spinner, ActionButton, Modal, Container } from '../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';
// 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';
// Utils
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
interface Props {
interface ComponentProps {
getApps: Function;
apps: App[];
loading: boolean;
searching: boolean;
}
export const Apps = (props: Props): JSX.Element => {
// Get Redux state
const {
apps: { apps, loading },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const Apps = (props: ComponentProps): JSX.Element => {
const { getApps, apps, loading, searching = false } = props;
// Get Redux action creators
const dispatch = useDispatch();
const { getApps, setEditApp } = 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(),
});
// Load apps if array is empty
useEffect(() => {
if (!apps.length) {
if (apps.length === 0) {
getApps();
}
}, []);
}, [getApps]);
// Form
const [modalIsOpen, setModalIsOpen] = useState(false);
const [showTable, setShowTable] = useState(false);
// Observe if user is authenticated -> set default view if not
useEffect(() => {
if (!isAuthenticated) {
setShowTable(false);
setModalIsOpen(false);
}
}, [isAuthenticated]);
// Form actions
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
setIsInUpdate(false);
};
const toggleEdit = (): void => {
setShowTable(!showTable);
setIsInEdit(!isInEdit);
setIsInUpdate(false);
};
const openFormForUpdating = (app: App): void => {
setEditApp(app);
const toggleUpdate = (app: App): void => {
setAppInUpdate(app);
setIsInUpdate(true);
setModalIsOpen(true);
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}>
<AppForm modalHandler={toggleModal} />
{!isInUpdate ? (
<AppForm modalHandler={toggleModal} />
) : (
<AppForm modalHandler={toggleModal} app={appInUpdate} />
)}
</Modal>
<Headline
@ -82,29 +84,29 @@ export const Apps = (props: Props): JSX.Element => {
subtitle={<Link to="/">Go back</Link>}
/>
{isAuthenticated && (
<div className={classes.ActionsContainer}>
<ActionButton
name="Add"
icon="mdiPlusBox"
handler={() => {
setEditApp(null);
toggleModal();
}}
/>
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
</div>
)}
<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 />
) : !showTable ? (
<AppGrid apps={apps} searching={props.searching} />
) : !isInEdit ? (
<AppGrid apps={apps} searching />
) : (
<AppTable openFormForUpdating={openFormForUpdating} />
<AppTable updateAppHandler={toggleUpdate} />
)}
</div>
</Container>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps,
loading: state.app.loading,
};
};
export default connect(mapStateToProps, { getApps })(Apps);

View file

@ -10,10 +10,6 @@
text-transform: uppercase;
}
.BookmarkHeader:hover {
cursor: pointer;
}
.Bookmarks {
display: flex;
flex-direction: column;

View file

@ -1,52 +1,22 @@
import { Fragment } 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 } from '../../../interfaces';
// Other
import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import { Icon } from '../../UI';
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
interface Props {
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser } from '../../../utility';
import { Fragment } from 'react';
import { connect } from 'react-redux';
interface ComponentProps {
category: Category;
fromHomepage?: boolean;
config: Config;
}
export const BookmarkCard = (props: Props): JSX.Element => {
const { category, fromHomepage = false } = props;
const {
config: { config },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { setEditCategory } = bindActionCreators(actionCreators, dispatch);
const BookmarkCard = (props: ComponentProps): JSX.Element => {
return (
<div className={classes.BookmarkCard}>
<h3
className={
fromHomepage || !isAuthenticated ? '' : classes.BookmarkHeader
}
onClick={() => {
if (!fromHomepage && isAuthenticated) {
setEditCategory(category);
}
}}
>
{category.name}
</h3>
<h3>{props.category.name}</h3>
<div className={classes.Bookmarks}>
{category.bookmarks.map((bookmark: Bookmark) => {
{props.category.bookmarks.map((bookmark: Bookmark) => {
const redirectUrl = urlParser(bookmark.url)[1];
let iconEl: JSX.Element = <Fragment></Fragment>;
@ -54,25 +24,21 @@ export const BookmarkCard = (props: Props): JSX.Element => {
if (bookmark.icon) {
const { icon, name } = bookmark;
if (isImage(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
if (/.(jpeg|jpg|png)$/i.test(icon)) {
iconEl = (
<div className={classes.BookmarkIcon}>
<img
src={source}
src={`/uploads/${icon}`}
alt={`${name} icon`}
className={classes.CustomIcon}
/>
</div>
);
} else if (isSvg(icon)) {
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
} else if (/.(svg)$/i.test(icon)) {
iconEl = (
<div className={classes.BookmarkIcon}>
<svg
data-src={source}
data-src={`/uploads/${icon}`}
fill="var(--color-primary)"
className={classes.BookmarkIconSvg}
></svg>
@ -90,7 +56,7 @@ export const BookmarkCard = (props: Props): JSX.Element => {
return (
<a
href={redirectUrl}
target={config.bookmarksSameTab ? '' : '_blank'}
target={props.config.bookmarksSameTab ? '' : '_blank'}
rel="noreferrer"
key={`bookmark-${bookmark.id}`}
>
@ -103,3 +69,11 @@ export const BookmarkCard = (props: Props): JSX.Element => {
</div>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(BookmarkCard);

View file

@ -0,0 +1,363 @@
// 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

@ -20,3 +20,12 @@
grid-template-columns: repeat(4, 1fr);
}
}
.BookmarksMessage {
color: var(--color-primary);
}
.BookmarksMessage a {
color: var(--color-accent);
font-weight: 600;
}

View file

@ -4,61 +4,54 @@ import classes from './BookmarkGrid.module.css';
import { Category } from '../../../interfaces';
import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
import { Message } from '../../UI';
import BookmarkCard from '../BookmarkCard/BookmarkCard';
interface Props {
interface ComponentProps {
categories: Category[];
totalCategories?: number;
searching: boolean;
fromHomepage?: boolean;
}
export const BookmarkGrid = (props: Props): JSX.Element => {
const {
categories,
totalCategories,
searching,
fromHomepage = false,
} = props;
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
let bookmarks: JSX.Element;
if (categories.length) {
if (searching && !categories[0].bookmarks.length) {
bookmarks = <Message>No bookmarks match your search criteria</Message>;
if (props.categories.length > 0) {
if (props.searching && props.categories[0].bookmarks.length === 0) {
bookmarks = (
<p className={classes.BookmarksMessage}>
No bookmarks match your search criteria
</p>
);
} else {
bookmarks = (
<div className={classes.BookmarkGrid}>
{categories.map(
{props.categories.map(
(category: Category): JSX.Element => (
<BookmarkCard
category={category}
fromHomepage={fromHomepage}
key={category.id}
/>
<BookmarkCard category={category} key={category.id} />
)
)}
</div>
);
}
} else {
if (totalCategories) {
if (props.totalCategories) {
bookmarks = (
<Message>
<p className={classes.BookmarksMessage}>
There are no pinned categories. You can pin them from the{' '}
<Link to="/bookmarks">/bookmarks</Link> menu
</Message>
</p>
);
} else {
bookmarks = (
<Message>
<p className={classes.BookmarksMessage}>
You don't have any bookmarks. You can add a new one from{' '}
<Link to="/bookmarks">/bookmarks</Link> menu
</Message>
</p>
);
}
}
return bookmarks;
};
export default BookmarkGrid;

View file

@ -0,0 +1,29 @@
.TableActions {
display: flex;
align-items: center;
}
.TableAction {
width: 22px;
}
.TableAction:hover {
cursor: pointer;
}
.Message {
width: 100%;
display: flex;
justify-content: center;
align-items: baseline;
color: var(--color-primary);
margin-bottom: 20px;
}
.Message a {
color: var(--color-accent);
}
.Message a:hover {
cursor: pointer;
}

View file

@ -0,0 +1,283 @@
import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import {
pinCategory,
deleteCategory,
deleteBookmark,
createNotification,
reorderCategories,
} from '../../../store/actions';
// Typescript
import {
Bookmark,
Category,
Config,
GlobalState,
NewNotification,
} 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';
interface ComponentProps {
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 => {
const [localCategories, setLocalCategories] = useState<Category[]>([]);
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
// Copy categories array
useEffect(() => {
setLocalCategories([...props.categories]);
}, [props.categories]);
// Check ordering
useEffect(() => {
const order = props.config.useOrdering;
if (order === 'orderId') {
setIsCustomOrder(true);
}
});
const deleteCategoryHandler = (category: Category): void => {
const proceed = window.confirm(
`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`
);
if (proceed) {
props.deleteCategory(category.id);
}
};
const deleteBookmarkHandler = (bookmark: Bookmark): void => {
const proceed = window.confirm(
`Are you sure you want to delete ${bookmark.name}?`
);
if (proceed) {
props.deleteBookmark(bookmark.id, bookmark.categoryId);
}
};
const keyboardActionHandler = (
e: KeyboardEvent,
category: Category,
handler: Function
) => {
if (e.key === 'Enter') {
handler(category);
}
};
const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) {
props.createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
return;
}
if (!result.destination) {
return;
}
const tmpCategories = [...localCategories];
const [movedApp] = tmpCategories.splice(result.source.index, 1);
tmpCategories.splice(result.destination.index, 0, movedApp);
setLocalCategories(tmpCategories);
props.reorderCategories(tmpCategories);
};
if (props.contentType === ContentType.category) {
return (
<Fragment>
<div className={classes.Message}>
{isCustomOrder ? (
<p>You can drag and drop single rows to reorder categories</p>
) : (
<p>
Custom order is disabled. You can change it in{' '}
<Link to="/settings/other">settings</Link>
</p>
)}
</div>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="categories">
{(provided) => (
<Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
{localCategories.map(
(category: Category, index): JSX.Element => {
return (
<Draggable
key={category.id}
draggableId={category.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td>{category.name}</td>
{!snapshot.isDragging && (
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() =>
deleteCategoryHandler(category)
}
onKeyDown={(e) =>
keyboardActionHandler(
e,
category,
deleteCategoryHandler
)
}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() =>
props.updateHandler(category)
}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinCategory(category)}
onKeyDown={(e) =>
keyboardActionHandler(
e,
category,
props.pinCategory
)
}
tabIndex={0}
>
{category.isPinned ? (
<Icon
icon="mdiPinOff"
color="var(--color-accent)"
/>
) : (
<Icon icon="mdiPin" />
)}
</div>
</td>
)}
</tr>
);
}}
</Draggable>
);
}
)}
</Table>
)}
</Droppable>
</DragDropContext>
</Fragment>
);
} else {
const bookmarks: { bookmark: Bookmark; categoryName: string }[] = [];
props.categories.forEach((category: Category) => {
category.bookmarks.forEach((bookmark: Bookmark) => {
bookmarks.push({
bookmark,
categoryName: category.name,
});
});
});
return (
<Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
{bookmarks.map(
(bookmark: { bookmark: Bookmark; categoryName: string }) => {
return (
<tr key={bookmark.bookmark.id}>
<td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td>
<td>{bookmark.bookmark.icon}</td>
<td>{bookmark.categoryName}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(bookmark.bookmark)}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
</td>
</tr>
);
}
)}
</Table>
);
}
};
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,34 +1,25 @@
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';
// UI
import {
Container,
Headline,
ActionButton,
Spinner,
Modal,
Message,
} from '../UI';
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
// Components
import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
import { Form } from './Form/Form';
import { Table } from './Table/Table';
import BookmarkGrid from './BookmarkGrid/BookmarkGrid';
import { Category, GlobalState, Bookmark } from '../../interfaces';
import Spinner from '../UI/Spinner/Spinner';
import Modal from '../UI/Modal/Modal';
import BookmarkForm from './BookmarkForm/BookmarkForm';
import BookmarkTable from './BookmarkTable/BookmarkTable';
interface Props {
interface ComponentProps {
loading: boolean;
categories: Category[];
getCategories: () => void;
searching: boolean;
}
@ -37,159 +28,145 @@ export enum ContentType {
bookmark,
}
export const Bookmarks = (props: Props): JSX.Element => {
// Get Redux state
const {
bookmarks: { loading, categories, categoryInEdit },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const Bookmarks = (props: ComponentProps): JSX.Element => {
const { getCategories, categories, loading, searching = false } = props;
// Get Redux action creators
const dispatch = useDispatch();
const { getCategories, setEditCategory, setEditBookmark } =
bindActionCreators(actionCreators, dispatch);
// Load categories if array is empty
useEffect(() => {
if (!categories.length) {
getCategories();
}
}, []);
// Form
const [modalIsOpen, setModalIsOpen] = useState(false);
const [formContentType, setFormContentType] = useState(ContentType.category);
const [isInUpdate, setIsInUpdate] = useState(false);
// Table
const [showTable, setShowTable] = useState(false);
const [isInEdit, setIsInEdit] = useState(false);
const [tableContentType, setTableContentType] = useState(
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(),
});
// Observe if user is authenticated -> set default view (grid) if not
useEffect(() => {
if (!isAuthenticated) {
setShowTable(false);
setModalIsOpen(false);
if (categories.length === 0) {
getCategories();
}
}, [isAuthenticated]);
}, [getCategories]);
useEffect(() => {
if (categoryInEdit && !modalIsOpen) {
setTableContentType(ContentType.bookmark);
setShowTable(true);
}
}, [categoryInEdit]);
useEffect(() => {
setShowTable(false);
setEditCategory(null);
}, []);
// Form actions
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
};
const openFormForAdding = (contentType: ContentType) => {
const addActionHandler = (contentType: ContentType) => {
setFormContentType(contentType);
setIsInUpdate(false);
toggleModal();
};
const openFormForUpdating = (data: Category | Bookmark): void => {
setIsInUpdate(true);
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
};
if (instanceOfCategory(data)) {
setFormContentType(ContentType.category);
setEditCategory(data);
} else {
setFormContentType(ContentType.bookmark);
setEditBookmark(data);
}
toggleModal();
};
// Table actions
const showTableForEditing = (contentType: ContentType) => {
const editActionHandler = (contentType: ContentType) => {
// We're in the edit mode and the same button was clicked - go back to list
if (showTable && contentType === tableContentType) {
setEditCategory(null);
setShowTable(false);
if (isInEdit && contentType === tableContentType) {
setIsInEdit(false);
} else {
setShowTable(true);
setIsInEdit(true);
setTableContentType(contentType);
}
};
const finishEditing = () => {
setShowTable(false);
setEditCategory(null);
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
};
const goToUpdateMode = (data: Category | Bookmark): void => {
setIsInUpdate(true);
if (instanceOfCategory(data)) {
setFormContentType(ContentType.category);
setCategoryInUpdate(data);
} else {
setFormContentType(ContentType.bookmark);
setBookmarkInUpdate(data);
}
toggleModal();
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
<Form
modalHandler={toggleModal}
contentType={formContentType}
inUpdate={isInUpdate}
/>
{!isInUpdate ? (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
/>
) : formContentType === ContentType.category ? (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
category={categoryInUpdate}
/>
) : (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
bookmark={bookmarkInUpdate}
/>
)}
</Modal>
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
{isAuthenticated && (
<div className={classes.ActionsContainer}>
<ActionButton
name="Add Category"
icon="mdiPlusBox"
handler={() => openFormForAdding(ContentType.category)}
/>
<ActionButton
name="Add Bookmark"
icon="mdiPlusBox"
handler={() => openFormForAdding(ContentType.bookmark)}
/>
<ActionButton
name="Edit Categories"
icon="mdiPencil"
handler={() => showTableForEditing(ContentType.category)}
/>
{showTable && tableContentType === ContentType.bookmark && (
<ActionButton
name="Finish Editing"
icon="mdiPencil"
handler={finishEditing}
/>
)}
</div>
)}
{categories.length && isAuthenticated && !showTable ? (
<Message isPrimary={false}>
Click on category name to edit its bookmarks
</Message>
) : (
<></>
)}
<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 />
) : !showTable ? (
<BookmarkGrid categories={categories} searching={props.searching} />
) : !isInEdit ? (
<BookmarkGrid categories={categories} searching />
) : (
<Table
<BookmarkTable
contentType={tableContentType}
openFormForUpdating={openFormForUpdating}
categories={categories}
updateHandler={goToUpdateMode}
/>
)}
</Container>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.bookmark.loading,
categories: state.bookmark.categories,
};
};
export default connect(mapStateToProps, { getCategories })(Bookmarks);

View file

@ -1,275 +0,0 @@
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();
for (let field of ['name', 'url', 'icon'] as const) {
if (/^ +$/.test(formData[field])) {
createNotification({
title: 'Error',
message: `Field cannot be empty: ${field}`,
});
return;
}
}
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 ? 1 : 0}`);
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, categoryId: formData.categoryId });
setCustomIcon(null);
};
return (
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
{/* NAME */}
<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>
{/* URL */}
<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>
{/* CATEGORY */}
<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>
{/* ICON */}
{!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,.ico"
/>
<span
onClick={() => {
setCustomIcon(null);
toggleUseCustomIcon(!useCustomIcon);
}}
className={classes.Switch}
>
Switch to MDI
</span>
</InputGroup>
)}
{/* VISIBILTY */}
<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

@ -1,100 +0,0 @@
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);
modalHandler();
}
setFormData(newCategoryTemplate);
};
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

@ -1,54 +0,0 @@
// Typescript
import { ContentType } from '../Bookmarks';
// Utils
import { CategoryForm } from './CategoryForm';
import { BookmarksForm } from './BookmarksForm';
import { Fragment } from 'react';
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bookmarkTemplate, categoryTemplate } from '../../../utility';
interface Props {
modalHandler: () => void;
contentType: ContentType;
inUpdate?: boolean;
}
export const Form = (props: Props): JSX.Element => {
const { categoryInEdit, bookmarkInEdit } = useSelector(
(state: State) => state.bookmarks
);
const { modalHandler, contentType, inUpdate } = 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={categoryInEdit || categoryTemplate}
/>
) : (
<BookmarksForm
modalHandler={modalHandler}
bookmark={bookmarkInEdit || bookmarkTemplate}
/>
)}
</Fragment>
)}
</Fragment>
);
};

View file

@ -1,188 +0,0 @@
import { useState, useEffect, Fragment } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from 'react-beautiful-dnd';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { Bookmark, Category } from '../../../interfaces';
// UI
import { Message, Table } from '../../UI';
import { TableActions } from '../../Actions/TableActions';
import { bookmarkTemplate } from '../../../utility';
interface Props {
openFormForUpdating: (data: Category | Bookmark) => void;
}
export const BookmarksTable = ({ openFormForUpdating }: Props): JSX.Element => {
const {
bookmarks: { categoryInEdit },
config: { config },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const {
deleteBookmark,
updateBookmark,
createNotification,
reorderBookmarks,
} = bindActionCreators(actionCreators, dispatch);
const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]);
// Copy bookmarks array
useEffect(() => {
if (categoryInEdit) {
setLocalBookmarks([...categoryInEdit.bookmarks]);
}
}, [categoryInEdit]);
// Drag and drop handler
const dragEndHanlder = (result: DropResult): void => {
if (config.useOrdering !== 'orderId') {
createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
return;
}
if (!result.destination) {
return;
}
const tmpBookmarks = [...localBookmarks];
const [movedBookmark] = tmpBookmarks.splice(result.source.index, 1);
tmpBookmarks.splice(result.destination.index, 0, movedBookmark);
setLocalBookmarks(tmpBookmarks);
const categoryId = categoryInEdit?.id || -1;
reorderBookmarks(tmpBookmarks, categoryId);
};
// Action hanlders
const deleteBookmarkHandler = (id: number, name: string) => {
const categoryId = categoryInEdit?.id || -1;
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
if (proceed) {
deleteBookmark(id, categoryId);
}
};
const updateBookmarkHandler = (id: number) => {
const bookmark =
categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate;
openFormForUpdating(bookmark);
};
const changeBookmarkVisibiltyHandler = (id: number) => {
const bookmark =
categoryInEdit?.bookmarks.find((b) => b.id === id) || bookmarkTemplate;
const categoryId = categoryInEdit?.id || -1;
const [prev, curr] = [categoryId, categoryId];
updateBookmark(
id,
{ ...bookmark, isPublic: !bookmark.isPublic },
{ prev, curr }
);
};
return (
<Fragment>
{!categoryInEdit ? (
<Message isPrimary={false}>
Switch to grid view and click on the name of category you want to edit
</Message>
) : (
<Message isPrimary={false}>
Editing bookmarks from&nbsp;<span>{categoryInEdit.name}</span>
&nbsp;category
</Message>
)}
{categoryInEdit && (
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="bookmarks">
{(provided) => (
<Table
headers={[
'Name',
'URL',
'Icon',
'Visibility',
'Category',
'Actions',
]}
innerRef={provided.innerRef}
>
{localBookmarks.map((bookmark, index): JSX.Element => {
return (
<Draggable
key={bookmark.id}
draggableId={bookmark.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td style={{ width: '200px' }}>{bookmark.name}</td>
<td style={{ width: '200px' }}>{bookmark.url}</td>
<td style={{ width: '200px' }}>{bookmark.icon}</td>
<td style={{ width: '200px' }}>
{bookmark.isPublic ? 'Visible' : 'Hidden'}
</td>
<td style={{ width: '200px' }}>
{categoryInEdit.name}
</td>
{!snapshot.isDragging && (
<TableActions
entity={bookmark}
deleteHandler={deleteBookmarkHandler}
updateHandler={updateBookmarkHandler}
changeVisibilty={changeBookmarkVisibiltyHandler}
showPin={false}
/>
)}
</tr>
);
}}
</Draggable>
);
})}
</Table>
)}
</Droppable>
</DragDropContext>
)}
</Fragment>
);
};

View file

@ -1,166 +0,0 @@
import { useState, useEffect, Fragment } from 'react';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
// Typescript
import { Bookmark, Category } from '../../../interfaces';
// UI
import { Message, Table } from '../../UI';
import { TableActions } from '../../Actions/TableActions';
interface Props {
openFormForUpdating: (data: Category | Bookmark) => void;
}
export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
const {
config: { config },
bookmarks: { categories },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const {
pinCategory,
deleteCategory,
createNotification,
reorderCategories,
updateCategory,
} = bindActionCreators(actionCreators, dispatch);
const [localCategories, setLocalCategories] = useState<Category[]>([]);
// Copy categories array
useEffect(() => {
setLocalCategories([...categories]);
}, [categories]);
// Drag and drop handler
const dragEndHanlder = (result: DropResult): void => {
if (config.useOrdering !== 'orderId') {
createNotification({
title: 'Error',
message: 'Custom order is disabled',
});
return;
}
if (!result.destination) {
return;
}
const tmpCategories = [...localCategories];
const [movedCategory] = tmpCategories.splice(result.source.index, 1);
tmpCategories.splice(result.destination.index, 0, movedCategory);
setLocalCategories(tmpCategories);
reorderCategories(tmpCategories);
};
// Action handlers
const deleteCategoryHandler = (id: number, name: string) => {
const proceed = window.confirm(
`Are you sure you want to delete ${name}? It will delete ALL assigned bookmarks`
);
if (proceed) {
deleteCategory(id);
}
};
const updateCategoryHandler = (id: number) => {
const category = categories.find((c) => c.id === id) as Category;
openFormForUpdating(category);
};
const pinCategoryHandler = (id: number) => {
const category = categories.find((c) => c.id === id) as Category;
pinCategory(category);
};
const changeCategoryVisibiltyHandler = (id: number) => {
const category = categories.find((c) => c.id === id) as Category;
updateCategory(id, { ...category, isPublic: !category.isPublic });
};
return (
<Fragment>
<Message isPrimary={false}>
{config.useOrdering === 'orderId' ? (
<p>You can drag and drop single rows to reorder categories</p>
) : (
<p>
Custom order is disabled. You can change it in the{' '}
<Link to="/settings/general">settings</Link>
</p>
)}
</Message>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId="categories">
{(provided) => (
<Table
headers={['Name', 'Visibility', 'Actions']}
innerRef={provided.innerRef}
>
{localCategories.map((category, index): JSX.Element => {
return (
<Draggable
key={category.id}
draggableId={category.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td style={{ width: '300px' }}>{category.name}</td>
<td style={{ width: '300px' }}>
{category.isPublic ? 'Visible' : 'Hidden'}
</td>
{!snapshot.isDragging && (
<TableActions
entity={category}
deleteHandler={deleteCategoryHandler}
updateHandler={updateCategoryHandler}
pinHanlder={pinCategoryHandler}
changeVisibilty={changeCategoryVisibiltyHandler}
/>
)}
</tr>
);
}}
</Draggable>
);
})}
</Table>
)}
</Droppable>
</DragDropContext>
</Fragment>
);
};

View file

@ -1,20 +0,0 @@
import { Category, Bookmark } from '../../../interfaces';
import { ContentType } from '../Bookmarks';
import { BookmarksTable } from './BookmarksTable';
import { CategoryTable } from './CategoryTable';
interface Props {
contentType: ContentType;
openFormForUpdating: (data: Category | Bookmark) => void;
}
export const Table = (props: Props): JSX.Element => {
const tableEl =
props.contentType === ContentType.category ? (
<CategoryTable openFormForUpdating={props.openFormForUpdating} />
) : (
<BookmarksTable openFormForUpdating={props.openFormForUpdating} />
);
return tableEl;
};

View file

@ -1,25 +1,17 @@
import { useEffect, useState } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
// Redux
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// CSS
import classes from './Header.module.css';
// Components
import { WeatherWidget } from '../../Widgets/WeatherWidget/WeatherWidget';
// Utils
import { Config, GlobalState } from '../../../interfaces';
import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget';
import { getDateTime } from './functions/getDateTime';
import { greeter } from './functions/greeter';
import classes from './Header.module.css';
export const Header = (): JSX.Element => {
const { hideHeader, hideDate, showTime } = useSelector(
(state: State) => state.config.config
);
interface Props {
config: Config;
}
const Header = (props: Props): JSX.Element => {
const [dateTime, setDateTime] = useState<string>(getDateTime());
const [greeting, setGreeting] = useState<string>(greeter());
@ -36,18 +28,22 @@ export const Header = (): JSX.Element => {
return (
<header className={classes.Header}>
{(!hideDate || showTime) && <p>{dateTime}</p>}
<p>{dateTime}</p>
<Link to="/settings" className={classes.SettingsLink}>
Go to Settings
</Link>
{!hideHeader && (
<span className={classes.HeaderMain}>
<h1>{greeting}</h1>
<WeatherWidget />
</span>
)}
<span className={classes.HeaderMain}>
<h1>{greeting}</h1>
<WeatherWidget />
</span>
</header>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(Header);

View file

@ -1,5 +1,3 @@
import { parseTime } from '../../../../utility';
export const getDateTime = (): string => {
const days = localStorage.getItem('daySchema')?.split(';') || [
'Sunday',
@ -29,43 +27,14 @@ export const getDateTime = (): string => {
const now = new Date();
const useAmericanDate = localStorage.useAmericanDate === 'true';
const showTime = localStorage.showTime === 'true';
const hideDate = localStorage.hideDate === 'true';
// Date
let dateEl = '';
if (!hideDate) {
if (!useAmericanDate) {
dateEl = `${days[now.getDay()]}, ${now.getDate()} ${
months[now.getMonth()]
} ${now.getFullYear()}`;
} else {
dateEl = `${days[now.getDay()]}, ${
months[now.getMonth()]
} ${now.getDate()} ${now.getFullYear()}`;
}
if (!useAmericanDate) {
return `${days[now.getDay()]}, ${now.getDate()} ${
months[now.getMonth()]
} ${now.getFullYear()}`;
} else {
return `${days[now.getDay()]}, ${
months[now.getMonth()]
} ${now.getDate()} ${now.getFullYear()}`;
}
// Time
const p = parseTime;
let timeEl = '';
if (showTime) {
const time = `${p(now.getHours())}:${p(now.getMinutes())}:${p(
now.getSeconds()
)}`;
timeEl = time;
}
// Separator
let separator = '';
if (!hideDate && showTime) {
separator = ' - ';
}
// Output
return `${dateEl}${separator}${timeEl}`;
};

View file

@ -2,42 +2,47 @@ import { useState, useEffect, Fragment } from 'react';
import { Link } from 'react-router-dom';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { State } from '../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../store';
import { connect } from 'react-redux';
import { getApps, getCategories } from '../../store/actions';
// Typescript
import { App, Category } from '../../interfaces';
import { GlobalState } from '../../interfaces/GlobalState';
import { App, Category, Config } from '../../interfaces';
// UI
import { Icon, Container, SectionHeadline, Spinner, Message } from '../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';
// 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';
// Utils
import { escapeRegex } from '../../utility';
interface ComponentProps {
getApps: Function;
getCategories: Function;
appsLoading: boolean;
apps: App[];
categoriesLoading: boolean;
categories: Category[];
config: Config;
}
export const Home = (): JSX.Element => {
const Home = (props: ComponentProps): JSX.Element => {
const {
apps: { apps, loading: appsLoading },
bookmarks: { categories, loading: bookmarksLoading },
config: { config },
auth: { isAuthenticated },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { getApps, getCategories } = bindActionCreators(
actionCreators,
dispatch
);
getApps,
apps,
appsLoading,
getCategories,
categories,
categoriesLoading,
} = props;
// Local search query
const [localSearch, setLocalSearch] = useState<null | string>(null);
@ -51,24 +56,20 @@ export const Home = (): 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, description }) =>
new RegExp(escapeRegex(localSearch), 'i').test(
`${name} ${description}`
)
),
...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)),
]);
// Search through bookmarks
@ -78,9 +79,7 @@ export const Home = (): JSX.Element => {
category.bookmarks = categories
.map(({ bookmarks }) => bookmarks)
.flat()
.filter(({ name }) =>
new RegExp(escapeRegex(localSearch), 'i').test(name)
);
.filter(({ name }) => new RegExp(localSearch, 'i').test(name));
setBookmarkSearchResult([category]);
} else {
@ -91,7 +90,7 @@ export const Home = (): JSX.Element => {
return (
<Container>
{!config.hideSearch ? (
{!props.config.hideSearch ? (
<SearchBar
setLocalSearch={setLocalSearch}
appSearchResult={appSearchResult}
@ -101,20 +100,9 @@ export const Home = (): JSX.Element => {
<div></div>
)}
<Header />
{!props.config.hideHeader ? <Header /> : <div></div>}
{!isAuthenticated &&
!apps.some((a) => a.isPinned) &&
!categories.some((c) => c.isPinned) ? (
<Message>
Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
login and start customizing your new homepage
</Message>
) : (
<></>
)}
{!config.hideApps && (isAuthenticated || apps.some((a) => a.isPinned)) ? (
{!props.config.hideApps ? (
<Fragment>
<SectionHeadline title="Applications" link="/applications" />
{appsLoading ? (
@ -133,32 +121,28 @@ export const Home = (): JSX.Element => {
<div className={classes.HomeSpace}></div>
</Fragment>
) : (
<></>
<div></div>
)}
{!config.hideCategories &&
(isAuthenticated || categories.some((c) => c.isPinned)) ? (
{!props.config.hideCategories ? (
<Fragment>
<SectionHeadline title="Bookmarks" link="/bookmarks" />
{bookmarksLoading ? (
{categoriesLoading ? (
<Spinner />
) : (
<BookmarkGrid
categories={
!bookmarkSearchResult
? categories.filter(
({ isPinned, bookmarks }) => isPinned && bookmarks.length
)
? categories.filter(({ isPinned }) => isPinned)
: bookmarkSearchResult
}
totalCategories={categories.length}
searching={!!localSearch}
fromHomepage={true}
/>
)}
</Fragment>
) : (
<></>
<div></div>
)}
<Link to="/settings" className={classes.SettingsButton}>
@ -167,3 +151,15 @@ export const Home = (): 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,20 +1,21 @@
import { useSelector } from 'react-redux';
import { Notification as NotificationInterface } from '../../interfaces';
import { connect } from 'react-redux';
import { GlobalState, Notification as _Notification } from '../../interfaces';
import classes from './NotificationCenter.module.css';
import { Notification } from '../UI';
import { State } from '../../store/reducers';
import Notification from '../UI/Notification/Notification';
export const NotificationCenter = (): JSX.Element => {
const { notifications } = useSelector((state: State) => state.notification);
interface ComponentProps {
notifications: _Notification[];
}
const NotificationCenter = (props: ComponentProps): JSX.Element => {
return (
<div
className={classes.NotificationCenter}
style={{ height: `${notifications.length * 75}px` }}
style={{ height: `${props.notifications.length * 75}px` }}
>
{notifications.map((notification: NotificationInterface) => {
{props.notifications.map((notification: _Notification) => {
return (
<Notification
title={notification.title}
@ -28,3 +29,11 @@ export const NotificationCenter = (): JSX.Element => {
</div>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
notifications: state.notification.notifications,
};
};
export default connect(mapStateToProps)(NotificationCenter);

View file

@ -1,13 +0,0 @@
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

@ -9,7 +9,6 @@
border-bottom: 2px solid var(--color-accent);
opacity: 0.5;
transition: all 0.2s;
border-radius: 0px;
}
.SearchBar:focus {

View file

@ -1,33 +1,42 @@
import { useRef, useEffect, KeyboardEvent } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { connect } from 'react-redux';
import { createNotification } from '../../store/actions';
// Typescript
import { App, Category } from '../../interfaces';
import {
App,
Category,
Config,
GlobalState,
NewNotification,
} 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 Props {
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
setLocalSearch: (query: string) => void;
appSearchResult: App[] | null;
bookmarkSearchResult: Category[] | null;
config: Config;
loading: boolean;
}
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 SearchBar = (props: ComponentProps): JSX.Element => {
const {
setLocalSearch,
createNotification,
config,
loading,
appSearchResult,
bookmarkSearchResult,
} = props;
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
@ -45,17 +54,12 @@ export const SearchBar = (props: Props): JSX.Element => {
if (key === 'Escape') {
clearSearch();
} else if (document.activeElement !== inputRef.current) {
if (key === '`') {
inputRef.current.focus();
clearSearch();
}
}
};
window.addEventListener('keyup', keyOutsideFocus);
window.addEventListener('keydown', keyOutsideFocus);
return () => window.removeEventListener('keyup', keyOutsideFocus);
return () => window.removeEventListener('keydown', keyOutsideFocus);
}, []);
const clearSearch = () => {
@ -64,22 +68,16 @@ export const SearchBar = (props: Props): JSX.Element => {
};
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
const {
isLocal,
encodedURL,
primarySearch,
secondarySearch,
isURL,
sameTab,
rawQuery,
} = searchParser(inputRef.current.value);
const { isLocal, search, query, isURL, sameTab } = searchParser(
inputRef.current.value
);
if (isLocal) {
setLocalSearch(encodedURL);
setLocalSearch(search);
}
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
if (!primarySearch.prefix) {
if (!query.prefix) {
// Prefix not found -> emit notification
createNotification({
title: 'Error',
@ -96,21 +94,19 @@ export const SearchBar = (props: Props): JSX.Element => {
} else if (bookmarkSearchResult?.[0]?.bookmarks?.length) {
redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab);
} else {
// no local results -> search the internet with the default search provider if query is not empty
if (!/^ *$/.test(rawQuery)) {
let template = primarySearch.template;
// no local results -> search the internet with the default search provider
let template = query.template;
if (primarySearch.prefix === 'l') {
template = secondarySearch.template;
}
const url = `${template}${encodedURL}`;
redirectUrl(url, sameTab);
if (query.prefix === 'l') {
template = 'https://duckduckgo.com/?q=';
}
const url = `${template}${search}`;
redirectUrl(url, sameTab);
}
} else {
// Valid query -> redirect to search results
const url = `${primarySearch.template}${encodedURL}`;
const url = `${query.template}${search}`;
redirectUrl(url, sameTab);
}
} else if (e.code === 'Escape') {
@ -130,3 +126,12 @@ export const SearchBar = (props: Props): 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,14 +1,8 @@
.text {
.AppVersion {
color: var(--color-primary);
margin-bottom: 15px;
}
.text a,
.text span {
.AppVersion a {
color: var(--color-accent);
}
.separator {
margin: 30px 0;
border: 1px solid var(--color-primary);
}
}

View file

@ -1,57 +1,34 @@
import { Fragment } from 'react';
// UI
import { Button, SettingsHeadline } from '../../UI';
import { AuthForm } from './AuthForm/AuthForm';
import classes from './AppDetails.module.css';
// Store
import { useSelector } from 'react-redux';
import { State } from '../../../store/reducers';
// Other
import Button from '../../UI/Buttons/Button/Button';
import { checkVersion } from '../../../utility';
export const AppDetails = (): JSX.Element => {
const { isAuthenticated } = useSelector((state: State) => state.auth);
const AppDetails = (): JSX.Element => {
return (
<Fragment>
<SettingsHeadline text="Authentication" />
<AuthForm />
{isAuthenticated && (
<Fragment>
<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>
)}
<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>
);
};
)
}
export default AppDetails;

View file

@ -1,110 +0,0 @@
import { FormEvent, Fragment, useEffect, useState, useRef } 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',
});
const passwordInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
passwordInputRef.current?.focus();
}, []);
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"
ref={passwordInputRef}
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

@ -1,122 +0,0 @@
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,100 +0,0 @@
import { Fragment, useState } from 'react';
// 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';
// UI
import { Modal, Icon, Button, CompactTable, ActionIcons } from '../../../UI';
// Components
import { QueriesForm } from './QueriesForm';
export const CustomQueries = (): JSX.Element => {
const { customQueries, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { deleteQuery, createNotification } = bindActionCreators(
actionCreators,
dispatch
);
const [modalIsOpen, setModalIsOpen] = useState(false);
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
const updateHandler = (query: Query) => {
setEditableQuery(query);
setModalIsOpen(true);
};
const deleteHandler = (query: Query) => {
const currentProvider = config.defaultSearchProvider;
const isCurrent = currentProvider === query.prefix;
if (isCurrent) {
createNotification({
title: 'Error',
message: 'Cannot delete active provider',
});
} else if (
window.confirm(`Are you sure you want to delete this provider?`)
) {
deleteQuery(query.prefix);
}
};
return (
<Fragment>
<Modal
isOpen={modalIsOpen}
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
>
{editableQuery ? (
<QueriesForm
modalHandler={() => setModalIsOpen(!modalIsOpen)}
query={editableQuery}
/>
) : (
<QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
)}
</Modal>
<section>
{customQueries.length ? (
<CompactTable headers={['Name', 'Prefix', 'Actions']}>
{customQueries.map((q: Query, idx) => (
<Fragment key={idx}>
<span>{q.name}</span>
<span>{q.prefix}</span>
<ActionIcons>
<span onClick={() => updateHandler(q)}>
<Icon icon="mdiPencil" />
</span>
<span onClick={() => deleteHandler(q)}>
<Icon icon="mdiDelete" />
</span>
</ActionIcons>
</Fragment>
))}
</CompactTable>
) : (
<></>
)}
<Button
click={() => {
setEditableQuery(null);
setModalIsOpen(true);
}}
>
Add new search provider
</Button>
</section>
</Fragment>
);
};

View file

@ -1,242 +0,0 @@
// React
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
import { useDispatch, useSelector } from 'react-redux';
// Typescript
import { Query, GeneralForm } from '../../../interfaces';
// Components
import { CustomQueries } from './CustomQueries/CustomQueries';
// UI
import { Button, SettingsHeadline, InputGroup } from '../../UI';
// Utils
import { inputHandler, generalSettingsTemplate } from '../../../utility';
// Data
import searchQueries from '../../../utility/searchQueries.json';
// Redux
import { State } from '../../../store/reducers';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
export const GeneralSettings = (): JSX.Element => {
const {
config: { loading, customQueries, config },
bookmarks: { categories },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { updateConfig, sortApps, sortCategories, sortBookmarks } =
bindActionCreators(actionCreators, dispatch);
const queries = searchQueries.queries;
// Initial state
const [formData, setFormData] = useState<GeneralForm>(
generalSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await updateConfig(formData);
// Sort entities with new settings
if (formData.useOrdering !== config.useOrdering) {
sortApps();
sortCategories();
for (let { id } of categories) {
sortBookmarks(id);
}
}
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<GeneralForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<Fragment>
<form
onSubmit={(e) => formSubmitHandler(e)}
style={{ marginBottom: '30px' }}
>
{/* === GENERAL OPTIONS === */}
<SettingsHeadline text="General" />
{/* SORT TYPE */}
<InputGroup>
<label htmlFor="useOrdering">Sorting type</label>
<select
id="useOrdering"
name="useOrdering"
value={formData.useOrdering}
onChange={(e) => inputChangeHandler(e)}
>
<option value="createdAt">By creation date</option>
<option value="name">Alphabetical order</option>
<option value="orderId">Custom order</option>
</select>
</InputGroup>
{/* === APPS OPTIONS === */}
<SettingsHeadline text="Apps" />
{/* PIN APPS */}
<InputGroup>
<label htmlFor="pinAppsByDefault">
Pin new applications by default
</label>
<select
id="pinAppsByDefault"
name="pinAppsByDefault"
value={formData.pinAppsByDefault ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* APPS OPPENING */}
<InputGroup>
<label htmlFor="appsSameTab">Open applications in the same tab</label>
<select
id="appsSameTab"
name="appsSameTab"
value={formData.appsSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* === BOOKMARKS OPTIONS === */}
<SettingsHeadline text="Bookmarks" />
{/* PIN CATEGORIES */}
<InputGroup>
<label htmlFor="pinCategoriesByDefault">
Pin new categories by default
</label>
<select
id="pinCategoriesByDefault"
name="pinCategoriesByDefault"
value={formData.pinCategoriesByDefault ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* BOOKMARKS OPPENING */}
<InputGroup>
<label htmlFor="bookmarksSameTab">
Open bookmarks in the same tab
</label>
<select
id="bookmarksSameTab"
name="bookmarksSameTab"
value={formData.bookmarksSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* === SEARCH OPTIONS === */}
<SettingsHeadline text="Search" />
<InputGroup>
<label htmlFor="defaultSearchProvider">Primary search provider</label>
<select
id="defaultSearchProvider"
name="defaultSearchProvider"
value={formData.defaultSearchProvider}
onChange={(e) => inputChangeHandler(e)}
>
{[...queries, ...customQueries].map((query: Query, idx) => {
const isCustom = idx >= queries.length;
return (
<option key={idx} value={query.prefix}>
{isCustom && '+'} {query.name}
</option>
);
})}
</select>
</InputGroup>
{formData.defaultSearchProvider === 'l' && (
<InputGroup>
<label htmlFor="secondarySearchProvider">
Secondary search provider
</label>
<select
id="secondarySearchProvider"
name="secondarySearchProvider"
value={formData.secondarySearchProvider}
onChange={(e) => inputChangeHandler(e)}
>
{[...queries, ...customQueries].map((query: Query, idx) => {
const isCustom = idx >= queries.length;
return (
<option key={idx} value={query.prefix}>
{isCustom && '+'} {query.name}
</option>
);
})}
</select>
<span>
Will be used when "Local search" is primary search provider and
there are not any local results
</span>
</InputGroup>
)}
<InputGroup>
<label htmlFor="searchSameTab">
Open search results in the same tab
</label>
<select
id="searchSameTab"
name="searchSameTab"
value={formData.searchSameTab ? 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>
{/* CUSTOM QUERIES */}
<SettingsHeadline text="Custom search providers" />
<CustomQueries />
</Fragment>
);
};

View file

@ -0,0 +1,356 @@
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
// Redux
import { connect } from 'react-redux';
import {
createNotification,
updateConfig,
sortApps,
sortCategories,
} from '../../../store/actions';
// Typescript
import {
Config,
GlobalState,
NewNotification,
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';
// Utils
import { otherSettingsTemplate, inputHandler } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: OtherSettingsForm) => void;
sortApps: () => void;
sortCategories: () => void;
loading: boolean;
config: Config;
}
const OtherSettings = (props: ComponentProps): JSX.Element => {
const { config } = props;
// Initial state
const [formData, setFormData] = useState<OtherSettingsForm>(
otherSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await props.updateConfig(formData);
// Update local page title
document.title = formData.customTitle;
// Sort apps and categories with new settings
props.sortApps();
props.sortCategories();
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<OtherSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
{/* OTHER OPTIONS */}
<SettingsHeadline text="Miscellaneous" />
{/* PAGE TITLE */}
<InputGroup>
<label htmlFor="customTitle">Custom page title</label>
<input
type="text"
id="customTitle"
name="customTitle"
placeholder="Flame"
value={formData.customTitle}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* DATE FORMAT */}
<InputGroup>
<label htmlFor="useAmericanDate">Date formatting</label>
<select
id="useAmericanDate"
name="useAmericanDate"
value={formData.useAmericanDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Friday, October 22 2021</option>
<option value={0}>Friday, 22 October 2021</option>
</select>
</InputGroup>
{/* BEAHVIOR OPTIONS */}
<SettingsHeadline text="App Behavior" />
{/* PIN APPS */}
<InputGroup>
<label htmlFor="pinAppsByDefault">
Pin new applications by default
</label>
<select
id="pinAppsByDefault"
name="pinAppsByDefault"
value={formData.pinAppsByDefault ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* PIN CATEGORIES */}
<InputGroup>
<label htmlFor="pinCategoriesByDefault">
Pin new categories by default
</label>
<select
id="pinCategoriesByDefault"
name="pinCategoriesByDefault"
value={formData.pinCategoriesByDefault ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* SORT TYPE */}
<InputGroup>
<label htmlFor="useOrdering">Sorting type</label>
<select
id="useOrdering"
name="useOrdering"
value={formData.useOrdering}
onChange={(e) => inputChangeHandler(e)}
>
<option value="createdAt">By creation date</option>
<option value="name">Alphabetical order</option>
<option value="orderId">Custom order</option>
</select>
</InputGroup>
{/* APPS OPPENING */}
<InputGroup>
<label htmlFor="appsSameTab">Open applications in the same tab</label>
<select
id="appsSameTab"
name="appsSameTab"
value={formData.appsSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* BOOKMARKS OPPENING */}
<InputGroup>
<label htmlFor="bookmarksSameTab">Open bookmarks in the same tab</label>
<select
id="bookmarksSameTab"
name="bookmarksSameTab"
value={formData.bookmarksSameTab ? 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 HEADER */}
<InputGroup>
<label htmlFor="hideHeader">Hide greeting and date</label>
<select
id="hideHeader"
name="hideHeader"
value={formData.hideHeader ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* CUSTOM GREETINGS */}
<InputGroup>
<label htmlFor="greetingsSchema">Custom greetings</label>
<input
type="text"
id="greetingsSchema"
name="greetingsSchema"
placeholder="Good day;Hi;Bye!"
value={formData.greetingsSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Greetings must be separated with semicolon. Only 4 messages can be
used
</span>
</InputGroup>
{/* CUSTOM DAYS */}
<InputGroup>
<label htmlFor="daySchema">Custom weekday names</label>
<input
type="text"
id="daySchema"
name="daySchema"
placeholder="Sunday;Monday;Tuesday"
value={formData.daySchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* CUSTOM MONTHS */}
<InputGroup>
<label htmlFor="monthSchema">Custom month names</label>
<input
type="text"
id="monthSchema"
name="monthSchema"
placeholder="January;February;March"
value={formData.monthSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* HIDE APPS */}
<InputGroup>
<label htmlFor="hideApps">Hide applications</label>
<select
id="hideApps"
name="hideApps"
value={formData.hideApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE CATEGORIES */}
<InputGroup>
<label htmlFor="hideCategories">Hide categories</label>
<select
id="hideCategories"
name="hideCategories"
value={formData.hideCategories ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</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

@ -0,0 +1,30 @@
.QueriesGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.QueriesGrid span {
color: var(--color-primary);
}
.QueriesGrid span:last-child {
margin-bottom: 10px;
}
.ActionIcons {
display: flex;
}
.ActionIcons svg {
width: 20px;
}
.ActionIcons svg:hover {
cursor: pointer;
}
.Separator {
grid-column: 1 / 4;
border-bottom: 1px solid var(--color-primary);
margin: 10px 0;
}

View file

@ -0,0 +1,118 @@
import { Fragment, useState } from 'react';
import { connect } from 'react-redux';
import classes from './CustomQueries.module.css';
import Modal from '../../../UI/Modal/Modal';
import Icon from '../../../UI/Icons/Icon/Icon';
import {
Config,
GlobalState,
NewNotification,
Query,
} from '../../../../interfaces';
import QueriesForm from './QueriesForm';
import { deleteQuery, createNotification } from '../../../../store/actions';
import Button from '../../../UI/Buttons/Button/Button';
interface Props {
customQueries: Query[];
deleteQuery: (prefix: string) => {};
createNotification: (notification: NewNotification) => void;
config: Config;
}
const CustomQueries = (props: Props): JSX.Element => {
const { customQueries, deleteQuery, createNotification } = props;
const [modalIsOpen, setModalIsOpen] = useState(false);
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
const updateHandler = (query: Query) => {
setEditableQuery(query);
setModalIsOpen(true);
};
const deleteHandler = (query: Query) => {
const currentProvider = props.config.defaultSearchProvider;
const isCurrent = currentProvider === query.prefix;
if (isCurrent) {
createNotification({
title: 'Error',
message: 'Cannot delete active provider',
});
} else if (
window.confirm(`Are you sure you want to delete this provider?`)
) {
deleteQuery(query.prefix);
}
};
return (
<Fragment>
<Modal
isOpen={modalIsOpen}
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
>
{editableQuery ? (
<QueriesForm
modalHandler={() => setModalIsOpen(!modalIsOpen)}
query={editableQuery}
/>
) : (
<QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
)}
</Modal>
<div>
<div className={classes.QueriesGrid}>
{customQueries.length > 0 && (
<Fragment>
<span>Name</span>
<span>Prefix</span>
<span>Actions</span>
<div className={classes.Separator}></div>
</Fragment>
)}
{customQueries.map((q: Query, idx) => (
<Fragment key={idx}>
<span>{q.name}</span>
<span>{q.prefix}</span>
<span className={classes.ActionIcons}>
<span onClick={() => updateHandler(q)}>
<Icon icon="mdiPencil" />
</span>
<span onClick={() => deleteHandler(q)}>
<Icon icon="mdiDelete" />
</span>
</span>
</Fragment>
))}
</div>
<Button
click={() => {
setEditableQuery(null);
setModalIsOpen(true);
}}
>
Add new search provider
</Button>
</div>
</Fragment>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
customQueries: state.config.customQueries,
config: state.config.config,
};
};
export default connect(mapStateToProps, { deleteQuery, createNotification })(
CustomQueries
);

View file

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

@ -0,0 +1,159 @@
// React
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
import { connect } from 'react-redux';
// State
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript
import {
Config,
GlobalState,
NewNotification,
Query,
SearchForm,
} from '../../../interfaces';
// Components
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';
// Utils
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;
}
const SearchSettings = (props: Props): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
// Get config
useEffect(() => {
setFormData({
...props.config,
});
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await props.updateConfig(formData);
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<SearchForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<Fragment>
{/* GENERAL SETTINGS */}
<form
onSubmit={(e) => formSubmitHandler(e)}
style={{ marginBottom: '30px' }}
>
<SettingsHeadline text="General" />
<InputGroup>
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
<select
id="defaultSearchProvider"
name="defaultSearchProvider"
value={formData.defaultSearchProvider}
onChange={(e) => inputChangeHandler(e)}
>
{[...queries, ...props.customQueries].map((query: Query, idx) => {
const isCustom = idx >= queries.length;
return (
<option key={idx} value={query.prefix}>
{isCustom && '+'} {query.name}
</option>
);
})}
</select>
</InputGroup>
<InputGroup>
<label htmlFor="searchSameTab">
Open search results in the same tab
</label>
<select
id="searchSameTab"
name="searchSameTab"
value={formData.searchSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor="hideSearch">Hide search bar</label>
<select
id="hideSearch"
name="hideSearch"
value={formData.hideSearch ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
<select
id="disableAutofocus"
name="disableAutofocus"
value={formData.disableAutofocus ? 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>
{/* CUSTOM QUERIES */}
<SettingsHeadline text="Custom search providers" />
<CustomQueries />
</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,9 +1,6 @@
//
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';
@ -11,35 +8,28 @@ import { Route as SettingsRoute } from '../../interfaces';
import classes from './Settings.module.css';
// Components
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 { GeneralSettings } from './GeneralSettings/GeneralSettings';
import { DockerSettings } from './DockerSettings/DockerSettings';
import { ProtectedRoute } from '../Routing/ProtectedRoute';
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';
// UI
import { Container, Headline } from '../UI';
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
// Data
import clientRoutes from './settings.json';
export const Settings = (): JSX.Element => {
const routes = clientRoutes.routes;
const { isAuthenticated } = useSelector((state: State) => state.auth);
const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
import { routes } from './settings.json';
const Settings = (): JSX.Element => {
return (
<Container>
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.Settings}>
{/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}>
{tabs.map(({ name, dest }: SettingsRoute, idx) => (
{routes.map(({ name, dest }: SettingsRoute, idx) => (
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
@ -56,20 +46,10 @@ export const Settings = (): JSX.Element => {
<section className={classes.SettingsContent}>
<Switch>
<Route exact path="/settings" component={Themer} />
<ProtectedRoute
path="/settings/weather"
component={WeatherSettings}
/>
<ProtectedRoute
path="/settings/general"
component={GeneralSettings}
/>
<ProtectedRoute path="/settings/interface" component={UISettings} />
<ProtectedRoute
path="/settings/docker"
component={DockerSettings}
/>
<ProtectedRoute path="/settings/css" component={StyleSettings} />
<Route path="/settings/weather" component={WeatherSettings} />
<Route path="/settings/search" component={SearchSettings} />
<Route path="/settings/other" component={OtherSettings} />
<Route path="/settings/css" component={StyleSettings} />
<Route path="/settings/app" component={AppDetails} />
</Switch>
</section>
@ -77,3 +57,5 @@ export const Settings = (): JSX.Element => {
</Container>
);
};
export default Settings;

View file

@ -2,60 +2,54 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
import axios from 'axios';
// Redux
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { connect } from 'react-redux';
import { createNotification } from '../../../store/actions';
// Typescript
import { ApiResponse } from '../../../interfaces';
import { ApiResponse, NewNotification } from '../../../interfaces';
// Other
import { InputGroup, Button } from '../../UI';
import { applyAuth } from '../../../utility';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
export const StyleSettings = (): JSX.Element => {
const dispatch = useDispatch();
const { createNotification } = bindActionCreators(actionCreators, dispatch);
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
}
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 },
{ headers: applyAuth() }
)
axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles })
.then(() => {
createNotification({
props.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}
@ -63,5 +57,7 @@ export const StyleSettings = (): JSX.Element => {
</InputGroup>
<Button>Save CSS</Button>
</form>
);
};
)
}
export default connect(null, { createNotification })(StyleSettings);

View file

@ -1,7 +0,0 @@
.ThemeBuilder {
margin-bottom: 30px;
}
.Buttons button:not(:last-child) {
margin-right: 10px;
}

View file

@ -1,95 +0,0 @@
import { useState, useEffect } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// Other
import { Theme } from '../../../../interfaces';
// UI
import { Button, Modal } from '../../../UI';
import { ThemeGrid } from '../ThemeGrid/ThemeGrid';
import classes from './ThemeBuilder.module.css';
import { ThemeCreator } from './ThemeCreator';
import { ThemeEditor } from './ThemeEditor';
interface Props {
themes: Theme[];
}
export const ThemeBuilder = ({ themes }: Props): JSX.Element => {
const {
auth: { isAuthenticated },
theme: { themeInEdit, userThemes },
} = useSelector((state: State) => state);
const { editTheme } = bindActionCreators(actionCreators, useDispatch());
const [showModal, toggleShowModal] = useState(false);
const [isInEdit, toggleIsInEdit] = useState(false);
useEffect(() => {
if (themeInEdit) {
toggleIsInEdit(false);
toggleShowModal(true);
}
}, [themeInEdit]);
useEffect(() => {
if (isInEdit && !userThemes.length) {
toggleIsInEdit(false);
toggleShowModal(false);
}
}, [userThemes]);
return (
<div className={classes.ThemeBuilder}>
{/* MODALS */}
<Modal
isOpen={showModal}
setIsOpen={() => toggleShowModal(!showModal)}
cb={() => editTheme(null)}
>
{isInEdit ? (
<ThemeEditor modalHandler={() => toggleShowModal(!showModal)} />
) : (
<ThemeCreator modalHandler={() => toggleShowModal(!showModal)} />
)}
</Modal>
{/* USER THEMES */}
<ThemeGrid themes={themes} />
{/* BUTTONS */}
{isAuthenticated && (
<div className={classes.Buttons}>
<Button
click={() => {
editTheme(null);
toggleIsInEdit(false);
toggleShowModal(!showModal);
}}
>
Create new theme
</Button>
{themes.length ? (
<Button
click={() => {
toggleIsInEdit(true);
toggleShowModal(!showModal);
}}
>
Edit user themes
</Button>
) : (
<></>
)}
</div>
)}
</div>
);
};

View file

@ -1,5 +0,0 @@
.ColorsContainer {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-gap: 10px;
}

View file

@ -1,152 +0,0 @@
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// UI
import { Button, InputGroup, ModalForm } from '../../../UI';
import classes from './ThemeCreator.module.css';
// Other
import { Theme } from '../../../../interfaces';
interface Props {
modalHandler: () => void;
}
export const ThemeCreator = ({ modalHandler }: Props): JSX.Element => {
const {
theme: { activeTheme, themeInEdit },
} = useSelector((state: State) => state);
const { addTheme, updateTheme, editTheme } = bindActionCreators(
actionCreators,
useDispatch()
);
const [formData, setFormData] = useState<Theme>({
name: '',
isCustom: true,
colors: {
primary: '#ffffff',
accent: '#ffffff',
background: '#ffffff',
},
});
useEffect(() => {
setFormData({ ...formData, colors: activeTheme.colors });
}, [activeTheme]);
useEffect(() => {
if (themeInEdit) {
setFormData(themeInEdit);
}
}, [themeInEdit]);
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData({
...formData,
[name]: value,
});
};
const setColor = ({
target: { value, name },
}: ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
colors: {
...formData.colors,
[name]: value,
},
});
};
const closeModal = () => {
editTheme(null);
modalHandler();
};
const formHandler = (e: FormEvent) => {
e.preventDefault();
if (!themeInEdit) {
addTheme(formData);
} else {
updateTheme(formData, themeInEdit.name);
}
// close modal
closeModal();
// clear theme name
setFormData({ ...formData, name: '' });
};
return (
<ModalForm formHandler={formHandler} modalHandler={closeModal}>
<InputGroup>
<label htmlFor="name">Theme name</label>
<input
type="text"
name="name"
id="name"
placeholder="my_theme"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<div className={classes.ColorsContainer}>
<InputGroup>
<label htmlFor="primary">Primary color</label>
<input
type="color"
name="primary"
id="primary"
required
value={formData.colors.primary}
onChange={(e) => setColor(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="accent">Accent color</label>
<input
type="color"
name="accent"
id="accent"
required
value={formData.colors.accent}
onChange={(e) => setColor(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="background">Background color</label>
<input
type="color"
name="background"
id="background"
required
value={formData.colors.background}
onChange={(e) => setColor(e)}
/>
</InputGroup>
</div>
{!themeInEdit ? (
<Button>Add theme</Button>
) : (
<Button>Update theme</Button>
)}
</ModalForm>
);
};

View file

@ -1,57 +0,0 @@
import { Fragment } from 'react';
// Redux
import { useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { Theme } from '../../../../interfaces';
import { actionCreators } from '../../../../store';
import { State } from '../../../../store/reducers';
// Other
import { ActionIcons, CompactTable, Icon, ModalForm } from '../../../UI';
interface Props {
modalHandler: () => void;
}
export const ThemeEditor = (props: Props): JSX.Element => {
const {
theme: { userThemes },
} = useSelector((state: State) => state);
const { deleteTheme, editTheme } = bindActionCreators(
actionCreators,
useDispatch()
);
const updateHandler = (theme: Theme) => {
props.modalHandler();
editTheme(theme);
};
const deleteHandler = (theme: Theme) => {
if (window.confirm(`Are you sure you want to delete this theme?`)) {
deleteTheme(theme.name);
}
};
return (
<ModalForm formHandler={() => {}} modalHandler={props.modalHandler}>
<CompactTable headers={['Name', 'Actions']}>
{userThemes.map((t, idx) => (
<Fragment key={idx}>
<span>{t.name}</span>
<ActionIcons>
<span onClick={() => updateHandler(t)}>
<Icon icon="mdiPencil" />
</span>
<span onClick={() => deleteHandler(t)}>
<Icon icon="mdiDelete" />
</span>
</ActionIcons>
</Fragment>
))}
</CompactTable>
</ModalForm>
);
};

View file

@ -1,22 +0,0 @@
// Components
import { ThemePreview } from '../ThemePreview/ThemePreview';
// Other
import { Theme } from '../../../../interfaces';
import classes from './ThemeGrid.module.css';
interface Props {
themes: Theme[];
}
export const ThemeGrid = ({ themes }: Props): JSX.Element => {
return (
<div className={classes.ThemerGrid}>
{themes.map(
(theme: Theme, idx: number): JSX.Element => (
<ThemePreview key={idx} theme={theme} />
)
)}
</div>
);
};

View file

@ -1,38 +0,0 @@
// Redux
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../../store';
// Other
import { Theme } from '../../../../interfaces/Theme';
import classes from './ThemePreview.module.css';
interface Props {
theme: Theme;
}
export const ThemePreview = ({
theme: { colors, name },
}: Props): JSX.Element => {
const { setTheme } = bindActionCreators(actionCreators, useDispatch());
return (
<div className={classes.ThemePreview} onClick={() => setTheme(colors)}>
<div className={classes.ColorsPreview}>
<div
className={classes.ColorPreview}
style={{ backgroundColor: colors.background }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: colors.primary }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: colors.accent }}
></div>
</div>
<p>{name}</p>
</div>
);
};

View file

@ -1,105 +0,0 @@
import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
// Typescript
import { Theme, ThemeSettingsForm } from '../../../interfaces';
// Components
import { Button, InputGroup, SettingsHeadline, Spinner } from '../../UI';
import { ThemeBuilder } from './ThemeBuilder/ThemeBuilder';
import { ThemeGrid } from './ThemeGrid/ThemeGrid';
// Other
import {
inputHandler,
parseThemeToPAB,
themeSettingsTemplate,
} from '../../../utility';
export const Themer = (): JSX.Element => {
const {
auth: { isAuthenticated },
config: { loading, config },
theme: { themes, userThemes },
} = useSelector((state: State) => state);
const dispatch = useDispatch();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state
const [formData, setFormData] = useState<ThemeSettingsForm>(
themeSettingsTemplate
);
// 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<ThemeSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
const customThemesEl = (
<Fragment>
<SettingsHeadline text="User themes" />
<ThemeBuilder themes={userThemes} />
</Fragment>
);
return (
<Fragment>
<SettingsHeadline text="App themes" />
{!themes.length ? <Spinner /> : <ThemeGrid themes={themes} />}
{!userThemes.length ? isAuthenticated && customThemesEl : customThemesEl}
{isAuthenticated && (
<form onSubmit={formSubmitHandler}>
<SettingsHeadline text="Other settings" />
<InputGroup>
<label htmlFor="defaultTheme">Default theme for new users</label>
<select
id="defaultTheme"
name="defaultTheme"
value={formData.defaultTheme}
onChange={(e) => inputChangeHandler(e)}
>
{[...themes, ...userThemes].map((theme: Theme, idx) => (
<option key={idx} value={parseThemeToPAB(theme.colors)}>
{theme.isCustom && '+'} {theme.name}
</option>
))}
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
)}
</Fragment>
);
};

View file

@ -1,243 +0,0 @@
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 { UISettingsForm } from '../../../interfaces';
// UI
import { InputGroup, Button, SettingsHeadline } from '../../UI';
// Utils
import { uiSettingsTemplate, inputHandler } from '../../../utility';
export const UISettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
// Initial state
const [formData, setFormData] = useState<UISettingsForm>(uiSettingsTemplate);
// Get config
useEffect(() => {
setFormData({
...config,
});
}, [loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await updateConfig(formData);
// Update local page title
document.title = formData.customTitle;
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<UISettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
{/* === OTHER OPTIONS === */}
<SettingsHeadline text="Miscellaneous" />
{/* PAGE TITLE */}
<InputGroup>
<label htmlFor="customTitle">Custom page title</label>
<input
type="text"
id="customTitle"
name="customTitle"
placeholder="Flame"
value={formData.customTitle}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* === SEARCH OPTIONS === */}
<SettingsHeadline text="Search" />
{/* HIDE SEARCHBAR */}
<InputGroup>
<label htmlFor="hideSearch">Hide search bar</label>
<select
id="hideSearch"
name="hideSearch"
value={formData.hideSearch ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* AUTOFOCUS SEARCHBAR */}
<InputGroup>
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
<select
id="disableAutofocus"
name="disableAutofocus"
value={formData.disableAutofocus ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* === HEADER OPTIONS === */}
<SettingsHeadline text="Header" />
{/* HIDE HEADER */}
<InputGroup>
<label htmlFor="hideHeader">
Hide headline (greetings and weather)
</label>
<select
id="hideHeader"
name="hideHeader"
value={formData.hideHeader ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE DATE */}
<InputGroup>
<label htmlFor="hideDate">Hide date</label>
<select
id="hideDate"
name="hideDate"
value={formData.hideDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE TIME */}
<InputGroup>
<label htmlFor="showTime">Hide time</label>
<select
id="showTime"
name="showTime"
value={formData.showTime ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={0}>True</option>
<option value={1}>False</option>
</select>
</InputGroup>
{/* DATE FORMAT */}
<InputGroup>
<label htmlFor="useAmericanDate">Date formatting</label>
<select
id="useAmericanDate"
name="useAmericanDate"
value={formData.useAmericanDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Friday, October 22 2021</option>
<option value={0}>Friday, 22 October 2021</option>
</select>
</InputGroup>
{/* CUSTOM GREETINGS */}
<InputGroup>
<label htmlFor="greetingsSchema">Custom greetings</label>
<input
type="text"
id="greetingsSchema"
name="greetingsSchema"
placeholder="Good day;Hi;Bye!"
value={formData.greetingsSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Greetings must be separated with semicolon. All 4 messages must be
filled, even if they are the same
</span>
</InputGroup>
{/* CUSTOM DAYS */}
<InputGroup>
<label htmlFor="daySchema">Custom weekday names</label>
<input
type="text"
id="daySchema"
name="daySchema"
placeholder="Sunday;Monday;Tuesday"
value={formData.daySchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* CUSTOM MONTHS */}
<InputGroup>
<label htmlFor="monthSchema">Custom month names</label>
<input
type="text"
id="monthSchema"
name="monthSchema"
placeholder="January;February;March"
value={formData.monthSchema}
onChange={(e) => inputChangeHandler(e)}
/>
<span>Names must be separated with semicolon</span>
</InputGroup>
{/* === SECTIONS OPTIONS === */}
<SettingsHeadline text="Sections" />
{/* HIDE APPS */}
<InputGroup>
<label htmlFor="hideApps">Hide applications</label>
<select
id="hideApps"
name="hideApps"
value={formData.hideApps ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
{/* HIDE BOOKMARK CATEGORIES */}
<InputGroup>
<label htmlFor="hideCategories">Hide bookmarks</label>
<select
id="hideCategories"
name="hideCategories"
value={formData.hideCategories ? 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

@ -2,29 +2,34 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
import axios from 'axios';
// Redux
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { actionCreators } from '../../../store';
import { State } from '../../../store/reducers';
import { connect } from 'react-redux';
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript
import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
import {
ApiResponse,
Config,
GlobalState,
NewNotification,
Weather,
WeatherForm,
} from '../../../interfaces';
// UI
import { InputGroup, Button, SettingsHeadline } from '../../UI';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// Utils
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
export const WeatherSettings = (): JSX.Element => {
const { loading, config } = useSelector((state: State) => state.config);
const dispatch = useDispatch();
const { createNotification, updateConfig } = bindActionCreators(
actionCreators,
dispatch
);
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: WeatherForm) => void;
loading: boolean;
config: Config;
}
const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<WeatherForm>(
weatherSettingsTemplate
@ -33,9 +38,9 @@ export const WeatherSettings = (): JSX.Element => {
// Get config
useEffect(() => {
setFormData({
...config,
...props.config,
});
}, [loading]);
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
@ -43,26 +48,26 @@ export const WeatherSettings = (): JSX.Element => {
// Check for api key input
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
createNotification({
props.createNotification({
title: 'Warning',
message: 'API key is missing. Weather Module will NOT work',
});
}
// Save settings
await updateConfig(formData);
await props.updateConfig(formData);
// Update weather
axios
.get<ApiResponse<Weather>>('/api/weather/update')
.then(() => {
createNotification({
props.createNotification({
title: 'Success',
message: 'Weather updated',
});
})
.catch((err) => {
createNotification({
props.createNotification({
title: 'Error',
message: err.response.data.error,
});
@ -82,23 +87,8 @@ export const WeatherSettings = (): JSX.Element => {
});
};
// Get user location
const getLocation = () => {
window.navigator.geolocation.getCurrentPosition(
({ coords: { latitude, longitude } }) => {
setFormData({
...formData,
lat: latitude,
long: longitude,
});
}
);
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<SettingsHeadline text="API" />
{/* API KEY */}
<InputGroup>
<label htmlFor="WEATHER_API_KEY">API key</label>
<input
@ -118,11 +108,8 @@ export const WeatherSettings = (): JSX.Element => {
. Key is required for weather module to work.
</span>
</InputGroup>
<SettingsHeadline text="Location" />
{/* LAT */}
<InputGroup>
<label htmlFor="lat">Latitude</label>
<label htmlFor="lat">Location latitude</label>
<input
type="number"
id="lat"
@ -133,14 +120,19 @@ export const WeatherSettings = (): JSX.Element => {
step="any"
lang="en-150"
/>
<span onClick={getLocation}>
<a href="#">Click to get current location</a>
<span>
You can use
<a
href="https://www.latlong.net/convert-address-to-lat-long.html"
target="blank"
>
{' '}
latlong.net
</a>
</span>
</InputGroup>
{/* LONG */}
<InputGroup>
<label htmlFor="long">Longitude</label>
<label htmlFor="long">Location longitude</label>
<input
type="number"
id="long"
@ -152,9 +144,6 @@ export const WeatherSettings = (): JSX.Element => {
lang="en-150"
/>
</InputGroup>
<SettingsHeadline text="Other" />
{/* TEMPERATURE */}
<InputGroup>
<label htmlFor="isCelsius">Temperature unit</label>
<select
@ -167,22 +156,18 @@ export const WeatherSettings = (): JSX.Element => {
<option value={0}>Fahrenheit</option>
</select>
</InputGroup>
{/* WEATHER DATA */}
<InputGroup>
<label htmlFor="weatherData">Additional weather data</label>
<select
id="weatherData"
name="weatherData"
value={formData.weatherData}
onChange={(e) => inputChangeHandler(e)}
>
<option value="cloud">Cloud coverage</option>
<option value="humidity">Humidity</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,38 +2,27 @@
"routes": [
{
"name": "Theme",
"dest": "/settings",
"authRequired": false
},
{
"name": "General",
"dest": "/settings/general",
"authRequired": true
},
{
"name": "Interface",
"dest": "/settings/interface",
"authRequired": true
"dest": "/settings"
},
{
"name": "Weather",
"dest": "/settings/weather",
"authRequired": true
"dest": "/settings/weather"
},
{
"name": "Docker",
"dest": "/settings/docker",
"authRequired": true
"name": "Search",
"dest": "/settings/search"
},
{
"name": "Other",
"dest": "/settings/other"
},
{
"name": "CSS",
"dest": "/settings/css",
"authRequired": true
"dest": "/settings/css"
},
{
"name": "App",
"dest": "/settings/app",
"authRequired": false
"dest": "/settings/app"
}
]
}

View file

@ -14,6 +14,7 @@
text-transform: capitalize;
margin: 8px 0;
color: var(--color-primary);
/* align-self: flex-start; */
}
.ColorsPreview {
@ -31,4 +32,4 @@
width: 40px;
height: 40px;
}
}
}

View file

@ -0,0 +1,31 @@
import { Theme } from '../../interfaces/Theme';
import classes from './ThemePreview.module.css';
interface ComponentProps {
theme: Theme;
applyTheme: Function;
}
const ThemePreview = (props: ComponentProps): JSX.Element => {
return (
<div className={classes.ThemePreview} onClick={() => props.applyTheme(props.theme.name)}>
<div className={classes.ColorsPreview}>
<div
className={classes.ColorPreview}
style={{ backgroundColor: props.theme.colors.background }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: props.theme.colors.primary }}
></div>
<div
className={classes.ColorPreview}
style={{ backgroundColor: props.theme.colors.accent }}
></div>
</div>
<p>{props.theme.name}</p>
</div>
)
}
export default ThemePreview;

View file

@ -15,4 +15,4 @@
.ThemerGrid {
grid-template-columns: 1fr 1fr 1fr;
}
}
}

View file

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

View file

@ -6,8 +6,7 @@
"background": "#1a1a1a",
"primary": "#FFFDEA",
"accent": "#5c5c5c"
},
"isCustom": false
}
},
{
"name": "gazette",
@ -15,8 +14,7 @@
"background": "#F2F7FF",
"primary": "#000000",
"accent": "#5c5c5c"
},
"isCustom": false
}
},
{
"name": "espresso",
@ -24,8 +22,7 @@
"background": "#21211F",
"primary": "#D1B59A",
"accent": "#4E4E4E"
},
"isCustom": false
}
},
{
"name": "cab",
@ -33,8 +30,7 @@
"background": "#F6D305",
"primary": "#1F1F1F",
"accent": "#424242"
},
"isCustom": false
}
},
{
"name": "cloud",
@ -42,8 +38,7 @@
"background": "#f1f2f0",
"primary": "#35342f",
"accent": "#37bbe4"
},
"isCustom": false
}
},
{
"name": "lime",
@ -51,8 +46,7 @@
"background": "#263238",
"primary": "#AABBC3",
"accent": "#aeea00"
},
"isCustom": false
}
},
{
"name": "white",
@ -60,8 +54,7 @@
"background": "#ffffff",
"primary": "#222222",
"accent": "#dddddd"
},
"isCustom": false
}
},
{
"name": "tron",
@ -69,8 +62,7 @@
"background": "#242B33",
"primary": "#EFFBFF",
"accent": "#6EE2FF"
},
"isCustom": false
}
},
{
"name": "blues",
@ -78,8 +70,7 @@
"background": "#2B2C56",
"primary": "#EFF1FC",
"accent": "#6677EB"
},
"isCustom": false
}
},
{
"name": "passion",
@ -87,8 +78,7 @@
"background": "#f5f5f5",
"primary": "#12005e",
"accent": "#8e24aa"
},
"isCustom": false
}
},
{
"name": "chalk",
@ -96,8 +86,7 @@
"background": "#263238",
"primary": "#AABBC3",
"accent": "#FF869A"
},
"isCustom": false
}
},
{
"name": "paper",
@ -105,8 +94,7 @@
"background": "#F8F6F1",
"primary": "#4C432E",
"accent": "#AA9A73"
},
"isCustom": false
}
},
{
"name": "neon",
@ -114,8 +102,7 @@
"background": "#091833",
"primary": "#EFFBFF",
"accent": "#ea00d9"
},
"isCustom": false
}
},
{
"name": "pumpkin",
@ -123,8 +110,7 @@
"background": "#2d3436",
"primary": "#EFFBFF",
"accent": "#ffa500"
},
"isCustom": false
}
},
{
"name": "onedark",
@ -132,17 +118,7 @@
"background": "#282c34",
"primary": "#dfd9d6",
"accent": "#98c379"
},
"isCustom": false
},
{
"name": "mint",
"colors": {
"background": "#282525",
"primary": "#d9d9d9",
"accent": "#50fbc2"
},
"isCustom": false
}
}
]
}

View file

@ -2,45 +2,55 @@ import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import classes from './ActionButton.module.css';
import { Icon } from '../..';
import Icon from '../../Icons/Icon/Icon';
interface Props {
interface ComponentProps {
name: string;
icon: string;
link?: string;
handler?: () => void;
}
export const ActionButton = (props: Props): JSX.Element => {
const ActionButton = (props: ComponentProps): 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,17 +1,21 @@
import { ReactNode } from 'react';
import classes from './Button.module.css';
interface Props {
children: ReactNode;
interface ComponentProps {
children: string;
click?: any;
}
export const Button = (props: Props): JSX.Element => {
const { children, click } = props;
const Button = (props: ComponentProps): 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

@ -23,7 +23,7 @@
.InputGroup span {
font-size: 12px;
color: var(--color-primary);
color: var(--color-primary)
}
.InputGroup span a {
@ -37,14 +37,4 @@
.InputGroup textarea {
resize: none;
height: 50vh;
}
.InputGroup input[type='color'] {
margin: 0;
padding: 0;
background-color: transparent;
}
.InputGroup input[type='color']:hover {
cursor: pointer;
}
}

View file

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

View file

@ -1,27 +1,31 @@
import { ReactNode, SyntheticEvent } from 'react';
import { SyntheticEvent } from 'react';
import classes from './ModalForm.module.css';
import { Icon } from '../..';
import Icon from '../../Icons/Icon/Icon';
interface ComponentProps {
children: ReactNode;
children: JSX.Element | JSX.Element[];
modalHandler?: () => void;
formHandler: (e: SyntheticEvent<HTMLFormElement>) => void;
}
export const ModalForm = (props: ComponentProps): JSX.Element => {
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, ReactNode } from 'react';
import { Fragment } from 'react';
import classes from './Headline.module.css';
interface Props {
interface ComponentProps {
title: string;
subtitle?: ReactNode;
subtitle?: string | JSX.Element;
}
export const Headline = (props: Props): JSX.Element => {
const Headline = (props: ComponentProps): 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,15 +2,17 @@ import { Link } from 'react-router-dom';
import classes from './SectionHeadline.module.css';
interface Props {
interface ComponentProps {
title: string;
link: string;
link: string
}
export const SectionHeadline = (props: Props): JSX.Element => {
const SectionHeadline = (props: ComponentProps): JSX.Element => {
return (
<Link to={props.link}>
<h2 className={classes.SectionHeadline}>{props.title}</h2>
</Link>
);
};
)
}
export default SectionHeadline;

View file

@ -1,9 +1,11 @@
import classes from './SettingsHeadline.module.css';
const classes = require('./SettingsHeadline.module.css');
interface Props {
text: string;
}
export const SettingsHeadline = (props: Props): JSX.Element => {
const SettingsHeadline = (props: Props): JSX.Element => {
return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
};
export default SettingsHeadline;

View file

@ -1,11 +0,0 @@
.ActionIcons {
display: flex;
}
.ActionIcons svg {
width: 20px;
}
.ActionIcons svg:hover {
cursor: pointer;
}

View file

@ -1,10 +0,0 @@
import { ReactNode } from 'react';
import styles from './ActionIcons.module.css';
interface Props {
children: ReactNode;
}
export const ActionIcons = ({ children }: Props): JSX.Element => {
return <span className={styles.ActionIcons}>{children}</span>;
};

View file

@ -1,4 +1,6 @@
.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 Props {
interface ComponentProps {
icon: string;
color?: string;
}
export const Icon = (props: Props): JSX.Element => {
const Icon = (props: ComponentProps): JSX.Element => {
const MDIcons = require('@mdi/js');
let iconPath = MDIcons[props.icon];
@ -22,5 +22,7 @@ export const Icon = (props: Props): JSX.Element => {
path={iconPath}
color={props.color ? props.color : 'var(--color-primary)'}
/>
);
};
)
}
export default Icon;

View file

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

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