Merge branch 'master' of https://github.com/pawelmalak/flame into merge_upstream_2020-12-06
10
.dev/DEV_GUIDELINES.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
## Adding new config key
|
||||
|
||||
1. Edit utils/init/initialConfig.json
|
||||
2. Edit client/src/interfaces/Config.ts
|
||||
3. Edit client/src/utility/templateObjects/configTemplate.ts
|
||||
|
||||
If config value will be used in a form:
|
||||
|
||||
4. Edit client/src/interfaces/Forms.ts
|
||||
5. Edit client/src/utility/templateObjects/settingsTemplate.ts
|
166
.dev/bookmarks_importer.py
Executable file
|
@ -0,0 +1,166 @@
|
|||
import sqlite3
|
||||
from bs4 import BeautifulSoup
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from io import BytesIO
|
||||
import re
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import argparse
|
||||
|
||||
|
||||
"""
|
||||
Imports html bookmarks file into Flame.
|
||||
Tested only on Firefox html exports so far.
|
||||
|
||||
Usage:
|
||||
python3 bookmarks_importer.py --bookmarks <path to bookmarks file> --data <path to flame data dir>
|
||||
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--bookmarks', type=str, required=True)
|
||||
parser.add_argument('--data', type=str, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
bookmarks_path = args.bookmarks
|
||||
data_path = args.data
|
||||
created = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + datetime.now().astimezone().strftime(" %z")
|
||||
updated = created
|
||||
if data_path[-1] != '/':
|
||||
data_path = data_path + '/'
|
||||
|
||||
|
||||
|
||||
|
||||
def Base64toPNG(codec, name):
|
||||
|
||||
"""
|
||||
Convert base64 encoded image to png file
|
||||
Reference: https://github.com/python-pillow/Pillow/issues/3400#issuecomment-428104239
|
||||
|
||||
Parameters:
|
||||
codec (str): icon in html bookmark format.e.g. 'data:image/png;base64,<image encoding>'
|
||||
name (str): name for export file
|
||||
|
||||
Returns:
|
||||
icon_name(str): name of png output E.g. 1636473849374--mybookmark.png
|
||||
None: if image not produced successfully
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
unix_t = str(int(datetime.now(tz=timezone.utc).timestamp() * 1000))
|
||||
icon_name = unix_t + '--' + re.sub(r'\W+', '', name).lower() + '.png'
|
||||
image_path = data_path + 'uploads/' + icon_name
|
||||
if os.path.exists(image_path):
|
||||
return image_path
|
||||
base64_data = re.sub('^data:image/.+;base64,', '', codec)
|
||||
byte_data = base64.b64decode(base64_data)
|
||||
image_data = BytesIO(byte_data)
|
||||
img = Image.open(image_data)
|
||||
img.save(image_path, "PNG")
|
||||
return icon_name
|
||||
except UnidentifiedImageError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def FlameBookmarkParser(bookmarks_path):
|
||||
|
||||
"""
|
||||
Parses HTML bookmarks file
|
||||
Reference: https://stackoverflow.com/questions/68621107/extracting-bookmarks-and-folder-hierarchy-from-google-chrome-with-beautifulsoup
|
||||
|
||||
Parameters:
|
||||
bookmarks_path (str): path to bookmarks.html
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
soup = BeautifulSoup()
|
||||
with open(bookmarks_path) as f:
|
||||
soup = BeautifulSoup(f.read(), 'lxml')
|
||||
|
||||
dt = soup.find_all('dt')
|
||||
folder_name =''
|
||||
for i in dt:
|
||||
n = i.find_next()
|
||||
if n.name == 'h3':
|
||||
folder_name = n.text
|
||||
continue
|
||||
else:
|
||||
url = n.get("href")
|
||||
website_name = n.text
|
||||
icon = n.get("icon")
|
||||
if icon != None:
|
||||
icon_name = Base64toPNG(icon, website_name)
|
||||
cat_id = AddFlameCategory(folder_name)
|
||||
AddFlameBookmark(website_name, url, cat_id, icon_name)
|
||||
|
||||
|
||||
|
||||
|
||||
def AddFlameCategory(cat_name):
|
||||
"""
|
||||
Parses HTML bookmarks file
|
||||
|
||||
Parameters:
|
||||
cat_name (str): category name
|
||||
|
||||
Returns:
|
||||
cat_id (int): primary key id of cat_name
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
con = sqlite3.connect(data_path + 'db.sqlite')
|
||||
cur = con.cursor()
|
||||
count_sql = ("SELECT count(*) FROM categories WHERE name = ?;")
|
||||
cur.execute(count_sql, [cat_name])
|
||||
count = int(cur.fetchall()[0][0])
|
||||
if count > 0:
|
||||
getid_sql = ("SELECT id FROM categories WHERE name = ?;")
|
||||
cur.execute(getid_sql, [cat_name])
|
||||
cat_id = int(cur.fetchall()[0][0])
|
||||
return cat_id
|
||||
|
||||
is_pinned = 1
|
||||
|
||||
insert_sql = "INSERT OR IGNORE INTO categories(name, isPinned, createdAt, updatedAt) VALUES (?, ?, ?, ?);"
|
||||
cur.execute(insert_sql, (cat_name, is_pinned, created, updated))
|
||||
con.commit()
|
||||
|
||||
getid_sql = ("SELECT id FROM categories WHERE name = ?;")
|
||||
cur.execute(getid_sql, [cat_name])
|
||||
cat_id = int(cur.fetchall()[0][0])
|
||||
return cat_id
|
||||
|
||||
|
||||
|
||||
|
||||
def AddFlameBookmark(website_name, url, cat_id, icon_name):
|
||||
con = sqlite3.connect(data_path + 'db.sqlite')
|
||||
cur = con.cursor()
|
||||
if icon_name == None:
|
||||
insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?);"
|
||||
cur.execute(insert_sql, (website_name, url, cat_id, created, updated))
|
||||
con.commit()
|
||||
else:
|
||||
insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?);"
|
||||
cur.execute(insert_sql, (website_name, url, cat_id, icon_name, created, updated))
|
||||
con.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
FlameBookmarkParser(bookmarks_path)
|
9
.dev/getMdi.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
// Script to get all icon names from materialdesignicons.com
|
||||
const getMdi = () => {
|
||||
const icons = document.querySelectorAll('#icons div span');
|
||||
const names = [...icons].map((icon) => icon.textContent.replace('mdi-', ''));
|
||||
const output = names.map((name) => ({ name }));
|
||||
output.pop();
|
||||
const json = JSON.stringify(output);
|
||||
console.log(json);
|
||||
};
|
|
@ -1,6 +1,4 @@
|
|||
FROM node:14-alpine
|
||||
|
||||
RUN apk update && apk add --no-cache nano curl
|
||||
FROM node:16 as builder
|
||||
|
||||
# Get package.json and install modules
|
||||
COPY package*.json /tmp/package.json
|
||||
|
@ -23,8 +21,15 @@ RUN mkdir -p ./public ./data \
|
|||
&& mv ./client/build/* ./public \
|
||||
&& rm -rf ./client
|
||||
|
||||
FROM node:16-alpine
|
||||
|
||||
COPY --from=builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PASSWORD=flame_password
|
||||
|
||||
CMD ["node", "server.js"]
|
|
@ -1,16 +1,26 @@
|
|||
FROM node:lts-alpine as build-front
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./client .
|
||||
|
||||
RUN npm install --production \
|
||||
&& npm run build
|
||||
|
||||
FROM node:lts-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p ./public
|
||||
|
||||
COPY --from=build-front /app/build/ ./public
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "skaffold"]
|
|
@ -1,12 +1,10 @@
|
|||
FROM node:14-alpine
|
||||
|
||||
RUN apk update && apk add --no-cache nano curl
|
||||
FROM node:16-alpine3.11 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN apk --no-cache --virtual build-dependencies add python make g++ \
|
||||
RUN apk --no-cache --virtual build-dependencies add python python3 make g++ \
|
||||
&& npm install --production
|
||||
|
||||
COPY . .
|
||||
|
@ -17,11 +15,17 @@ RUN mkdir -p ./public ./data \
|
|||
&& npm run build \
|
||||
&& cd .. \
|
||||
&& mv ./client/build/* ./public \
|
||||
&& rm -rf ./client \
|
||||
&& apk del build-dependencies
|
||||
&& rm -rf ./client
|
||||
|
||||
FROM node:16-alpine3.11
|
||||
|
||||
COPY --from=builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5005
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PASSWORD=flame_password
|
||||
|
||||
CMD ["node", "server.js"]
|
22
.docker/docker-compose.yml
Normal file
|
@ -0,0 +1,22 @@
|
|||
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
|
|
@ -1,6 +1,5 @@
|
|||
node_modules
|
||||
github
|
||||
.github
|
||||
public
|
||||
build.sh
|
||||
k8s
|
||||
skaffold.yaml
|
4
.env
|
@ -1,3 +1,5 @@
|
|||
PORT=5005
|
||||
NODE_ENV=development
|
||||
VERSION=1.7.0
|
||||
VERSION=2.1.1
|
||||
PASSWORD=flame_password
|
||||
SECRET=e02eb43d69953658c6d07311d6313f2d4467672cb881f96b29368ba1f3f4da4b
|
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
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
Before Width: | Height: | Size: 90 KiB |
BIN
.github/_bookmarks.png
vendored
Before Width: | Height: | Size: 93 KiB |
BIN
.github/_home.png
vendored
Before Width: | Height: | Size: 75 KiB |
BIN
.github/apps.png
vendored
Normal file
After Width: | Height: | Size: 104 KiB |
BIN
.github/bookmarks.png
vendored
Normal file
After Width: | Height: | Size: 105 KiB |
BIN
.github/home.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
.github/settings.png
vendored
Normal file
After Width: | Height: | Size: 62 KiB |
0
.github/_themes.png → .github/themes.png
vendored
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
1
.gitignore
vendored
|
@ -1,4 +1,5 @@
|
|||
node_modules
|
||||
data
|
||||
public
|
||||
!client/public
|
||||
build.sh
|
|
@ -1 +1,2 @@
|
|||
*.md
|
||||
docker-compose.yml
|
74
CHANGELOG.md
|
@ -1,3 +1,63 @@
|
|||
### 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))
|
||||
- Added iOS "Add to homescreen" icon ([#131](https://github.com/pawelmalak/flame/issues/131))
|
||||
- Added experimental script to import bookmarks ([#141](https://github.com/pawelmalak/flame/issues/141))
|
||||
- Added 3 new themes
|
||||
|
||||
### v1.7.3 (2021-10-28)
|
||||
- Fixed bug with custom CSS not updating
|
||||
|
||||
### v1.7.2 (2021-10-28)
|
||||
- Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121))
|
||||
- Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124))
|
||||
- Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125))
|
||||
- Added option to disable search bar autofocus ([#127](https://github.com/pawelmalak/flame/issues/127))
|
||||
|
||||
### v1.7.1 (2021-10-22)
|
||||
- Fixed search action not being triggered by Numpad Enter
|
||||
- Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92))
|
||||
- Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100))
|
||||
- Added Traefik integration ([#102](https://github.com/pawelmalak/flame/issues/102))
|
||||
- Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118))
|
||||
- Performance improvements
|
||||
|
||||
### v1.7.0 (2021-10-11)
|
||||
- Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67))
|
||||
- Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71))
|
||||
|
@ -38,12 +98,12 @@
|
|||
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
|
||||
- Added changelog file
|
||||
|
||||
### v1.6 (2021-07-17)
|
||||
### v1.6.0 (2021-07-17)
|
||||
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
|
||||
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
|
||||
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
|
||||
|
||||
### v1.5 (2021-06-24)
|
||||
### v1.5.0 (2021-06-24)
|
||||
- Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
|
||||
- Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
|
||||
- Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
|
||||
|
@ -51,7 +111,7 @@
|
|||
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
|
||||
- Improved Logger
|
||||
|
||||
### v1.4 (2021-06-18)
|
||||
### v1.4.0 (2021-06-18)
|
||||
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
|
||||
|
@ -60,14 +120,14 @@
|
|||
- Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
|
||||
- Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
|
||||
|
||||
### v1.3 (2021-06-14)
|
||||
### v1.3.0 (2021-06-14)
|
||||
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
|
||||
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
|
||||
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
|
||||
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
|
||||
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
|
||||
|
||||
### v1.2 (2021-06-10)
|
||||
### v1.2.0 (2021-06-10)
|
||||
- Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
|
||||
- Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
|
||||
- Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
|
||||
|
@ -76,11 +136,11 @@
|
|||
- Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18))
|
||||
- Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20))
|
||||
|
||||
### v1.1 (2021-06-09)
|
||||
### v1.1.0 (2021-06-09)
|
||||
- Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
|
||||
- Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
|
||||
|
||||
### v1.0 (2021-06-08)
|
||||
### v1.0.0 (2021-06-08)
|
||||
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.
|
||||
|
|
204
README.md
|
@ -1,84 +1,96 @@
|
|||
# Flame
|
||||
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
|
||||

|
||||

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

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

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

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
### Authentication
|
||||
|
||||
Visit [project wiki](https://github.com/pawelmalak/flame/wiki/Authentication) to read more about authentication
|
||||
|
||||
### Search bar
|
||||
|
||||
#### Searching
|
||||
|
||||
To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
|
||||
The default search setting is to search through all your apps and bookmarks. If you want to search using specific search engine, you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
|
||||
|
||||
> You can change where to open search results (same/new tab) in the settings
|
||||
|
||||
#### Supported search engines
|
||||
|
||||
| Name | Prefix | Search URL |
|
||||
| ---------- | ------ | ----------------------------------- |
|
||||
| Disroot | /ds | http://search.disroot.org/search?q= |
|
||||
| DuckDuckGo | /d | https://duckduckgo.com/?q= |
|
||||
| Google | /g | https://www.google.com/search?q= |
|
||||
|
||||
#### Supported services
|
||||
|
||||
| Name | Prefix | Search URL |
|
||||
| ------------------ | ------ | --------------------------------------------- |
|
||||
| IMDb | /im | https://www.imdb.com/find?q= |
|
||||
| Reddit | /r | https://www.reddit.com/search?q= |
|
||||
| Spotify | /sp | https://open.spotify.com/search/ |
|
||||
| The Movie Database | /mv | https://www.themoviedb.org/search?query= |
|
||||
| Youtube | /yt | https://www.youtube.com/results?search_query= |
|
||||
For list of supported search engines, shortcuts and more about searching functionality visit [project wiki](https://github.com/pawelmalak/flame/wiki/Search-bar).
|
||||
|
||||
### Setting up weather module
|
||||
|
||||
|
@ -164,9 +178,9 @@ labels:
|
|||
- flame.order=1 # Optional, default is 500; lower number is first in the list
|
||||
```
|
||||
|
||||
And you must have activated the Docker sync option in the settings panel.
|
||||
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Docker
|
||||
|
||||
You can set up different apps in the same label adding `;` between each one.
|
||||
You can also set up different apps in the same label adding `;` between each one.
|
||||
|
||||
```yml
|
||||
labels:
|
||||
|
@ -214,9 +228,25 @@ metadata:
|
|||
- flame.pawelmalak/order=1 # Optional, default is 500; lower number is first in the list
|
||||
```
|
||||
|
||||
And you must have activated the Kubernetes sync option in the settings panel.
|
||||
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Docker
|
||||
|
||||
### Custom CSS
|
||||
### Import HTML Bookmarks (Experimental)
|
||||
|
||||
- Requirements
|
||||
- python3
|
||||
- pip packages: Pillow, beautifulsoup4
|
||||
- Backup your `db.sqlite` before running script!
|
||||
- Known Issues:
|
||||
- generated icons are sometimes incorrect
|
||||
|
||||
```bash
|
||||
pip3 install Pillow, beautifulsoup4
|
||||
|
||||
cd flame/.dev
|
||||
python3 bookmarks_importer.py --bookmarks <path to bookmarks.html> --data <path to flame data folder>
|
||||
```
|
||||
|
||||
### Custom CSS and themes
|
||||
|
||||
> This is an experimental feature. Its behaviour might change in the future.
|
||||
>
|
||||
|
|
3
api.js
|
@ -1,6 +1,6 @@
|
|||
const { join } = require('path');
|
||||
const express = require('express');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const { errorHandler } = require('./middleware');
|
||||
|
||||
const api = express();
|
||||
|
||||
|
@ -21,6 +21,7 @@ api.use('/api/weather', require('./routes/weather'));
|
|||
api.use('/api/categories', require('./routes/category'));
|
||||
api.use('/api/bookmarks', require('./routes/bookmark'));
|
||||
api.use('/api/queries', require('./routes/queries'));
|
||||
api.use('/api/auth', require('./routes/auth'));
|
||||
|
||||
// Custom error handler
|
||||
api.use(errorHandler);
|
||||
|
|
|
@ -1 +1 @@
|
|||
REACT_APP_VERSION=1.7.0
|
||||
REACT_APP_VERSION=2.1.1
|
464
client/package-lock.json
generated
|
@ -1806,9 +1806,9 @@
|
|||
}
|
||||
},
|
||||
"@mdi/js": {
|
||||
"version": "5.9.55",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-5.9.55.tgz",
|
||||
"integrity": "sha512-BbeHMgeK2/vjdJIRnx12wvQ6s8xAYfvMmEAVsUx9b+7GiQGQ9Za8jpwp17dMKr9CgKRvemlAM4S7S3QOtEbp4A=="
|
||||
"version": "6.4.95",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-6.4.95.tgz",
|
||||
"integrity": "sha512-b1/P//1D2KOzta8YRGyoSLGsAlWyUHfxzVBhV4e/ppnjM4DfBgay/vWz7Eg5Ee80JZ4zsQz8h54X+KOahtBk5Q=="
|
||||
},
|
||||
"@mdi/react": {
|
||||
"version": "1.5.0",
|
||||
|
@ -2047,20 +2047,45 @@
|
|||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "7.30.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.4.tgz",
|
||||
"integrity": "sha512-GObDVMaI4ARrZEXaRy4moolNAxWPKvEYNV/fa6Uc2eAzR/t4otS6A7EhrntPBIQLeehL9DbVhscvvv7gd6hWqA==",
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.0.tgz",
|
||||
"integrity": "sha512-8Ay4UDiMlB5YWy+ZvCeRyFFofs53ebxrWnOFvCoM1HpMAX4cHyuSrCuIM9l2lVuUWUt+Gr3loz/nCwdrnG6ShQ==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^4.2.0",
|
||||
"aria-query": "^4.2.2",
|
||||
"aria-query": "^5.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.4",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.4.4",
|
||||
"pretty-format": "^26.6.2"
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "27.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
|
||||
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^16.0.0",
|
||||
"chalk": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
||||
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
|
@ -2069,10 +2094,15 @@
|
|||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"aria-query": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
|
||||
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
|
||||
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
|
@ -2096,6 +2126,29 @@
|
|||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
|
||||
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
|
||||
"requires": {
|
||||
"@jest/types": "^27.2.5",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
@ -2107,9 +2160,9 @@
|
|||
}
|
||||
},
|
||||
"@testing-library/jest-dom": {
|
||||
"version": "5.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.12.0.tgz",
|
||||
"integrity": "sha512-N9Y82b2Z3j6wzIoAqajlKVF1Zt7sOH0pPee0sUHXHc5cv2Fdn23r+vpWm0MBBoGJtPOly5+Bdx1lnc3CD+A+ow==",
|
||||
"version": "5.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.15.0.tgz",
|
||||
"integrity": "sha512-lOMuQidnL1tWHLEWIhL6UvSZC1Qt3OkNe1khvi2h6xFiqpe5O8arYs46OU0qyUGq0cSTbroQyMktYNXu3a7sAA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@types/testing-library__jest-dom": "^5.9.1",
|
||||
|
@ -2117,6 +2170,7 @@
|
|||
"chalk": "^3.0.0",
|
||||
"css": "^3.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.5.6",
|
||||
"lodash": "^4.17.15",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
|
@ -2191,18 +2245,18 @@
|
|||
}
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz",
|
||||
"integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==",
|
||||
"version": "12.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz",
|
||||
"integrity": "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^7.28.1"
|
||||
"@testing-library/dom": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"@testing-library/user-event": {
|
||||
"version": "12.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz",
|
||||
"integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==",
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
}
|
||||
|
@ -2213,9 +2267,9 @@
|
|||
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA=="
|
||||
},
|
||||
"@types/aria-query": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz",
|
||||
"integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg=="
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
|
||||
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.14",
|
||||
|
@ -2286,9 +2340,9 @@
|
|||
}
|
||||
},
|
||||
"@types/history": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz",
|
||||
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA=="
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
|
||||
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ=="
|
||||
},
|
||||
"@types/hoist-non-react-statics": {
|
||||
"version": "3.3.1",
|
||||
|
@ -2305,9 +2359,9 @@
|
|||
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
|
||||
},
|
||||
"@types/http-proxy": {
|
||||
"version": "1.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz",
|
||||
"integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==",
|
||||
"version": "1.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz",
|
||||
"integrity": "sha512-9hdj6iXH64tHSLTY+Vt2eYOGzSogC+JQ2H7bdPWkuh7KXP5qLllWx++t+K9Wk556c3dkDdPws/SpMRi0sdCT1w==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
|
@ -2334,12 +2388,126 @@
|
|||
}
|
||||
},
|
||||
"@types/jest": {
|
||||
"version": "26.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz",
|
||||
"integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==",
|
||||
"version": "27.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz",
|
||||
"integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==",
|
||||
"requires": {
|
||||
"jest-diff": "^26.0.0",
|
||||
"pretty-format": "^26.0.0"
|
||||
"jest-diff": "^27.0.0",
|
||||
"pretty-format": "^27.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "27.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
|
||||
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^16.0.0",
|
||||
"chalk": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
||||
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"diff-sequences": {
|
||||
"version": "27.0.6",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz",
|
||||
"integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ=="
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"jest-diff": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.3.1.tgz",
|
||||
"integrity": "sha512-PCeuAH4AWUo2O5+ksW4pL9v5xJAcIKPUPfIhZBcG1RKv/0+dvaWTQK1Nrau8d67dp65fOqbeMdoil+6PedyEPQ==",
|
||||
"requires": {
|
||||
"chalk": "^4.0.0",
|
||||
"diff-sequences": "^27.0.6",
|
||||
"jest-get-type": "^27.3.1",
|
||||
"pretty-format": "^27.3.1"
|
||||
}
|
||||
},
|
||||
"jest-get-type": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.3.1.tgz",
|
||||
"integrity": "sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg=="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
|
||||
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
|
||||
"requires": {
|
||||
"@jest/types": "^27.2.5",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/json-schema": {
|
||||
|
@ -2358,9 +2526,9 @@
|
|||
"integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA=="
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "12.20.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.12.tgz",
|
||||
"integrity": "sha512-KQZ1al2hKOONAs2MFv+yTQP1LkDWMrRJ9YCVRalXltOfXsBmH5IownLxQaiq0lnAHwAViLnh2aTYqrPcRGEbgg=="
|
||||
"version": "16.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz",
|
||||
"integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w=="
|
||||
},
|
||||
"@types/normalize-package-data": {
|
||||
"version": "2.4.0",
|
||||
|
@ -2378,9 +2546,9 @@
|
|||
"integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA=="
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
||||
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
|
||||
"integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ=="
|
||||
},
|
||||
"@types/q": {
|
||||
"version": "1.5.4",
|
||||
|
@ -2388,9 +2556,9 @@
|
|||
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "17.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.5.tgz",
|
||||
"integrity": "sha512-bj4biDB9ZJmGAYTWSKJly6bMr4BLUiBrx9ujiJEoP9XIDY9CTaPGxE5QWN/1WjpPLzYF7/jRNnV2nNxNe970sw==",
|
||||
"version": "17.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz",
|
||||
"integrity": "sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==",
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
|
@ -2398,25 +2566,25 @@
|
|||
}
|
||||
},
|
||||
"@types/react-beautiful-dnd": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz",
|
||||
"integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==",
|
||||
"version": "13.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz",
|
||||
"integrity": "sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==",
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"version": "17.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
||||
"integrity": "sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w==",
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz",
|
||||
"integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==",
|
||||
"requires": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"@types/react-redux": {
|
||||
"version": "7.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz",
|
||||
"integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==",
|
||||
"version": "7.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz",
|
||||
"integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==",
|
||||
"requires": {
|
||||
"@types/hoist-non-react-statics": "^3.3.0",
|
||||
"@types/react": "*",
|
||||
|
@ -2425,9 +2593,9 @@
|
|||
}
|
||||
},
|
||||
"@types/react-router": {
|
||||
"version": "5.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.14.tgz",
|
||||
"integrity": "sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw==",
|
||||
"version": "5.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.17.tgz",
|
||||
"integrity": "sha512-RNSXOyb3VyRs/EOGmjBhhGKTbnN6fHWvy5FNLzWfOWOGjgVUKqJZXfpKzLmgoU8h6Hj8mpALj/mbXQASOb92wQ==",
|
||||
"requires": {
|
||||
"@types/history": "*",
|
||||
"@types/react": "*"
|
||||
|
@ -2452,9 +2620,9 @@
|
|||
}
|
||||
},
|
||||
"@types/scheduler": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz",
|
||||
"integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA=="
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
|
||||
},
|
||||
"@types/source-list-map": {
|
||||
"version": "0.1.2",
|
||||
|
@ -2472,9 +2640,9 @@
|
|||
"integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ=="
|
||||
},
|
||||
"@types/testing-library__jest-dom": {
|
||||
"version": "5.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz",
|
||||
"integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==",
|
||||
"version": "5.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.1.tgz",
|
||||
"integrity": "sha512-Gk9vaXfbzc5zCXI9eYE9BI5BNHEp4D3FWjgqBE/ePGYElLAP+KvxBcsdkwfIVvezs605oiyd/VrpiHe3Oeg+Aw==",
|
||||
"requires": {
|
||||
"@types/jest": "*"
|
||||
}
|
||||
|
@ -3159,11 +3327,18 @@
|
|||
"integrity": "sha512-1uIESzroqpaTzt9uX48HO+6gfnKu3RwvWdCcWSrX4csMInJfCo1yvKPNXCwXFRpJqRW25tiASb6No0YH57PXqg=="
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.21.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
||||
"version": "0.24.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
|
||||
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.10.0"
|
||||
"follow-redirects": "^1.14.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"follow-redirects": {
|
||||
"version": "1.14.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
|
||||
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"axobject-query": {
|
||||
|
@ -3722,6 +3897,15 @@
|
|||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||
"optional": true
|
||||
},
|
||||
"bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"bluebird": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||
|
@ -4906,9 +5090,9 @@
|
|||
}
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
|
||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
|
||||
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
|
||||
},
|
||||
"cyclist": {
|
||||
"version": "1.0.1",
|
||||
|
@ -5202,9 +5386,9 @@
|
|||
"integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0="
|
||||
},
|
||||
"dns-packet": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.1.tgz",
|
||||
"integrity": "sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz",
|
||||
"integrity": "sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==",
|
||||
"requires": {
|
||||
"ip": "^1.1.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
|
@ -5227,9 +5411,9 @@
|
|||
}
|
||||
},
|
||||
"dom-accessibility-api": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz",
|
||||
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ=="
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz",
|
||||
"integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g=="
|
||||
},
|
||||
"dom-converter": {
|
||||
"version": "0.2.0",
|
||||
|
@ -6620,6 +6804,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"optional": true
|
||||
},
|
||||
"filesize": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filesize/-/filesize-6.1.0.tgz",
|
||||
|
@ -7481,9 +7671,9 @@
|
|||
}
|
||||
},
|
||||
"http-proxy-middleware": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz",
|
||||
"integrity": "sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.1.tgz",
|
||||
"integrity": "sha512-cfaXRVoZxSed/BmkA7SwBVNI9Kj7HFltaE5rqYOub5kWzWZ+gofV2koVN1j2rMW7pEfSSlCHGJ31xmuyFyfLOg==",
|
||||
"requires": {
|
||||
"@types/http-proxy": "^1.17.5",
|
||||
"http-proxy": "^1.18.1",
|
||||
|
@ -9688,6 +9878,11 @@
|
|||
"object.assign": "^4.1.2"
|
||||
}
|
||||
},
|
||||
"jwt-decode": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
|
||||
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
|
||||
},
|
||||
"killable": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz",
|
||||
|
@ -10277,6 +10472,12 @@
|
|||
"resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz",
|
||||
"integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE="
|
||||
},
|
||||
"nan": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.15.0.tgz",
|
||||
"integrity": "sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==",
|
||||
"optional": true
|
||||
},
|
||||
"nanoid": {
|
||||
"version": "3.1.22",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.22.tgz",
|
||||
|
@ -10935,9 +11136,9 @@
|
|||
"integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
|
||||
},
|
||||
"path-parse": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"path-to-regexp": {
|
||||
"version": "0.1.7",
|
||||
|
@ -12114,9 +12315,9 @@
|
|||
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.3.2.tgz",
|
||||
"integrity": "sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==",
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
||||
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-bytes": {
|
||||
|
@ -12548,16 +12749,31 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"react-redux": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
|
||||
"integrity": "sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==",
|
||||
"version": "7.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz",
|
||||
"integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.1",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@babel/runtime": "^7.15.4",
|
||||
"@types/react-redux": "^7.1.20",
|
||||
"hoist-non-react-statics": "^3.3.2",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-is": "^16.13.1"
|
||||
"react-is": "^17.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.0.tgz",
|
||||
"integrity": "sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-refresh": {
|
||||
|
@ -12793,9 +13009,9 @@
|
|||
}
|
||||
},
|
||||
"redux": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.0.tgz",
|
||||
"integrity": "sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz",
|
||||
"integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
|
@ -12806,9 +13022,9 @@
|
|||
"integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A=="
|
||||
},
|
||||
"redux-thunk": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
|
||||
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.0.tgz",
|
||||
"integrity": "sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA=="
|
||||
},
|
||||
"regenerate": {
|
||||
"version": "1.4.2",
|
||||
|
@ -14489,9 +14705,9 @@
|
|||
"integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA=="
|
||||
},
|
||||
"tar": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
|
||||
"integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
|
||||
"version": "6.1.11",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
|
||||
"integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
|
||||
"requires": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
|
@ -14737,9 +14953,9 @@
|
|||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
|
||||
},
|
||||
"tmpl": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz",
|
||||
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE="
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
|
||||
},
|
||||
"to-arraybuffer": {
|
||||
"version": "1.0.1",
|
||||
|
@ -14926,9 +15142,9 @@
|
|||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
|
||||
"integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg=="
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz",
|
||||
"integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA=="
|
||||
},
|
||||
"unbox-primitive": {
|
||||
"version": "1.0.1",
|
||||
|
@ -15123,9 +15339,9 @@
|
|||
}
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
|
||||
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
|
||||
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
|
@ -15396,7 +15612,11 @@
|
|||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"bindings": "^1.5.0",
|
||||
"nan": "^2.12.1"
|
||||
}
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "3.1.0",
|
||||
|
@ -15525,9 +15745,9 @@
|
|||
}
|
||||
},
|
||||
"web-vitals": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.2.tgz",
|
||||
"integrity": "sha512-PFMKIY+bRSXlMxVAQ+m2aw9c/ioUYfDgrYot0YUa+/xa0sakubWhSDyxAKwzymvXVdF4CZI71g06W+mqhzu6ig=="
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.2.tgz",
|
||||
"integrity": "sha512-nZnEH8dj+vJFqCRYdvYv0a59iLXsb8jJkt+xvXfwgnkyPdsSLtKNlYmtTDiHmTNGXeSXtpjTTUcNvFtrAk6VMQ=="
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "6.1.0",
|
||||
|
@ -15995,7 +16215,11 @@
|
|||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz",
|
||||
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"bindings": "^1.5.0",
|
||||
"nan": "^2.12.1"
|
||||
}
|
||||
},
|
||||
"glob-parent": {
|
||||
"version": "3.1.0",
|
||||
|
@ -16250,9 +16474,9 @@
|
|||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
|
||||
"integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz",
|
||||
"integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==",
|
||||
"requires": {
|
||||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
|
@ -16694,9 +16918,9 @@
|
|||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "7.4.5",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.4.5.tgz",
|
||||
"integrity": "sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g=="
|
||||
"version": "7.5.5",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
|
||||
"integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w=="
|
||||
},
|
||||
"xml-name-validator": {
|
||||
"version": "3.0.0",
|
||||
|
|
|
@ -3,33 +3,34 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@mdi/js": "^5.9.55",
|
||||
"@mdi/js": "^6.4.95",
|
||||
"@mdi/react": "^1.5.0",
|
||||
"@testing-library/jest-dom": "^5.12.0",
|
||||
"@testing-library/react": "^11.2.6",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/node": "^12.20.12",
|
||||
"@types/react": "^17.0.5",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"@types/react-redux": "^7.1.16",
|
||||
"@testing-library/jest-dom": "^5.15.0",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/react": "^17.0.34",
|
||||
"@types/react-beautiful-dnd": "^13.1.2",
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-redux": "^7.1.20",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"axios": "^0.21.1",
|
||||
"axios": "^0.24.0",
|
||||
"external-svg-loader": "^1.3.4",
|
||||
"http-proxy-middleware": "^2.0.0",
|
||||
"http-proxy-middleware": "^2.0.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-redux": "^7.2.6",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"redux": "^4.1.0",
|
||||
"redux": "^4.1.2",
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"redux-thunk": "^2.4.0",
|
||||
"skycons-ts": "^0.2.0",
|
||||
"typescript": "^4.2.4",
|
||||
"web-vitals": "^1.1.2"
|
||||
"typescript": "^4.4.4",
|
||||
"web-vitals": "^2.1.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
|
@ -56,6 +57,6 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^2.3.2"
|
||||
"prettier": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
|
BIN
client/public/icons/apple-touch-icon-114x114.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
client/public/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 7.4 KiB |
BIN
client/public/icons/apple-touch-icon-144x144.png
Normal file
After Width: | Height: | Size: 7.1 KiB |
BIN
client/public/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
client/public/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/apple-touch-icon-57x57.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
BIN
client/public/icons/apple-touch-icon-72x72.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
client/public/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
client/public/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
@ -2,7 +2,51 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/icons/favicon.ico" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/icons/apple-touch-icon-180x180.png"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
|
|
|
@ -1,38 +1,79 @@
|
|||
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 { store } from './store/store';
|
||||
import { Provider } from 'react-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';
|
||||
|
||||
// Utils
|
||||
import { checkVersion } from './utility';
|
||||
import { checkVersion, decodeToken } from './utility';
|
||||
|
||||
// Routes
|
||||
import Home from './components/Home/Home';
|
||||
import Apps from './components/Apps/Apps';
|
||||
import Settings from './components/Settings/Settings';
|
||||
import Bookmarks from './components/Bookmarks/Bookmarks';
|
||||
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
||||
import { Home } from './components/Home/Home';
|
||||
import { Apps } from './components/Apps/Apps';
|
||||
import { Settings } from './components/Settings/Settings';
|
||||
import { Bookmarks } from './components/Bookmarks/Bookmarks';
|
||||
import { NotificationCenter } from './components/NotificationCenter/NotificationCenter';
|
||||
|
||||
// Get config pairs from database
|
||||
// Get config
|
||||
store.dispatch<any>(getConfig());
|
||||
|
||||
// Set theme
|
||||
if (localStorage.theme) {
|
||||
store.dispatch<any>(setTheme(localStorage.theme));
|
||||
// Validate token
|
||||
if (localStorage.token) {
|
||||
store.dispatch<any>(autoLogin());
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
checkVersion();
|
||||
export const App = (): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
|
||||
// fetch queries
|
||||
store.dispatch<any>(fetchQueries());
|
||||
const dispath = useDispatch();
|
||||
const { fetchQueries, setTheme, logout, createNotification } =
|
||||
bindActionCreators(actionCreators, dispath);
|
||||
|
||||
useEffect(() => {
|
||||
// check if token is valid
|
||||
const tokenIsValid = setInterval(() => {
|
||||
if (localStorage.token) {
|
||||
const expiresIn = decodeToken(localStorage.token).exp * 1000;
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (now > expiresIn) {
|
||||
logout();
|
||||
createNotification({
|
||||
title: 'Info',
|
||||
message: 'Session expired. You have been logged out',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// set user theme if present
|
||||
if (localStorage.theme) {
|
||||
setTheme(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(config.defaultTheme, false);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
const App = (): JSX.Element => {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<>
|
||||
<BrowserRouter>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Home} />
|
||||
|
@ -42,8 +83,6 @@ const App = (): JSX.Element => {
|
|||
</Switch>
|
||||
</BrowserRouter>
|
||||
<NotificationCenter />
|
||||
</Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
12
client/src/components/Actions/TableActions.module.css
Normal file
|
@ -0,0 +1,12 @@
|
|||
.TableActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.TableAction {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.TableAction:hover {
|
||||
cursor: pointer;
|
||||
}
|
81
client/src/components/Actions/TableActions.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
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>
|
||||
);
|
||||
};
|
|
@ -10,7 +10,7 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.AppCardIcon {
|
||||
.AppIcon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
margin-right: 0.5em;
|
||||
|
|
|
@ -1,64 +1,100 @@
|
|||
import { Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Category } from '../../../interfaces';
|
||||
import { iconParser, searchConfig, urlParser } from '../../../utility';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
import { Icon } from '../../UI';
|
||||
import classes from './AppCard.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
category: Category;
|
||||
apps: App[]
|
||||
pinHandler?: Function;
|
||||
fromHomepage?: boolean;
|
||||
}
|
||||
|
||||
const AppCard = (props: ComponentProps): JSX.Element => {
|
||||
export const AppCard = (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);
|
||||
|
||||
return (
|
||||
<div className={classes.AppCard}>
|
||||
<h3>{props.category.name}</h3>
|
||||
<h3
|
||||
className={
|
||||
fromHomepage || !isAuthenticated ? '' : classes.AppHeader
|
||||
}
|
||||
onClick={() => {
|
||||
if (!fromHomepage && isAuthenticated) {
|
||||
setEditCategory(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</h3>
|
||||
|
||||
<div className={classes.Apps}>
|
||||
{props.apps.map((app: App) => {
|
||||
const [displayUrl, redirectUrl] = urlParser(app.url);
|
||||
{category.apps.map((app: App) => {
|
||||
const redirectUrl = urlParser(app.url)[1];
|
||||
|
||||
let iconEl: JSX.Element;
|
||||
const { icon } = app;
|
||||
let iconEl: JSX.Element = <Fragment></Fragment>;
|
||||
|
||||
if (app.icon) {
|
||||
const { icon, name } = app;
|
||||
|
||||
if (isImage(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
if (/.(jpeg|jpg|png)$/i.test(icon)) {
|
||||
iconEl = (
|
||||
<div className={classes.AppIcon}>
|
||||
<img
|
||||
src={`/uploads/${icon}`}
|
||||
alt={`${app.name} icon`}
|
||||
src={source}
|
||||
alt={`${name} icon`}
|
||||
className={classes.CustomIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (/.(svg)$/i.test(icon)) {
|
||||
} else if (isSvg(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.CustomIcon}>
|
||||
<div className={classes.AppIcon}>
|
||||
<svg
|
||||
data-src={`/uploads/${icon}`}
|
||||
fill='var(--color-primary)'
|
||||
className={classes.CustomIcon}
|
||||
data-src={source}
|
||||
fill="var(--color-primary)"
|
||||
className={classes.AppIconSvg}
|
||||
></svg>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
iconEl = <Icon icon={iconParser(icon)} />;
|
||||
iconEl = (
|
||||
<div className={classes.AppIcon}>
|
||||
<Icon icon={iconParser(icon)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={redirectUrl}
|
||||
target={searchConfig('appsSameTab', false) ? '' : '_blank'}
|
||||
rel='noreferrer'
|
||||
key={`app-${app.id}`}>
|
||||
<div className={classes.AppCardIcon}>{iconEl}</div>
|
||||
<div className={classes.AppCardDetails}>
|
||||
<h5>{app.name}</h5>
|
||||
<span>{displayUrl}</span>
|
||||
</div>
|
||||
target={config.appsSameTab ? '' : '_blank'}
|
||||
rel="noreferrer"
|
||||
key={`app-${app.id}`}
|
||||
>
|
||||
{app.icon && iconEl}
|
||||
{app.name}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppCard;
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,325 +0,0 @@
|
|||
import { ChangeEvent, Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { App, Category, GlobalState, NewApp, NewCategory, NewNotification } from '../../../interfaces';
|
||||
import {
|
||||
addApp,
|
||||
addAppCategory,
|
||||
createNotification,
|
||||
getAppCategories,
|
||||
updateApp,
|
||||
updateAppCategory,
|
||||
} from '../../../store/actions';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||
import { ContentType } from '../Apps';
|
||||
import classes from './AppForm.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
modalHandler: () => void;
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
category?: Category;
|
||||
app?: App;
|
||||
addAppCategory: (formData: NewCategory) => void;
|
||||
addApp: (formData: NewApp | FormData) => void;
|
||||
updateAppCategory: (id: number, formData: NewCategory) => void;
|
||||
updateApp: (id: number, formData: NewApp | FormData, previousCategoryId: number) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
|
||||
const AppForm = (props: ComponentProps): JSX.Element => {
|
||||
const [useCustomIcon, setUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
const [categoryData, setCategoryData] = useState<NewCategory>({
|
||||
name: '',
|
||||
type: 'apps'
|
||||
})
|
||||
|
||||
const [appData, setAppData] = useState<NewApp>({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1,
|
||||
icon: '',
|
||||
});
|
||||
|
||||
// Load category data if provided for editing
|
||||
useEffect(() => {
|
||||
if (props.category) {
|
||||
setCategoryData({ name: props.category.name, type: props.category.type });
|
||||
} else {
|
||||
setCategoryData({ name: '', type: "apps" });
|
||||
}
|
||||
}, [props.category]);
|
||||
|
||||
// Load app data if provided for editing
|
||||
useEffect(() => {
|
||||
if (props.app) {
|
||||
setAppData({
|
||||
name: props.app.name,
|
||||
url: props.app.url,
|
||||
categoryId: props.app.categoryId,
|
||||
icon: props.app.icon,
|
||||
});
|
||||
} else {
|
||||
setAppData({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1,
|
||||
icon: '',
|
||||
});
|
||||
}
|
||||
}, [props.app]);
|
||||
|
||||
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const createFormData = (): FormData => {
|
||||
const data = new FormData();
|
||||
Object.entries(appData).forEach((entry: [string, any]) => {
|
||||
data.append(entry[0], entry[1]);
|
||||
});
|
||||
if (customIcon) {
|
||||
data.append('icon', customIcon);
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
if (!props.category && !props.app) {
|
||||
// Add new
|
||||
if (props.contentType === ContentType.category) {
|
||||
// Add category
|
||||
props.addAppCategory(categoryData);
|
||||
setCategoryData({ name: '', type: 'apps' });
|
||||
} else if (props.contentType === ContentType.app) {
|
||||
// Add app
|
||||
if (appData.categoryId === -1) {
|
||||
props.createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select a category'
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
props.addApp(data);
|
||||
} else {
|
||||
props.addApp(appData);
|
||||
}
|
||||
setAppData({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: appData.categoryId,
|
||||
icon: ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Update
|
||||
if (props.contentType === ContentType.category && props.category) {
|
||||
// Update category
|
||||
props.updateAppCategory(props.category.id, categoryData);
|
||||
setCategoryData({ name: '', type: 'apps' });
|
||||
} else if (props.contentType === ContentType.app && props.app) {
|
||||
// Update app
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
props.updateApp(props.app.id, data, props.app.categoryId);
|
||||
} else {
|
||||
props.updateApp(props.app.id, appData, props.app.categoryId);
|
||||
props.modalHandler();
|
||||
}
|
||||
}
|
||||
|
||||
setAppData({
|
||||
name: '',
|
||||
url: '',
|
||||
categoryId: -1,
|
||||
icon: ''
|
||||
});
|
||||
|
||||
setCustomIcon(null);
|
||||
}
|
||||
}
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, setDataFunction: Dispatch<SetStateAction<any>>, data: any): void => {
|
||||
setDataFunction({
|
||||
...data,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
const toggleUseCustomIcon = (): void => {
|
||||
setUseCustomIcon(!useCustomIcon);
|
||||
setCustomIcon(null);
|
||||
};
|
||||
|
||||
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files) {
|
||||
setCustomIcon(e.target.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
let button = <Button>Submit</Button>
|
||||
|
||||
if (!props.category && !props.app) {
|
||||
if (props.contentType === ContentType.category) {
|
||||
button = <Button>Add new category</Button>;
|
||||
} else {
|
||||
button = <Button>Add new app</Button>;
|
||||
}
|
||||
} else if (props.category) {
|
||||
button = <Button>Update category</Button>
|
||||
} else if (props.app) {
|
||||
button = <Button>Update app</Button>
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
modalHandler={props.modalHandler}
|
||||
formHandler={formSubmitHandler}
|
||||
>
|
||||
{props.contentType === ContentType.category
|
||||
? (
|
||||
<Fragment>
|
||||
<InputGroup>
|
||||
<label htmlFor='categoryName'>Category Name</label>
|
||||
<input
|
||||
type='text'
|
||||
name='name'
|
||||
id='categoryName'
|
||||
placeholder='Social Media'
|
||||
required
|
||||
value={categoryData.name}
|
||||
onChange={(e) => inputChangeHandler(e, setCategoryData, categoryData)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Fragment>
|
||||
)
|
||||
: (
|
||||
<Fragment>
|
||||
<InputGroup>
|
||||
<label htmlFor='name'>App Name</label>
|
||||
<input
|
||||
type='text'
|
||||
name='name'
|
||||
id='name'
|
||||
placeholder='Bookstack'
|
||||
required
|
||||
value={appData.name}
|
||||
onChange={(e) => inputChangeHandler(e, setAppData, appData)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor='url'>App URL</label>
|
||||
<input
|
||||
type='text'
|
||||
name='url'
|
||||
id='url'
|
||||
placeholder='bookstack.example.com'
|
||||
required
|
||||
value={appData.url}
|
||||
onChange={(e) => inputChangeHandler(e, setAppData, appData)}
|
||||
/>
|
||||
<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'>App Category</label>
|
||||
<select
|
||||
name='categoryId'
|
||||
id='categoryId'
|
||||
required
|
||||
onChange={(e) => inputChangeHandler(e, setAppData, appData)}
|
||||
value={appData.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
|
||||
// use mdi icon
|
||||
? (<InputGroup>
|
||||
<label htmlFor='icon'>App Icon</label>
|
||||
<input
|
||||
type='text'
|
||||
name='icon'
|
||||
id='icon'
|
||||
placeholder='book-open-outline'
|
||||
required
|
||||
value={appData.icon}
|
||||
onChange={(e) => inputChangeHandler(e, setAppData, appData)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI.
|
||||
<a
|
||||
href='https://materialdesignicons.com/'
|
||||
target='blank'>
|
||||
{' '}Click here for reference
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon()}
|
||||
className={classes.Switch}>
|
||||
Switch to custom icon upload
|
||||
</span>
|
||||
</InputGroup>)
|
||||
// upload custom icon
|
||||
: (<InputGroup>
|
||||
<label htmlFor='icon'>App Icon</label>
|
||||
<input
|
||||
type='file'
|
||||
name='icon'
|
||||
id='icon'
|
||||
required
|
||||
onChange={(e) => fileChangeHandler(e)}
|
||||
accept='.jpg,.jpeg,.png,.svg'
|
||||
/>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon()}
|
||||
className={classes.Switch}>
|
||||
Switch to MDI
|
||||
</span>
|
||||
</InputGroup>)
|
||||
}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
{button}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
categories: state.app.categories
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchMap = {
|
||||
getAppCategories,
|
||||
addAppCategory,
|
||||
addApp,
|
||||
updateAppCategory,
|
||||
updateApp,
|
||||
createNotification
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, dispatchMap)(AppForm);
|
|
@ -20,21 +20,3 @@
|
|||
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;
|
||||
}
|
|
@ -1,63 +1,62 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { App, Category } from '../../../interfaces';
|
||||
import AppCard from '../AppCard/AppCard';
|
||||
import { Category } from '../../../interfaces';
|
||||
import { Message } from '../../UI';
|
||||
import { AppCard } from '../AppCard/AppCard';
|
||||
import classes from './AppGrid.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
apps: App[];
|
||||
totalCategories?: number;
|
||||
searching: boolean;
|
||||
fromHomepage?: boolean;
|
||||
}
|
||||
|
||||
const AppGrid = (props: ComponentProps): JSX.Element => {
|
||||
export const AppGrid = (props: Props): JSX.Element => {
|
||||
const {
|
||||
categories,
|
||||
totalCategories,
|
||||
searching,
|
||||
fromHomepage = false,
|
||||
} = props;
|
||||
|
||||
let apps: JSX.Element;
|
||||
|
||||
if (props.categories.length > 0) {
|
||||
if (props.apps.length > 0) {
|
||||
if (categories.length) {
|
||||
if (searching && !categories[0].apps.length) {
|
||||
apps = <Message>No apps match your search criteria</Message>;
|
||||
} else {
|
||||
apps = (
|
||||
<div className={classes.AppGrid}>
|
||||
{props.categories.map((category: Category): JSX.Element => {
|
||||
return <AppCard key={category.id} category={category} apps={props.apps.filter((app: App) => app.categoryId === category.id)} />
|
||||
})}
|
||||
{categories.map(
|
||||
(category: Category): JSX.Element => (
|
||||
<AppCard
|
||||
category={category}
|
||||
fromHomepage={fromHomepage}
|
||||
key={category.id}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
if (props.searching) {
|
||||
apps = (
|
||||
<p className={classes.AppsMessage}>
|
||||
No apps match your search criteria
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
apps = (
|
||||
<p className={classes.AppsMessage}>
|
||||
You don't have any applications. You can add a new one from the{' '}
|
||||
<Link to="/applications">/applications</Link> menu
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (props.totalCategories) {
|
||||
if (totalCategories) {
|
||||
apps = (
|
||||
<p className={classes.AppsMessage}>
|
||||
There are no pinned application categories. You can pin them from the{' '}
|
||||
<Link to="/applications">/applications</Link> menu
|
||||
</p>
|
||||
<Message>
|
||||
There are no pinned categories. You can pin them from the{' '}
|
||||
<Link to="/apps">/apps</Link> menu
|
||||
</Message>
|
||||
);
|
||||
} else {
|
||||
apps = (
|
||||
<p className={classes.AppsMessage}>
|
||||
You don't have any applications. You can add a new one from the{' '}
|
||||
<Link to="/applications">/applications</Link> menu
|
||||
</p>
|
||||
<Message>
|
||||
You don't have any apps. You can add a new one from{' '}
|
||||
<Link to="/apps">/apps</Link> menu
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
export default AppGrid;
|
||||
|
|
|
@ -1,100 +1,51 @@
|
|||
import { Fragment, KeyboardEvent, useEffect, useState } from 'react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Category, NewNotification } from '../../../interfaces';
|
||||
import {
|
||||
createNotification,
|
||||
deleteApp,
|
||||
deleteAppCategory,
|
||||
pinApp,
|
||||
pinAppCategory,
|
||||
reorderAppCategories,
|
||||
reorderApps,
|
||||
updateConfig,
|
||||
} from '../../../store/actions';
|
||||
import { searchConfig } from '../../../utility';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import Table from '../../UI/Table/Table';
|
||||
import { ContentType } from '../Apps';
|
||||
import classes from './AppTable.module.css';
|
||||
import { App, Category } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { appTemplate } from '../../../utility';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { Message, Table } from '../../UI';
|
||||
|
||||
interface ComponentProps {
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
apps: App[];
|
||||
pinAppCategory: (category: Category) => void;
|
||||
deleteAppCategory: (id: number) => void;
|
||||
reorderAppCategories: (categories: Category[]) => void;
|
||||
updateHandler: (data: Category | App) => void;
|
||||
pinApp: (app: App) => void;
|
||||
deleteApp: (id: number, categoryId: number) => void;
|
||||
reorderApps: (apps: App[]) => void;
|
||||
updateConfig: (formData: any) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
// Redux
|
||||
// Typescript
|
||||
// UI
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | App) => void;
|
||||
}
|
||||
|
||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
||||
const [localCategories, setLocalCategories] = useState<Category[]>([]);
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||
export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
apps: { categoryInEdit },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
// Copy categories array
|
||||
useEffect(() => {
|
||||
setLocalCategories([...props.categories]);
|
||||
}, [props.categories]);
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
deleteApp,
|
||||
updateApp,
|
||||
createNotification,
|
||||
reorderApps,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
|
||||
// Copy apps array
|
||||
useEffect(() => {
|
||||
setLocalApps([...props.apps]);
|
||||
}, [props.apps]);
|
||||
|
||||
// Check ordering
|
||||
useEffect(() => {
|
||||
const order = searchConfig("useOrdering", "");
|
||||
|
||||
if (order === "orderId") {
|
||||
setIsCustomOrder(true);
|
||||
if (categoryInEdit) {
|
||||
setLocalApps([...categoryInEdit.apps]);
|
||||
}
|
||||
}, []);
|
||||
}, [categoryInEdit]);
|
||||
|
||||
const deleteCategoryHandler = (category: Category): void => {
|
||||
const proceed = window.confirm(
|
||||
`Are you sure you want to delete ${category.name}? It will delete ALL assigned apps`
|
||||
);
|
||||
|
||||
if (proceed) {
|
||||
props.deleteAppCategory(category.id);
|
||||
}
|
||||
};
|
||||
|
||||
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, app.categoryId);
|
||||
}
|
||||
};
|
||||
|
||||
// Support keyboard navigation for actions
|
||||
const keyboardActionHandler = (
|
||||
e: KeyboardEvent,
|
||||
object: any,
|
||||
handler: Function
|
||||
) => {
|
||||
if (e.key === "Enter") {
|
||||
handler(object);
|
||||
}
|
||||
};
|
||||
|
||||
const dragEndHandler = (result: DropResult): void => {
|
||||
if (!isCustomOrder) {
|
||||
props.createNotification({
|
||||
title: "Error",
|
||||
message: "Custom order is disabled",
|
||||
// Drag and drop handler
|
||||
const dragEndHanlder = (result: DropResult): void => {
|
||||
if (config.useOrdering !== 'orderId') {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Custom order is disabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -103,149 +54,76 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (props.contentType === ContentType.app) {
|
||||
const tmpApps = [...localApps];
|
||||
const [movedApp] = tmpApps.splice(result.source.index, 1);
|
||||
tmpApps.splice(result.destination.index, 0, movedApp);
|
||||
|
||||
setLocalApps(tmpApps);
|
||||
props.reorderApps(tmpApps);
|
||||
} else if (props.contentType === ContentType.category) {
|
||||
const tmpCategories = [...localCategories];
|
||||
const [movedCategory] = tmpCategories.splice(result.source.index, 1);
|
||||
tmpCategories.splice(result.destination.index, 0, movedCategory);
|
||||
|
||||
setLocalCategories(tmpCategories);
|
||||
props.reorderAppCategories(tmpCategories);
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
reorderApps(tmpApps, categoryId);
|
||||
};
|
||||
|
||||
// Action hanlders
|
||||
const deleteAppHandler = (id: number, name: string) => {
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
|
||||
if (proceed) {
|
||||
deleteApp(id, categoryId);
|
||||
}
|
||||
};
|
||||
|
||||
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={dragEndHandler}>
|
||||
<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,
|
||||
const updateAppHandler = (id: number) => {
|
||||
const app =
|
||||
categoryInEdit?.apps.find((b) => b.id === id) || appTemplate;
|
||||
|
||||
openFormForUpdating(app);
|
||||
};
|
||||
|
||||
const changeAppVisibiltyHandler = (id: number) => {
|
||||
const app =
|
||||
categoryInEdit?.apps.find((b) => b.id === id) || appTemplate;
|
||||
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
const [prev, curr] = [categoryId, categoryId];
|
||||
|
||||
updateApp(
|
||||
id,
|
||||
{ ...app, isPublic: !app.isPublic },
|
||||
{ prev, curr }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
style={style}
|
||||
>
|
||||
<td>{category.name}</td>
|
||||
{!snapshot.isDragging && category.id >= 0 && (
|
||||
<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.pinAppCategory(category)}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
category,
|
||||
props.pinAppCategory
|
||||
)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{category.isPinned ? (
|
||||
<Icon
|
||||
icon="mdiPinOff"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="mdiPin" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className={classes.Message}>
|
||||
{isCustomOrder ? (
|
||||
<p>You can drag and drop single rows to reorder application</p>
|
||||
{!categoryInEdit ? (
|
||||
<Message isPrimary={false}>
|
||||
Switch to grid view and click on the name of category you want to edit
|
||||
</Message>
|
||||
) : (
|
||||
<p>
|
||||
Custom order is disabled. You can change it in{" "}
|
||||
<Link to="/settings/other">settings</Link>
|
||||
</p>
|
||||
<Message isPrimary={false}>
|
||||
Editing apps from <span>{categoryInEdit.name}</span>
|
||||
category
|
||||
</Message>
|
||||
)}
|
||||
</div>
|
||||
<DragDropContext onDragEnd={dragEndHandler}>
|
||||
|
||||
{categoryInEdit && (
|
||||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId="apps">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={["Name", "URL", "Icon", "Category", "Actions"]}
|
||||
headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'Icon',
|
||||
'Visibility',
|
||||
'Category',
|
||||
'Actions',
|
||||
]}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localApps.map((app: App, index): JSX.Element => {
|
||||
{localApps.map((app, index): JSX.Element => {
|
||||
return (
|
||||
<Draggable
|
||||
key={app.id}
|
||||
|
@ -255,15 +133,12 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
{(provided, snapshot) => {
|
||||
const style = {
|
||||
border: snapshot.isDragging
|
||||
? "1px solid var(--color-accent)"
|
||||
: "none",
|
||||
borderRadius: "4px",
|
||||
? '1px solid var(--color-accent)'
|
||||
: 'none',
|
||||
borderRadius: '4px',
|
||||
...provided.draggableProps.style,
|
||||
};
|
||||
|
||||
const category = localCategories.find((category: Category) => category.id === app.categoryId);
|
||||
const categoryName = category?.name;
|
||||
|
||||
return (
|
||||
<tr
|
||||
{...provided.draggableProps}
|
||||
|
@ -271,58 +146,24 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
ref={provided.innerRef}
|
||||
style={style}
|
||||
>
|
||||
<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" }}>{categoryName}</td>
|
||||
{!snapshot.isDragging && (
|
||||
<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.updateHandler(app)}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
app,
|
||||
props.updateHandler
|
||||
)
|
||||
}
|
||||
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 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>
|
||||
<td style={{ width: '200px' }}>
|
||||
{categoryInEdit.name}
|
||||
</td>
|
||||
|
||||
{!snapshot.isDragging && (
|
||||
<TableActions
|
||||
entity={app}
|
||||
deleteHandler={deleteAppHandler}
|
||||
updateHandler={updateAppHandler}
|
||||
changeVisibilty={changeAppVisibiltyHandler}
|
||||
showPin={false}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
|
@ -334,20 +175,7 @@ const AppTable = (props: ComponentProps): JSX.Element => {
|
|||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const actions = {
|
||||
pinAppCategory,
|
||||
deleteAppCategory,
|
||||
reorderAppCategories,
|
||||
pinApp,
|
||||
deleteApp,
|
||||
reorderApps,
|
||||
updateConfig,
|
||||
createNotification,
|
||||
};
|
||||
|
||||
export default connect(null, actions)(AppTable);
|
||||
|
|
|
@ -1,25 +1,18 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Category, GlobalState } from '../../interfaces';
|
||||
import { getAppCategories, getApps } from '../../store/actions';
|
||||
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Modal from '../UI/Modal/Modal';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import AppForm from './AppForm/AppForm';
|
||||
import AppGrid from './AppGrid/AppGrid';
|
||||
import { App, Category } from '../../interfaces';
|
||||
import { actionCreators } from '../../store';
|
||||
import { State } from '../../store/reducers';
|
||||
import { ActionButton, Container, Headline, Message, Modal, Spinner } from '../UI';
|
||||
import { AppGrid } from './AppGrid/AppGrid';
|
||||
import classes from './Apps.module.css';
|
||||
import AppTable from './AppTable/AppTable';
|
||||
import { Form } from './Form/Form';
|
||||
import { Table } from './Table/Table';
|
||||
|
||||
interface ComponentProps {
|
||||
loading: boolean;
|
||||
categories: Category[];
|
||||
getAppCategories: () => void;
|
||||
apps: App[];
|
||||
getApps: () => void;
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
|
@ -28,131 +21,159 @@ export enum ContentType {
|
|||
app,
|
||||
}
|
||||
|
||||
const Apps = (props: ComponentProps): JSX.Element => {
|
||||
const { apps, getApps, getAppCategories, categories, loading, searching = false } = props;
|
||||
export const Apps = (props: Props): JSX.Element => {
|
||||
// Get Redux state
|
||||
const {
|
||||
apps: { loading, categories, categoryInEdit },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
// Get Redux action creators
|
||||
const dispatch = useDispatch();
|
||||
const { getCategories, setEditCategory, setEditApp } =
|
||||
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 [isInEdit, setIsInEdit] = useState(false);
|
||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||
|
||||
// Table
|
||||
const [showTable, setShowTable] = 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,
|
||||
type: "apps",
|
||||
apps: [],
|
||||
bookmarks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const [appInUpdate, setAppInUpdate] = useState<App>({
|
||||
name: "string",
|
||||
url: "string",
|
||||
categoryId: -1,
|
||||
icon: "string",
|
||||
isPinned: false,
|
||||
orderId: 0,
|
||||
id: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Observe if user is authenticated -> set default view (grid) if not
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setShowTable(false);
|
||||
setModalIsOpen(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apps.length === 0) {
|
||||
getApps();
|
||||
if (categoryInEdit && !modalIsOpen) {
|
||||
setTableContentType(ContentType.app);
|
||||
setShowTable(true);
|
||||
}
|
||||
}, [getApps]);
|
||||
}, [categoryInEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) {
|
||||
getAppCategories();
|
||||
}
|
||||
}, [getAppCategories]);
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
}, []);
|
||||
|
||||
// Form actions
|
||||
const toggleModal = (): void => {
|
||||
setModalIsOpen(!modalIsOpen);
|
||||
};
|
||||
|
||||
const addActionHandler = (contentType: ContentType) => {
|
||||
const openFormForAdding = (contentType: ContentType) => {
|
||||
setFormContentType(contentType);
|
||||
setIsInUpdate(false);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
const editActionHandler = (contentType: ContentType) => {
|
||||
// We"re in the edit mode and the same button was clicked - go back to list
|
||||
if (isInEdit && contentType === tableContentType) {
|
||||
setIsInEdit(false);
|
||||
const openFormForUpdating = (data: Category | App): void => {
|
||||
setIsInUpdate(true);
|
||||
|
||||
const instanceOfCategory = (object: any): object is Category => {
|
||||
return 'apps' in object;
|
||||
};
|
||||
|
||||
if (instanceOfCategory(data)) {
|
||||
setFormContentType(ContentType.category);
|
||||
setEditCategory(data);
|
||||
} else {
|
||||
setIsInEdit(true);
|
||||
setFormContentType(ContentType.app);
|
||||
setEditApp(data);
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
// Table actions
|
||||
const showTableForEditing = (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);
|
||||
} else {
|
||||
setShowTable(true);
|
||||
setTableContentType(contentType);
|
||||
}
|
||||
};
|
||||
|
||||
const instanceOfCategory = (object: any): object is Category => {
|
||||
return !("categoryId" in object);
|
||||
};
|
||||
|
||||
const goToUpdateMode = (data: Category | App): void => {
|
||||
setIsInUpdate(true);
|
||||
if (instanceOfCategory(data)) {
|
||||
setFormContentType(ContentType.category);
|
||||
setCategoryInUpdate(data);
|
||||
} else {
|
||||
setFormContentType(ContentType.app);
|
||||
setAppInUpdate(data);
|
||||
}
|
||||
toggleModal();
|
||||
const finishEditing = () => {
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
|
||||
{!isInUpdate ? (
|
||||
<AppForm modalHandler={toggleModal} contentType={formContentType} />
|
||||
) : (
|
||||
formContentType === ContentType.category ? (
|
||||
<AppForm modalHandler={toggleModal} contentType={formContentType} category={categoryInUpdate} />
|
||||
) : (
|
||||
<AppForm modalHandler={toggleModal} contentType={formContentType} app={appInUpdate} />
|
||||
)
|
||||
)}
|
||||
<Form
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
inUpdate={isInUpdate}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Headline
|
||||
title="All Applications"
|
||||
subtitle={(<Link to="/">Go back</Link>)}
|
||||
/>
|
||||
<Headline title="All Apps" subtitle={<Link to="/">Go back</Link>} />
|
||||
|
||||
{isAuthenticated && (
|
||||
<div className={classes.ActionsContainer}>
|
||||
<ActionButton name="Add Category" icon="mdiPlusBox" handler={() => addActionHandler(ContentType.category)} />
|
||||
<ActionButton name="Add App" icon="mdiPlusBox" handler={() => addActionHandler(ContentType.app)} />
|
||||
<ActionButton name="Edit Categories" icon="mdiPencil" handler={() => editActionHandler(ContentType.category)} />
|
||||
<ActionButton name="Edit Apps" icon="mdiPencil" handler={() => editActionHandler(ContentType.app)} />
|
||||
<ActionButton
|
||||
name="Add Category"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => openFormForAdding(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Add App"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => openFormForAdding(ContentType.app)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Categories"
|
||||
icon="mdiPencil"
|
||||
handler={() => showTableForEditing(ContentType.category)}
|
||||
/>
|
||||
{showTable && tableContentType === ContentType.app && (
|
||||
<ActionButton
|
||||
name="Finish Editing"
|
||||
icon="mdiPencil"
|
||||
handler={finishEditing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length && isAuthenticated && !showTable ? (
|
||||
<Message isPrimary={false}>
|
||||
Click on category name to edit its apps
|
||||
</Message>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (!isInEdit ? (
|
||||
<AppGrid categories={categories} apps={apps} searching />
|
||||
) : !showTable ? (
|
||||
<AppGrid categories={categories} searching={props.searching} />
|
||||
) : (
|
||||
<AppTable contentType={tableContentType} categories={categories} apps={apps} updateHandler={goToUpdateMode} />
|
||||
)
|
||||
<Table
|
||||
contentType={tableContentType}
|
||||
openFormForUpdating={openFormForUpdating}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.app.loading,
|
||||
categories: state.app.categories,
|
||||
apps: state.app.apps,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { getApps, getAppCategories })(Apps);
|
||||
|
|
260
client/src/components/Apps/Form/AppsForm.tsx
Normal file
|
@ -0,0 +1,260 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Category, NewApp } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { inputHandler, newAppTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, ModalForm } from '../../UI';
|
||||
import classes from './Form.module.css';
|
||||
|
||||
// Redux
|
||||
// Typescript
|
||||
// UI
|
||||
// CSS
|
||||
// Utils
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
app?: App;
|
||||
}
|
||||
|
||||
export const AppsForm = ({
|
||||
app,
|
||||
modalHandler,
|
||||
}: Props): JSX.Element => {
|
||||
const { categories } = useSelector((state: State) => state.apps);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { addApp, updateApp, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<NewApp>(newAppTemplate);
|
||||
|
||||
// Load app data if provided for editing
|
||||
useEffect(() => {
|
||||
if (app) {
|
||||
setFormData({ ...app });
|
||||
} else {
|
||||
setFormData(newAppTemplate);
|
||||
}
|
||||
}, [app]);
|
||||
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<NewApp>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files) {
|
||||
setCustomIcon(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Apps form handler
|
||||
const formSubmitHandler = (e: FormEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const createFormData = (): FormData => {
|
||||
const data = new FormData();
|
||||
if (customIcon) {
|
||||
data.append('icon', customIcon);
|
||||
}
|
||||
data.append('name', formData.name);
|
||||
data.append('url', formData.url);
|
||||
data.append('categoryId', `${formData.categoryId}`);
|
||||
data.append('isPublic', `${formData.isPublic ? 1 : 0}`);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const checkCategory = (): boolean => {
|
||||
if (formData.categoryId < 0) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select category',
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!app) {
|
||||
// add new app
|
||||
if (!checkCategory()) return;
|
||||
|
||||
if (formData.categoryId < 0) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
addApp(data);
|
||||
} else {
|
||||
addApp(formData);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...newAppTemplate,
|
||||
categoryId: formData.categoryId,
|
||||
isPublic: formData.isPublic,
|
||||
});
|
||||
} else {
|
||||
// update
|
||||
if (!checkCategory()) return;
|
||||
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
updateApp(app.id, data, {
|
||||
prev: app.categoryId,
|
||||
curr: formData.categoryId,
|
||||
});
|
||||
} else {
|
||||
updateApp(app.id, formData, {
|
||||
prev: app.categoryId,
|
||||
curr: formData.categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
modalHandler();
|
||||
}
|
||||
|
||||
setFormData({ ...newAppTemplate, categoryId: formData.categoryId });
|
||||
setCustomIcon(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm modalHandler={modalHandler} formHandler={formSubmitHandler}>
|
||||
{/* NAME */}
|
||||
<InputGroup>
|
||||
<label htmlFor="name">App 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">App 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">App 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">App 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">App 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">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>
|
||||
|
||||
<Button>{app ? 'Update app' : 'Add new app'}</Button>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
97
client/src/components/Apps/Form/CategoryForm.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { Category, NewCategory } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { inputHandler, newAppCategoryTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, ModalForm } from '../../UI';
|
||||
|
||||
// Redux
|
||||
// Typescript
|
||||
// UI
|
||||
// Utils
|
||||
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>(newAppCategoryTemplate);
|
||||
|
||||
// Load category data if provided for editing
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setFormData({ ...category });
|
||||
} else {
|
||||
setFormData(newAppCategoryTemplate);
|
||||
}
|
||||
}, [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(newAppCategoryTemplate);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
54
client/src/components/Apps/Form/Form.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Fragment } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { State } from '../../../store/reducers';
|
||||
import { appCategoryTemplate, appTemplate } from '../../../utility';
|
||||
import { ContentType } from '../Apps';
|
||||
import { AppsForm } from './AppsForm';
|
||||
import { CategoryForm } from './CategoryForm';
|
||||
|
||||
// Typescript
|
||||
// Utils
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
contentType: ContentType;
|
||||
inUpdate?: boolean;
|
||||
}
|
||||
|
||||
export const Form = (props: Props): JSX.Element => {
|
||||
const { categoryInEdit, appInEdit } = useSelector(
|
||||
(state: State) => state.apps
|
||||
);
|
||||
|
||||
const { modalHandler, contentType, inUpdate } = props;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!inUpdate ? (
|
||||
// form: add new
|
||||
<Fragment>
|
||||
{contentType === ContentType.category ? (
|
||||
<CategoryForm modalHandler={modalHandler} />
|
||||
) : (
|
||||
<AppsForm modalHandler={modalHandler} />
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
// form: update
|
||||
<Fragment>
|
||||
{contentType === ContentType.category ? (
|
||||
<CategoryForm
|
||||
modalHandler={modalHandler}
|
||||
category={categoryInEdit || appCategoryTemplate}
|
||||
/>
|
||||
) : (
|
||||
<AppsForm
|
||||
modalHandler={modalHandler}
|
||||
app={appInEdit || appTemplate}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
181
client/src/components/Apps/Table/AppsTable.tsx
Normal file
|
@ -0,0 +1,181 @@
|
|||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Category } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { appTemplate } from '../../../utility';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { Message, Table } from '../../UI';
|
||||
|
||||
// Redux
|
||||
// Typescript
|
||||
// UI
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | App) => void;
|
||||
}
|
||||
|
||||
export const AppsTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
apps: { categoryInEdit },
|
||||
config: { config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const {
|
||||
deleteApp,
|
||||
updateApp,
|
||||
createNotification,
|
||||
reorderApps,
|
||||
} = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||
|
||||
// Copy apps array
|
||||
useEffect(() => {
|
||||
if (categoryInEdit) {
|
||||
setLocalApps([...categoryInEdit.apps]);
|
||||
}
|
||||
}, [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 tmpApps = [...localApps];
|
||||
const [movedApp] = tmpApps.splice(result.source.index, 1);
|
||||
tmpApps.splice(result.destination.index, 0, movedApp);
|
||||
|
||||
setLocalApps(tmpApps);
|
||||
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
reorderApps(tmpApps, categoryId);
|
||||
};
|
||||
|
||||
// Action hanlders
|
||||
const deleteAppHandler = (id: number, name: string) => {
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
|
||||
const proceed = window.confirm(`Are you sure you want to delete ${name}?`);
|
||||
if (proceed) {
|
||||
deleteApp(id, categoryId);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAppHandler = (id: number) => {
|
||||
const app =
|
||||
categoryInEdit?.apps.find((b) => b.id === id) || appTemplate;
|
||||
|
||||
openFormForUpdating(app);
|
||||
};
|
||||
|
||||
const changeAppVisibiltyHandler = (id: number) => {
|
||||
const app =
|
||||
categoryInEdit?.apps.find((b) => b.id === id) || appTemplate;
|
||||
|
||||
const categoryId = categoryInEdit?.id || -1;
|
||||
const [prev, curr] = [categoryId, categoryId];
|
||||
|
||||
updateApp(
|
||||
id,
|
||||
{ ...app, isPublic: !app.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 apps from <span>{categoryInEdit.name}</span>
|
||||
category
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{categoryInEdit && (
|
||||
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||
<Droppable droppableId="apps">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={[
|
||||
'Name',
|
||||
'URL',
|
||||
'Icon',
|
||||
'Visibility',
|
||||
'Category',
|
||||
'Actions',
|
||||
]}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localApps.map((app, index): JSX.Element => {
|
||||
return (
|
||||
<Draggable
|
||||
key={app.id}
|
||||
draggableId={app.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' }}>{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>
|
||||
<td style={{ width: '200px' }}>
|
||||
{categoryInEdit.name}
|
||||
</td>
|
||||
|
||||
{!snapshot.isDragging && (
|
||||
<TableActions
|
||||
entity={app}
|
||||
deleteHandler={deleteAppHandler}
|
||||
updateHandler={updateAppHandler}
|
||||
changeVisibilty={changeAppVisibiltyHandler}
|
||||
showPin={false}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
159
client/src/components/Apps/Table/CategoryTable.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Category } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { TableActions } from '../../Actions/TableActions';
|
||||
import { Message, Table } from '../../UI';
|
||||
|
||||
// Redux
|
||||
// Typescript
|
||||
// UI
|
||||
interface Props {
|
||||
openFormForUpdating: (data: Category | App) => void;
|
||||
}
|
||||
|
||||
export const CategoryTable = ({ openFormForUpdating }: Props): JSX.Element => {
|
||||
const {
|
||||
config: { config },
|
||||
apps: { 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 apps`
|
||||
);
|
||||
|
||||
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/interface">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>
|
||||
);
|
||||
};
|
20
client/src/components/Apps/Table/Table.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { App, Category } from '../../../interfaces';
|
||||
import { ContentType } from '../Apps';
|
||||
import { AppsTable } from './AppsTable';
|
||||
import { CategoryTable } from './CategoryTable';
|
||||
|
||||
interface Props {
|
||||
contentType: ContentType;
|
||||
openFormForUpdating: (data: Category | App) => void;
|
||||
}
|
||||
|
||||
export const Table = (props: Props): JSX.Element => {
|
||||
const tableEl =
|
||||
props.contentType === ContentType.category ? (
|
||||
<CategoryTable openFormForUpdating={props.openFormForUpdating} />
|
||||
) : (
|
||||
<AppsTable openFormForUpdating={props.openFormForUpdating} />
|
||||
);
|
||||
|
||||
return tableEl;
|
||||
};
|
|
@ -10,6 +10,10 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.BookmarkHeader:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Bookmarks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -1,22 +1,47 @@
|
|||
import { Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import { iconParser, searchConfig, urlParser } from '../../../utility';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { iconParser, isImage, isSvg, isUrl, urlParser } from '../../../utility';
|
||||
import { Icon } from '../../UI';
|
||||
import classes from './BookmarkCard.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
category: Category;
|
||||
bookmarks: Bookmark[];
|
||||
pinHandler?: Function;
|
||||
fromHomepage?: boolean;
|
||||
}
|
||||
|
||||
const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className={classes.BookmarkCard}>
|
||||
<h3>{props.category.name}</h3>
|
||||
<h3
|
||||
className={
|
||||
fromHomepage || !isAuthenticated ? '' : classes.BookmarkHeader
|
||||
}
|
||||
onClick={() => {
|
||||
if (!fromHomepage && isAuthenticated) {
|
||||
setEditCategory(category);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{category.name}
|
||||
</h3>
|
||||
|
||||
<div className={classes.Bookmarks}>
|
||||
{props.bookmarks.map((bookmark: Bookmark) => {
|
||||
{category.bookmarks.map((bookmark: Bookmark) => {
|
||||
const redirectUrl = urlParser(bookmark.url)[1];
|
||||
|
||||
let iconEl: JSX.Element = <Fragment></Fragment>;
|
||||
|
@ -24,21 +49,25 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||
if (bookmark.icon) {
|
||||
const { icon, name } = bookmark;
|
||||
|
||||
if (/.(jpeg|jpg|png)$/i.test(icon)) {
|
||||
if (isImage(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<img
|
||||
src={`/uploads/${icon}`}
|
||||
src={source}
|
||||
alt={`${name} icon`}
|
||||
className={classes.CustomIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (/.(svg)$/i.test(icon)) {
|
||||
} else if (isSvg(icon)) {
|
||||
const source = isUrl(icon) ? icon : `/uploads/${icon}`;
|
||||
|
||||
iconEl = (
|
||||
<div className={classes.BookmarkIcon}>
|
||||
<svg
|
||||
data-src={`/uploads/${icon}`}
|
||||
data-src={source}
|
||||
fill="var(--color-primary)"
|
||||
className={classes.BookmarkIconSvg}
|
||||
></svg>
|
||||
|
@ -56,7 +85,7 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||
return (
|
||||
<a
|
||||
href={redirectUrl}
|
||||
target={searchConfig('bookmarksSameTab', false) ? '' : '_blank'}
|
||||
target={config.bookmarksSameTab ? '' : '_blank'}
|
||||
rel="noreferrer"
|
||||
key={`bookmark-${bookmark.id}`}
|
||||
>
|
||||
|
@ -69,5 +98,3 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookmarkCard;
|
||||
|
|
|
@ -1,327 +0,0 @@
|
|||
import { ChangeEvent, Dispatch, Fragment, SetStateAction, SyntheticEvent, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Bookmark, Category, GlobalState, NewBookmark, NewCategory, NewNotification } from '../../../interfaces';
|
||||
import {
|
||||
addBookmark,
|
||||
addBookmarkCategory,
|
||||
createNotification,
|
||||
getBookmarkCategories,
|
||||
updateBookmark,
|
||||
updateBookmarkCategory,
|
||||
} from '../../../store/actions';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
import classes from './BookmarkForm.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
modalHandler: () => void;
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
category?: Category;
|
||||
bookmark?: Bookmark;
|
||||
addBookmarkCategory: (formData: NewCategory) => void;
|
||||
addBookmark: (formData: NewBookmark | FormData) => void;
|
||||
updateBookmarkCategory: (id: number, formData: NewCategory) => void;
|
||||
updateBookmark: (
|
||||
id: number,
|
||||
formData: NewBookmark | FormData,
|
||||
previousCategoryId: number
|
||||
) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
|
||||
const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
||||
const [useCustomIcon, setUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
const [categoryData, setCategoryData] = useState<NewCategory>({
|
||||
name: '',
|
||||
type: 'bookmarks',
|
||||
});
|
||||
|
||||
const [bookmarkData, setBookmarkData] = useState<NewBookmark>({
|
||||
name: "",
|
||||
url: "",
|
||||
categoryId: -1,
|
||||
icon: '',
|
||||
});
|
||||
|
||||
// Load category data if provided for editing
|
||||
useEffect(() => {
|
||||
if (props.category) {
|
||||
setCategoryData({ name: props.category.name, type: props.category.type });
|
||||
} else {
|
||||
setCategoryData({ name: '', type: 'bookmarks' });
|
||||
}
|
||||
}, [props.category]);
|
||||
|
||||
// Load bookmark data if provided for editing
|
||||
useEffect(() => {
|
||||
if (props.bookmark) {
|
||||
setBookmarkData({
|
||||
name: props.bookmark.name,
|
||||
url: props.bookmark.url,
|
||||
categoryId: props.bookmark.categoryId,
|
||||
icon: props.bookmark.icon,
|
||||
});
|
||||
} else {
|
||||
setBookmarkData({
|
||||
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);
|
||||
}
|
||||
Object.entries(bookmarkData).forEach((entry: [string, any]) => {
|
||||
data.append(entry[0], entry[1]);
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
if (!props.category && !props.bookmark) {
|
||||
// Add new
|
||||
if (props.contentType === ContentType.category) {
|
||||
// Add category
|
||||
props.addBookmarkCategory(categoryData);
|
||||
setCategoryData({ name: "", type: "bookmarks" });
|
||||
} else if (props.contentType === ContentType.bookmark) {
|
||||
// Add bookmark
|
||||
if (bookmarkData.categoryId === -1) {
|
||||
props.createNotification({
|
||||
title: 'Error',
|
||||
message: 'Please select category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (customIcon) {
|
||||
const data = createFormData();
|
||||
props.addBookmark(data);
|
||||
} else {
|
||||
props.addBookmark(bookmarkData);
|
||||
}
|
||||
setBookmarkData({
|
||||
name: "",
|
||||
url: "",
|
||||
categoryId: bookmarkData.categoryId,
|
||||
icon: ''
|
||||
});
|
||||
|
||||
// setCustomIcon(null);
|
||||
}
|
||||
} else {
|
||||
// Update
|
||||
if (props.contentType === ContentType.category && props.category) {
|
||||
// Update category
|
||||
props.updateBookmarkCategory(props.category.id, categoryData);
|
||||
setCategoryData({ name: "", type: "bookmarks" });
|
||||
} else if (props.contentType === ContentType.bookmark && props.bookmark) {
|
||||
// Update bookmark
|
||||
props.updateBookmark(
|
||||
props.bookmark.id,
|
||||
createFormData(),
|
||||
props.bookmark.categoryId
|
||||
);
|
||||
|
||||
setBookmarkData({
|
||||
name: "",
|
||||
url: "",
|
||||
categoryId: -1,
|
||||
icon: '',
|
||||
});
|
||||
|
||||
setCustomIcon(null);
|
||||
}
|
||||
|
||||
props.modalHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, setDataFunction: Dispatch<SetStateAction<any>>, data: any): void => {
|
||||
setDataFunction({
|
||||
...data,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const toggleUseCustomIcon = (): void => {
|
||||
setUseCustomIcon(!useCustomIcon);
|
||||
setCustomIcon(null);
|
||||
};
|
||||
|
||||
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="name"
|
||||
id="categoryName"
|
||||
placeholder="Social Media"
|
||||
required
|
||||
value={categoryData.name}
|
||||
onChange={(e) => inputChangeHandler(e, setCategoryData, categoryData)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Bookmark Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Reddit"
|
||||
required
|
||||
value={bookmarkData.name}
|
||||
onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="url">Bookmark URL</label>
|
||||
<input
|
||||
type="text"
|
||||
name="url"
|
||||
id="url"
|
||||
placeholder="reddit.com"
|
||||
required
|
||||
value={bookmarkData.url}
|
||||
onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)}
|
||||
/>
|
||||
<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
|
||||
value={bookmarkData.categoryId}
|
||||
onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)}
|
||||
>
|
||||
<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={bookmarkData.icon}
|
||||
onChange={(e) => inputChangeHandler(e, setBookmarkData, bookmarkData)}
|
||||
/>
|
||||
<span>
|
||||
Use icon name from MDI.
|
||||
<a href="https://materialdesignicons.com/" target="blank">
|
||||
{' '}
|
||||
Click here for reference
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
onClick={() => toggleUseCustomIcon()}
|
||||
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={() => toggleUseCustomIcon()}
|
||||
className={classes.Switch}
|
||||
>
|
||||
Switch to MDI
|
||||
</span>
|
||||
</InputGroup>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
{button}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
categories: state.bookmark.categories,
|
||||
};
|
||||
};
|
||||
|
||||
const dispatchMap = {
|
||||
getBookmarkCategories,
|
||||
addBookmarkCategory,
|
||||
addBookmark,
|
||||
updateBookmarkCategory,
|
||||
updateBookmark,
|
||||
createNotification,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);
|
|
@ -20,12 +20,3 @@
|
|||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.BookmarksMessage {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.BookmarksMessage a {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
|
@ -1,56 +1,62 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Bookmark, Category } from '../../../interfaces';
|
||||
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
||||
import { Category } from '../../../interfaces';
|
||||
import { Message } from '../../UI';
|
||||
import { BookmarkCard } from '../BookmarkCard/BookmarkCard';
|
||||
import classes from './BookmarkGrid.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
bookmarks: Bookmark[];
|
||||
totalCategories?: number;
|
||||
searching: boolean;
|
||||
fromHomepage?: boolean;
|
||||
}
|
||||
|
||||
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
|
||||
export const BookmarkGrid = (props: Props): JSX.Element => {
|
||||
const {
|
||||
categories,
|
||||
totalCategories,
|
||||
searching,
|
||||
fromHomepage = false,
|
||||
} = props;
|
||||
|
||||
let bookmarks: JSX.Element;
|
||||
|
||||
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>
|
||||
);
|
||||
if (categories.length) {
|
||||
if (searching && !categories[0].bookmarks.length) {
|
||||
bookmarks = <Message>No bookmarks match your search criteria</Message>;
|
||||
} else {
|
||||
bookmarks = (
|
||||
<div className={classes.BookmarkGrid}>
|
||||
{props.categories.map(
|
||||
{categories.map(
|
||||
(category: Category): JSX.Element => (
|
||||
<BookmarkCard key={category.id} category={category} bookmarks={props.bookmarks.filter( (bookmark: Bookmark) => bookmark.categoryId === category.id)} />
|
||||
<BookmarkCard
|
||||
category={category}
|
||||
fromHomepage={fromHomepage}
|
||||
key={category.id}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (props.totalCategories) {
|
||||
if (totalCategories) {
|
||||
bookmarks = (
|
||||
<p className={classes.BookmarksMessage}>
|
||||
There are no pinned bookmark categories. You can pin them from the{' '}
|
||||
<Message>
|
||||
There are no pinned categories. You can pin them from the{' '}
|
||||
<Link to="/bookmarks">/bookmarks</Link> menu
|
||||
</p>
|
||||
</Message>
|
||||
);
|
||||
} else {
|
||||
bookmarks = (
|
||||
<p className={classes.BookmarksMessage}>
|
||||
<Message>
|
||||
You don't have any bookmarks. You can add a new one from{' '}
|
||||
<Link to="/bookmarks">/bookmarks</Link> menu
|
||||
</p>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return bookmarks;
|
||||
};
|
||||
|
||||
export default BookmarkGrid;
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
.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;
|
||||
}
|
|
@ -1,372 +0,0 @@
|
|||
import { Fragment, KeyboardEvent, useEffect, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Bookmark, Category, NewNotification } from '../../../interfaces';
|
||||
import {
|
||||
createNotification,
|
||||
deleteBookmark,
|
||||
deleteBookmarkCategory,
|
||||
pinBookmark,
|
||||
pinBookmarkCategory,
|
||||
reorderBookmarkCategories,
|
||||
reorderBookmarks,
|
||||
updateConfig,
|
||||
} from '../../../store/actions';
|
||||
import { searchConfig } from '../../../utility';
|
||||
import Icon from '../../UI/Icons/Icon/Icon';
|
||||
import Table from '../../UI/Table/Table';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
import classes from './BookmarkTable.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
contentType: ContentType;
|
||||
categories: Category[];
|
||||
bookmarks: Bookmark[];
|
||||
pinBookmarkCategory: (category: Category) => void;
|
||||
deleteBookmarkCategory: (id: number) => void;
|
||||
reorderBookmarkCategories: (categories: Category[]) => void;
|
||||
updateHandler: (data: Category | Bookmark) => void;
|
||||
pinBookmark: (bookmark: Bookmark) => void;
|
||||
deleteBookmark: (id: number, categoryId: number) => void;
|
||||
reorderBookmarks: (bookmarks: Bookmark[]) => void;
|
||||
updateConfig: (formData: any) => void;
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
|
||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
||||
const [localCategories, setLocalCategories] = useState<Category[]>([]);
|
||||
const [localBookmarks, setLocalBookmarks] = useState<Bookmark[]>([]);
|
||||
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||
|
||||
// Copy categories array
|
||||
useEffect(() => {
|
||||
setLocalCategories([...props.categories]);
|
||||
}, [props.categories]);
|
||||
|
||||
// Copy bookmarks array
|
||||
useEffect(() => {
|
||||
setLocalBookmarks([...props.bookmarks]);
|
||||
}, [props.bookmarks]);
|
||||
|
||||
// Check ordering
|
||||
useEffect(() => {
|
||||
const order = searchConfig("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.deleteBookmarkCategory(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);
|
||||
}
|
||||
};
|
||||
|
||||
// Support keyboard navigation for actions
|
||||
const keyboardActionHandler = (
|
||||
e: KeyboardEvent,
|
||||
object: any,
|
||||
handler: Function
|
||||
) => {
|
||||
if (e.key === "Enter") {
|
||||
handler(object);
|
||||
}
|
||||
};
|
||||
|
||||
const dragEndHandler = (result: DropResult): void => {
|
||||
if (!isCustomOrder) {
|
||||
props.createNotification({
|
||||
title: "Error",
|
||||
message: "Custom order is disabled",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.contentType === ContentType.bookmark) {
|
||||
const tmpBookmarks = [...localBookmarks];
|
||||
const [movedBookmark] = tmpBookmarks.splice(result.source.index, 1);
|
||||
tmpBookmarks.splice(result.destination.index, 0, movedBookmark);
|
||||
|
||||
setLocalBookmarks(tmpBookmarks);
|
||||
props.reorderBookmarks(tmpBookmarks);
|
||||
} else if (props.contentType === ContentType.category) {
|
||||
const tmpCategories = [...localCategories];
|
||||
const [movedCategory] = tmpCategories.splice(result.source.index, 1);
|
||||
tmpCategories.splice(result.destination.index, 0, movedCategory);
|
||||
|
||||
setLocalCategories(tmpCategories);
|
||||
props.reorderBookmarkCategories(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={dragEndHandler}>
|
||||
<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.pinBookmarkCategory(category)
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
category,
|
||||
props.pinBookmarkCategory
|
||||
)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{category.isPinned ? (
|
||||
<Icon
|
||||
icon="mdiPinOff"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="mdiPin" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className={classes.Message}>
|
||||
{isCustomOrder ? (
|
||||
<p>You can drag and drop single rows to reorder bookmark</p>
|
||||
) : (
|
||||
<p>
|
||||
Custom order is disabled. You can change it in{" "}
|
||||
<Link to="/settings/other">settings</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DragDropContext onDragEnd={dragEndHandler}>
|
||||
<Droppable droppableId="bookmarks">
|
||||
{(provided) => (
|
||||
<Table
|
||||
headers={["Name", "URL", "Icon", "Category", "Actions"]}
|
||||
innerRef={provided.innerRef}
|
||||
>
|
||||
{localBookmarks.map(
|
||||
(bookmark: 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,
|
||||
};
|
||||
|
||||
const category = localCategories.find(
|
||||
(category: Category) =>
|
||||
category.id === bookmark.categoryId
|
||||
);
|
||||
const categoryName = category?.name;
|
||||
|
||||
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" }}>{categoryName}</td>
|
||||
{!snapshot.isDragging && (
|
||||
<td className={classes.TableActions}>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() =>
|
||||
deleteBookmarkHandler(bookmark)
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
bookmark,
|
||||
deleteBookmarkHandler
|
||||
)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon icon="mdiDelete" />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() =>
|
||||
props.updateHandler(bookmark)
|
||||
}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
bookmark,
|
||||
props.updateHandler
|
||||
)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Icon icon="mdiPencil" />
|
||||
</div>
|
||||
<div
|
||||
className={classes.TableAction}
|
||||
onClick={() => props.pinBookmark(bookmark)}
|
||||
onKeyDown={(e) =>
|
||||
keyboardActionHandler(
|
||||
e,
|
||||
bookmark,
|
||||
props.pinBookmark
|
||||
)
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
{bookmark.isPinned ? (
|
||||
<Icon
|
||||
icon="mdiPinOff"
|
||||
color="var(--color-accent)"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon="mdiPin" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const actions = {
|
||||
pinBookmarkCategory,
|
||||
deleteBookmarkCategory,
|
||||
reorderBookmarkCategories,
|
||||
pinBookmark,
|
||||
deleteBookmark,
|
||||
reorderBookmarks,
|
||||
updateConfig,
|
||||
createNotification,
|
||||
};
|
||||
|
||||
export default connect(null, actions)(BookmarkTable);
|
|
@ -1,25 +1,18 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { Bookmark, Category, GlobalState } from '../../interfaces';
|
||||
import { getBookmarkCategories, getBookmarks } from '../../store/actions';
|
||||
import ActionButton from '../UI/Buttons/ActionButton/ActionButton';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Modal from '../UI/Modal/Modal';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import BookmarkForm from './BookmarkForm/BookmarkForm';
|
||||
import BookmarkGrid from './BookmarkGrid/BookmarkGrid';
|
||||
import { Bookmark, Category } from '../../interfaces';
|
||||
import { actionCreators } from '../../store';
|
||||
import { State } from '../../store/reducers';
|
||||
import { ActionButton, Container, Headline, Message, Modal, Spinner } from '../UI';
|
||||
import { BookmarkGrid } from './BookmarkGrid/BookmarkGrid';
|
||||
import classes from './Bookmarks.module.css';
|
||||
import BookmarkTable from './BookmarkTable/BookmarkTable';
|
||||
import { Form } from './Form/Form';
|
||||
import { Table } from './Table/Table';
|
||||
|
||||
interface ComponentProps {
|
||||
loading: boolean;
|
||||
categories: Category[];
|
||||
getBookmarkCategories: () => void;
|
||||
bookmarks: Bookmark[];
|
||||
getBookmarks: () => void;
|
||||
interface Props {
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
|
@ -28,153 +21,159 @@ export enum ContentType {
|
|||
bookmark,
|
||||
}
|
||||
|
||||
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
||||
const { bookmarks, getBookmarks, getBookmarkCategories, categories, loading, searching = false } = props;
|
||||
export const Bookmarks = (props: Props): JSX.Element => {
|
||||
// Get Redux state
|
||||
const {
|
||||
bookmarks: { loading, categories, categoryInEdit },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
// 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 [isInEdit, setIsInEdit] = useState(false);
|
||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||
|
||||
// Table
|
||||
const [showTable, setShowTable] = 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,
|
||||
type: "bookmarks",
|
||||
apps: [],
|
||||
bookmarks: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
|
||||
name: "string",
|
||||
url: "string",
|
||||
categoryId: -1,
|
||||
icon: "string",
|
||||
isPinned: false,
|
||||
orderId: 0,
|
||||
id: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
// Observe if user is authenticated -> set default view (grid) if not
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setShowTable(false);
|
||||
setModalIsOpen(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookmarks || bookmarks.length === 0) {
|
||||
getBookmarks();
|
||||
if (categoryInEdit && !modalIsOpen) {
|
||||
setTableContentType(ContentType.bookmark);
|
||||
setShowTable(true);
|
||||
}
|
||||
}, [getBookmarks]);
|
||||
}, [categoryInEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categories.length === 0) {
|
||||
getBookmarkCategories();
|
||||
}
|
||||
}, [getBookmarkCategories]);
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
}, []);
|
||||
|
||||
// Form actions
|
||||
const toggleModal = (): void => {
|
||||
setModalIsOpen(!modalIsOpen);
|
||||
};
|
||||
|
||||
const addActionHandler = (contentType: ContentType) => {
|
||||
const openFormForAdding = (contentType: ContentType) => {
|
||||
setFormContentType(contentType);
|
||||
setIsInUpdate(false);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
const editActionHandler = (contentType: ContentType) => {
|
||||
// We're in the edit mode and the same button was clicked - go back to list
|
||||
if (isInEdit && contentType === tableContentType) {
|
||||
setIsInEdit(false);
|
||||
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 {
|
||||
setIsInEdit(true);
|
||||
setFormContentType(ContentType.bookmark);
|
||||
setEditBookmark(data);
|
||||
}
|
||||
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
// Table actions
|
||||
const showTableForEditing = (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);
|
||||
} else {
|
||||
setShowTable(true);
|
||||
setTableContentType(contentType);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
const finishEditing = () => {
|
||||
setShowTable(false);
|
||||
setEditCategory(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
|
||||
{!isInUpdate ? (
|
||||
<BookmarkForm modalHandler={toggleModal} contentType={formContentType} />
|
||||
) : formContentType === ContentType.category ? (
|
||||
<BookmarkForm
|
||||
<Form
|
||||
modalHandler={toggleModal}
|
||||
contentType={formContentType}
|
||||
category={categoryInUpdate}
|
||||
inUpdate={isInUpdate}
|
||||
/>
|
||||
) : (
|
||||
<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={() => addActionHandler(ContentType.category)}
|
||||
handler={() => openFormForAdding(ContentType.category)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Add Bookmark"
|
||||
icon="mdiPlusBox"
|
||||
handler={() => addActionHandler(ContentType.bookmark)}
|
||||
handler={() => openFormForAdding(ContentType.bookmark)}
|
||||
/>
|
||||
<ActionButton
|
||||
name="Edit Categories"
|
||||
icon="mdiPencil"
|
||||
handler={() => editActionHandler(ContentType.category)}
|
||||
handler={() => showTableForEditing(ContentType.category)}
|
||||
/>
|
||||
{showTable && tableContentType === ContentType.bookmark && (
|
||||
<ActionButton
|
||||
name="Edit Bookmarks"
|
||||
name="Finish Editing"
|
||||
icon="mdiPencil"
|
||||
handler={() => editActionHandler(ContentType.bookmark)}
|
||||
handler={finishEditing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length && isAuthenticated && !showTable ? (
|
||||
<Message isPrimary={false}>
|
||||
Click on category name to edit its bookmarks
|
||||
</Message>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : !isInEdit ? (
|
||||
<BookmarkGrid categories={categories} bookmarks={bookmarks} searching />
|
||||
) : !showTable ? (
|
||||
<BookmarkGrid categories={categories} searching={props.searching} />
|
||||
) : (
|
||||
<BookmarkTable
|
||||
<Table
|
||||
contentType={tableContentType}
|
||||
categories={categories}
|
||||
bookmarks={bookmarks}
|
||||
updateHandler={goToUpdateMode}
|
||||
openFormForUpdating={openFormForUpdating}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.bookmark.loading,
|
||||
categories: state.bookmark.categories,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { getBookmarks, getBookmarkCategories })(Bookmarks);
|
||||
|
|
264
client/src/components/Bookmarks/Form/BookmarksForm.tsx
Normal file
|
@ -0,0 +1,264 @@
|
|||
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Bookmark, Category, NewBookmark } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { ModalForm, InputGroup, Button } from '../../UI';
|
||||
|
||||
// CSS
|
||||
import classes from './Form.module.css';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, newBookmarkTemplate } from '../../../utility';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
bookmark?: Bookmark;
|
||||
}
|
||||
|
||||
export const BookmarksForm = ({
|
||||
bookmark,
|
||||
modalHandler,
|
||||
}: Props): JSX.Element => {
|
||||
const { categories } = useSelector((state: State) => state.bookmarks);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { addBookmark, updateBookmark, createNotification } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<NewBookmark>(newBookmarkTemplate);
|
||||
|
||||
// Load bookmark data if provided for editing
|
||||
useEffect(() => {
|
||||
if (bookmark) {
|
||||
setFormData({ ...bookmark });
|
||||
} else {
|
||||
setFormData(newBookmarkTemplate);
|
||||
}
|
||||
}, [bookmark]);
|
||||
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<NewBookmark>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
if (e.target.files) {
|
||||
setCustomIcon(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
// Bookmarks form handler
|
||||
const formSubmitHandler = (e: FormEvent): void => {
|
||||
e.preventDefault();
|
||||
|
||||
const createFormData = (): FormData => {
|
||||
const data = new FormData();
|
||||
if (customIcon) {
|
||||
data.append('icon', customIcon);
|
||||
}
|
||||
data.append('name', formData.name);
|
||||
data.append('url', formData.url);
|
||||
data.append('categoryId', `${formData.categoryId}`);
|
||||
data.append('isPublic', `${formData.isPublic ? 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>
|
||||
);
|
||||
};
|
97
client/src/components/Bookmarks/Form/CategoryForm.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { Category, NewCategory } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { inputHandler, newBookmarkCategoryTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, ModalForm } from '../../UI';
|
||||
|
||||
// Redux
|
||||
// Typescript
|
||||
// UI
|
||||
// Utils
|
||||
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>(newBookmarkCategoryTemplate);
|
||||
|
||||
// Load category data if provided for editing
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setFormData({ ...category });
|
||||
} else {
|
||||
setFormData(newBookmarkCategoryTemplate);
|
||||
}
|
||||
}, [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(newBookmarkCategoryTemplate);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
54
client/src/components/Bookmarks/Form/Form.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Fragment } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bookmarkCategoryTemplate, bookmarkTemplate } from '../../../utility';
|
||||
import { ContentType } from '../Bookmarks';
|
||||
import { BookmarksForm } from './BookmarksForm';
|
||||
import { CategoryForm } from './CategoryForm';
|
||||
|
||||
// Typescript
|
||||
// Utils
|
||||
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 || bookmarkCategoryTemplate}
|
||||
/>
|
||||
) : (
|
||||
<BookmarksForm
|
||||
modalHandler={modalHandler}
|
||||
bookmark={bookmarkInEdit || bookmarkTemplate}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
188
client/src/components/Bookmarks/Table/BookmarksTable.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
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 <span>{categoryInEdit.name}</span>
|
||||
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>
|
||||
);
|
||||
};
|
166
client/src/components/Bookmarks/Table/CategoryTable.tsx
Normal file
|
@ -0,0 +1,166 @@
|
|||
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/interface">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>
|
||||
);
|
||||
};
|
20
client/src/components/Bookmarks/Table/Table.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
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;
|
||||
};
|
31
client/src/components/Home/Header/Header.module.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
.Header h1 {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 4em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Header p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.HeaderMain {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.SettingsLink {
|
||||
visibility: visible;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.SettingsLink {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
53
client/src/components/Home/Header/Header.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
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 { getDateTime } from './functions/getDateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
const { hideHeader, hideDate, showTime } = useSelector(
|
||||
(state: State) => state.config.config
|
||||
);
|
||||
|
||||
const [dateTime, setDateTime] = useState<string>(getDateTime());
|
||||
const [greeting, setGreeting] = useState<string>(greeter());
|
||||
|
||||
useEffect(() => {
|
||||
let dateTimeInterval: NodeJS.Timeout;
|
||||
|
||||
dateTimeInterval = setInterval(() => {
|
||||
setDateTime(getDateTime());
|
||||
setGreeting(greeter());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(dateTimeInterval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
{(!hideDate || showTime) && <p>{dateTime}</p>}
|
||||
|
||||
<Link to="/settings" className={classes.SettingsLink}>
|
||||
Go to Settings
|
||||
</Link>
|
||||
|
||||
{!hideHeader && (
|
||||
<span className={classes.HeaderMain}>
|
||||
<h1>{greeting}</h1>
|
||||
<WeatherWidget />
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
71
client/src/components/Home/Header/functions/getDateTime.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { parseTime } from '../../../../utility';
|
||||
|
||||
export const getDateTime = (): string => {
|
||||
const days = localStorage.getItem('daySchema')?.split(';') || [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
const months = localStorage.getItem('monthSchema')?.split(';') || [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
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()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
};
|
17
client/src/components/Home/Header/functions/greeter.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
export const greeter = (): string => {
|
||||
const now = new Date().getHours();
|
||||
let msg: string;
|
||||
|
||||
const greetingsSchemaRaw =
|
||||
localStorage.getItem('greetingsSchema') ||
|
||||
'Good evening!;Good afternoon!;Good morning!;Good night!';
|
||||
const greetingsSchema = greetingsSchemaRaw.split(';');
|
||||
|
||||
if (now >= 18) msg = greetingsSchema[0];
|
||||
else if (now >= 12) msg = greetingsSchema[1];
|
||||
else if (now >= 6) msg = greetingsSchema[2];
|
||||
else if (now >= 0) msg = greetingsSchema[3];
|
||||
else msg = 'Hello!';
|
||||
|
||||
return msg;
|
||||
};
|
|
@ -1,24 +1,3 @@
|
|||
.Header h1 {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 4em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Header p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.HeaderMain {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.SettingsButton {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
|
@ -40,19 +19,10 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.SettingsLink {
|
||||
visibility: visible;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.SettingsButton {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.SettingsLink {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.HomeSpace {
|
||||
|
|
|
@ -1,144 +1,121 @@
|
|||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { App, Bookmark, Category, GlobalState } from '../../interfaces';
|
||||
import { getAppCategories, getApps, getBookmarkCategories, getBookmarks } from '../../store/actions';
|
||||
import { searchConfig } from '../../utility';
|
||||
import AppGrid from '../Apps/AppGrid/AppGrid';
|
||||
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||
import SearchBar from '../SearchBar/SearchBar';
|
||||
import SectionHeadline from '../UI/Headlines/SectionHeadline/SectionHeadline';
|
||||
import Icon from '../UI/Icons/Icon/Icon';
|
||||
import { Category } from '../../interfaces';
|
||||
import { actionCreators } from '../../store';
|
||||
import { State } from '../../store/reducers';
|
||||
import { escapeRegex } from '../../utility';
|
||||
import { AppGrid } from '../Apps/AppGrid/AppGrid';
|
||||
import { BookmarkGrid } from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||
import { SearchBar } from '../SearchBar/SearchBar';
|
||||
import { Icon, Message, SectionHeadline, Spinner } from '../UI';
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Spinner from '../UI/Spinner/Spinner';
|
||||
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
|
||||
import { dateTime } from './functions/dateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
import { Header } from './Header/Header';
|
||||
import classes from './Home.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
getApps: () => void;
|
||||
getAppCategories: () => void;
|
||||
getBookmarks: () => void;
|
||||
getBookmarkCategories: () => void;
|
||||
appsLoading: boolean;
|
||||
bookmarkCategoriesLoading: boolean;
|
||||
appCategories: Category[];
|
||||
apps: App[];
|
||||
bookmarkCategories: Category[];
|
||||
bookmarks: Bookmark[];
|
||||
}
|
||||
|
||||
const Home = (props: ComponentProps): JSX.Element => {
|
||||
export const Home = (): JSX.Element => {
|
||||
const {
|
||||
getAppCategories,
|
||||
getApps,
|
||||
getBookmarkCategories,
|
||||
getBookmarks,
|
||||
appCategories,
|
||||
apps,
|
||||
bookmarkCategories,
|
||||
bookmarks,
|
||||
appsLoading,
|
||||
bookmarkCategoriesLoading,
|
||||
} = props;
|
||||
apps: { categories: appCategories, loading: appsLoading },
|
||||
bookmarks: { categories: bookmarkCategories, loading: bookmarksLoading },
|
||||
config: { config },
|
||||
auth: { isAuthenticated },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const [header, setHeader] = useState({
|
||||
dateTime: dateTime(),
|
||||
greeting: greeter(),
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
const { getCategories } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
// Local search query
|
||||
const [localSearch, setLocalSearch] = useState<null | string>(null);
|
||||
const [appSearchResult, setAppSearchResult] = useState<null | Category[]>(null);
|
||||
const [bookmarkSearchResult, setBookmarkSearchResult] = useState<
|
||||
null | Category[]
|
||||
>(null);
|
||||
|
||||
// Load apps
|
||||
useEffect(() => {
|
||||
if (apps.length === 0) {
|
||||
getApps();
|
||||
if (!appCategories.length && !bookmarkCategories.length) {
|
||||
getCategories();
|
||||
}
|
||||
}, [getApps]);
|
||||
|
||||
// Load bookmarks
|
||||
useEffect(() => {
|
||||
if (bookmarks.length === 0) {
|
||||
getBookmarks();
|
||||
}
|
||||
}, [getBookmarks]);
|
||||
|
||||
// Refresh greeter and time
|
||||
useEffect(() => {
|
||||
let interval: any;
|
||||
|
||||
// Start interval only when hideHeader is false
|
||||
if (searchConfig("hideHeader", 0) !== 1) {
|
||||
interval = setInterval(() => {
|
||||
setHeader({
|
||||
dateTime: dateTime(),
|
||||
greeting: greeter(),
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Search categories
|
||||
const searchInCategories = (query: string, categoriesToSearch: Category[]): Category[] => {
|
||||
const category: Category = {
|
||||
name: "Search Results",
|
||||
type: categoriesToSearch[0]?.type,
|
||||
isPinned: true,
|
||||
apps: categoriesToSearch
|
||||
.map((c: Category) => c.id >= 0 ? c.apps : apps.filter((app: App) => app.categoryId === c.id))
|
||||
.flat()
|
||||
.filter((app: App) => new RegExp(query, 'i').test(app.name)),
|
||||
bookmarks: categoriesToSearch
|
||||
.map((c: Category) => c.id >= 0 ? c.bookmarks : bookmarks.filter((bookmark: Bookmark) => bookmark.categoryId === c.id))
|
||||
.flat()
|
||||
.filter((bookmark: Bookmark) => new RegExp(query, 'i').test(bookmark.name)),
|
||||
id: 0,
|
||||
orderId: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
// Load bookmarks
|
||||
// useEffect(() => {
|
||||
// if (!bookmarkCategories.length) {
|
||||
// getBookmarkCategories();
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
return [category];
|
||||
};
|
||||
useEffect(() => {
|
||||
if (localSearch) {
|
||||
// Search through apps
|
||||
setAppSearchResult([
|
||||
...appCategories.filter(({ name }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(name)
|
||||
),
|
||||
]);
|
||||
|
||||
const categoryContainsPinnedItems = (category: Category, allItems: App[] | Bookmark[]): boolean => {
|
||||
if (category.apps?.filter((app: App) => app.isPinned).length > 0) return true;
|
||||
if (category.bookmarks?.filter((bookmark: Bookmark) => bookmark.isPinned).length > 0) return true;
|
||||
if (category.id < 0) { // Is a default category
|
||||
return allItems.findIndex((item: App | Bookmark) => item.categoryId === category.id && item.isPinned) >= 0;
|
||||
// Search through bookmarks
|
||||
const appCategory = { ...bookmarkCategories[0] };
|
||||
|
||||
appCategory.name = 'Search Results';
|
||||
appCategory.apps = appCategories
|
||||
.map(({ apps }) => apps)
|
||||
.flat()
|
||||
.filter(({ name }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(name)
|
||||
);
|
||||
|
||||
setBookmarkSearchResult([appCategory]);
|
||||
|
||||
// Search through bookmarks
|
||||
const bookmarkCategory = { ...bookmarkCategories[0] };
|
||||
|
||||
bookmarkCategory.name = 'Search Results';
|
||||
bookmarkCategory.bookmarks = bookmarkCategories
|
||||
.map(({ bookmarks }) => bookmarks)
|
||||
.flat()
|
||||
.filter(({ name }) =>
|
||||
new RegExp(escapeRegex(localSearch), 'i').test(name)
|
||||
);
|
||||
|
||||
setBookmarkSearchResult([bookmarkCategory]);
|
||||
} else {
|
||||
setAppSearchResult(null);
|
||||
setBookmarkSearchResult(null);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}, [localSearch]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{searchConfig('hideSearch', 0) !== 1 ? (
|
||||
<SearchBar setLocalSearch={setLocalSearch} />
|
||||
{!config.hideSearch ? (
|
||||
<SearchBar
|
||||
setLocalSearch={setLocalSearch}
|
||||
appSearchResult={appSearchResult}
|
||||
bookmarkSearchResult={bookmarkSearchResult}
|
||||
/>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
|
||||
{searchConfig('hideHeader', 0) !== 1 ? (
|
||||
<header className={classes.Header}>
|
||||
<p>{header.dateTime}</p>
|
||||
<Link to="/settings" className={classes.SettingsLink}>
|
||||
Go to Settings
|
||||
</Link>
|
||||
<span className={classes.HeaderMain}>
|
||||
<h1>{header.greeting}</h1>
|
||||
<WeatherWidget />
|
||||
</span>
|
||||
</header>
|
||||
<Header />
|
||||
|
||||
{!isAuthenticated &&
|
||||
!appCategories.some((a) => a.isPinned) &&
|
||||
!bookmarkCategories.some((c) => c.isPinned) ? (
|
||||
<Message>
|
||||
Welcome to Flame! Go to <Link to="/settings/app">/settings</Link>,
|
||||
login and start customizing your new homepage
|
||||
</Message>
|
||||
) : (
|
||||
<div></div>
|
||||
<></>
|
||||
)}
|
||||
|
||||
{searchConfig('hideApps', 0) !== 1 ? (
|
||||
{!config.hideApps && (isAuthenticated || appCategories.some((a) => a.isPinned)) ? (
|
||||
<Fragment>
|
||||
<SectionHeadline title="Applications" link="/applications" />
|
||||
{appsLoading ? (
|
||||
|
@ -146,16 +123,9 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
) : (
|
||||
<AppGrid
|
||||
categories={
|
||||
!localSearch
|
||||
? appCategories.filter((category: Category) => category.isPinned && categoryContainsPinnedItems(category, apps))
|
||||
: searchInCategories(localSearch, appCategories)
|
||||
}
|
||||
apps={
|
||||
!localSearch
|
||||
? apps.filter((app: App) => app.isPinned)
|
||||
: apps.filter((app: App) =>
|
||||
new RegExp(localSearch, 'i').test(app.name)
|
||||
)
|
||||
!appSearchResult
|
||||
? appCategories.filter(({ isPinned }) => isPinned)
|
||||
: appSearchResult
|
||||
}
|
||||
totalCategories={appCategories.length}
|
||||
searching={!!localSearch}
|
||||
|
@ -164,35 +134,30 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
<div className={classes.HomeSpace}></div>
|
||||
</Fragment>
|
||||
) : (
|
||||
<div></div>
|
||||
<></>
|
||||
)}
|
||||
|
||||
{searchConfig('hideBookmarks', 0) !== 1 ? (
|
||||
{!config.hideBookmarks &&
|
||||
(isAuthenticated || bookmarkCategories.some((c) => c.isPinned)) ? (
|
||||
<Fragment>
|
||||
<SectionHeadline title="Bookmarks" link="/bookmarks" />
|
||||
{bookmarkCategoriesLoading ? (
|
||||
{bookmarksLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<BookmarkGrid
|
||||
categories={
|
||||
!localSearch
|
||||
? bookmarkCategories.filter((category: Category) => category.isPinned && categoryContainsPinnedItems(category, bookmarks))
|
||||
: searchInCategories(localSearch, bookmarkCategories)
|
||||
}
|
||||
bookmarks={
|
||||
!localSearch
|
||||
? bookmarks.filter((bookmark: Bookmark) => bookmark.isPinned)
|
||||
: bookmarks.filter((bookmark: Bookmark) =>
|
||||
new RegExp(localSearch, 'i').test(bookmark.name)
|
||||
)
|
||||
!bookmarkSearchResult
|
||||
? bookmarkCategories.filter(({ isPinned }) => isPinned)
|
||||
: bookmarkSearchResult
|
||||
}
|
||||
totalCategories={bookmarkCategories.length}
|
||||
searching={!!localSearch}
|
||||
fromHomepage={true}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<div></div>
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Link to="/settings" className={classes.SettingsButton}>
|
||||
|
@ -201,16 +166,3 @@ const Home = (props: ComponentProps): JSX.Element => {
|
|||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
appCategories: state.app.categories,
|
||||
appsLoading: state.app.loading,
|
||||
apps: state.app.apps,
|
||||
bookmarkCategoriesLoading: state.bookmark.loading,
|
||||
bookmarkCategories: state.bookmark.categories,
|
||||
bookmarks: state.bookmark.bookmarks,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { getApps, getAppCategories, getBookmarks, getBookmarkCategories })(Home);
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
export const dateTime = (): string => {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
export const greeter = (): string => {
|
||||
const now = new Date().getHours();
|
||||
let msg: string;
|
||||
|
||||
if (now >= 18) msg = 'Good evening!';
|
||||
else if (now >= 12) msg = 'Good afternoon!';
|
||||
else if (now >= 6) msg = 'Good morning!';
|
||||
else if (now >= 0) msg = 'Good night!';
|
||||
else msg = 'Hello!';
|
||||
|
||||
return msg;
|
||||
}
|
|
@ -1,21 +1,20 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { GlobalState, Notification as _Notification } from '../../interfaces';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Notification as NotificationInterface } from '../../interfaces';
|
||||
|
||||
import classes from './NotificationCenter.module.css';
|
||||
|
||||
import Notification from '../UI/Notification/Notification';
|
||||
import { Notification } from '../UI';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
interface ComponentProps {
|
||||
notifications: _Notification[];
|
||||
}
|
||||
export const NotificationCenter = (): JSX.Element => {
|
||||
const { notifications } = useSelector((state: State) => state.notification);
|
||||
|
||||
const NotificationCenter = (props: ComponentProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
className={classes.NotificationCenter}
|
||||
style={{ height: `${props.notifications.length * 75}px` }}
|
||||
style={{ height: `${notifications.length * 75}px` }}
|
||||
>
|
||||
{props.notifications.map((notification: _Notification) => {
|
||||
{notifications.map((notification: NotificationInterface) => {
|
||||
return (
|
||||
<Notification
|
||||
title={notification.title}
|
||||
|
@ -29,11 +28,3 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
notifications: state.notification.notifications,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NotificationCenter);
|
||||
|
|
13
client/src/components/Routing/ProtectedRoute.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { useSelector } from 'react-redux';
|
||||
import { Redirect, Route, RouteProps } from 'react-router';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
export const ProtectedRoute = ({ ...rest }: RouteProps) => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Route {...rest} />;
|
||||
} else {
|
||||
return <Redirect to="/settings/app" />;
|
||||
}
|
||||
};
|
|
@ -1,32 +1,65 @@
|
|||
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import { KeyboardEvent, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification } from '../../store/actions';
|
||||
|
||||
// Typescript
|
||||
import { NewNotification } from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import { Category } from '../../interfaces';
|
||||
import { actionCreators } from '../../store';
|
||||
import { State } from '../../store/reducers';
|
||||
import { redirectUrl, searchParser, urlParser } from '../../utility';
|
||||
import classes from './SearchBar.module.css';
|
||||
|
||||
// Redux
|
||||
// Typescript
|
||||
// CSS
|
||||
// Utils
|
||||
import { searchParser, urlParser, redirectUrl } from '../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
interface Props {
|
||||
setLocalSearch: (query: string) => void;
|
||||
appSearchResult: Category[] | null;
|
||||
bookmarkSearchResult: Category[] | null;
|
||||
}
|
||||
|
||||
const SearchBar = (props: ComponentProps): JSX.Element => {
|
||||
const { setLocalSearch, createNotification } = props;
|
||||
export const SearchBar = (props: Props): JSX.Element => {
|
||||
const { config, loading } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const { setLocalSearch, appSearchResult, bookmarkSearchResult } = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
|
||||
|
||||
// Search bar autofocus
|
||||
useEffect(() => {
|
||||
if (!loading && !config.disableAutofocus) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Listen for keyboard events outside of search bar
|
||||
useEffect(() => {
|
||||
const keyOutsideFocus = (e: any) => {
|
||||
const { key } = e as KeyboardEvent;
|
||||
|
||||
if (key === 'Escape') {
|
||||
clearSearch();
|
||||
} else if (document.activeElement !== inputRef.current) {
|
||||
if (key === '`') {
|
||||
inputRef.current.focus();
|
||||
clearSearch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keyup', keyOutsideFocus);
|
||||
|
||||
return () => window.removeEventListener('keyup', keyOutsideFocus);
|
||||
}, []);
|
||||
|
||||
const clearSearch = () => {
|
||||
inputRef.current.value = '';
|
||||
setLocalSearch('');
|
||||
};
|
||||
|
||||
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { isLocal, search, query, isURL, sameTab } = searchParser(
|
||||
inputRef.current.value
|
||||
|
@ -36,32 +69,53 @@ const SearchBar = (props: ComponentProps): JSX.Element => {
|
|||
setLocalSearch(search);
|
||||
}
|
||||
|
||||
if (e.code === 'Enter') {
|
||||
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
|
||||
if (!query.prefix) {
|
||||
// Prefix not found -> emit notification
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Prefix not found',
|
||||
});
|
||||
} else if (isURL) {
|
||||
// URL or IP passed -> redirect
|
||||
const url = urlParser(inputRef.current.value)[1];
|
||||
redirectUrl(url, sameTab);
|
||||
} else if (isLocal) {
|
||||
setLocalSearch(search);
|
||||
// Local query -> redirect if at least 1 result found
|
||||
if (appSearchResult?.[0]?.apps?.length) {
|
||||
redirectUrl(appSearchResult[0].apps[0].url, sameTab);
|
||||
} 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
|
||||
let template = query.template;
|
||||
|
||||
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 = `${query.template}${search}`;
|
||||
redirectUrl(url, sameTab);
|
||||
}
|
||||
} else if (e.code === 'Escape') {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.SearchContainer}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={classes.SearchBar}
|
||||
onKeyUp={(e) => searchHandler(e)}
|
||||
onDoubleClick={clearSearch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(null, { createNotification })(SearchBar);
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
.AppVersion {
|
||||
.text {
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.AppVersion a {
|
||||
.text a,
|
||||
.text span {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 30px 0;
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
|
|
@ -1,34 +1,43 @@
|
|||
import { Fragment } from 'react';
|
||||
|
||||
import { Button, SettingsHeadline } from '../../UI';
|
||||
import classes from './AppDetails.module.css';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import { checkVersion } from '../../../utility';
|
||||
import { AuthForm } from './AuthForm/AuthForm';
|
||||
|
||||
const AppDetails = (): JSX.Element => {
|
||||
export const AppDetails = (): JSX.Element => {
|
||||
return (
|
||||
<Fragment>
|
||||
<p className={classes.AppVersion}>
|
||||
<SettingsHeadline text="Authentication" />
|
||||
<AuthForm />
|
||||
|
||||
<hr className={classes.separator} />
|
||||
|
||||
<div>
|
||||
<SettingsHeadline text="App version" />
|
||||
<p className={classes.text}>
|
||||
<a
|
||||
href='https://github.com/pawelmalak/flame'
|
||||
target='_blank'
|
||||
rel='noreferrer'>
|
||||
href="https://github.com/pawelmalak/flame"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Flame
|
||||
</a>
|
||||
{' '}
|
||||
</a>{' '}
|
||||
version {process.env.REACT_APP_VERSION}
|
||||
</p>
|
||||
<p className={classes.AppVersion}>
|
||||
See changelog {' '}
|
||||
|
||||
<p className={classes.text}>
|
||||
See changelog{' '}
|
||||
<a
|
||||
href='https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md'
|
||||
target='_blank'
|
||||
rel='noreferrer'>
|
||||
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;
|
||||
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
|
110
client/src/components/Settings/AppDetails/AuthForm/AuthForm.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
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>
|
||||
);
|
||||
};
|
122
client/src/components/Settings/DockerSettings/DockerSettings.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { DockerSettingsForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, dockerSettingsTemplate } from '../../../utility';
|
||||
|
||||
export const DockerSettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<DockerSettingsForm>(
|
||||
dockerSettingsTemplate
|
||||
);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...config,
|
||||
});
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await updateConfig(formData);
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<DockerSettingsForm>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
<SettingsHeadline text="Docker" />
|
||||
{/* CUSTOM DOCKER SOCKET HOST */}
|
||||
<InputGroup>
|
||||
<label htmlFor="dockerHost">Docker host</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dockerHost"
|
||||
name="dockerHost"
|
||||
placeholder="dockerHost:port"
|
||||
value={formData.dockerHost}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* USE DOCKER API */}
|
||||
<InputGroup>
|
||||
<label htmlFor="dockerApps">Use Docker API</label>
|
||||
<select
|
||||
id="dockerApps"
|
||||
name="dockerApps"
|
||||
value={formData.dockerApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* UNPIN DOCKER APPS */}
|
||||
<InputGroup>
|
||||
<label htmlFor="unpinStoppedApps">
|
||||
Unpin stopped containers / other apps
|
||||
</label>
|
||||
<select
|
||||
id="unpinStoppedApps"
|
||||
name="unpinStoppedApps"
|
||||
value={formData.unpinStoppedApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* KUBERNETES SETTINGS */}
|
||||
<SettingsHeadline text="Kubernetes" />
|
||||
{/* USE KUBERNETES */}
|
||||
<InputGroup>
|
||||
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
|
||||
<select
|
||||
id="kubernetesApps"
|
||||
name="kubernetesApps"
|
||||
value={formData.kubernetesApps ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -1,306 +0,0 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces';
|
||||
import {
|
||||
createNotification,
|
||||
sortAppCategories,
|
||||
sortApps,
|
||||
sortBookmarkCategories,
|
||||
updateConfig,
|
||||
} from '../../../store/actions';
|
||||
import { searchConfig } from '../../../utility';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: SettingsForm) => void;
|
||||
sortAppCategories: () => void;
|
||||
sortApps: () => void;
|
||||
sortBookmarkCategories: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<SettingsForm>({
|
||||
customTitle: document.title,
|
||||
pinAppsByDefault: 1,
|
||||
pinBookmarksByDefault: 1,
|
||||
pinCategoriesByDefault: 1,
|
||||
hideHeader: 0,
|
||||
hideApps: 0,
|
||||
hideBookmarks: 0,
|
||||
useOrdering: 'createdAt',
|
||||
appsSameTab: 0,
|
||||
bookmarksSameTab: 0,
|
||||
dockerApps: 1,
|
||||
dockerHost: 'localhost',
|
||||
kubernetesApps: 1,
|
||||
unpinStoppedApps: 1,
|
||||
});
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
customTitle: searchConfig('customTitle', 'Flame'),
|
||||
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
|
||||
pinBookmarksByDefault: searchConfig('pinBookmarksByDefault', 1),
|
||||
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
|
||||
hideHeader: searchConfig('hideHeader', 0),
|
||||
hideApps: searchConfig('hideApps', 0),
|
||||
hideBookmarks: searchConfig('hideBookmarks', 0),
|
||||
useOrdering: searchConfig('useOrdering', 'createdAt'),
|
||||
appsSameTab: searchConfig('appsSameTab', 0),
|
||||
bookmarksSameTab: searchConfig('bookmarksSameTab', 0),
|
||||
dockerApps: searchConfig('dockerApps', 0),
|
||||
dockerHost: searchConfig('dockerHost', 'localhost'),
|
||||
kubernetesApps: searchConfig('kubernetesApps', 0),
|
||||
unpinStoppedApps: searchConfig('unpinStoppedApps', 0),
|
||||
});
|
||||
}, [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;
|
||||
|
||||
// Apply new sort settings
|
||||
props.sortAppCategories();
|
||||
props.sortApps();
|
||||
props.sortBookmarkCategories();
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
isNumber?: boolean
|
||||
) => {
|
||||
let value: string | number = e.target.value;
|
||||
|
||||
if (isNumber) {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
{/* OTHER OPTIONS */}
|
||||
<SettingsHeadline text="Miscellaneous" />
|
||||
<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>
|
||||
|
||||
{/* BEAHVIOR OPTIONS */}
|
||||
<SettingsHeadline text="App Behavior" />
|
||||
<InputGroup>
|
||||
<label htmlFor="pinAppsByDefault">
|
||||
Pin new applications by default
|
||||
</label>
|
||||
<select
|
||||
id="pinAppsByDefault"
|
||||
name="pinAppsByDefault"
|
||||
value={formData.pinAppsByDefault}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="pinBookmarksByDefault">Pin new bookmarks by default</label>
|
||||
<select
|
||||
id="pinBookmarksByDefault"
|
||||
name="pinBookmarksByDefault"
|
||||
value={formData.pinBookmarksByDefault}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="pinCategoriesByDefault">
|
||||
Pin new categories by default
|
||||
</label>
|
||||
<select
|
||||
id="pinCategoriesByDefault"
|
||||
name="pinCategoriesByDefault"
|
||||
value={formData.pinCategoriesByDefault}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<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>
|
||||
<InputGroup>
|
||||
<label htmlFor="appsSameTab">Open applications in the same tab</label>
|
||||
<select
|
||||
id="appsSameTab"
|
||||
name="appsSameTab"
|
||||
value={formData.appsSameTab}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="bookmarksSameTab">Open bookmarks in the same tab</label>
|
||||
<select
|
||||
id="bookmarksSameTab"
|
||||
name="bookmarksSameTab"
|
||||
value={formData.bookmarksSameTab}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* MODULES OPTIONS */}
|
||||
<SettingsHeadline text="Modules" />
|
||||
<InputGroup>
|
||||
<label htmlFor="hideHeader">Hide greeting and date</label>
|
||||
<select
|
||||
id="hideHeader"
|
||||
name="hideHeader"
|
||||
value={formData.hideHeader}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="hideApps">Hide applications</label>
|
||||
<select
|
||||
id="hideApps"
|
||||
name="hideApps"
|
||||
value={formData.hideApps}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="hideBookmarks">Hide bookmarks</label>
|
||||
<select
|
||||
id="hideBookmarks"
|
||||
name="hideBookmarks"
|
||||
value={formData.hideBookmarks}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* DOCKER SETTINGS */}
|
||||
<SettingsHeadline text="Docker" />
|
||||
<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>
|
||||
<InputGroup>
|
||||
<label htmlFor="dockerApps">Use Docker API</label>
|
||||
<select
|
||||
id="dockerApps"
|
||||
name="dockerApps"
|
||||
value={formData.dockerApps}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="unpinStoppedApps">
|
||||
Unpin stopped containers / other apps
|
||||
</label>
|
||||
<select
|
||||
id="unpinStoppedApps"
|
||||
name="unpinStoppedApps"
|
||||
value={formData.unpinStoppedApps}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
{/* KUBERNETES SETTINGS */}
|
||||
<SettingsHeadline text="Kubernetes" />
|
||||
<InputGroup>
|
||||
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
|
||||
<select
|
||||
id="kubernetesApps"
|
||||
name="kubernetesApps"
|
||||
value={formData.kubernetesApps}
|
||||
onChange={(e) => inputChangeHandler(e, 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,
|
||||
};
|
||||
};
|
||||
|
||||
const actions = {
|
||||
createNotification,
|
||||
updateConfig,
|
||||
sortApps,
|
||||
sortAppCategories,
|
||||
sortBookmarkCategories
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, actions)(OtherSettings);
|
|
@ -1,24 +1,31 @@
|
|||
import { Fragment, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { State } from '../../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Query } from '../../../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './CustomQueries.module.css';
|
||||
|
||||
import Modal from '../../../UI/Modal/Modal';
|
||||
import Icon from '../../../UI/Icons/Icon/Icon';
|
||||
import { GlobalState, NewNotification, Query } from '../../../../interfaces';
|
||||
import QueriesForm from './QueriesForm';
|
||||
import { deleteQuery, createNotification } from '../../../../store/actions';
|
||||
import Button from '../../../UI/Buttons/Button/Button';
|
||||
import { searchConfig } from '../../../../utility';
|
||||
// UI
|
||||
import { Modal, Icon, Button } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
customQueries: Query[];
|
||||
deleteQuery: (prefix: string) => {};
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
// Components
|
||||
import { QueriesForm } from './QueriesForm';
|
||||
|
||||
const CustomQueries = (props: Props): JSX.Element => {
|
||||
const { customQueries, deleteQuery, createNotification } = props;
|
||||
export const CustomQueries = (): JSX.Element => {
|
||||
const { customQueries, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { deleteQuery, createNotification } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
|
||||
|
@ -29,7 +36,7 @@ const CustomQueries = (props: Props): JSX.Element => {
|
|||
};
|
||||
|
||||
const deleteHandler = (query: Query) => {
|
||||
const currentProvider = searchConfig('defaultSearchProvider', 'l');
|
||||
const currentProvider = config.defaultSearchProvider;
|
||||
const isCurrent = currentProvider === query.prefix;
|
||||
|
||||
if (isCurrent) {
|
||||
|
@ -100,13 +107,3 @@ const CustomQueries = (props: Props): JSX.Element => {
|
|||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
customQueries: state.config.customQueries,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { deleteQuery, createNotification })(
|
||||
CustomQueries
|
||||
);
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../../store';
|
||||
|
||||
import { Query } from '../../../../interfaces';
|
||||
import Button from '../../../UI/Buttons/Button/Button';
|
||||
import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
|
||||
import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
|
||||
import { connect } from 'react-redux';
|
||||
import { addQuery, updateQuery } from '../../../../store/actions';
|
||||
|
||||
import { Button, InputGroup, ModalForm } from '../../../UI';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
addQuery: (query: Query) => {};
|
||||
updateQuery: (query: Query, Oldprefix: string) => {};
|
||||
query?: Query;
|
||||
}
|
||||
|
||||
const QueriesForm = (props: Props): JSX.Element => {
|
||||
const { modalHandler, addQuery, updateQuery, query } = props;
|
||||
export const QueriesForm = (props: Props): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { addQuery, updateQuery } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const { modalHandler, query } = props;
|
||||
|
||||
const [formData, setFormData] = useState<Query>({
|
||||
name: '',
|
||||
|
@ -77,6 +83,7 @@ const QueriesForm = (props: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Prefix</label>
|
||||
<input
|
||||
|
@ -89,6 +96,7 @@ const QueriesForm = (props: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Query Template</label>
|
||||
<input
|
||||
|
@ -101,9 +109,8 @@ const QueriesForm = (props: Props): JSX.Element => {
|
|||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(null, { addQuery, updateQuery })(QueriesForm);
|
||||
|
|
|
@ -1,78 +1,63 @@
|
|||
// React
|
||||
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// State
|
||||
import { createNotification, updateConfig } from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
Query,
|
||||
SearchForm,
|
||||
} from '../../../interfaces';
|
||||
import { Query, SearchForm } from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import CustomQueries from './CustomQueries/CustomQueries';
|
||||
import { CustomQueries } from './CustomQueries/CustomQueries';
|
||||
|
||||
// UI
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import { Button, SettingsHeadline, InputGroup } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { searchConfig } from '../../../utility';
|
||||
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[];
|
||||
}
|
||||
// Redux
|
||||
import { State } from '../../../store/reducers';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
export const SearchSettings = (): JSX.Element => {
|
||||
const { loading, customQueries, config } = useSelector(
|
||||
(state: State) => state.config
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const SearchSettings = (props: Props): JSX.Element => {
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<SearchForm>({
|
||||
hideSearch: 0,
|
||||
defaultSearchProvider: 'l',
|
||||
searchSameTab: 0,
|
||||
});
|
||||
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
hideSearch: searchConfig('hideSearch', 0),
|
||||
defaultSearchProvider: searchConfig('defaultSearchProvider', 'l'),
|
||||
searchSameTab: searchConfig('searchSameTab', 0),
|
||||
...config,
|
||||
});
|
||||
}, [props.loading]);
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
await updateConfig(formData);
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
isNumber?: boolean
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
let value: string | number = e.target.value;
|
||||
|
||||
if (isNumber) {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: value,
|
||||
inputHandler<SearchForm>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -85,14 +70,14 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
>
|
||||
<SettingsHeadline text="General" />
|
||||
<InputGroup>
|
||||
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
|
||||
<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) => {
|
||||
{[...queries, ...customQueries].map((query: Query, idx) => {
|
||||
const isCustom = idx >= queries.length;
|
||||
|
||||
return (
|
||||
|
@ -103,6 +88,7 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
})}
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<InputGroup>
|
||||
<label htmlFor="searchSameTab">
|
||||
Open search results in the same tab
|
||||
|
@ -110,25 +96,40 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
<select
|
||||
id="searchSameTab"
|
||||
name="searchSameTab"
|
||||
value={formData.searchSameTab}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
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}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
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>
|
||||
|
||||
|
@ -138,17 +139,3 @@ const SearchSettings = (props: Props): JSX.Element => {
|
|||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.config.loading,
|
||||
customQueries: state.config.customQueries,
|
||||
};
|
||||
};
|
||||
|
||||
const actions = {
|
||||
createNotification,
|
||||
updateConfig,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, actions)(SearchSettings);
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
//
|
||||
import { NavLink, Link, Switch, Route } from 'react-router-dom';
|
||||
|
||||
// Redux
|
||||
import { useSelector } from 'react-redux';
|
||||
import { State } from '../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import { Route as SettingsRoute } from '../../interfaces';
|
||||
|
||||
|
@ -8,28 +11,33 @@ import { Route as SettingsRoute } from '../../interfaces';
|
|||
import classes from './Settings.module.css';
|
||||
|
||||
// Components
|
||||
import Themer from '../Themer/Themer';
|
||||
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
||||
import OtherSettings from './OtherSettings/OtherSettings';
|
||||
import AppDetails from './AppDetails/AppDetails';
|
||||
import StyleSettings from './StyleSettings/StyleSettings';
|
||||
import SearchSettings from './SearchSettings/SearchSettings';
|
||||
import { Themer } from './Themer/Themer';
|
||||
import { WeatherSettings } from './WeatherSettings/WeatherSettings';
|
||||
import { UISettings } from './UISettings/UISettings';
|
||||
import { AppDetails } from './AppDetails/AppDetails';
|
||||
import { StyleSettings } from './StyleSettings/StyleSettings';
|
||||
import { SearchSettings } from './SearchSettings/SearchSettings';
|
||||
import { DockerSettings } from './DockerSettings/DockerSettings';
|
||||
import { ProtectedRoute } from '../Routing/ProtectedRoute';
|
||||
|
||||
// UI
|
||||
import { Container } from '../UI/Layout/Layout';
|
||||
import Headline from '../UI/Headlines/Headline/Headline';
|
||||
import { Container, Headline } from '../UI';
|
||||
|
||||
// Data
|
||||
import { routes } from './settings.json';
|
||||
|
||||
const Settings = (): JSX.Element => {
|
||||
export const Settings = (): JSX.Element => {
|
||||
const { isAuthenticated } = useSelector((state: State) => state.auth);
|
||||
|
||||
const tabs = isAuthenticated ? routes : routes.filter((r) => !r.authRequired);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
|
||||
<div className={classes.Settings}>
|
||||
{/* NAVIGATION MENU */}
|
||||
<nav className={classes.SettingsNav}>
|
||||
{routes.map(({ name, dest }: SettingsRoute, idx) => (
|
||||
{tabs.map(({ name, dest }: SettingsRoute, idx) => (
|
||||
<NavLink
|
||||
className={classes.SettingsNavLink}
|
||||
activeClassName={classes.SettingsNavLinkActive}
|
||||
|
@ -46,10 +54,20 @@ const Settings = (): JSX.Element => {
|
|||
<section className={classes.SettingsContent}>
|
||||
<Switch>
|
||||
<Route exact path="/settings" component={Themer} />
|
||||
<Route path="/settings/weather" component={WeatherSettings} />
|
||||
<Route path="/settings/search" component={SearchSettings} />
|
||||
<Route path="/settings/other" component={OtherSettings} />
|
||||
<Route path="/settings/css" component={StyleSettings} />
|
||||
<ProtectedRoute
|
||||
path="/settings/weather"
|
||||
component={WeatherSettings}
|
||||
/>
|
||||
<ProtectedRoute
|
||||
path="/settings/search"
|
||||
component={SearchSettings}
|
||||
/>
|
||||
<ProtectedRoute path="/settings/interface" component={UISettings} />
|
||||
<ProtectedRoute
|
||||
path="/settings/docker"
|
||||
component={DockerSettings}
|
||||
/>
|
||||
<ProtectedRoute path="/settings/css" component={StyleSettings} />
|
||||
<Route path="/settings/app" component={AppDetails} />
|
||||
</Switch>
|
||||
</section>
|
||||
|
@ -57,5 +75,3 @@ const Settings = (): JSX.Element => {
|
|||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
|
|
|
@ -2,54 +2,60 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
|||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification } from '../../../store/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { ApiResponse, NewNotification } from '../../../interfaces';
|
||||
import { ApiResponse } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
// Other
|
||||
import { InputGroup, Button } from '../../UI';
|
||||
import { applyAuth } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
}
|
||||
export const StyleSettings = (): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification } = bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
const StyleSettings = (props: ComponentProps): JSX.Element => {
|
||||
const [customStyles, setCustomStyles] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
axios.get<ApiResponse<string>>('/api/config/0/css')
|
||||
.then(data => setCustomStyles(data.data.data))
|
||||
.catch(err => console.log(err.response));
|
||||
}, [])
|
||||
axios
|
||||
.get<ApiResponse<string>>('/api/config/0/css')
|
||||
.then((data) => setCustomStyles(data.data.data))
|
||||
.catch((err) => console.log(err.response));
|
||||
}, []);
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
setCustomStyles(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const formSubmitHandler = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
axios.put<ApiResponse<{}>>('/api/config/0/css', { styles: customStyles })
|
||||
axios
|
||||
.put<ApiResponse<{}>>(
|
||||
'/api/config/0/css',
|
||||
{ styles: customStyles },
|
||||
{ headers: applyAuth() }
|
||||
)
|
||||
.then(() => {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Success',
|
||||
message: 'CSS saved. Reload page to see changes'
|
||||
message: 'CSS saved. Reload page to see changes',
|
||||
});
|
||||
})
|
||||
})
|
||||
.catch(err => console.log(err.response));
|
||||
}
|
||||
.catch((err) => console.log(err.response));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
<InputGroup>
|
||||
<label htmlFor='customStyles'>Custom CSS</label>
|
||||
<label htmlFor="customStyles">Custom CSS</label>
|
||||
<textarea
|
||||
id='customStyles'
|
||||
name='customStyles'
|
||||
id="customStyles"
|
||||
name="customStyles"
|
||||
value={customStyles}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
spellCheck={false}
|
||||
|
@ -57,7 +63,5 @@ const StyleSettings = (props: ComponentProps): JSX.Element => {
|
|||
</InputGroup>
|
||||
<Button>Save CSS</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(null, { createNotification })(StyleSettings);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
text-transform: capitalize;
|
||||
margin: 8px 0;
|
||||
color: var(--color-primary);
|
||||
/* align-self: flex-start; */
|
||||
}
|
||||
|
||||
.ColorsPreview {
|
|
@ -1,14 +1,17 @@
|
|||
import { Theme } from '../../interfaces/Theme';
|
||||
import { Theme } from '../../../interfaces/Theme';
|
||||
import classes from './ThemePreview.module.css';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
applyTheme: Function;
|
||||
}
|
||||
|
||||
const ThemePreview = (props: ComponentProps): JSX.Element => {
|
||||
export const ThemePreview = (props: Props): JSX.Element => {
|
||||
return (
|
||||
<div className={classes.ThemePreview} onClick={() => props.applyTheme(props.theme.name)}>
|
||||
<div
|
||||
className={classes.ThemePreview}
|
||||
onClick={() => props.applyTheme(props.theme.name)}
|
||||
>
|
||||
<div className={classes.ColorsPreview}>
|
||||
<div
|
||||
className={classes.ColorPreview}
|
||||
|
@ -25,7 +28,5 @@ const ThemePreview = (props: ComponentProps): JSX.Element => {
|
|||
</div>
|
||||
<p>{props.theme.name}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemePreview;
|
||||
);
|
||||
};
|
101
client/src/components/Settings/Themer/Themer.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { ChangeEvent, FormEvent, Fragment, useEffect, useState } from 'react';
|
||||
|
||||
// Redux
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
|
||||
// Typescript
|
||||
import { Theme, ThemeSettingsForm } from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import { ThemePreview } from './ThemePreview';
|
||||
import { Button, InputGroup, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Other
|
||||
import classes from './Themer.module.css';
|
||||
import { themes } from './themes.json';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { inputHandler, themeSettingsTemplate } from '../../../utility';
|
||||
|
||||
export const Themer = (): JSX.Element => {
|
||||
const {
|
||||
auth: { isAuthenticated },
|
||||
config: { loading, config },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { setTheme, 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,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SettingsHeadline text="Set theme" />
|
||||
<div className={classes.ThemerGrid}>
|
||||
{themes.map(
|
||||
(theme: Theme, idx: number): JSX.Element => (
|
||||
<ThemePreview key={idx} theme={theme} applyTheme={setTheme} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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.map((theme: Theme, idx) => (
|
||||
<option key={idx} value={theme.name}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</InputGroup>
|
||||
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
|
@ -95,6 +95,30 @@
|
|||
"primary": "#4C432E",
|
||||
"accent": "#AA9A73"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "neon",
|
||||
"colors": {
|
||||
"background": "#091833",
|
||||
"primary": "#EFFBFF",
|
||||
"accent": "#ea00d9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pumpkin",
|
||||
"colors": {
|
||||
"background": "#2d3436",
|
||||
"primary": "#EFFBFF",
|
||||
"accent": "#ffa500"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "onedark",
|
||||
"colors": {
|
||||
"background": "#282c34",
|
||||
"primary": "#dfd9d6",
|
||||
"accent": "#98c379"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
303
client/src/components/Settings/UISettings/UISettings.tsx
Normal file
|
@ -0,0 +1,303 @@
|
|||
import { ChangeEvent, FormEvent, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
|
||||
import { OtherSettingsForm } from '../../../interfaces';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
import { inputHandler, otherSettingsTemplate } from '../../../utility';
|
||||
import { Button, InputGroup, SettingsHeadline } from '../../UI';
|
||||
|
||||
export const UISettings = (): JSX.Element => {
|
||||
const {
|
||||
config: { loading, config },
|
||||
apps: { categories: appCategories },
|
||||
bookmarks: { categories: bookmarkCategories },
|
||||
} = useSelector((state: State) => state);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { updateConfig, sortApps, sortCategories, sortBookmarks } =
|
||||
bindActionCreators(actionCreators, dispatch);
|
||||
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<OtherSettingsForm>(
|
||||
otherSettingsTemplate
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
// Sort entities with new settings
|
||||
if (formData.useOrdering !== config.useOrdering) {
|
||||
sortCategories();
|
||||
|
||||
for (let { id } of appCategories) {
|
||||
sortApps(id);
|
||||
}
|
||||
|
||||
for (let { id } of bookmarkCategories) {
|
||||
sortBookmarks(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
|
||||
{/* === 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. 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>
|
||||
|
||||
{/* === 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 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="hideBookmarks">Hide bookmarks</label>
|
||||
<select
|
||||
id="hideBookmarks"
|
||||
name="hideBookmarks"
|
||||
value={formData.hideBookmarks ? 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>
|
||||
);
|
||||
};
|
|
@ -2,43 +2,40 @@ import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
|||
import axios from 'axios';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification, updateConfig } from '../../../store/actions';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { actionCreators } from '../../../store';
|
||||
import { State } from '../../../store/reducers';
|
||||
|
||||
// Typescript
|
||||
import { ApiResponse, GlobalState, NewNotification, Weather, WeatherForm } from '../../../interfaces';
|
||||
import { ApiResponse, Weather, WeatherForm } from '../../../interfaces';
|
||||
|
||||
// UI
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import { InputGroup, Button, SettingsHeadline } from '../../UI';
|
||||
|
||||
// Utils
|
||||
import { searchConfig } from '../../../utility';
|
||||
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: WeatherForm) => void;
|
||||
loading: boolean;
|
||||
}
|
||||
export const WeatherSettings = (): JSX.Element => {
|
||||
const { loading, config } = useSelector((state: State) => state.config);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { createNotification, updateConfig } = bindActionCreators(
|
||||
actionCreators,
|
||||
dispatch
|
||||
);
|
||||
|
||||
const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<WeatherForm>({
|
||||
WEATHER_API_KEY: '',
|
||||
lat: 0,
|
||||
long: 0,
|
||||
isCelsius: 1
|
||||
})
|
||||
const [formData, setFormData] = useState<WeatherForm>(
|
||||
weatherSettingsTemplate
|
||||
);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
WEATHER_API_KEY: searchConfig('WEATHER_API_KEY', ''),
|
||||
lat: searchConfig('lat', 0),
|
||||
long: searchConfig('long', 0),
|
||||
isCelsius: searchConfig('isCelsius', 1)
|
||||
})
|
||||
}, [props.loading]);
|
||||
...config,
|
||||
});
|
||||
}, [loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
|
@ -46,122 +43,140 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
|||
|
||||
// Check for api key input
|
||||
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
|
||||
props.createNotification({
|
||||
createNotification({
|
||||
title: 'Warning',
|
||||
message: 'API key is missing. Weather Module will NOT work'
|
||||
})
|
||||
}
|
||||
|
||||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
|
||||
// Update weather
|
||||
axios.get<ApiResponse<Weather>>('/api/weather/update')
|
||||
.then(() => {
|
||||
props.createNotification({
|
||||
title: 'Success',
|
||||
message: 'Weather updated'
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
props.createNotification({
|
||||
title: 'Error',
|
||||
message: err.response.data.error
|
||||
})
|
||||
message: 'API key is missing. Weather Module will NOT work',
|
||||
});
|
||||
}
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
|
||||
let value: string | number = e.target.value;
|
||||
// Save settings
|
||||
await updateConfig(formData);
|
||||
|
||||
if (isNumber) {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: value
|
||||
// Update weather
|
||||
axios
|
||||
.get<ApiResponse<Weather>>('/api/weather/update')
|
||||
.then(() => {
|
||||
createNotification({
|
||||
title: 'Success',
|
||||
message: 'Weather updated',
|
||||
});
|
||||
})
|
||||
}
|
||||
.catch((err) => {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: err.response.data.error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<WeatherForm>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||
<SettingsHeadline text="API" />
|
||||
{/* API KEY */}
|
||||
<InputGroup>
|
||||
<label htmlFor='WEATHER_API_KEY'>API key</label>
|
||||
<label htmlFor="WEATHER_API_KEY">API key</label>
|
||||
<input
|
||||
type='text'
|
||||
id='WEATHER_API_KEY'
|
||||
name='WEATHER_API_KEY'
|
||||
placeholder='secret'
|
||||
type="text"
|
||||
id="WEATHER_API_KEY"
|
||||
name="WEATHER_API_KEY"
|
||||
placeholder="secret"
|
||||
value={formData.WEATHER_API_KEY}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
<span>
|
||||
Using
|
||||
<a
|
||||
href='https://www.weatherapi.com/pricing.aspx'
|
||||
target='blank'>
|
||||
{' '}Weather API
|
||||
<a href="https://www.weatherapi.com/pricing.aspx" target="blank">
|
||||
{' '}
|
||||
Weather API
|
||||
</a>
|
||||
. Key is required for weather module to work.
|
||||
</span>
|
||||
</InputGroup>
|
||||
|
||||
<SettingsHeadline text="Location" />
|
||||
{/* LAT */}
|
||||
<InputGroup>
|
||||
<label htmlFor='lat'>Location latitude</label>
|
||||
<label htmlFor="lat">Latitude</label>
|
||||
<input
|
||||
type='number'
|
||||
id='lat'
|
||||
name='lat'
|
||||
placeholder='52.22'
|
||||
type="number"
|
||||
id="lat"
|
||||
name="lat"
|
||||
placeholder="52.22"
|
||||
value={formData.lat}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
step='any'
|
||||
lang='en-150'
|
||||
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
|
||||
step="any"
|
||||
lang="en-150"
|
||||
/>
|
||||
<span>
|
||||
You can use
|
||||
<a
|
||||
href='https://www.latlong.net/convert-address-to-lat-long.html'
|
||||
target='blank'>
|
||||
{' '}latlong.net
|
||||
href="https://www.latlong.net/convert-address-to-lat-long.html"
|
||||
target="blank"
|
||||
>
|
||||
{' '}
|
||||
latlong.net
|
||||
</a>
|
||||
</span>
|
||||
</InputGroup>
|
||||
|
||||
{/* LONG */}
|
||||
<InputGroup>
|
||||
<label htmlFor='long'>Location longitude</label>
|
||||
<label htmlFor="long">Longitude</label>
|
||||
<input
|
||||
type='number'
|
||||
id='long'
|
||||
name='long'
|
||||
placeholder='21.01'
|
||||
type="number"
|
||||
id="long"
|
||||
name="long"
|
||||
placeholder="21.01"
|
||||
value={formData.long}
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
step='any'
|
||||
lang='en-150'
|
||||
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
|
||||
step="any"
|
||||
lang="en-150"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<SettingsHeadline text="Other" />
|
||||
{/* TEMPERATURE */}
|
||||
<InputGroup>
|
||||
<label htmlFor='isCelsius'>Temperature unit</label>
|
||||
<label htmlFor="isCelsius">Temperature unit</label>
|
||||
<select
|
||||
id='isCelsius'
|
||||
name='isCelsius'
|
||||
onChange={(e) => inputChangeHandler(e, true)}
|
||||
value={formData.isCelsius}
|
||||
id="isCelsius"
|
||||
name="isCelsius"
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
value={formData.isCelsius ? 1 : 0}
|
||||
>
|
||||
<option value={1}>Celsius</option>
|
||||
<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
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, { createNotification, updateConfig })(WeatherSettings);
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,27 +2,38 @@
|
|||
"routes": [
|
||||
{
|
||||
"name": "Theme",
|
||||
"dest": "/settings"
|
||||
"dest": "/settings",
|
||||
"authRequired": false
|
||||
},
|
||||
{
|
||||
"name": "Weather",
|
||||
"dest": "/settings/weather"
|
||||
"dest": "/settings/weather",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "Search",
|
||||
"dest": "/settings/search"
|
||||
"dest": "/settings/search",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "Other",
|
||||
"dest": "/settings/other"
|
||||
"name": "Interface",
|
||||
"dest": "/settings/interface",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "Docker",
|
||||
"dest": "/settings/docker",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "CSS",
|
||||
"dest": "/settings/css"
|
||||
"dest": "/settings/css",
|
||||
"authRequired": true
|
||||
},
|
||||
{
|
||||
"name": "App",
|
||||
"dest": "/settings/app"
|
||||
"dest": "/settings/app",
|
||||
"authRequired": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
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);
|
|
@ -2,55 +2,45 @@ import { Fragment } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import classes from './ActionButton.module.css';
|
||||
import Icon from '../../Icons/Icon/Icon';
|
||||
import { Icon } from '../..';
|
||||
|
||||
interface ComponentProps {
|
||||
interface Props {
|
||||
name: string;
|
||||
icon: string;
|
||||
link?: string;
|
||||
handler?: () => void;
|
||||
}
|
||||
|
||||
const ActionButton = (props: ComponentProps): JSX.Element => {
|
||||
export const ActionButton = (props: Props): JSX.Element => {
|
||||
const body = (
|
||||
<Fragment>
|
||||
<div className={classes.ActionButtonIcon}>
|
||||
<Icon icon={props.icon} />
|
||||
</div>
|
||||
<div className={classes.ActionButtonName}>
|
||||
{props.name}
|
||||
</div>
|
||||
<div className={classes.ActionButtonName}>{props.name}</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
if (props.link) {
|
||||
return (
|
||||
<Link
|
||||
to={props.link}
|
||||
tabIndex={0}>
|
||||
<Link to={props.link} tabIndex={0}>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
} else if (props.handler) {
|
||||
return (
|
||||
<div
|
||||
className={classes.ActionButton}
|
||||
onClick={props.handler}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && props.handler) props.handler()
|
||||
if (e.key === 'Enter' && props.handler) props.handler();
|
||||
}}
|
||||
tabIndex={0}
|
||||
>{body}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className={classes.ActionButton}>
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return <div className={classes.ActionButton}>{body}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export default ActionButton;
|
||||
};
|
||||
|
|