Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08afaece2e | ||
|
|
4f2ba0a96d | ||
|
|
9db46faabe | ||
|
|
567af1c66e | ||
|
|
2485f4ff33 | ||
|
|
bce51bb2c4 | ||
|
|
7febd59ad7 | ||
|
|
1388a1876e | ||
|
|
aca8b0261e | ||
|
|
4e20527834 | ||
|
|
4ed29fe276 | ||
|
|
b45eecada2 | ||
|
|
1d70bd132a | ||
|
|
88694c7e27 | ||
|
|
3dd255f359 | ||
|
|
feb7275cf8 | ||
|
|
da13ca6092 | ||
|
|
3d3e2eed8c | ||
|
|
df6d96f5b6 | ||
|
|
0ec77c33bf | ||
|
|
98924ac006 | ||
|
|
4ef9652ede | ||
|
|
cfb471e578 | ||
|
|
76e50624e7 | ||
|
|
34279c8b8c | ||
|
|
b7de1e3d27 | ||
|
|
85ee5da025 | ||
|
|
e5cba605fa | ||
|
|
6f44200a3c | ||
|
|
7129fe83da | ||
|
|
6f8a017bfb | ||
|
|
55f192f664 | ||
|
|
edb04c375f | ||
|
|
38ffdf1bff | ||
|
|
a885440fef | ||
|
|
16341ca6da | ||
|
|
fc219f704c | ||
|
|
63346f7e38 | ||
|
|
04be0d1316 | ||
|
|
65a33f16fd | ||
|
|
fdec74acc6 | ||
|
|
231dbc4577 | ||
|
|
459523dfd2 | ||
|
|
591824dd0c | ||
|
|
da928f20a2 | ||
|
|
a162450568 | ||
|
|
084218027c | ||
|
|
bf1aa9e85c | ||
|
|
afc0f16470 | ||
|
|
59271d3376 | ||
|
|
84bd641cf2 | ||
|
|
1d8e36b46d | ||
|
|
1625932e52 | ||
|
|
6a6f1750b1 | ||
|
|
4252457871 | ||
|
|
9606978bd7 | ||
|
|
ebae61a688 | ||
|
|
43f38a2f44 | ||
|
|
53d50ca869 | ||
|
|
fac280ff0a | ||
|
|
6ae6c58f4c | ||
|
|
8521995758 | ||
|
|
19f95c433c | ||
|
|
45fb337c87 | ||
|
|
8808f65b47 | ||
|
|
5cef34a467 | ||
|
|
8681f75bab | ||
|
|
c1b61f9cd9 | ||
|
|
78a018f686 | ||
|
|
36c9b7648a | ||
|
|
5c60c7c156 | ||
|
|
683c948f6c | ||
|
|
1699146f79 | ||
|
|
a01661d0d5 | ||
|
|
1962af01e6 | ||
|
|
39349dded1 | ||
|
|
b53509aa69 | ||
|
|
b5ba9856ed | ||
|
|
b94df53267 | ||
|
|
4b42f991f8 | ||
|
|
2ceff6828a | ||
|
|
d39eda49de | ||
|
|
a5d6cf04cf | ||
|
|
1fbe0746a4 | ||
|
|
f93659b661 | ||
|
|
88785aaa32 | ||
|
|
4143ae8198 | ||
|
|
f1c48e8a15 | ||
|
|
6445a5009a | ||
|
|
112a35c08f | ||
|
|
7970ac3031 | ||
|
|
c03f302fa6 | ||
|
|
0c3a27febd | ||
|
|
aec00982ba | ||
|
|
8026533a06 | ||
|
|
550e1e155b | ||
|
|
12974ab01b | ||
|
|
6c067bee31 | ||
|
|
db4a10171e | ||
|
|
472cfd6610 | ||
|
|
e3ed429da1 | ||
|
|
5ae4d6e7c4 | ||
|
|
4c3255107c | ||
|
|
41a3f5dae3 | ||
|
|
4ca3b509cf | ||
|
|
28680bec1a | ||
|
|
ae3141e37b | ||
|
|
5b900872af | ||
|
|
754dc3a7b9 | ||
|
|
8974fb3b49 | ||
|
|
ce173f2c42 | ||
|
|
9a1ec76ffd | ||
|
|
a9be4df157 | ||
|
|
e884c84aa8 | ||
|
|
ad5e7646c1 | ||
|
|
ff1d11f512 | ||
|
|
5e7cb72b82 | ||
|
|
f137498e7e | ||
|
|
d257fbf9a3 | ||
|
|
a5504e6e80 | ||
|
|
5968663be4 | ||
|
|
66cc59c48e | ||
|
|
f5f735372a |
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,2 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
github
|
github
|
||||||
|
public
|
||||||
|
build.sh
|
||||||
|
k8s
|
||||||
|
skaffold.yaml
|
||||||
|
|||||||
BIN
.github/_apps.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
.github/_bookmarks.png
vendored
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
.github/_home.png
vendored
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
.github/_themes.png
vendored
Normal file
|
After Width: | Height: | Size: 226 KiB |
8
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
node_modules/
|
node_modules
|
||||||
data/
|
data
|
||||||
.env
|
public
|
||||||
|
!client/public
|
||||||
|
build.sh
|
||||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.md
|
||||||
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"printWidth": 80,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
110
CHANGELOG.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
### 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))
|
||||||
|
- Fixed bug related to creating new apps/bookmarks with custom icon ([#83](https://github.com/pawelmalak/flame/issues/83))
|
||||||
|
- URL can now be assigned to notifications. Clicking on "New version is available" popup will now redirect to changelog ([#86](https://github.com/pawelmalak/flame/issues/86))
|
||||||
|
- Added static fonts ([#94](https://github.com/pawelmalak/flame/issues/94))
|
||||||
|
- Fixed bug with overriding app icon created with docker labels
|
||||||
|
|
||||||
|
### v1.6.9 (2021-10-09)
|
||||||
|
- Added option for remote docker host ([#97](https://github.com/pawelmalak/flame/issues/97))
|
||||||
|
|
||||||
|
### v1.6.8 (2021-10-05)
|
||||||
|
- Implemented migration system for database
|
||||||
|
|
||||||
|
### v1.6.7 (2021-10-04)
|
||||||
|
- Add multiple labels to Docker Compose ([#90](https://github.com/pawelmalak/flame/issues/90))
|
||||||
|
- Custom icons via Docker Compose labels ([#91](https://github.com/pawelmalak/flame/issues/91))
|
||||||
|
|
||||||
|
### v1.6.6 (2021-09-06)
|
||||||
|
- Added local search (filter) for apps and bookmarks ([#47](https://github.com/pawelmalak/flame/issues/47))
|
||||||
|
|
||||||
|
### v1.6.5 (2021-08-28)
|
||||||
|
- Added support for more URL schemes ([#74](https://github.com/pawelmalak/flame/issues/74))
|
||||||
|
|
||||||
|
### v1.6.4 (2021-08-17)
|
||||||
|
- Added Kubernetes integration ([#72 continued](https://github.com/pawelmalak/flame/issues/72))
|
||||||
|
|
||||||
|
### v1.6.3 (2021-08-09)
|
||||||
|
- Added support for custom SVG icons ([#73](https://github.com/pawelmalak/flame/issues/73))
|
||||||
|
- Added Deezer and Tidal to search queries
|
||||||
|
|
||||||
|
### v1.6.2 (2021-08-06)
|
||||||
|
- Fixed changelog link
|
||||||
|
- Added support for Docker API ([#14](https://github.com/pawelmalak/flame/issues/14))
|
||||||
|
|
||||||
|
### v1.6.1 (2021-07-28)
|
||||||
|
- Added option to upload custom icons for bookmarks ([#52](https://github.com/pawelmalak/flame/issues/52))
|
||||||
|
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
|
||||||
|
- Added changelog file
|
||||||
|
|
||||||
|
### v1.6 (2021-07-17)
|
||||||
|
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
|
||||||
|
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
|
||||||
|
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
|
||||||
|
|
||||||
|
### v1.5 (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))
|
||||||
|
- Added Search bar with support for 3 search engines and 4 services ([#44](https://github.com/pawelmalak/flame/issues/44))
|
||||||
|
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
|
||||||
|
- Improved Logger
|
||||||
|
|
||||||
|
### v1.4 (2021-06-18)
|
||||||
|
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||||
|
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||||
|
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
|
||||||
|
- New apps will be placed correctly in the array depending on used sorting settings ([#37](https://github.com/pawelmalak/flame/issues/37))
|
||||||
|
- Added app version to settings with option to check for updates manually ([#38](https://github.com/pawelmalak/flame/issues/38))
|
||||||
|
- Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
|
||||||
|
- Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
|
||||||
|
|
||||||
|
### v1.3 (2021-06-14)
|
||||||
|
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
|
||||||
|
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
|
||||||
|
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
|
||||||
|
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
|
||||||
|
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
|
||||||
|
|
||||||
|
### v1.2 (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))
|
||||||
|
- Added background highlight while hovering over application card ([#15](https://github.com/pawelmalak/flame/issues/15))
|
||||||
|
- Created CRON job to clear old weather data from the database ([#16](https://github.com/pawelmalak/flame/issues/16))
|
||||||
|
- 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)
|
||||||
|
- 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)
|
||||||
|
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.
|
||||||
11
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:14-alpine
|
FROM node:14 as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,13 +10,20 @@ COPY . .
|
|||||||
|
|
||||||
RUN mkdir -p ./public ./data \
|
RUN mkdir -p ./public ./data \
|
||||||
&& cd ./client \
|
&& cd ./client \
|
||||||
|
&& npm install --production \
|
||||||
&& npm run build \
|
&& npm run build \
|
||||||
&& cd .. \
|
&& cd .. \
|
||||||
&& mv ./client/build/* ./public \
|
&& mv ./client/build/* ./public \
|
||||||
&& rm -rf ./client
|
&& rm -rf ./client
|
||||||
|
|
||||||
|
FROM node:14-alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 5005
|
EXPOSE 5005
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
16
Dockerfile.dev
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:lts-alpine as build-front
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ./client .
|
||||||
|
RUN npm install --production \
|
||||||
|
&& npm run build
|
||||||
|
|
||||||
|
FROM node:lts-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p ./public
|
||||||
|
COPY --from=build-front /app/build/ ./public
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
CMD ["npm", "run", "skaffold"]
|
||||||
29
Dockerfile.multiarch
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM node:14 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p ./public ./data \
|
||||||
|
&& cd ./client \
|
||||||
|
&& npm install --production \
|
||||||
|
&& npm run build \
|
||||||
|
&& cd .. \
|
||||||
|
&& mv ./client/build/* ./public \
|
||||||
|
&& rm -rf ./client
|
||||||
|
|
||||||
|
FROM node:14-alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 5005
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
21
LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2021 Paweł Malak
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
204
README.md
@@ -1,11 +1,13 @@
|
|||||||
# Flame
|
# Flame
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui)
|
|
||||||
|
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
|
## Technology
|
||||||
|
|
||||||
- Backend
|
- Backend
|
||||||
- Node.js + Express
|
- Node.js + Express
|
||||||
- Sequelize ORM + SQLite
|
- Sequelize ORM + SQLite
|
||||||
@@ -15,9 +17,12 @@ Flame is self-hosted startpage for your server. It's inspired (heavily) by [SUI]
|
|||||||
- TypeScript
|
- TypeScript
|
||||||
- Deployment
|
- Deployment
|
||||||
- Docker
|
- Docker
|
||||||
|
- Kubernetes
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# clone repository
|
||||||
git clone https://github.com/pawelmalak/flame
|
git clone https://github.com/pawelmalak/flame
|
||||||
cd flame
|
cd flame
|
||||||
|
|
||||||
@@ -28,31 +33,190 @@ npm run dev-init
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment with Docker
|
## Installation
|
||||||
```sh
|
|
||||||
# build image
|
|
||||||
docker build -t flame .
|
|
||||||
|
|
||||||
# run container
|
### With Docker (recommended)
|
||||||
docker run -p 5005:5005 -v <host_dir>:/app/data flame
|
|
||||||
|
[Docker Hub link](https://hub.docker.com/r/pawelmalak/flame)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker pull pawelmalak/flame:latest
|
||||||
|
|
||||||
|
# for ARM architecture (e.g. RaspberryPi)
|
||||||
|
docker pull pawelmalak/flame:multiarch
|
||||||
```
|
```
|
||||||
|
|
||||||
## Functionality
|
|
||||||
- Applications
|
|
||||||
- Create, update and delete applications using GUI
|
|
||||||
- Pin your favourite apps to homescreen
|
|
||||||
|
|
||||||

|
#### Building images
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# build image for amd64 only
|
||||||
|
docker build -t flame .
|
||||||
|
|
||||||
|
# build multiarch image for amd64, armv7 and arm64
|
||||||
|
# building failed multiple times with 2GB memory usage limit so you might want to increase it
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/arm/v7,linux/arm64,linux/amd64 \
|
||||||
|
-f Dockerfile.multiarch \
|
||||||
|
-t flame:multiarch .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Deployment
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# run container
|
||||||
|
docker run -p 5005:5005 -v /path/to/data:/app/data flame
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Docker-Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '2.1'
|
||||||
|
services:
|
||||||
|
flame:
|
||||||
|
image: pawelmalak/flame:latest
|
||||||
|
container_name: flame
|
||||||
|
volumes:
|
||||||
|
- <host_dir>:/app/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # optional but required for Docker integration feature
|
||||||
|
ports:
|
||||||
|
- 5005:5005
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Skaffold
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# use skaffold
|
||||||
|
skaffold dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Docker
|
||||||
|
|
||||||
|
Follow instructions from wiki: [Installation without Docker](https://github.com/pawelmalak/flame/wiki/Installation-without-docker)
|
||||||
|
|
||||||
|
## Functionality
|
||||||
|
|
||||||
|
- Applications
|
||||||
|
- Create, update, delete and organize applications using GUI
|
||||||
|
- Pin your favourite apps to the homescreen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
- Bookmarks
|
- Bookmarks
|
||||||
- Create, update and delete bookmarks and categories using GUI
|
- Create, update, delete and organize bookmarks and categories using GUI
|
||||||
- Pin your favourite categories to homescreen
|
- Pin your favourite categories to the homescreen
|
||||||
|
- Import html bookmarks (experimental)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
- Weather
|
- Weather
|
||||||
- Get current temperature, cloud coverage and weather status with animated icons
|
|
||||||
- Themes
|
|
||||||
- Customize your page by choosing from 12 color themes
|
|
||||||
|
|
||||||

|
- Get current temperature, cloud coverage and weather status with animated icons
|
||||||
|
|
||||||
|
- Themes
|
||||||
|
- Customize your page by choosing from 15 color themes
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Search bar
|
||||||
|
|
||||||
|
#### Searching
|
||||||
|
|
||||||
|
To use search bar you need to type your search query with selected prefix. For example, to search for "what is docker" using google search you would type: `/g what is docker`.
|
||||||
|
|
||||||
|
> You can change where to open search results (same/new tab) in the settings
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
1. Obtain API Key from [Weather API](https://www.weatherapi.com/pricing.aspx).
|
||||||
|
> Free plan allows for 1M calls per month. Flame is making less then 3K API calls per month.
|
||||||
|
2. Get lat/long for your location. You can get them from [latlong.net](https://www.latlong.net/convert-address-to-lat-long.html).
|
||||||
|
3. Enter and save data. Weather widget will now update and should be visible on Home page.
|
||||||
|
|
||||||
|
### Docker integration
|
||||||
|
|
||||||
|
In order to use the Docker integration, each container must have the following labels:
|
||||||
|
|
||||||
|
```yml
|
||||||
|
labels:
|
||||||
|
- flame.type=application # "app" works too
|
||||||
|
- flame.name=My container
|
||||||
|
- flame.url=https://example.com
|
||||||
|
- flame.icon=icon-name # optional, default is "docker"
|
||||||
|
# - flame.icon=custom to make changes in app. ie: custom icon upload
|
||||||
|
```
|
||||||
|
|
||||||
|
> "Use Docker API" option must be enabled for this to work. You can find it in Settings > Other > Docker section
|
||||||
|
|
||||||
|
You can also set up different apps in the same label adding `;` between each one.
|
||||||
|
|
||||||
|
```yml
|
||||||
|
labels:
|
||||||
|
- flame.type=application
|
||||||
|
- flame.name=First App;Second App
|
||||||
|
- flame.url=https://example1.com;https://example2.com
|
||||||
|
- flame.icon=icon-name1;icon-name2
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to use a remote docker host follow this instructions in the host:
|
||||||
|
|
||||||
|
- Open the file `/lib/systemd/system/docker.service`, search for `ExecStart` and edit the value
|
||||||
|
|
||||||
|
```text
|
||||||
|
ExecStart=/usr/bin/dockerd -H tcp://0.0.0.0:${PORT} -H unix:///var/run/docker.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
>The above command will bind the docker engine server to the Unix socket as well as TCP port of your choice. “0.0.0.0” means docker-engine accepts connections from all IP addresses.
|
||||||
|
|
||||||
|
- Restart the daemon and Docker service
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo service docker restart
|
||||||
|
```
|
||||||
|
|
||||||
|
- Test if it is working
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl http://${IP}:${PORT}/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes integration
|
||||||
|
|
||||||
|
In order to use the Kubernetes integration, each ingress must have the following annotations:
|
||||||
|
|
||||||
|
```yml
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
- flame.pawelmalak/type=application # "app" works too
|
||||||
|
- flame.pawelmalak/name=My container
|
||||||
|
- flame.pawelmalak/url=https://example.com
|
||||||
|
- flame.pawelmalak/icon=icon-name # optional, default is "kubernetes"
|
||||||
|
```
|
||||||
|
|
||||||
|
> "Use Kubernetes Ingress API" option must be enabled for this to work. You can find it in Settings > Other > Kubernetes section
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
See project wiki for [Custom CSS](https://github.com/pawelmalak/flame/wiki/Custom-CSS) and [Custom theme with CSS](https://github.com/pawelmalak/flame/wiki/Custom-theme-with-CSS).
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
|
const Logger = require('./utils/Logger');
|
||||||
|
const logger = new Logger();
|
||||||
|
|
||||||
class Socket {
|
class Socket {
|
||||||
constructor(server) {
|
constructor(server) {
|
||||||
this.webSocketServer = new WebSocket.Server({ server })
|
this.webSocketServer = new WebSocket.Server({ server })
|
||||||
|
|
||||||
this.webSocketServer.on('listening', () => {
|
this.webSocketServer.on('listening', () => {
|
||||||
console.log('Socket: listen');
|
logger.log('Socket: listen');
|
||||||
})
|
})
|
||||||
|
|
||||||
this.webSocketServer.on('connection', (webSocketClient) => {
|
this.webSocketServer.on('connection', (webSocketClient) => {
|
||||||
console.log('Socket: new connection');
|
// console.log('Socket: new connection');
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
api.js
@@ -1,14 +1,15 @@
|
|||||||
const path = require('path');
|
const { join } = require('path');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const errorHandler = require('./middleware/errorHandler');
|
const errorHandler = require('./middleware/errorHandler');
|
||||||
|
|
||||||
const api = express();
|
const api = express();
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
api.use(express.static(path.join(__dirname, 'public')));
|
api.use(express.static(join(__dirname, 'public')));
|
||||||
|
api.use('/uploads', express.static(join(__dirname, 'data/uploads')));
|
||||||
api.get(/^\/(?!api)/, (req, res) => {
|
api.get(/^\/(?!api)/, (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public/index.html'));
|
res.sendFile(join(__dirname, 'public/index.html'));
|
||||||
})
|
});
|
||||||
|
|
||||||
// Body parser
|
// Body parser
|
||||||
api.use(express.json());
|
api.use(express.json());
|
||||||
@@ -19,8 +20,9 @@ api.use('/api/config', require('./routes/config'));
|
|||||||
api.use('/api/weather', require('./routes/weather'));
|
api.use('/api/weather', require('./routes/weather'));
|
||||||
api.use('/api/categories', require('./routes/category'));
|
api.use('/api/categories', require('./routes/category'));
|
||||||
api.use('/api/bookmarks', require('./routes/bookmark'));
|
api.use('/api/bookmarks', require('./routes/bookmark'));
|
||||||
|
api.use('/api/queries', require('./routes/queries'));
|
||||||
|
|
||||||
// Custom error handler
|
// Custom error handler
|
||||||
api.use(errorHandler);
|
api.use(errorHandler);
|
||||||
|
|
||||||
module.exports = api;
|
module.exports = api;
|
||||||
|
|||||||
1
client/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
REACT_APP_VERSION=1.7.4
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Getting Started with Create React App
|
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
|
||||||
|
|
||||||
## Available Scripts
|
|
||||||
|
|
||||||
In the project directory, you can run:
|
|
||||||
|
|
||||||
### `npm start`
|
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|
||||||
|
|
||||||
The page will reload if you make edits.\
|
|
||||||
You will also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `npm test`
|
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `npm run build`
|
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
|
||||||
|
|
||||||
### `npm run eject`
|
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
|
||||||
|
|
||||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
|
||||||
|
|
||||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
|
||||||
|
|
||||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
||||||
486
client/package-lock.json
generated
@@ -1806,9 +1806,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@mdi/js": {
|
"@mdi/js": {
|
||||||
"version": "5.9.55",
|
"version": "6.4.95",
|
||||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-5.9.55.tgz",
|
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-6.4.95.tgz",
|
||||||
"integrity": "sha512-BbeHMgeK2/vjdJIRnx12wvQ6s8xAYfvMmEAVsUx9b+7GiQGQ9Za8jpwp17dMKr9CgKRvemlAM4S7S3QOtEbp4A=="
|
"integrity": "sha512-b1/P//1D2KOzta8YRGyoSLGsAlWyUHfxzVBhV4e/ppnjM4DfBgay/vWz7Eg5Ee80JZ4zsQz8h54X+KOahtBk5Q=="
|
||||||
},
|
},
|
||||||
"@mdi/react": {
|
"@mdi/react": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
@@ -2047,20 +2047,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@testing-library/dom": {
|
"@testing-library/dom": {
|
||||||
"version": "7.30.4",
|
"version": "8.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.4.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.0.tgz",
|
||||||
"integrity": "sha512-GObDVMaI4ARrZEXaRy4moolNAxWPKvEYNV/fa6Uc2eAzR/t4otS6A7EhrntPBIQLeehL9DbVhscvvv7gd6hWqA==",
|
"integrity": "sha512-8Ay4UDiMlB5YWy+ZvCeRyFFofs53ebxrWnOFvCoM1HpMAX4cHyuSrCuIM9l2lVuUWUt+Gr3loz/nCwdrnG6ShQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@types/aria-query": "^4.2.0",
|
"@types/aria-query": "^4.2.0",
|
||||||
"aria-query": "^4.2.2",
|
"aria-query": "^5.0.0",
|
||||||
"chalk": "^4.1.0",
|
"chalk": "^4.1.0",
|
||||||
"dom-accessibility-api": "^0.5.4",
|
"dom-accessibility-api": "^0.5.9",
|
||||||
"lz-string": "^1.4.4",
|
"lz-string": "^1.4.4",
|
||||||
"pretty-format": "^26.6.2"
|
"pretty-format": "^27.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": {
|
"ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -2069,10 +2094,15 @@
|
|||||||
"color-convert": "^2.0.1"
|
"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": {
|
"chalk": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"ansi-styles": "^4.1.0",
|
"ansi-styles": "^4.1.0",
|
||||||
"supports-color": "^7.1.0"
|
"supports-color": "^7.1.0"
|
||||||
@@ -2096,6 +2126,29 @@
|
|||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
"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": {
|
"supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -2107,9 +2160,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@testing-library/jest-dom": {
|
"@testing-library/jest-dom": {
|
||||||
"version": "5.12.0",
|
"version": "5.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.15.0.tgz",
|
||||||
"integrity": "sha512-N9Y82b2Z3j6wzIoAqajlKVF1Zt7sOH0pPee0sUHXHc5cv2Fdn23r+vpWm0MBBoGJtPOly5+Bdx1lnc3CD+A+ow==",
|
"integrity": "sha512-lOMuQidnL1tWHLEWIhL6UvSZC1Qt3OkNe1khvi2h6xFiqpe5O8arYs46OU0qyUGq0cSTbroQyMktYNXu3a7sAA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.9.2",
|
"@babel/runtime": "^7.9.2",
|
||||||
"@types/testing-library__jest-dom": "^5.9.1",
|
"@types/testing-library__jest-dom": "^5.9.1",
|
||||||
@@ -2117,6 +2170,7 @@
|
|||||||
"chalk": "^3.0.0",
|
"chalk": "^3.0.0",
|
||||||
"css": "^3.0.0",
|
"css": "^3.0.0",
|
||||||
"css.escape": "^1.5.1",
|
"css.escape": "^1.5.1",
|
||||||
|
"dom-accessibility-api": "^0.5.6",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"redent": "^3.0.0"
|
"redent": "^3.0.0"
|
||||||
},
|
},
|
||||||
@@ -2191,18 +2245,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@testing-library/react": {
|
"@testing-library/react": {
|
||||||
"version": "11.2.6",
|
"version": "12.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz",
|
||||||
"integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==",
|
"integrity": "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@testing-library/dom": "^7.28.1"
|
"@testing-library/dom": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@testing-library/user-event": {
|
"@testing-library/user-event": {
|
||||||
"version": "12.8.3",
|
"version": "13.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||||
"integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==",
|
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.12.5"
|
"@babel/runtime": "^7.12.5"
|
||||||
}
|
}
|
||||||
@@ -2213,9 +2267,9 @@
|
|||||||
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA=="
|
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA=="
|
||||||
},
|
},
|
||||||
"@types/aria-query": {
|
"@types/aria-query": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
|
||||||
"integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg=="
|
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="
|
||||||
},
|
},
|
||||||
"@types/babel__core": {
|
"@types/babel__core": {
|
||||||
"version": "7.1.14",
|
"version": "7.1.14",
|
||||||
@@ -2286,9 +2340,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/history": {
|
"@types/history": {
|
||||||
"version": "4.7.8",
|
"version": "4.7.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
|
||||||
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA=="
|
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ=="
|
||||||
},
|
},
|
||||||
"@types/hoist-non-react-statics": {
|
"@types/hoist-non-react-statics": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
@@ -2305,9 +2359,9 @@
|
|||||||
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
|
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
|
||||||
},
|
},
|
||||||
"@types/http-proxy": {
|
"@types/http-proxy": {
|
||||||
"version": "1.17.6",
|
"version": "1.17.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz",
|
||||||
"integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==",
|
"integrity": "sha512-9hdj6iXH64tHSLTY+Vt2eYOGzSogC+JQ2H7bdPWkuh7KXP5qLllWx++t+K9Wk556c3dkDdPws/SpMRi0sdCT1w==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
@@ -2334,12 +2388,126 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/jest": {
|
"@types/jest": {
|
||||||
"version": "26.0.23",
|
"version": "27.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz",
|
||||||
"integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==",
|
"integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"jest-diff": "^26.0.0",
|
"jest-diff": "^27.0.0",
|
||||||
"pretty-format": "^26.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": {
|
"@types/json-schema": {
|
||||||
@@ -2358,9 +2526,9 @@
|
|||||||
"integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA=="
|
"integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA=="
|
||||||
},
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "12.20.12",
|
"version": "16.11.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz",
|
||||||
"integrity": "sha512-KQZ1al2hKOONAs2MFv+yTQP1LkDWMrRJ9YCVRalXltOfXsBmH5IownLxQaiq0lnAHwAViLnh2aTYqrPcRGEbgg=="
|
"integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w=="
|
||||||
},
|
},
|
||||||
"@types/normalize-package-data": {
|
"@types/normalize-package-data": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
@@ -2378,9 +2546,9 @@
|
|||||||
"integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA=="
|
"integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA=="
|
||||||
},
|
},
|
||||||
"@types/prop-types": {
|
"@types/prop-types": {
|
||||||
"version": "15.7.3",
|
"version": "15.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
|
||||||
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
|
"integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ=="
|
||||||
},
|
},
|
||||||
"@types/q": {
|
"@types/q": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
@@ -2388,27 +2556,43 @@
|
|||||||
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
|
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
|
||||||
},
|
},
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
"version": "17.0.5",
|
"version": "17.0.34",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz",
|
||||||
"integrity": "sha512-bj4biDB9ZJmGAYTWSKJly6bMr4BLUiBrx9ujiJEoP9XIDY9CTaPGxE5QWN/1WjpPLzYF7/jRNnV2nNxNe970sw==",
|
"integrity": "sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"@types/scheduler": "*",
|
"@types/scheduler": "*",
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/react-autosuggest": {
|
||||||
|
"version": "10.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-autosuggest/-/react-autosuggest-10.1.5.tgz",
|
||||||
|
"integrity": "sha512-qfMzrp6Is0VYRF5a97Bv/+P2F9ZtFY4YE2825yyWV4VxCpvcfvQHEqGNkDFIPme7t3B2BpQ784QBllYAxemERQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/react-beautiful-dnd": {
|
||||||
|
"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": {
|
"@types/react-dom": {
|
||||||
"version": "17.0.3",
|
"version": "17.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz",
|
||||||
"integrity": "sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w==",
|
"integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/react-redux": {
|
"@types/react-redux": {
|
||||||
"version": "7.1.16",
|
"version": "7.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz",
|
||||||
"integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==",
|
"integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/hoist-non-react-statics": "^3.3.0",
|
"@types/hoist-non-react-statics": "^3.3.0",
|
||||||
"@types/react": "*",
|
"@types/react": "*",
|
||||||
@@ -2417,9 +2601,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/react-router": {
|
"@types/react-router": {
|
||||||
"version": "5.1.14",
|
"version": "5.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.17.tgz",
|
||||||
"integrity": "sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw==",
|
"integrity": "sha512-RNSXOyb3VyRs/EOGmjBhhGKTbnN6fHWvy5FNLzWfOWOGjgVUKqJZXfpKzLmgoU8h6Hj8mpALj/mbXQASOb92wQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/history": "*",
|
"@types/history": "*",
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
@@ -2444,9 +2628,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@types/scheduler": {
|
"@types/scheduler": {
|
||||||
"version": "0.16.1",
|
"version": "0.16.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||||
"integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA=="
|
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
|
||||||
},
|
},
|
||||||
"@types/source-list-map": {
|
"@types/source-list-map": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
@@ -2464,9 +2648,9 @@
|
|||||||
"integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ=="
|
"integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ=="
|
||||||
},
|
},
|
||||||
"@types/testing-library__jest-dom": {
|
"@types/testing-library__jest-dom": {
|
||||||
"version": "5.9.5",
|
"version": "5.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.1.tgz",
|
||||||
"integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==",
|
"integrity": "sha512-Gk9vaXfbzc5zCXI9eYE9BI5BNHEp4D3FWjgqBE/ePGYElLAP+KvxBcsdkwfIVvezs605oiyd/VrpiHe3Oeg+Aw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/jest": "*"
|
"@types/jest": "*"
|
||||||
}
|
}
|
||||||
@@ -3151,11 +3335,18 @@
|
|||||||
"integrity": "sha512-1uIESzroqpaTzt9uX48HO+6gfnKu3RwvWdCcWSrX4csMInJfCo1yvKPNXCwXFRpJqRW25tiASb6No0YH57PXqg=="
|
"integrity": "sha512-1uIESzroqpaTzt9uX48HO+6gfnKu3RwvWdCcWSrX4csMInJfCo1yvKPNXCwXFRpJqRW25tiASb6No0YH57PXqg=="
|
||||||
},
|
},
|
||||||
"axios": {
|
"axios": {
|
||||||
"version": "0.21.1",
|
"version": "0.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
|
||||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
|
||||||
"requires": {
|
"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": {
|
"axobject-query": {
|
||||||
@@ -4614,6 +4805,14 @@
|
|||||||
"postcss": "^7.0.5"
|
"postcss": "^7.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"css-box-model": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
|
||||||
|
"requires": {
|
||||||
|
"tiny-invariant": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"css-color-names": {
|
"css-color-names": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz",
|
||||||
@@ -4890,9 +5089,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"csstype": {
|
"csstype": {
|
||||||
"version": "3.0.8",
|
"version": "3.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
|
||||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
|
||||||
},
|
},
|
||||||
"cyclist": {
|
"cyclist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -5211,9 +5410,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dom-accessibility-api": {
|
"dom-accessibility-api": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz",
|
||||||
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ=="
|
"integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g=="
|
||||||
},
|
},
|
||||||
"dom-converter": {
|
"dom-converter": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
@@ -5561,6 +5760,11 @@
|
|||||||
"es6-symbol": "^3.1.1"
|
"es6-symbol": "^3.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"es6-promise": {
|
||||||
|
"version": "4.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
|
||||||
|
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
|
||||||
|
},
|
||||||
"es6-symbol": {
|
"es6-symbol": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
|
||||||
@@ -6446,6 +6650,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"external-svg-loader": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/external-svg-loader/-/external-svg-loader-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-73h7/rYYA4KnIV74M/0r6zHWPLuY/8QHnwKymwh+46tbQAZ0ZtoN98TJZI+CUYTfP2nXgqslCgSsxcr7eOw45w==",
|
||||||
|
"requires": {
|
||||||
|
"idb-keyval": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"extglob": {
|
"extglob": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
|
||||||
@@ -7457,9 +7669,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"http-proxy-middleware": {
|
"http-proxy-middleware": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.1.tgz",
|
||||||
"integrity": "sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow==",
|
"integrity": "sha512-cfaXRVoZxSed/BmkA7SwBVNI9Kj7HFltaE5rqYOub5kWzWZ+gofV2koVN1j2rMW7pEfSSlCHGJ31xmuyFyfLOg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@types/http-proxy": "^1.17.5",
|
"@types/http-proxy": "^1.17.5",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
@@ -7511,6 +7723,11 @@
|
|||||||
"postcss": "^7.0.14"
|
"postcss": "^7.0.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"idb-keyval": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-slx8Q6oywCCSfKgPgL0sEsXtPVnSbTLWpyiDcu6msHOyKOLari1TD1qocXVCft80umnkk3/Qqh3lwoFt8T/BPQ=="
|
||||||
|
},
|
||||||
"identity-obj-proxy": {
|
"identity-obj-proxy": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
|
||||||
@@ -9932,6 +10149,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
"integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
|
||||||
},
|
},
|
||||||
|
"memoize-one": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
|
||||||
|
},
|
||||||
"memory-fs": {
|
"memory-fs": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
|
||||||
@@ -12079,6 +12301,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
|
||||||
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
|
"integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
|
||||||
},
|
},
|
||||||
|
"prettier": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"pretty-bytes": {
|
"pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
@@ -12300,6 +12528,11 @@
|
|||||||
"performance-now": "^2.1.0"
|
"performance-now": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"raf-schd": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
|
||||||
|
},
|
||||||
"randombytes": {
|
"randombytes": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
@@ -12362,6 +12595,32 @@
|
|||||||
"whatwg-fetch": "^3.4.1"
|
"whatwg-fetch": "^3.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-autosuggest": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-autosuggest/-/react-autosuggest-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-/azBHmc6z/31s/lBf6irxPf/7eejQdR0IqnZUzjdSibtlS8+Rw/R79pgDAo6Ft5QqCUTyEQ+f0FhL+1olDQ8OA==",
|
||||||
|
"requires": {
|
||||||
|
"es6-promise": "^4.2.8",
|
||||||
|
"prop-types": "^15.7.2",
|
||||||
|
"react-themeable": "^1.1.0",
|
||||||
|
"section-iterator": "^2.0.0",
|
||||||
|
"shallow-equal": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"react-beautiful-dnd": {
|
||||||
|
"version": "13.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
|
||||||
|
"integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==",
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.9.2",
|
||||||
|
"css-box-model": "^1.2.0",
|
||||||
|
"memoize-one": "^5.1.1",
|
||||||
|
"raf-schd": "^4.0.2",
|
||||||
|
"react-redux": "^7.2.0",
|
||||||
|
"redux": "^4.0.4",
|
||||||
|
"use-memo-one": "^1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-dev-utils": {
|
"react-dev-utils": {
|
||||||
"version": "11.0.4",
|
"version": "11.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz",
|
||||||
@@ -12489,16 +12748,31 @@
|
|||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||||
},
|
},
|
||||||
"react-redux": {
|
"react-redux": {
|
||||||
"version": "7.2.4",
|
"version": "7.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz",
|
||||||
"integrity": "sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==",
|
"integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.12.1",
|
"@babel/runtime": "^7.15.4",
|
||||||
"@types/react-redux": "^7.1.16",
|
"@types/react-redux": "^7.1.20",
|
||||||
"hoist-non-react-statics": "^3.3.2",
|
"hoist-non-react-statics": "^3.3.2",
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
"prop-types": "^15.7.2",
|
"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": {
|
"react-refresh": {
|
||||||
@@ -12618,6 +12892,21 @@
|
|||||||
"workbox-webpack-plugin": "5.1.4"
|
"workbox-webpack-plugin": "5.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-themeable": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-themeable/-/react-themeable-1.1.0.tgz",
|
||||||
|
"integrity": "sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4=",
|
||||||
|
"requires": {
|
||||||
|
"object-assign": "^3.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz",
|
||||||
|
"integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"read-pkg": {
|
"read-pkg": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
|
||||||
@@ -12734,9 +13023,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"redux": {
|
"redux": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz",
|
||||||
"integrity": "sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==",
|
"integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@babel/runtime": "^7.9.2"
|
"@babel/runtime": "^7.9.2"
|
||||||
}
|
}
|
||||||
@@ -12747,9 +13036,9 @@
|
|||||||
"integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A=="
|
"integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A=="
|
||||||
},
|
},
|
||||||
"redux-thunk": {
|
"redux-thunk": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.0.tgz",
|
||||||
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
|
"integrity": "sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA=="
|
||||||
},
|
},
|
||||||
"regenerate": {
|
"regenerate": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
@@ -13452,6 +13741,11 @@
|
|||||||
"ajv-keywords": "^3.5.2"
|
"ajv-keywords": "^3.5.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"section-iterator": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/section-iterator/-/section-iterator-2.0.0.tgz",
|
||||||
|
"integrity": "sha1-v0RNev7rlK1Dw5rS+yYVFifMuio="
|
||||||
|
},
|
||||||
"select-hose": {
|
"select-hose": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
|
||||||
@@ -13626,6 +13920,11 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shallow-equal": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
|
||||||
|
},
|
||||||
"shebang-command": {
|
"shebang-command": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
|
||||||
@@ -14867,9 +15166,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"version": "4.2.4",
|
"version": "4.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz",
|
||||||
"integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg=="
|
"integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA=="
|
||||||
},
|
},
|
||||||
"unbox-primitive": {
|
"unbox-primitive": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -15077,6 +15376,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
|
||||||
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
|
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
|
||||||
},
|
},
|
||||||
|
"use-memo-one": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ=="
|
||||||
|
},
|
||||||
"util": {
|
"util": {
|
||||||
"version": "0.11.1",
|
"version": "0.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz",
|
||||||
@@ -15461,9 +15765,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"web-vitals": {
|
"web-vitals": {
|
||||||
"version": "1.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.2.tgz",
|
||||||
"integrity": "sha512-PFMKIY+bRSXlMxVAQ+m2aw9c/ioUYfDgrYot0YUa+/xa0sakubWhSDyxAKwzymvXVdF4CZI71g06W+mqhzu6ig=="
|
"integrity": "sha512-nZnEH8dj+vJFqCRYdvYv0a59iLXsb8jJkt+xvXfwgnkyPdsSLtKNlYmtTDiHmTNGXeSXtpjTTUcNvFtrAk6VMQ=="
|
||||||
},
|
},
|
||||||
"webidl-conversions": {
|
"webidl-conversions": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
|
|||||||
@@ -3,30 +3,35 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^5.9.55",
|
"@mdi/js": "^6.4.95",
|
||||||
"@mdi/react": "^1.5.0",
|
"@mdi/react": "^1.5.0",
|
||||||
"@testing-library/jest-dom": "^5.12.0",
|
"@testing-library/jest-dom": "^5.15.0",
|
||||||
"@testing-library/react": "^11.2.6",
|
"@testing-library/react": "^12.1.2",
|
||||||
"@testing-library/user-event": "^12.8.3",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@types/jest": "^26.0.23",
|
"@types/jest": "^27.0.2",
|
||||||
"@types/node": "^12.20.12",
|
"@types/node": "^16.11.6",
|
||||||
"@types/react": "^17.0.5",
|
"@types/react": "^17.0.34",
|
||||||
"@types/react-dom": "^17.0.3",
|
"@types/react-autosuggest": "^10.1.5",
|
||||||
"@types/react-redux": "^7.1.16",
|
"@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",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.24.0",
|
||||||
"http-proxy-middleware": "^2.0.0",
|
"external-svg-loader": "^1.3.4",
|
||||||
|
"http-proxy-middleware": "^2.0.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
|
"react-autosuggest": "^10.1.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-redux": "^7.2.4",
|
"react-redux": "^7.2.6",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"react-scripts": "4.0.3",
|
"react-scripts": "4.0.3",
|
||||||
"redux": "^4.1.0",
|
"redux": "^4.1.2",
|
||||||
"redux-devtools-extension": "^2.13.9",
|
"redux-devtools-extension": "^2.13.9",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.4.0",
|
||||||
"skycons-ts": "^0.2.0",
|
"skycons-ts": "^0.2.0",
|
||||||
"typescript": "^4.2.4",
|
"typescript": "^4.4.4",
|
||||||
"web-vitals": "^1.1.2"
|
"web-vitals": "^2.1.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
@@ -51,5 +56,8 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"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.0 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,23 +2,61 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<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="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Web site created using create-react-app"
|
content="Flame - self-hosted startpage for your server"
|
||||||
/>
|
/>
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
<link rel="stylesheet" href="%PUBLIC_URL%/flame.css" />
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
|
|
||||||
<title>Flame</title>
|
<title>Flame</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow:
|
Disallow: /
|
||||||
@@ -1,41 +1,49 @@
|
|||||||
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
import { BrowserRouter, Route, Switch } from 'react-router-dom';
|
||||||
import { setTheme } from './store/actions';
|
import { fetchQueries, getConfig, setTheme } from './store/actions';
|
||||||
|
import 'external-svg-loader';
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
import store from './store/store';
|
import { store } from './store/store';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
import classes from './App.module.css';
|
// Utils
|
||||||
|
import { checkVersion } from './utility';
|
||||||
|
|
||||||
|
// Routes
|
||||||
import Home from './components/Home/Home';
|
import Home from './components/Home/Home';
|
||||||
import Apps from './components/Apps/Apps';
|
import Apps from './components/Apps/Apps';
|
||||||
import Settings from './components/Settings/Settings';
|
import Settings from './components/Settings/Settings';
|
||||||
import Bookmarks from './components/Bookmarks/Bookmarks';
|
import Bookmarks from './components/Bookmarks/Bookmarks';
|
||||||
|
|
||||||
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
store.dispatch<any>(getConfig());
|
||||||
|
|
||||||
|
// Set theme
|
||||||
if (localStorage.theme) {
|
if (localStorage.theme) {
|
||||||
store.dispatch<any>(setTheme(localStorage.theme));
|
store.dispatch<any>(setTheme(localStorage.theme));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localStorage.customTitle) {
|
// Check for updates
|
||||||
document.title = localStorage.customTitle;
|
checkVersion();
|
||||||
}
|
|
||||||
|
// fetch queries
|
||||||
|
store.dispatch<any>(fetchQueries());
|
||||||
|
|
||||||
const App = (): JSX.Element => {
|
const App = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/' component={Home} />
|
<Route exact path="/" component={Home} />
|
||||||
<Route path='/settings' component={Settings} />
|
<Route path="/settings" component={Settings} />
|
||||||
<Route path='/applications' component={Apps} />
|
<Route path="/applications" component={Apps} />
|
||||||
<Route path='/bookmarks' component={Bookmarks} />
|
<Route path="/bookmarks" component={Bookmarks} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<NotificationCenter />
|
<NotificationCenter />
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff2
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff2
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff2
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff2
Normal file
@@ -33,10 +33,18 @@
|
|||||||
.AppCard {
|
.AppCard {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.10s;
|
transition: all 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.AppCard:hover {
|
.AppCard:hover {
|
||||||
background-color: rgba(0,0,0,0.2);
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.CustomIcon {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +1,64 @@
|
|||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import classes from './AppCard.module.css';
|
import classes from './AppCard.module.css';
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import { iconParser } from '../../../utility/iconParser';
|
import { iconParser, urlParser } from '../../../utility';
|
||||||
|
|
||||||
import { App } from '../../../interfaces';
|
import { App, Config, GlobalState } from '../../../interfaces';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
app: App;
|
app: App;
|
||||||
pinHandler?: Function;
|
pinHandler?: Function;
|
||||||
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppCard = (props: ComponentProps): JSX.Element => {
|
const AppCard = (props: ComponentProps): JSX.Element => {
|
||||||
const redirectHandler = (url: string): void => {
|
const [displayUrl, redirectUrl] = urlParser(props.app.url);
|
||||||
window.open(url);
|
|
||||||
|
let iconEl: JSX.Element;
|
||||||
|
const { icon } = props.app;
|
||||||
|
|
||||||
|
if (/.(jpeg|jpg|png)$/i.test(icon)) {
|
||||||
|
iconEl = (
|
||||||
|
<img
|
||||||
|
src={`/uploads/${icon}`}
|
||||||
|
alt={`${props.app.name} icon`}
|
||||||
|
className={classes.CustomIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (/.(svg)$/i.test(icon)) {
|
||||||
|
iconEl = (
|
||||||
|
<div className={classes.CustomIcon}>
|
||||||
|
<svg
|
||||||
|
data-src={`/uploads/${icon}`}
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
className={classes.CustomIcon}
|
||||||
|
></svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
iconEl = <Icon icon={iconParser(icon)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={`http://${props.app.url}`} target='_blank' className={classes.AppCard}>
|
<a
|
||||||
<div className={classes.AppCardIcon}>
|
href={redirectUrl}
|
||||||
<Icon icon={iconParser(props.app.icon)} />
|
target={props.config.appsSameTab ? '' : '_blank'}
|
||||||
</div>
|
rel="noreferrer"
|
||||||
|
className={classes.AppCard}
|
||||||
|
>
|
||||||
|
<div className={classes.AppCardIcon}>{iconEl}</div>
|
||||||
<div className={classes.AppCardDetails}>
|
<div className={classes.AppCardDetails}>
|
||||||
<h5>{props.app.name}</h5>
|
<h5>{props.app.name}</h5>
|
||||||
<span>{props.app.url}</span>
|
<span>{displayUrl}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AppCard;
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(AppCard);
|
||||||
|
|||||||
7
client/src/components/Apps/AppForm/AppForm.module.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.Switch {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Switch:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -1,73 +1,97 @@
|
|||||||
import { useState, useEffect, useRef, ChangeEvent, SyntheticEvent } from 'react';
|
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { addApp, updateApp } from '../../../store/actions';
|
import { addApp, updateApp } from '../../../store/actions';
|
||||||
import { App, NewApp } from '../../../interfaces';
|
import { App, NewApp } from '../../../interfaces';
|
||||||
|
|
||||||
|
import classes from './AppForm.module.css';
|
||||||
|
|
||||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
modalHandler: () => void;
|
modalHandler: () => void;
|
||||||
addApp: (formData: NewApp) => any;
|
addApp: (formData: NewApp | FormData) => any;
|
||||||
updateApp: (id: number, formData: NewApp) => any;
|
updateApp: (id: number, formData: NewApp | FormData) => any;
|
||||||
app?: App;
|
app?: App;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppForm = (props: ComponentProps): JSX.Element => {
|
const AppForm = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||||
|
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||||
const [formData, setFormData] = useState<NewApp>({
|
const [formData, setFormData] = useState<NewApp>({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
icon: ''
|
icon: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [inputRef])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.app) {
|
if (props.app) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: props.app.name,
|
name: props.app.name,
|
||||||
url: props.app.url,
|
url: props.app.url,
|
||||||
icon: props.app.icon
|
icon: props.app.icon,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
icon: ''
|
icon: '',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [props.app])
|
}, [props.app]);
|
||||||
|
|
||||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
[e.target.name]: e.target.value
|
[e.target.name]: e.target.value,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const fileChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
if (e.target.files) {
|
||||||
|
setCustomIcon(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
||||||
e.preventDefault();
|
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);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
if (!props.app) {
|
if (!props.app) {
|
||||||
props.addApp(formData);
|
if (customIcon) {
|
||||||
|
const data = createFormData();
|
||||||
|
props.addApp(data);
|
||||||
|
} else {
|
||||||
|
props.addApp(formData);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
props.updateApp(props.app.id, formData);
|
if (customIcon) {
|
||||||
props.modalHandler();
|
const data = createFormData();
|
||||||
|
props.updateApp(props.app.id, data);
|
||||||
|
} else {
|
||||||
|
props.updateApp(props.app.id, formData);
|
||||||
|
props.modalHandler();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
icon: ''
|
icon: '',
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalForm
|
<ModalForm
|
||||||
@@ -75,57 +99,96 @@ const AppForm = (props: ComponentProps): JSX.Element => {
|
|||||||
formHandler={formSubmitHandler}
|
formHandler={formSubmitHandler}
|
||||||
>
|
>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='name'>App Name</label>
|
<label htmlFor="name">App Name</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type="text"
|
||||||
name='name'
|
name="name"
|
||||||
id='name'
|
id="name"
|
||||||
placeholder='Bookstack'
|
placeholder="Bookstack"
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
ref={inputRef}
|
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='url'>App URL</label>
|
<label htmlFor="url">App URL</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type="text"
|
||||||
name='url'
|
name="url"
|
||||||
id='url'
|
id="url"
|
||||||
placeholder='bookstack.example.com'
|
placeholder="bookstack.example.com"
|
||||||
required
|
required
|
||||||
value={formData.url}
|
value={formData.url}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
<span>Only urls without http[s]:// are supported</span>
|
|
||||||
</InputGroup>
|
|
||||||
<InputGroup>
|
|
||||||
<label htmlFor='icon'>App Icon</label>
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
name='icon'
|
|
||||||
id='icon'
|
|
||||||
placeholder='book-open-outline'
|
|
||||||
required
|
|
||||||
value={formData.icon}
|
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
|
||||||
/>
|
|
||||||
<span>
|
<span>
|
||||||
Use icon name from MDI.
|
|
||||||
<a
|
<a
|
||||||
href='https://materialdesignicons.com/'
|
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
|
||||||
target='blank'>
|
target="_blank"
|
||||||
{' '}Click here for reference
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{' '}
|
||||||
|
Check supported URL formats
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
{!props.app
|
{!useCustomIcon ? (
|
||||||
? <Button>Add new application</Button>
|
// use mdi icon
|
||||||
: <Button>Update application</Button>
|
<InputGroup>
|
||||||
}
|
<label htmlFor="icon">App Icon</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="icon"
|
||||||
|
id="icon"
|
||||||
|
placeholder="book-open-outline"
|
||||||
|
required
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Use icon name from MDI.
|
||||||
|
<a href="https://materialdesignicons.com/" target="blank">
|
||||||
|
{' '}
|
||||||
|
Click here for reference
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||||
|
className={classes.Switch}
|
||||||
|
>
|
||||||
|
Switch to custom icon upload
|
||||||
|
</span>
|
||||||
|
</InputGroup>
|
||||||
|
) : (
|
||||||
|
// 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={() => {
|
||||||
|
setCustomIcon(null);
|
||||||
|
toggleUseCustomIcon(!useCustomIcon);
|
||||||
|
}}
|
||||||
|
className={classes.Switch}
|
||||||
|
>
|
||||||
|
Switch to MDI
|
||||||
|
</span>
|
||||||
|
</InputGroup>
|
||||||
|
)}
|
||||||
|
{!props.app ? (
|
||||||
|
<Button>Add new application</Button>
|
||||||
|
) : (
|
||||||
|
<Button>Update application</Button>
|
||||||
|
)}
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(null, { addApp, updateApp })(AppForm);
|
export default connect(null, { addApp, updateApp })(AppForm);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import AppCard from '../AppCard/AppCard';
|
|||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
totalApps?: number;
|
totalApps?: number;
|
||||||
|
searching: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppGrid = (props: ComponentProps): JSX.Element => {
|
const AppGrid = (props: ComponentProps): JSX.Element => {
|
||||||
@@ -16,26 +17,37 @@ const AppGrid = (props: ComponentProps): JSX.Element => {
|
|||||||
apps = (
|
apps = (
|
||||||
<div className={classes.AppGrid}>
|
<div className={classes.AppGrid}>
|
||||||
{props.apps.map((app: App): JSX.Element => {
|
{props.apps.map((app: App): JSX.Element => {
|
||||||
return <AppCard
|
return <AppCard key={app.id} app={app} />;
|
||||||
key={app.id}
|
|
||||||
app={app}
|
|
||||||
/>
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
if (props.totalApps) {
|
if (props.totalApps) {
|
||||||
apps = (
|
if (props.searching) {
|
||||||
<p className={classes.AppsMessage}>There are no pinned applications. You can pin them from the <Link to='/applications'>/applications</Link> menu</p>
|
apps = (
|
||||||
);
|
<p className={classes.AppsMessage}>
|
||||||
|
No apps match your search criteria
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
apps = (
|
||||||
|
<p className={classes.AppsMessage}>
|
||||||
|
There are no pinned applications. You can pin them from the{' '}
|
||||||
|
<Link to="/applications">/applications</Link> menu
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
apps = (
|
apps = (
|
||||||
<p className={classes.AppsMessage}>You don't have any applications. You can add a new one from <Link to='/applications'>/applications</Link> menu</p>
|
<p className={classes.AppsMessage}>
|
||||||
|
You don't have any applications. You can add a new one from{' '}
|
||||||
|
<Link to="/applications">/applications</Link> menu
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return apps;
|
return apps;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AppGrid;
|
export default AppGrid;
|
||||||
|
|||||||
@@ -9,4 +9,21 @@
|
|||||||
|
|
||||||
.TableAction:hover {
|
.TableAction:hover {
|
||||||
cursor: pointer;
|
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,84 +1,224 @@
|
|||||||
import { KeyboardEvent } from 'react';
|
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
|
||||||
import { connect } from 'react-redux';
|
import {
|
||||||
import { App, GlobalState } from '../../../interfaces';
|
DragDropContext,
|
||||||
import { pinApp, deleteApp } from '../../../store/actions';
|
Droppable,
|
||||||
|
Draggable,
|
||||||
|
DropResult,
|
||||||
|
} from 'react-beautiful-dnd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import {
|
||||||
|
pinApp,
|
||||||
|
deleteApp,
|
||||||
|
reorderApps,
|
||||||
|
updateConfig,
|
||||||
|
createNotification,
|
||||||
|
} from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
|
||||||
|
|
||||||
|
// CSS
|
||||||
import classes from './AppTable.module.css';
|
import classes from './AppTable.module.css';
|
||||||
|
|
||||||
|
// UI
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import Table from '../../UI/Table/Table';
|
import Table from '../../UI/Table/Table';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
apps: App[];
|
apps: App[];
|
||||||
|
config: Config;
|
||||||
pinApp: (app: App) => void;
|
pinApp: (app: App) => void;
|
||||||
deleteApp: (id: number) => void;
|
deleteApp: (id: number) => void;
|
||||||
updateAppHandler: (app: App) => void;
|
updateAppHandler: (app: App) => void;
|
||||||
|
reorderApps: (apps: App[]) => void;
|
||||||
|
updateConfig: (formData: any) => void;
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppTable = (props: ComponentProps): JSX.Element => {
|
const AppTable = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [localApps, setLocalApps] = useState<App[]>([]);
|
||||||
|
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Copy apps array
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalApps([...props.apps]);
|
||||||
|
}, [props.apps]);
|
||||||
|
|
||||||
|
// Check ordering
|
||||||
|
useEffect(() => {
|
||||||
|
const order = props.config.useOrdering;
|
||||||
|
|
||||||
|
if (order === 'orderId') {
|
||||||
|
setIsCustomOrder(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const deleteAppHandler = (app: App): void => {
|
const deleteAppHandler = (app: App): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
|
const proceed = window.confirm(
|
||||||
|
`Are you sure you want to delete ${app.name} at ${app.url} ?`
|
||||||
|
);
|
||||||
|
|
||||||
if (proceed) {
|
if (proceed) {
|
||||||
props.deleteApp(app.id);
|
props.deleteApp(app.id);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
|
// Support keyboard navigation for actions
|
||||||
|
const keyboardActionHandler = (
|
||||||
|
e: KeyboardEvent,
|
||||||
|
app: App,
|
||||||
|
handler: Function
|
||||||
|
) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handler(app);
|
handler(app);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const dragEndHanlder = (result: DropResult): void => {
|
||||||
|
if (!isCustomOrder) {
|
||||||
|
props.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);
|
||||||
|
props.reorderApps(tmpApps);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table headers={[
|
<Fragment>
|
||||||
'Name',
|
<div className={classes.Message}>
|
||||||
'URL',
|
{isCustomOrder ? (
|
||||||
'Icon',
|
<p>You can drag and drop single rows to reorder application</p>
|
||||||
'Actions'
|
) : (
|
||||||
]}>
|
<p>
|
||||||
{props.apps.map((app: App): JSX.Element => {
|
Custom order is disabled. You can change it in{' '}
|
||||||
return (
|
<Link to="/settings/other">settings</Link>
|
||||||
<tr key={app.id}>
|
</p>
|
||||||
<td>{app.name}</td>
|
)}
|
||||||
<td>{app.url}</td>
|
</div>
|
||||||
<td>{app.icon}</td>
|
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||||
<td className={classes.TableActions}>
|
<Droppable droppableId="apps">
|
||||||
<div
|
{(provided) => (
|
||||||
className={classes.TableAction}
|
<Table
|
||||||
onClick={() => deleteAppHandler(app)}
|
headers={['Name', 'URL', 'Icon', 'Actions']}
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
innerRef={provided.innerRef}
|
||||||
tabIndex={0}>
|
>
|
||||||
<Icon icon='mdiDelete' />
|
{localApps.map((app: App, index): JSX.Element => {
|
||||||
</div>
|
return (
|
||||||
<div
|
<Draggable
|
||||||
className={classes.TableAction}
|
key={app.id}
|
||||||
onClick={() => props.updateAppHandler(app)}
|
draggableId={app.id.toString()}
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
index={index}
|
||||||
tabIndex={0}>
|
>
|
||||||
<Icon icon='mdiPencil' />
|
{(provided, snapshot) => {
|
||||||
</div>
|
const style = {
|
||||||
<div
|
border: snapshot.isDragging
|
||||||
className={classes.TableAction}
|
? '1px solid var(--color-accent)'
|
||||||
onClick={() => props.pinApp(app)}
|
: 'none',
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, app, props.pinApp)}
|
borderRadius: '4px',
|
||||||
tabIndex={0}>
|
...provided.draggableProps.style,
|
||||||
{app.isPinned
|
};
|
||||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
|
||||||
: <Icon icon='mdiPin' />
|
return (
|
||||||
}
|
<tr
|
||||||
</div>
|
{...provided.draggableProps}
|
||||||
</td>
|
{...provided.dragHandleProps}
|
||||||
</tr>
|
ref={provided.innerRef}
|
||||||
)
|
style={style}
|
||||||
})}
|
>
|
||||||
</Table>
|
<td style={{ width: '200px' }}>{app.name}</td>
|
||||||
)
|
<td style={{ width: '200px' }}>{app.url}</td>
|
||||||
}
|
<td style={{ width: '200px' }}>{app.icon}</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.updateAppHandler(app)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
keyboardActionHandler(
|
||||||
|
e,
|
||||||
|
app,
|
||||||
|
props.updateAppHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Icon icon="mdiPencil" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classes.TableAction}
|
||||||
|
onClick={() => props.pinApp(app)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
keyboardActionHandler(e, app, props.pinApp)
|
||||||
|
}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{app.isPinned ? (
|
||||||
|
<Icon
|
||||||
|
icon="mdiPinOff"
|
||||||
|
color="var(--color-accent)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon="mdiPin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: GlobalState) => {
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
return {
|
return {
|
||||||
apps: state.app.apps
|
apps: state.app.apps,
|
||||||
}
|
config: state.config.config,
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, { pinApp, deleteApp })(AppTable);
|
const actions = {
|
||||||
|
pinApp,
|
||||||
|
deleteApp,
|
||||||
|
reorderApps,
|
||||||
|
updateConfig,
|
||||||
|
createNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, actions)(AppTable);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
@@ -27,9 +27,12 @@ interface ComponentProps {
|
|||||||
getApps: Function;
|
getApps: Function;
|
||||||
apps: App[];
|
apps: App[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
searching: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Apps = (props: ComponentProps): JSX.Element => {
|
const Apps = (props: ComponentProps): JSX.Element => {
|
||||||
|
const { getApps, apps, loading, searching = false } = props;
|
||||||
|
|
||||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
const [isInEdit, setIsInEdit] = useState(false);
|
const [isInEdit, setIsInEdit] = useState(false);
|
||||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||||
@@ -38,77 +41,72 @@ const Apps = (props: ComponentProps): JSX.Element => {
|
|||||||
url: 'string',
|
url: 'string',
|
||||||
icon: 'string',
|
icon: 'string',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
orderId: 0,
|
||||||
id: 0,
|
id: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.apps.length === 0) {
|
if (apps.length === 0) {
|
||||||
props.getApps();
|
getApps();
|
||||||
}
|
}
|
||||||
}, [props.getApps]);
|
}, [getApps]);
|
||||||
|
|
||||||
const toggleModal = (): void => {
|
const toggleModal = (): void => {
|
||||||
setModalIsOpen(!modalIsOpen);
|
setModalIsOpen(!modalIsOpen);
|
||||||
setIsInUpdate(false);
|
setIsInUpdate(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleEdit = (): void => {
|
const toggleEdit = (): void => {
|
||||||
setIsInEdit(!isInEdit);
|
setIsInEdit(!isInEdit);
|
||||||
setIsInUpdate(false);
|
setIsInUpdate(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleUpdate = (app: App): void => {
|
const toggleUpdate = (app: App): void => {
|
||||||
setAppInUpdate(app);
|
setAppInUpdate(app);
|
||||||
setIsInUpdate(true);
|
setIsInUpdate(true);
|
||||||
setModalIsOpen(true);
|
setModalIsOpen(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}>
|
<Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}>
|
||||||
{!isInUpdate
|
{!isInUpdate ? (
|
||||||
? <AppForm modalHandler={toggleModal} />
|
<AppForm modalHandler={toggleModal} />
|
||||||
: <AppForm modalHandler={toggleModal} app={appInUpdate} />
|
) : (
|
||||||
}
|
<AppForm modalHandler={toggleModal} app={appInUpdate} />
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Headline
|
<Headline
|
||||||
title='All Applications'
|
title="All Applications"
|
||||||
subtitle={(<Link to='/'>Go back</Link>)}
|
subtitle={<Link to="/">Go back</Link>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={classes.ActionsContainer}>
|
<div className={classes.ActionsContainer}>
|
||||||
<ActionButton
|
<ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
|
||||||
name='Add'
|
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
|
||||||
icon='mdiPlusBox'
|
|
||||||
handler={toggleModal}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
name='Edit'
|
|
||||||
icon='mdiPencil'
|
|
||||||
handler={toggleEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={classes.Apps}>
|
<div className={classes.Apps}>
|
||||||
{props.loading
|
{loading ? (
|
||||||
? <Spinner />
|
<Spinner />
|
||||||
: (!isInEdit
|
) : !isInEdit ? (
|
||||||
? <AppGrid apps={props.apps} />
|
<AppGrid apps={apps} searching />
|
||||||
: <AppTable updateAppHandler={toggleUpdate} />)
|
) : (
|
||||||
}
|
<AppTable updateAppHandler={toggleUpdate} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: GlobalState) => {
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
return {
|
return {
|
||||||
apps: state.app.apps,
|
apps: state.app.apps,
|
||||||
loading: state.app.loading
|
loading: state.app.loading,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, { getApps })(Apps);
|
export default connect(mapStateToProps, { getApps })(Apps);
|
||||||
|
|||||||
@@ -32,4 +32,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.BookmarkIconSvg {
|
||||||
|
width: 80%;
|
||||||
|
height: 80%;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-left: 2px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.CustomIcon {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
margin-top: 2px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Bookmark, Category } from '../../../interfaces';
|
import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
|
||||||
import classes from './BookmarkCard.module.css';
|
import classes from './BookmarkCard.module.css';
|
||||||
|
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
import { iconParser } from '../../../utility/iconParser';
|
import { iconParser, urlParser } from '../../../utility';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
category: Category;
|
category: Category;
|
||||||
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
||||||
@@ -13,22 +16,64 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
|
|||||||
<div className={classes.BookmarkCard}>
|
<div className={classes.BookmarkCard}>
|
||||||
<h3>{props.category.name}</h3>
|
<h3>{props.category.name}</h3>
|
||||||
<div className={classes.Bookmarks}>
|
<div className={classes.Bookmarks}>
|
||||||
{props.category.bookmarks.map((bookmark: Bookmark) => (
|
{props.category.bookmarks.map((bookmark: Bookmark) => {
|
||||||
<a
|
const redirectUrl = urlParser(bookmark.url)[1];
|
||||||
href={`http://${bookmark.url}`}
|
|
||||||
target='_blank'
|
let iconEl: JSX.Element = <Fragment></Fragment>;
|
||||||
key={`bookmark-${bookmark.id}`}>
|
|
||||||
{bookmark.icon && (
|
if (bookmark.icon) {
|
||||||
<div className={classes.BookmarkIcon}>
|
const { icon, name } = bookmark;
|
||||||
<Icon icon={iconParser(bookmark.icon)} />
|
|
||||||
</div>
|
if (/.(jpeg|jpg|png)$/i.test(icon)) {
|
||||||
)}
|
iconEl = (
|
||||||
{bookmark.name}
|
<div className={classes.BookmarkIcon}>
|
||||||
</a>
|
<img
|
||||||
))}
|
src={`/uploads/${icon}`}
|
||||||
|
alt={`${name} icon`}
|
||||||
|
className={classes.CustomIcon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (/.(svg)$/i.test(icon)) {
|
||||||
|
iconEl = (
|
||||||
|
<div className={classes.BookmarkIcon}>
|
||||||
|
<svg
|
||||||
|
data-src={`/uploads/${icon}`}
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
className={classes.BookmarkIconSvg}
|
||||||
|
></svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
iconEl = (
|
||||||
|
<div className={classes.BookmarkIcon}>
|
||||||
|
<Icon icon={iconParser(icon)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={redirectUrl}
|
||||||
|
target={props.config.bookmarksSameTab ? '' : '_blank'}
|
||||||
|
rel="noreferrer"
|
||||||
|
key={`bookmark-${bookmark.id}`}
|
||||||
|
>
|
||||||
|
{bookmark.icon && iconEl}
|
||||||
|
{bookmark.name}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default BookmarkCard;
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(BookmarkCard);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.Switch {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Switch:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -1,13 +1,42 @@
|
|||||||
import { useState, SyntheticEvent, Fragment, ChangeEvent, useEffect } from 'react';
|
// React
|
||||||
import { connect } from 'react-redux';
|
import {
|
||||||
|
useState,
|
||||||
|
SyntheticEvent,
|
||||||
|
Fragment,
|
||||||
|
ChangeEvent,
|
||||||
|
useEffect,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import {
|
||||||
|
getCategories,
|
||||||
|
addCategory,
|
||||||
|
addBookmark,
|
||||||
|
updateCategory,
|
||||||
|
updateBookmark,
|
||||||
|
createNotification,
|
||||||
|
} from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Category,
|
||||||
|
GlobalState,
|
||||||
|
NewBookmark,
|
||||||
|
NewCategory,
|
||||||
|
NewNotification,
|
||||||
|
} from '../../../interfaces';
|
||||||
|
import { ContentType } from '../Bookmarks';
|
||||||
|
|
||||||
|
// UI
|
||||||
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
|
||||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import { Bookmark, Category, GlobalState, NewBookmark, NewCategory, NewNotification } from '../../../interfaces';
|
|
||||||
import { ContentType } from '../Bookmarks';
|
|
||||||
import { getCategories, addCategory, addBookmark, updateCategory, updateBookmark, createNotification } from '../../../store/actions';
|
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
|
||||||
|
// CSS
|
||||||
|
import classes from './BookmarkForm.module.css';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
modalHandler: () => void;
|
modalHandler: () => void;
|
||||||
contentType: ContentType;
|
contentType: ContentType;
|
||||||
@@ -15,23 +44,32 @@ interface ComponentProps {
|
|||||||
category?: Category;
|
category?: Category;
|
||||||
bookmark?: Bookmark;
|
bookmark?: Bookmark;
|
||||||
addCategory: (formData: NewCategory) => void;
|
addCategory: (formData: NewCategory) => void;
|
||||||
addBookmark: (formData: NewBookmark) => void;
|
addBookmark: (formData: NewBookmark | FormData) => void;
|
||||||
updateCategory: (id: number, formData: NewCategory) => void;
|
updateCategory: (id: number, formData: NewCategory) => void;
|
||||||
updateBookmark: (id: number, formData: NewBookmark, previousCategoryId: number) => void;
|
updateBookmark: (
|
||||||
|
id: number,
|
||||||
|
formData: NewBookmark | FormData,
|
||||||
|
category: {
|
||||||
|
prev: number;
|
||||||
|
curr: number;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
createNotification: (notification: NewNotification) => void;
|
createNotification: (notification: NewNotification) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
|
||||||
|
const [customIcon, setCustomIcon] = useState<File | null>(null);
|
||||||
const [categoryName, setCategoryName] = useState<NewCategory>({
|
const [categoryName, setCategoryName] = useState<NewCategory>({
|
||||||
name: ''
|
name: '',
|
||||||
})
|
});
|
||||||
|
|
||||||
const [formData, setFormData] = useState<NewBookmark>({
|
const [formData, setFormData] = useState<NewBookmark>({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
categoryId: -1,
|
categoryId: -1,
|
||||||
icon: ''
|
icon: '',
|
||||||
})
|
});
|
||||||
|
|
||||||
// Load category data if provided for editing
|
// Load category data if provided for editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,7 +78,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
} else {
|
} else {
|
||||||
setCategoryName({ name: '' });
|
setCategoryName({ name: '' });
|
||||||
}
|
}
|
||||||
}, [props.category])
|
}, [props.category]);
|
||||||
|
|
||||||
// Load bookmark data if provided for editing
|
// Load bookmark data if provided for editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,21 +87,33 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
name: props.bookmark.name,
|
name: props.bookmark.name,
|
||||||
url: props.bookmark.url,
|
url: props.bookmark.url,
|
||||||
categoryId: props.bookmark.categoryId,
|
categoryId: props.bookmark.categoryId,
|
||||||
icon: props.bookmark.icon
|
icon: props.bookmark.icon,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
categoryId: -1,
|
categoryId: -1,
|
||||||
icon: ''
|
icon: '',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [props.bookmark])
|
}, [props.bookmark]);
|
||||||
|
|
||||||
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const createFormData = (): FormData => {
|
||||||
|
const data = new FormData();
|
||||||
|
if (customIcon) {
|
||||||
|
data.append('icon', customIcon);
|
||||||
|
}
|
||||||
|
data.append('name', formData.name);
|
||||||
|
data.append('url', formData.url);
|
||||||
|
data.append('categoryId', `${formData.categoryId}`);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
if (!props.category && !props.bookmark) {
|
if (!props.category && !props.bookmark) {
|
||||||
// Add new
|
// Add new
|
||||||
if (props.contentType === ContentType.category) {
|
if (props.contentType === ContentType.category) {
|
||||||
@@ -75,18 +125,26 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
if (formData.categoryId === -1) {
|
if (formData.categoryId === -1) {
|
||||||
props.createNotification({
|
props.createNotification({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
message: 'Please select category'
|
message: 'Please select category',
|
||||||
})
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
props.addBookmark(formData);
|
if (customIcon) {
|
||||||
|
const data = createFormData();
|
||||||
|
props.addBookmark(data);
|
||||||
|
} else {
|
||||||
|
props.addBookmark(formData);
|
||||||
|
}
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
categoryId: formData.categoryId,
|
categoryId: formData.categoryId,
|
||||||
icon: ''
|
icon: '',
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// setCustomIcon(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update
|
// Update
|
||||||
@@ -96,34 +154,54 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
setCategoryName({ name: '' });
|
setCategoryName({ name: '' });
|
||||||
} else if (props.contentType === ContentType.bookmark && props.bookmark) {
|
} else if (props.contentType === ContentType.bookmark && props.bookmark) {
|
||||||
// Update bookmark
|
// Update bookmark
|
||||||
props.updateBookmark(props.bookmark.id, formData, props.bookmark.categoryId);
|
if (customIcon) {
|
||||||
|
const data = createFormData();
|
||||||
|
props.updateBookmark(props.bookmark.id, data, {
|
||||||
|
prev: props.bookmark.categoryId,
|
||||||
|
curr: formData.categoryId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
props.updateBookmark(props.bookmark.id, formData, {
|
||||||
|
prev: props.bookmark.categoryId,
|
||||||
|
curr: formData.categoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
categoryId: -1,
|
categoryId: -1,
|
||||||
icon: ''
|
icon: '',
|
||||||
})
|
});
|
||||||
|
|
||||||
|
setCustomIcon(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
props.modalHandler();
|
props.modalHandler();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
[e.target.name]: e.target.value
|
[e.target.name]: e.target.value,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
|
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
categoryId: parseInt(e.target.value)
|
categoryId: parseInt(e.target.value),
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
let button = <Button>Submit</Button>
|
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.category && !props.bookmark) {
|
||||||
if (props.contentType === ContentType.category) {
|
if (props.contentType === ContentType.category) {
|
||||||
@@ -132,9 +210,9 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
button = <Button>Add new bookmark</Button>;
|
button = <Button>Add new bookmark</Button>;
|
||||||
}
|
}
|
||||||
} else if (props.category) {
|
} else if (props.category) {
|
||||||
button = <Button>Update category</Button>
|
button = <Button>Update category</Button>;
|
||||||
} else if (props.bookmark) {
|
} else if (props.bookmark) {
|
||||||
button = <Button>Update bookmark</Button>
|
button = <Button>Update bookmark</Button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,104 +220,136 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
|
|||||||
modalHandler={props.modalHandler}
|
modalHandler={props.modalHandler}
|
||||||
formHandler={formSubmitHandler}
|
formHandler={formSubmitHandler}
|
||||||
>
|
>
|
||||||
{props.contentType === ContentType.category
|
{props.contentType === ContentType.category ? (
|
||||||
? (
|
<Fragment>
|
||||||
<Fragment>
|
<InputGroup>
|
||||||
<InputGroup>
|
<label htmlFor="categoryName">Category Name</label>
|
||||||
<label htmlFor='categoryName'>Category Name</label>
|
<input
|
||||||
<input
|
type="text"
|
||||||
type='text'
|
name="categoryName"
|
||||||
name='categoryName'
|
id="categoryName"
|
||||||
id='categoryName'
|
placeholder="Social Media"
|
||||||
placeholder='Social Media'
|
required
|
||||||
required
|
value={categoryName.name}
|
||||||
value={categoryName.name}
|
onChange={(e) => setCategoryName({ name: e.target.value })}
|
||||||
onChange={(e) => setCategoryName({ name: e.target.value })}
|
/>
|
||||||
/>
|
</InputGroup>
|
||||||
</InputGroup>
|
</Fragment>
|
||||||
</Fragment>
|
) : (
|
||||||
)
|
<Fragment>
|
||||||
: (
|
<InputGroup>
|
||||||
<Fragment>
|
<label htmlFor="name">Bookmark Name</label>
|
||||||
<InputGroup>
|
<input
|
||||||
<label htmlFor='name'>Bookmark Name</label>
|
type="text"
|
||||||
<input
|
name="name"
|
||||||
type='text'
|
id="name"
|
||||||
name='name'
|
placeholder="Reddit"
|
||||||
id='name'
|
required
|
||||||
placeholder='Reddit'
|
value={formData.name}
|
||||||
required
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
value={formData.name}
|
/>
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
</InputGroup>
|
||||||
/>
|
<InputGroup>
|
||||||
</InputGroup>
|
<label htmlFor="url">Bookmark URL</label>
|
||||||
<InputGroup>
|
<input
|
||||||
<label htmlFor='url'>Bookmark URL</label>
|
type="text"
|
||||||
<input
|
name="url"
|
||||||
type='text'
|
id="url"
|
||||||
name='url'
|
placeholder="reddit.com"
|
||||||
id='url'
|
required
|
||||||
placeholder='reddit.com'
|
value={formData.url}
|
||||||
required
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
value={formData.url}
|
/>
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
<span>
|
||||||
/>
|
<a
|
||||||
<span>Only urls without http[s]:// are supported</span>
|
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
|
||||||
</InputGroup>
|
target="_blank"
|
||||||
<InputGroup>
|
rel="noreferrer"
|
||||||
<label htmlFor='categoryId'>Bookmark Category</label>
|
|
||||||
<select
|
|
||||||
name='categoryId'
|
|
||||||
id='categoryId'
|
|
||||||
required
|
|
||||||
onChange={(e) => selectChangeHandler(e)}
|
|
||||||
value={formData.categoryId}
|
|
||||||
>
|
>
|
||||||
<option value={-1}>Select category</option>
|
{' '}
|
||||||
{props.categories.map((category: Category): JSX.Element => {
|
Check supported URL formats
|
||||||
return (
|
</a>
|
||||||
<option
|
</span>
|
||||||
key={category.id}
|
</InputGroup>
|
||||||
value={category.id}
|
<InputGroup>
|
||||||
>
|
<label htmlFor="categoryId">Bookmark Category</label>
|
||||||
{category.name}
|
<select
|
||||||
</option>
|
name="categoryId"
|
||||||
)
|
id="categoryId"
|
||||||
})}
|
required
|
||||||
</select>
|
onChange={(e) => selectChangeHandler(e)}
|
||||||
</InputGroup>
|
value={formData.categoryId}
|
||||||
|
>
|
||||||
|
<option value={-1}>Select category</option>
|
||||||
|
{props.categories.map((category: Category): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
{!useCustomIcon ? (
|
||||||
|
// mdi
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='icon'>Bookmark Icon (optional)</label>
|
<label htmlFor="icon">Bookmark Icon (optional)</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type="text"
|
||||||
name='icon'
|
name="icon"
|
||||||
id='icon'
|
id="icon"
|
||||||
placeholder='book-open-outline'
|
placeholder="book-open-outline"
|
||||||
value={formData.icon}
|
value={formData.icon}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
Use icon name from MDI.
|
Use icon name from MDI.
|
||||||
<a
|
<a href="https://materialdesignicons.com/" target="blank">
|
||||||
href='https://materialdesignicons.com/'
|
{' '}
|
||||||
target='blank'>
|
Click here for reference
|
||||||
{' '}Click here for reference
|
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
|
||||||
|
className={classes.Switch}
|
||||||
|
>
|
||||||
|
Switch to custom icon upload
|
||||||
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Fragment>
|
) : (
|
||||||
)
|
// custom
|
||||||
}
|
<InputGroup>
|
||||||
|
<label htmlFor="icon">Bookmark Icon (optional)</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="icon"
|
||||||
|
id="icon"
|
||||||
|
onChange={(e) => fileChangeHandler(e)}
|
||||||
|
accept=".jpg,.jpeg,.png,.svg"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
onClick={() => {
|
||||||
|
setCustomIcon(null);
|
||||||
|
toggleUseCustomIcon(!useCustomIcon);
|
||||||
|
}}
|
||||||
|
className={classes.Switch}
|
||||||
|
>
|
||||||
|
Switch to MDI
|
||||||
|
</span>
|
||||||
|
</InputGroup>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
{button}
|
{button}
|
||||||
</ModalForm>
|
</ModalForm>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: GlobalState) => {
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
return {
|
return {
|
||||||
categories: state.bookmark.categories
|
categories: state.bookmark.categories,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const dispatchMap = {
|
const dispatchMap = {
|
||||||
getCategories,
|
getCategories,
|
||||||
@@ -247,7 +357,7 @@ const dispatchMap = {
|
|||||||
addBookmark,
|
addBookmark,
|
||||||
updateCategory,
|
updateCategory,
|
||||||
updateBookmark,
|
updateBookmark,
|
||||||
createNotification
|
createNotification,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);
|
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);
|
||||||
|
|||||||
@@ -2,37 +2,56 @@ import { Link } from 'react-router-dom';
|
|||||||
|
|
||||||
import classes from './BookmarkGrid.module.css';
|
import classes from './BookmarkGrid.module.css';
|
||||||
|
|
||||||
import { Bookmark, Category } from '../../../interfaces';
|
import { Category } from '../../../interfaces';
|
||||||
|
|
||||||
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
import BookmarkCard from '../BookmarkCard/BookmarkCard';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
totalCategories?: number;
|
totalCategories?: number;
|
||||||
|
searching: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
|
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
|
||||||
let bookmarks: JSX.Element;
|
let bookmarks: JSX.Element;
|
||||||
|
|
||||||
if (props.categories.length > 0) {
|
if (props.categories.length > 0) {
|
||||||
bookmarks = (
|
if (props.searching && props.categories[0].bookmarks.length === 0) {
|
||||||
<div className={classes.BookmarkGrid}>
|
|
||||||
{props.categories.map((category: Category): JSX.Element => <BookmarkCard category={category} key={category.id} />)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (props.totalCategories) {
|
|
||||||
bookmarks = (
|
bookmarks = (
|
||||||
<p className={classes.BookmarksMessage}>There are no pinned categories. You can pin them from the <Link to='/bookmarks'>/bookmarks</Link> menu</p>
|
<p className={classes.BookmarksMessage}>
|
||||||
|
No bookmarks match your search criteria
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
bookmarks = (
|
bookmarks = (
|
||||||
<p className={classes.BookmarksMessage}>You don't have any bookmarks. You can add a new one from <Link to='/bookmarks'>/bookmarks</Link> menu</p>
|
<div className={classes.BookmarkGrid}>
|
||||||
|
{props.categories.map(
|
||||||
|
(category: Category): JSX.Element => (
|
||||||
|
<BookmarkCard category={category} key={category.id} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (props.totalCategories) {
|
||||||
|
bookmarks = (
|
||||||
|
<p className={classes.BookmarksMessage}>
|
||||||
|
There are no pinned categories. You can pin them from the{' '}
|
||||||
|
<Link to="/bookmarks">/bookmarks</Link> menu
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bookmarks = (
|
||||||
|
<p className={classes.BookmarksMessage}>
|
||||||
|
You don't have any bookmarks. You can add a new one from{' '}
|
||||||
|
<Link to="/bookmarks">/bookmarks</Link> menu
|
||||||
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bookmarks;
|
return bookmarks;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default BookmarkGrid;
|
export default BookmarkGrid;
|
||||||
|
|||||||
@@ -9,4 +9,21 @@
|
|||||||
|
|
||||||
.TableAction:hover {
|
.TableAction:hover {
|
||||||
cursor: pointer;
|
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,134 +1,283 @@
|
|||||||
import { ContentType } from '../Bookmarks';
|
import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
|
||||||
import classes from './BookmarkTable.module.css';
|
import {
|
||||||
import { connect } from 'react-redux';
|
DragDropContext,
|
||||||
import { pinCategory, deleteCategory, deleteBookmark } from '../../../store/actions';
|
Droppable,
|
||||||
import { KeyboardEvent } from 'react';
|
Draggable,
|
||||||
|
DropResult,
|
||||||
|
} from 'react-beautiful-dnd';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import {
|
||||||
|
pinCategory,
|
||||||
|
deleteCategory,
|
||||||
|
deleteBookmark,
|
||||||
|
createNotification,
|
||||||
|
reorderCategories,
|
||||||
|
} from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import {
|
||||||
|
Bookmark,
|
||||||
|
Category,
|
||||||
|
Config,
|
||||||
|
GlobalState,
|
||||||
|
NewNotification,
|
||||||
|
} from '../../../interfaces';
|
||||||
|
import { ContentType } from '../Bookmarks';
|
||||||
|
|
||||||
|
// CSS
|
||||||
|
import classes from './BookmarkTable.module.css';
|
||||||
|
|
||||||
|
// UI
|
||||||
import Table from '../../UI/Table/Table';
|
import Table from '../../UI/Table/Table';
|
||||||
import { Bookmark, Category } from '../../../interfaces';
|
|
||||||
import Icon from '../../UI/Icons/Icon/Icon';
|
import Icon from '../../UI/Icons/Icon/Icon';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
contentType: ContentType;
|
contentType: ContentType;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
config: Config;
|
||||||
pinCategory: (category: Category) => void;
|
pinCategory: (category: Category) => void;
|
||||||
deleteCategory: (id: number) => void;
|
deleteCategory: (id: number) => void;
|
||||||
updateHandler: (data: Category | Bookmark) => void;
|
updateHandler: (data: Category | Bookmark) => void;
|
||||||
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
deleteBookmark: (bookmarkId: number, categoryId: number) => void;
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
reorderCategories: (categories: Category[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
const BookmarkTable = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [localCategories, setLocalCategories] = useState<Category[]>([]);
|
||||||
|
const [isCustomOrder, setIsCustomOrder] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Copy categories array
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalCategories([...props.categories]);
|
||||||
|
}, [props.categories]);
|
||||||
|
|
||||||
|
// Check ordering
|
||||||
|
useEffect(() => {
|
||||||
|
const order = props.config.useOrdering;
|
||||||
|
|
||||||
|
if (order === 'orderId') {
|
||||||
|
setIsCustomOrder(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const deleteCategoryHandler = (category: Category): void => {
|
const deleteCategoryHandler = (category: Category): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
|
const proceed = window.confirm(
|
||||||
|
`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`
|
||||||
|
);
|
||||||
|
|
||||||
if (proceed) {
|
if (proceed) {
|
||||||
props.deleteCategory(category.id);
|
props.deleteCategory(category.id);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteBookmarkHandler = (bookmark: Bookmark): void => {
|
const deleteBookmarkHandler = (bookmark: Bookmark): void => {
|
||||||
const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`);
|
const proceed = window.confirm(
|
||||||
|
`Are you sure you want to delete ${bookmark.name}?`
|
||||||
|
);
|
||||||
|
|
||||||
if (proceed) {
|
if (proceed) {
|
||||||
props.deleteBookmark(bookmark.id, bookmark.categoryId);
|
props.deleteBookmark(bookmark.id, bookmark.categoryId);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => {
|
const keyboardActionHandler = (
|
||||||
|
e: KeyboardEvent,
|
||||||
|
category: Category,
|
||||||
|
handler: Function
|
||||||
|
) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handler(category);
|
handler(category);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const dragEndHanlder = (result: DropResult): void => {
|
||||||
|
if (!isCustomOrder) {
|
||||||
|
props.createNotification({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Custom order is disabled',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.destination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpCategories = [...localCategories];
|
||||||
|
const [movedApp] = tmpCategories.splice(result.source.index, 1);
|
||||||
|
tmpCategories.splice(result.destination.index, 0, movedApp);
|
||||||
|
|
||||||
|
setLocalCategories(tmpCategories);
|
||||||
|
props.reorderCategories(tmpCategories);
|
||||||
|
};
|
||||||
|
|
||||||
if (props.contentType === ContentType.category) {
|
if (props.contentType === ContentType.category) {
|
||||||
return (
|
return (
|
||||||
<Table headers={[
|
<Fragment>
|
||||||
'Name',
|
<div className={classes.Message}>
|
||||||
'Actions'
|
{isCustomOrder ? (
|
||||||
]}>
|
<p>You can drag and drop single rows to reorder categories</p>
|
||||||
{props.categories.map((category: Category) => {
|
) : (
|
||||||
return (
|
<p>
|
||||||
<tr key={category.id}>
|
Custom order is disabled. You can change it in{' '}
|
||||||
<td>{category.name}</td>
|
<Link to="/settings/other">settings</Link>
|
||||||
<td className={classes.TableActions}>
|
</p>
|
||||||
<div
|
)}
|
||||||
className={classes.TableAction}
|
</div>
|
||||||
onClick={() => deleteCategoryHandler(category)}
|
<DragDropContext onDragEnd={dragEndHanlder}>
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)}
|
<Droppable droppableId="categories">
|
||||||
tabIndex={0}>
|
{(provided) => (
|
||||||
<Icon icon='mdiDelete' />
|
<Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
|
||||||
</div>
|
{localCategories.map(
|
||||||
<div
|
(category: Category, index): JSX.Element => {
|
||||||
className={classes.TableAction}
|
return (
|
||||||
onClick={() => props.updateHandler(category)}
|
<Draggable
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
key={category.id}
|
||||||
tabIndex={0}>
|
draggableId={category.id.toString()}
|
||||||
<Icon icon='mdiPencil' />
|
index={index}
|
||||||
</div>
|
>
|
||||||
<div
|
{(provided, snapshot) => {
|
||||||
className={classes.TableAction}
|
const style = {
|
||||||
onClick={() => props.pinCategory(category)}
|
border: snapshot.isDragging
|
||||||
onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
|
? '1px solid var(--color-accent)'
|
||||||
tabIndex={0}>
|
: 'none',
|
||||||
{category.isPinned
|
borderRadius: '4px',
|
||||||
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
|
...provided.draggableProps.style,
|
||||||
: <Icon icon='mdiPin' />
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<td>{category.name}</td>
|
||||||
|
{!snapshot.isDragging && (
|
||||||
|
<td className={classes.TableActions}>
|
||||||
|
<div
|
||||||
|
className={classes.TableAction}
|
||||||
|
onClick={() =>
|
||||||
|
deleteCategoryHandler(category)
|
||||||
|
}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
keyboardActionHandler(
|
||||||
|
e,
|
||||||
|
category,
|
||||||
|
deleteCategoryHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Icon icon="mdiDelete" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classes.TableAction}
|
||||||
|
onClick={() =>
|
||||||
|
props.updateHandler(category)
|
||||||
|
}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<Icon icon="mdiPencil" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classes.TableAction}
|
||||||
|
onClick={() => props.pinCategory(category)}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
keyboardActionHandler(
|
||||||
|
e,
|
||||||
|
category,
|
||||||
|
props.pinCategory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{category.isPinned ? (
|
||||||
|
<Icon
|
||||||
|
icon="mdiPinOff"
|
||||||
|
color="var(--color-accent)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon="mdiPin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</div>
|
)}
|
||||||
</td>
|
</Table>
|
||||||
</tr>
|
)}
|
||||||
)
|
</Droppable>
|
||||||
})}
|
</DragDropContext>
|
||||||
</Table>
|
</Fragment>
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
|
const bookmarks: { bookmark: Bookmark; categoryName: string }[] = [];
|
||||||
props.categories.forEach((category: Category) => {
|
props.categories.forEach((category: Category) => {
|
||||||
category.bookmarks.forEach((bookmark: Bookmark) => {
|
category.bookmarks.forEach((bookmark: Bookmark) => {
|
||||||
bookmarks.push({
|
bookmarks.push({
|
||||||
bookmark,
|
bookmark,
|
||||||
categoryName: category.name
|
categoryName: category.name,
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table headers={[
|
<Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
|
||||||
'Name',
|
{bookmarks.map(
|
||||||
'URL',
|
(bookmark: { bookmark: Bookmark; categoryName: string }) => {
|
||||||
'Icon',
|
return (
|
||||||
'Category',
|
<tr key={bookmark.bookmark.id}>
|
||||||
'Actions'
|
<td>{bookmark.bookmark.name}</td>
|
||||||
]}>
|
<td>{bookmark.bookmark.url}</td>
|
||||||
{bookmarks.map((bookmark: {bookmark: Bookmark, categoryName: string}) => {
|
<td>{bookmark.bookmark.icon}</td>
|
||||||
return (
|
<td>{bookmark.categoryName}</td>
|
||||||
<tr key={bookmark.bookmark.id}>
|
<td className={classes.TableActions}>
|
||||||
<td>{bookmark.bookmark.name}</td>
|
<div
|
||||||
<td>{bookmark.bookmark.url}</td>
|
className={classes.TableAction}
|
||||||
<td>{bookmark.bookmark.icon}</td>
|
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
||||||
<td>{bookmark.categoryName}</td>
|
tabIndex={0}
|
||||||
<td className={classes.TableActions}>
|
>
|
||||||
<div
|
<Icon icon="mdiDelete" />
|
||||||
className={classes.TableAction}
|
</div>
|
||||||
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
|
<div
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, deleteAppHandler)}
|
className={classes.TableAction}
|
||||||
tabIndex={0}>
|
onClick={() => props.updateHandler(bookmark.bookmark)}
|
||||||
<Icon icon='mdiDelete' />
|
tabIndex={0}
|
||||||
</div>
|
>
|
||||||
<div
|
<Icon icon="mdiPencil" />
|
||||||
className={classes.TableAction}
|
</div>
|
||||||
onClick={() => props.updateHandler(bookmark.bookmark)}
|
</td>
|
||||||
// onKeyDown={(e) => keyboardActionHandler(e, app, props.updateAppHandler)}
|
</tr>
|
||||||
tabIndex={0}>
|
);
|
||||||
<Icon icon='mdiPencil' />
|
}
|
||||||
</div>
|
)}
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Table>
|
</Table>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(null, { pinCategory, deleteCategory, deleteBookmark })(BookmarkTable);
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
pinCategory,
|
||||||
|
deleteCategory,
|
||||||
|
deleteBookmark,
|
||||||
|
createNotification,
|
||||||
|
reorderCategories,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, actions)(BookmarkTable);
|
||||||
|
|||||||
@@ -20,27 +20,33 @@ interface ComponentProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
getCategories: () => void;
|
getCategories: () => void;
|
||||||
|
searching: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ContentType {
|
export enum ContentType {
|
||||||
category,
|
category,
|
||||||
bookmark
|
bookmark,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
const Bookmarks = (props: ComponentProps): JSX.Element => {
|
||||||
|
const { getCategories, categories, loading, searching = false } = props;
|
||||||
|
|
||||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
const [formContentType, setFormContentType] = useState(ContentType.category);
|
const [formContentType, setFormContentType] = useState(ContentType.category);
|
||||||
const [isInEdit, setIsInEdit] = useState(false);
|
const [isInEdit, setIsInEdit] = useState(false);
|
||||||
const [tableContentType, setTableContentType] = useState(ContentType.category);
|
const [tableContentType, setTableContentType] = useState(
|
||||||
|
ContentType.category
|
||||||
|
);
|
||||||
const [isInUpdate, setIsInUpdate] = useState(false);
|
const [isInUpdate, setIsInUpdate] = useState(false);
|
||||||
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
|
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
|
||||||
name: '',
|
name: '',
|
||||||
id: -1,
|
id: -1,
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
|
orderId: 0,
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
});
|
||||||
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
|
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
|
||||||
name: '',
|
name: '',
|
||||||
url: '',
|
url: '',
|
||||||
@@ -48,24 +54,24 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||||||
icon: '',
|
icon: '',
|
||||||
id: -1,
|
id: -1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
})
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
props.getCategories();
|
getCategories();
|
||||||
}
|
}
|
||||||
}, [props.getCategories])
|
}, [getCategories]);
|
||||||
|
|
||||||
const toggleModal = (): void => {
|
const toggleModal = (): void => {
|
||||||
setModalIsOpen(!modalIsOpen);
|
setModalIsOpen(!modalIsOpen);
|
||||||
}
|
};
|
||||||
|
|
||||||
const addActionHandler = (contentType: ContentType) => {
|
const addActionHandler = (contentType: ContentType) => {
|
||||||
setFormContentType(contentType);
|
setFormContentType(contentType);
|
||||||
setIsInUpdate(false);
|
setIsInUpdate(false);
|
||||||
toggleModal();
|
toggleModal();
|
||||||
}
|
};
|
||||||
|
|
||||||
const editActionHandler = (contentType: ContentType) => {
|
const editActionHandler = (contentType: ContentType) => {
|
||||||
// We're in the edit mode and the same button was clicked - go back to list
|
// We're in the edit mode and the same button was clicked - go back to list
|
||||||
@@ -75,11 +81,11 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||||||
setIsInEdit(true);
|
setIsInEdit(true);
|
||||||
setTableContentType(contentType);
|
setTableContentType(contentType);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const instanceOfCategory = (object: any): object is Category => {
|
const instanceOfCategory = (object: any): object is Category => {
|
||||||
return 'bookmarks' in object;
|
return 'bookmarks' in object;
|
||||||
}
|
};
|
||||||
|
|
||||||
const goToUpdateMode = (data: Category | Bookmark): void => {
|
const goToUpdateMode = (data: Category | Bookmark): void => {
|
||||||
setIsInUpdate(true);
|
setIsInUpdate(true);
|
||||||
@@ -91,67 +97,76 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
|
|||||||
setBookmarkInUpdate(data);
|
setBookmarkInUpdate(data);
|
||||||
}
|
}
|
||||||
toggleModal();
|
toggleModal();
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
|
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
|
||||||
{!isInUpdate
|
{!isInUpdate ? (
|
||||||
? <BookmarkForm modalHandler={toggleModal} contentType={formContentType} />
|
<BookmarkForm
|
||||||
: formContentType === ContentType.category
|
modalHandler={toggleModal}
|
||||||
? <BookmarkForm modalHandler={toggleModal} contentType={formContentType} category={categoryInUpdate} />
|
contentType={formContentType}
|
||||||
: <BookmarkForm modalHandler={toggleModal} contentType={formContentType} bookmark={bookmarkInUpdate} />
|
/>
|
||||||
}
|
) : formContentType === ContentType.category ? (
|
||||||
|
<BookmarkForm
|
||||||
|
modalHandler={toggleModal}
|
||||||
|
contentType={formContentType}
|
||||||
|
category={categoryInUpdate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<BookmarkForm
|
||||||
|
modalHandler={toggleModal}
|
||||||
|
contentType={formContentType}
|
||||||
|
bookmark={bookmarkInUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Headline
|
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
|
||||||
title='All Bookmarks'
|
|
||||||
subtitle={(<Link to='/'>Go back</Link>)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={classes.ActionsContainer}>
|
<div className={classes.ActionsContainer}>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
name='Add Category'
|
name="Add Category"
|
||||||
icon='mdiPlusBox'
|
icon="mdiPlusBox"
|
||||||
handler={() => addActionHandler(ContentType.category)}
|
handler={() => addActionHandler(ContentType.category)}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
name='Add Bookmark'
|
name="Add Bookmark"
|
||||||
icon='mdiPlusBox'
|
icon="mdiPlusBox"
|
||||||
handler={() => addActionHandler(ContentType.bookmark)}
|
handler={() => addActionHandler(ContentType.bookmark)}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
name='Edit Categories'
|
name="Edit Categories"
|
||||||
icon='mdiPencil'
|
icon="mdiPencil"
|
||||||
handler={() => editActionHandler(ContentType.category)}
|
handler={() => editActionHandler(ContentType.category)}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
name='Edit Bookmarks'
|
name="Edit Bookmarks"
|
||||||
icon='mdiPencil'
|
icon="mdiPencil"
|
||||||
handler={() => editActionHandler(ContentType.bookmark)}
|
handler={() => editActionHandler(ContentType.bookmark)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.loading
|
{loading ? (
|
||||||
? <Spinner />
|
<Spinner />
|
||||||
: (!isInEdit
|
) : !isInEdit ? (
|
||||||
? <BookmarkGrid categories={props.categories} />
|
<BookmarkGrid categories={categories} searching />
|
||||||
: <BookmarkTable
|
) : (
|
||||||
contentType={tableContentType}
|
<BookmarkTable
|
||||||
categories={props.categories}
|
contentType={tableContentType}
|
||||||
updateHandler={goToUpdateMode}
|
categories={categories}
|
||||||
/>
|
updateHandler={goToUpdateMode}
|
||||||
)
|
/>
|
||||||
}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: GlobalState) => {
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
return {
|
return {
|
||||||
loading: state.bookmark.loading,
|
loading: state.bookmark.loading,
|
||||||
categories: state.bookmark.categories
|
categories: state.bookmark.categories,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, { getCategories })(Bookmarks);
|
export default connect(mapStateToProps, { getCategories })(Bookmarks);
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
client/src/components/Home/Header/Header.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Config, GlobalState } from '../../../interfaces';
|
||||||
|
import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget';
|
||||||
|
import { getDateTime } from './functions/getDateTime';
|
||||||
|
import { greeter } from './functions/greeter';
|
||||||
|
import classes from './Header.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = (props: Props): JSX.Element => {
|
||||||
|
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}>
|
||||||
|
<p>{dateTime}</p>
|
||||||
|
<Link to="/settings" className={classes.SettingsLink}>
|
||||||
|
Go to Settings
|
||||||
|
</Link>
|
||||||
|
<span className={classes.HeaderMain}>
|
||||||
|
<h1>{greeting}</h1>
|
||||||
|
<WeatherWidget />
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(Header);
|
||||||
40
client/src/components/Home/Header/functions/getDateTime.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
if (!useAmericanDate) {
|
||||||
|
return `${days[now.getDay()]}, ${now.getDate()} ${
|
||||||
|
months[now.getMonth()]
|
||||||
|
} ${now.getFullYear()}`;
|
||||||
|
} else {
|
||||||
|
return `${days[now.getDay()]}, ${
|
||||||
|
months[now.getMonth()]
|
||||||
|
} ${now.getDate()} ${now.getFullYear()}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
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 {
|
.SettingsButton {
|
||||||
width: 35px;
|
width: 35px;
|
||||||
height: 35px;
|
height: 35px;
|
||||||
@@ -40,21 +19,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SettingsLink {
|
|
||||||
visibility: visible;
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 769px) {
|
||||||
.SettingsButton {
|
.SettingsButton {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.SettingsLink {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.HomeSpace {
|
.HomeSpace {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useState, useEffect, Fragment } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
// Redux
|
// Redux
|
||||||
@@ -7,7 +7,7 @@ import { getApps, getCategories } from '../../store/actions';
|
|||||||
|
|
||||||
// Typescript
|
// Typescript
|
||||||
import { GlobalState } from '../../interfaces/GlobalState';
|
import { GlobalState } from '../../interfaces/GlobalState';
|
||||||
import { App, Category } from '../../interfaces';
|
import { App, Category, Config } from '../../interfaces';
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
import Icon from '../UI/Icons/Icon/Icon';
|
import Icon from '../UI/Icons/Icon/Icon';
|
||||||
@@ -21,7 +21,8 @@ import classes from './Home.module.css';
|
|||||||
// Components
|
// Components
|
||||||
import AppGrid from '../Apps/AppGrid/AppGrid';
|
import AppGrid from '../Apps/AppGrid/AppGrid';
|
||||||
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
|
||||||
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
|
import SearchBar from '../SearchBar/SearchBar';
|
||||||
|
import Header from './Header/Header';
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
getApps: Function;
|
getApps: Function;
|
||||||
@@ -30,88 +31,135 @@ interface ComponentProps {
|
|||||||
apps: App[];
|
apps: App[];
|
||||||
categoriesLoading: boolean;
|
categoriesLoading: boolean;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = (props: ComponentProps): JSX.Element => {
|
const Home = (props: ComponentProps): JSX.Element => {
|
||||||
|
const {
|
||||||
|
getApps,
|
||||||
|
apps,
|
||||||
|
appsLoading,
|
||||||
|
getCategories,
|
||||||
|
categories,
|
||||||
|
categoriesLoading,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Local search query
|
||||||
|
const [localSearch, setLocalSearch] = useState<null | string>(null);
|
||||||
|
const [appSearchResult, setAppSearchResult] = useState<null | App[]>(null);
|
||||||
|
const [bookmarkSearchResult, setBookmarkSearchResult] = useState<
|
||||||
|
null | Category[]
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Load applications
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.apps.length === 0) {
|
if (!apps.length) {
|
||||||
props.getApps();
|
getApps();
|
||||||
}
|
}
|
||||||
}, [props.getApps]);
|
}, [getApps]);
|
||||||
|
|
||||||
|
// Load bookmark categories
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categories.length) {
|
||||||
|
getCategories();
|
||||||
|
}
|
||||||
|
}, [getCategories]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.categories.length === 0) {
|
if (localSearch) {
|
||||||
props.getCategories();
|
// Search through apps
|
||||||
|
setAppSearchResult([
|
||||||
|
...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Search through bookmarks
|
||||||
|
const category = { ...categories[0] };
|
||||||
|
|
||||||
|
category.name = 'Search Results';
|
||||||
|
category.bookmarks = categories
|
||||||
|
.map(({ bookmarks }) => bookmarks)
|
||||||
|
.flat()
|
||||||
|
.filter(({ name }) => new RegExp(localSearch, 'i').test(name));
|
||||||
|
|
||||||
|
setBookmarkSearchResult([category]);
|
||||||
|
} else {
|
||||||
|
setAppSearchResult(null);
|
||||||
|
setBookmarkSearchResult(null);
|
||||||
}
|
}
|
||||||
}, [props.getCategories]);
|
}, [localSearch]);
|
||||||
|
|
||||||
const dateAndTime = (): 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()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<header className={classes.Header}>
|
{!props.config.hideSearch ? (
|
||||||
<p>{dateAndTime()}</p>
|
<SearchBar
|
||||||
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
|
setLocalSearch={setLocalSearch}
|
||||||
<span className={classes.HeaderMain}>
|
appSearchResult={appSearchResult}
|
||||||
<h1>{greeter()}</h1>
|
bookmarkSearchResult={bookmarkSearchResult}
|
||||||
<WeatherWidget />
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<SectionHeadline title='Applications' link='/applications' />
|
|
||||||
{props.appsLoading
|
|
||||||
? <Spinner />
|
|
||||||
: <AppGrid
|
|
||||||
apps={props.apps.filter((app: App) => app.isPinned)}
|
|
||||||
totalApps={props.apps.length}
|
|
||||||
/>
|
/>
|
||||||
}
|
) : (
|
||||||
|
<div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={classes.HomeSpace}></div>
|
{!props.config.hideHeader ? <Header /> : <div></div>}
|
||||||
|
|
||||||
<SectionHeadline title='Bookmarks' link='/bookmarks' />
|
{!props.config.hideApps ? (
|
||||||
{props.categoriesLoading
|
<Fragment>
|
||||||
? <Spinner />
|
<SectionHeadline title="Applications" link="/applications" />
|
||||||
: <BookmarkGrid
|
{appsLoading ? (
|
||||||
categories={props.categories.filter((category: Category) => category.isPinned)}
|
<Spinner />
|
||||||
totalCategories={props.categories.length}
|
) : (
|
||||||
/>
|
<AppGrid
|
||||||
}
|
apps={
|
||||||
|
!appSearchResult
|
||||||
|
? apps.filter(({ isPinned }) => isPinned)
|
||||||
|
: appSearchResult
|
||||||
|
}
|
||||||
|
totalApps={apps.length}
|
||||||
|
searching={!!localSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={classes.HomeSpace}></div>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link to='/settings' className={classes.SettingsButton}>
|
{!props.config.hideCategories ? (
|
||||||
<Icon icon='mdiCog' color='var(--color-background)' />
|
<Fragment>
|
||||||
|
<SectionHeadline title="Bookmarks" link="/bookmarks" />
|
||||||
|
{categoriesLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : (
|
||||||
|
<BookmarkGrid
|
||||||
|
categories={
|
||||||
|
!bookmarkSearchResult
|
||||||
|
? categories.filter(({ isPinned }) => isPinned)
|
||||||
|
: bookmarkSearchResult
|
||||||
|
}
|
||||||
|
totalCategories={categories.length}
|
||||||
|
searching={!!localSearch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<div></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link to="/settings" className={classes.SettingsButton}>
|
||||||
|
<Icon icon="mdiCog" color="var(--color-background)" />
|
||||||
</Link>
|
</Link>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: GlobalState) => {
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
return {
|
return {
|
||||||
appsLoading: state.app.loading,
|
appsLoading: state.app.loading,
|
||||||
apps: state.app.apps,
|
apps: state.app.apps,
|
||||||
categoriesLoading: state.bookmark.loading,
|
categoriesLoading: state.bookmark.loading,
|
||||||
categories: state.bookmark.categories
|
categories: state.bookmark.categories,
|
||||||
}
|
config: state.config.config,
|
||||||
}
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps, { getApps, getCategories })(Home);
|
export default connect(mapStateToProps, { getApps, getCategories })(Home);
|
||||||
|
|||||||
@@ -20,19 +20,20 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
|
|||||||
<Notification
|
<Notification
|
||||||
title={notification.title}
|
title={notification.title}
|
||||||
message={notification.message}
|
message={notification.message}
|
||||||
|
url={notification.url || null}
|
||||||
id={notification.id}
|
id={notification.id}
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: GlobalState) => {
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
return {
|
return {
|
||||||
notifications: state.notification.notifications
|
notifications: state.notification.notifications,
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(mapStateToProps)(NotificationCenter);
|
export default connect(mapStateToProps)(NotificationCenter);
|
||||||
|
|||||||
17
client/src/components/SearchBar/SearchBar.module.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.SearchBar {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
color: var(--color-primary);
|
||||||
|
/* font-size: 20px; */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--color-accent);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SearchBar:focus {
|
||||||
|
opacity: 1;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
137
client/src/components/SearchBar/SearchBar.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createNotification } from '../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import {
|
||||||
|
App,
|
||||||
|
Category,
|
||||||
|
Config,
|
||||||
|
GlobalState,
|
||||||
|
NewNotification,
|
||||||
|
} from '../../interfaces';
|
||||||
|
|
||||||
|
// CSS
|
||||||
|
import classes from './SearchBar.module.css';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { searchParser, urlParser, redirectUrl } from '../../utility';
|
||||||
|
|
||||||
|
interface ComponentProps {
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
setLocalSearch: (query: string) => void;
|
||||||
|
appSearchResult: App[] | null;
|
||||||
|
bookmarkSearchResult: Category[] | null;
|
||||||
|
config: Config;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchBar = (props: ComponentProps): JSX.Element => {
|
||||||
|
const {
|
||||||
|
setLocalSearch,
|
||||||
|
createNotification,
|
||||||
|
config,
|
||||||
|
loading,
|
||||||
|
appSearchResult,
|
||||||
|
bookmarkSearchResult,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', keyOutsideFocus);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('keydown', keyOutsideFocus);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
inputRef.current.value = '';
|
||||||
|
setLocalSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const { isLocal, search, query, isURL, sameTab } = searchParser(
|
||||||
|
inputRef.current.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLocal) {
|
||||||
|
setLocalSearch(search);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Local query -> redirect if at least 1 result found
|
||||||
|
if (appSearchResult?.length) {
|
||||||
|
redirectUrl(appSearchResult[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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
config: state.config.config,
|
||||||
|
loading: state.config.loading,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, { createNotification })(SearchBar);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.AppVersion {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.AppVersion a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
34
client/src/components/Settings/AppDetails/AppDetails.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
|
import classes from './AppDetails.module.css';
|
||||||
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
import { checkVersion } from '../../../utility';
|
||||||
|
|
||||||
|
const AppDetails = (): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<p className={classes.AppVersion}>
|
||||||
|
<a
|
||||||
|
href='https://github.com/pawelmalak/flame'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'>
|
||||||
|
Flame
|
||||||
|
</a>
|
||||||
|
{' '}
|
||||||
|
version {process.env.REACT_APP_VERSION}
|
||||||
|
</p>
|
||||||
|
<p className={classes.AppVersion}>
|
||||||
|
See changelog {' '}
|
||||||
|
<a
|
||||||
|
href='https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<Button click={() => checkVersion(true)}>Check for updates</Button>
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppDetails;
|
||||||
@@ -1,122 +1,356 @@
|
|||||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import {
|
||||||
|
createNotification,
|
||||||
|
updateConfig,
|
||||||
|
sortApps,
|
||||||
|
sortCategories,
|
||||||
|
} from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
GlobalState,
|
||||||
|
NewNotification,
|
||||||
|
OtherSettingsForm,
|
||||||
|
} from '../../../interfaces';
|
||||||
|
|
||||||
|
// UI
|
||||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
import { createNotification } from '../../../store/actions';
|
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||||
import { ApiResponse, Config, NewNotification } from '../../../interfaces';
|
|
||||||
|
|
||||||
interface FormState {
|
// Utils
|
||||||
customTitle: string;
|
import { otherSettingsTemplate, inputHandler } from '../../../utility';
|
||||||
pinAppsByDefault: number;
|
|
||||||
pinCategoriesByDefault: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
createNotification: (notification: NewNotification) => void;
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
updateConfig: (formData: OtherSettingsForm) => void;
|
||||||
|
sortApps: () => void;
|
||||||
|
sortCategories: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OtherSettings = (props: ComponentProps): JSX.Element => {
|
const OtherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
const [formData, setFormData] = useState<FormState>({
|
const { config } = props;
|
||||||
customTitle: document.title,
|
|
||||||
pinAppsByDefault: 0,
|
|
||||||
pinCategoriesByDefault: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// get initial config
|
// Initial state
|
||||||
|
const [formData, setFormData] = useState<OtherSettingsForm>(
|
||||||
|
otherSettingsTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get<ApiResponse<Config[]>>('/api/config?keys=customTitle,pinAppsByDefault,pinCategoriesByDefault')
|
setFormData({
|
||||||
.then(data => {
|
...config,
|
||||||
let tmpFormData = { ...formData };
|
});
|
||||||
|
}, [props.loading]);
|
||||||
|
|
||||||
data.data.data.forEach((config: Config) => {
|
// Form handler
|
||||||
let value: string | number = config.value;
|
const formSubmitHandler = async (e: FormEvent) => {
|
||||||
if (config.valueType === 'number') {
|
|
||||||
value = parseFloat(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpFormData = {
|
|
||||||
...tmpFormData,
|
|
||||||
[config.key]: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setFormData(tmpFormData);
|
|
||||||
})
|
|
||||||
.catch(err => console.log(err));
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const formSubmitHandler = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
axios.put<ApiResponse<{}>>('/api/config', formData)
|
// Save settings
|
||||||
.then(() => {
|
await props.updateConfig(formData);
|
||||||
props.createNotification({
|
|
||||||
title: 'Success',
|
|
||||||
message: 'Settings updated'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
|
|
||||||
// update local page title
|
// Update local page title
|
||||||
localStorage.setItem('customTitle', formData.customTitle);
|
|
||||||
document.title = formData.customTitle;
|
document.title = formData.customTitle;
|
||||||
}
|
|
||||||
|
|
||||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
|
// Sort apps and categories with new settings
|
||||||
let value: string | number = e.target.value;
|
props.sortApps();
|
||||||
|
props.sortCategories();
|
||||||
|
};
|
||||||
|
|
||||||
if (isNumber) {
|
// Input handler
|
||||||
value = parseFloat(value);
|
const inputChangeHandler = (
|
||||||
}
|
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
options?: { isNumber?: boolean; isBool?: boolean }
|
||||||
setFormData({
|
) => {
|
||||||
...formData,
|
inputHandler<OtherSettingsForm>({
|
||||||
[e.target.name]: value
|
e,
|
||||||
})
|
options,
|
||||||
}
|
setStateHandler: setFormData,
|
||||||
|
state: formData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||||
|
{/* OTHER OPTIONS */}
|
||||||
|
<SettingsHeadline text="Miscellaneous" />
|
||||||
|
{/* PAGE TITLE */}
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='customTitle'>Custom Page Title</label>
|
<label htmlFor="customTitle">Custom page title</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type="text"
|
||||||
id='customTitle'
|
id="customTitle"
|
||||||
name='customTitle'
|
name="customTitle"
|
||||||
placeholder='Flame'
|
placeholder="Flame"
|
||||||
value={formData.customTitle}
|
value={formData.customTitle}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<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='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>
|
|
||||||
<Button>Save changes</Button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(null, { createNotification })(OtherSettings);
|
{/* DATE FORMAT */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="useAmericanDate">Date formatting</label>
|
||||||
|
<select
|
||||||
|
id="useAmericanDate"
|
||||||
|
name="useAmericanDate"
|
||||||
|
value={formData.useAmericanDate ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>Friday, October 22 2021</option>
|
||||||
|
<option value={0}>Friday, 22 October 2021</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* BEAHVIOR OPTIONS */}
|
||||||
|
<SettingsHeadline text="App Behavior" />
|
||||||
|
{/* PIN APPS */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="pinAppsByDefault">
|
||||||
|
Pin new applications by default
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pinAppsByDefault"
|
||||||
|
name="pinAppsByDefault"
|
||||||
|
value={formData.pinAppsByDefault ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* PIN CATEGORIES */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="pinCategoriesByDefault">
|
||||||
|
Pin new categories by default
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pinCategoriesByDefault"
|
||||||
|
name="pinCategoriesByDefault"
|
||||||
|
value={formData.pinCategoriesByDefault ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* SORT TYPE */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="useOrdering">Sorting type</label>
|
||||||
|
<select
|
||||||
|
id="useOrdering"
|
||||||
|
name="useOrdering"
|
||||||
|
value={formData.useOrdering}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
>
|
||||||
|
<option value="createdAt">By creation date</option>
|
||||||
|
<option value="name">Alphabetical order</option>
|
||||||
|
<option value="orderId">Custom order</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* APPS OPPENING */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="appsSameTab">Open applications in the same tab</label>
|
||||||
|
<select
|
||||||
|
id="appsSameTab"
|
||||||
|
name="appsSameTab"
|
||||||
|
value={formData.appsSameTab ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* BOOKMARKS OPPENING */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="bookmarksSameTab">Open bookmarks in the same tab</label>
|
||||||
|
<select
|
||||||
|
id="bookmarksSameTab"
|
||||||
|
name="bookmarksSameTab"
|
||||||
|
value={formData.bookmarksSameTab ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* MODULES OPTIONS */}
|
||||||
|
<SettingsHeadline text="Modules" />
|
||||||
|
{/* HIDE HEADER */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="hideHeader">Hide greeting and date</label>
|
||||||
|
<select
|
||||||
|
id="hideHeader"
|
||||||
|
name="hideHeader"
|
||||||
|
value={formData.hideHeader ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* CUSTOM GREETINGS */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="greetingsSchema">Custom greetings</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="greetingsSchema"
|
||||||
|
name="greetingsSchema"
|
||||||
|
placeholder="Good day;Hi;Bye!"
|
||||||
|
value={formData.greetingsSchema}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Greetings must be separated with semicolon. Only 4 messages can be
|
||||||
|
used
|
||||||
|
</span>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* CUSTOM DAYS */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="daySchema">Custom weekday names</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="daySchema"
|
||||||
|
name="daySchema"
|
||||||
|
placeholder="Sunday;Monday;Tuesday"
|
||||||
|
value={formData.daySchema}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
<span>Names must be separated with semicolon</span>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* CUSTOM MONTHS */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="monthSchema">Custom month names</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="monthSchema"
|
||||||
|
name="monthSchema"
|
||||||
|
placeholder="January;February;March"
|
||||||
|
value={formData.monthSchema}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
<span>Names must be separated with semicolon</span>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* HIDE APPS */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="hideApps">Hide applications</label>
|
||||||
|
<select
|
||||||
|
id="hideApps"
|
||||||
|
name="hideApps"
|
||||||
|
value={formData.hideApps ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* HIDE CATEGORIES */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="hideCategories">Hide categories</label>
|
||||||
|
<select
|
||||||
|
id="hideCategories"
|
||||||
|
name="hideCategories"
|
||||||
|
value={formData.hideCategories ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* DOCKER SETTINGS */}
|
||||||
|
<SettingsHeadline text="Docker" />
|
||||||
|
{/* CUSTOM DOCKER SOCKET HOST */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="dockerHost">Docker Host</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="dockerHost"
|
||||||
|
name="dockerHost"
|
||||||
|
placeholder="dockerHost:port"
|
||||||
|
value={formData.dockerHost}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* USE DOCKER API */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="dockerApps">Use Docker API</label>
|
||||||
|
<select
|
||||||
|
id="dockerApps"
|
||||||
|
name="dockerApps"
|
||||||
|
value={formData.dockerApps ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* UNPIN DOCKER APPS */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="unpinStoppedApps">
|
||||||
|
Unpin stopped containers / other apps
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="unpinStoppedApps"
|
||||||
|
name="unpinStoppedApps"
|
||||||
|
value={formData.unpinStoppedApps ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* KUBERNETES SETTINGS */}
|
||||||
|
<SettingsHeadline text="Kubernetes" />
|
||||||
|
{/* USE KUBERNETES */}
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="kubernetesApps">Use Kubernetes Ingress API</label>
|
||||||
|
<select
|
||||||
|
id="kubernetesApps"
|
||||||
|
name="kubernetesApps"
|
||||||
|
value={formData.kubernetesApps ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<Button>Save changes</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
loading: state.config.loading,
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
createNotification,
|
||||||
|
updateConfig,
|
||||||
|
sortApps,
|
||||||
|
sortCategories,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, actions)(OtherSettings);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.QueriesGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.QueriesGrid span {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.QueriesGrid span:last-child {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActionIcons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActionIcons svg {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ActionIcons svg:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.Separator {
|
||||||
|
grid-column: 1 / 4;
|
||||||
|
border-bottom: 1px solid var(--color-primary);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import classes from './CustomQueries.module.css';
|
||||||
|
|
||||||
|
import Modal from '../../../UI/Modal/Modal';
|
||||||
|
import Icon from '../../../UI/Icons/Icon/Icon';
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
GlobalState,
|
||||||
|
NewNotification,
|
||||||
|
Query,
|
||||||
|
} from '../../../../interfaces';
|
||||||
|
import QueriesForm from './QueriesForm';
|
||||||
|
import { deleteQuery, createNotification } from '../../../../store/actions';
|
||||||
|
import Button from '../../../UI/Buttons/Button/Button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
customQueries: Query[];
|
||||||
|
deleteQuery: (prefix: string) => {};
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomQueries = (props: Props): JSX.Element => {
|
||||||
|
const { customQueries, deleteQuery, createNotification } = props;
|
||||||
|
|
||||||
|
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||||
|
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
|
||||||
|
|
||||||
|
const updateHandler = (query: Query) => {
|
||||||
|
setEditableQuery(query);
|
||||||
|
setModalIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHandler = (query: Query) => {
|
||||||
|
const currentProvider = props.config.defaultSearchProvider;
|
||||||
|
const isCurrent = currentProvider === query.prefix;
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
createNotification({
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Cannot delete active provider',
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
window.confirm(`Are you sure you want to delete this provider?`)
|
||||||
|
) {
|
||||||
|
deleteQuery(query.prefix);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Modal
|
||||||
|
isOpen={modalIsOpen}
|
||||||
|
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
|
||||||
|
>
|
||||||
|
{editableQuery ? (
|
||||||
|
<QueriesForm
|
||||||
|
modalHandler={() => setModalIsOpen(!modalIsOpen)}
|
||||||
|
query={editableQuery}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className={classes.QueriesGrid}>
|
||||||
|
{customQueries.length > 0 && (
|
||||||
|
<Fragment>
|
||||||
|
<span>Name</span>
|
||||||
|
<span>Prefix</span>
|
||||||
|
<span>Actions</span>
|
||||||
|
|
||||||
|
<div className={classes.Separator}></div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customQueries.map((q: Query, idx) => (
|
||||||
|
<Fragment key={idx}>
|
||||||
|
<span>{q.name}</span>
|
||||||
|
<span>{q.prefix}</span>
|
||||||
|
<span className={classes.ActionIcons}>
|
||||||
|
<span onClick={() => updateHandler(q)}>
|
||||||
|
<Icon icon="mdiPencil" />
|
||||||
|
</span>
|
||||||
|
<span onClick={() => deleteHandler(q)}>
|
||||||
|
<Icon icon="mdiDelete" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
click={() => {
|
||||||
|
setEditableQuery(null);
|
||||||
|
setModalIsOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add new search provider
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
customQueries: state.config.customQueries,
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, { deleteQuery, createNotification })(
|
||||||
|
CustomQueries
|
||||||
|
);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<Query>({
|
||||||
|
name: '',
|
||||||
|
prefix: '',
|
||||||
|
template: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[name]: value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formHandler = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
updateQuery(formData, query.prefix);
|
||||||
|
} else {
|
||||||
|
addQuery(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// close modal
|
||||||
|
modalHandler();
|
||||||
|
|
||||||
|
// clear form
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
prefix: '',
|
||||||
|
template: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query) {
|
||||||
|
setFormData(query);
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
prefix: '',
|
||||||
|
template: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm modalHandler={modalHandler} formHandler={formHandler}>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="name">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="name"
|
||||||
|
placeholder="Google"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="name">Prefix</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="prefix"
|
||||||
|
id="prefix"
|
||||||
|
placeholder="g"
|
||||||
|
required
|
||||||
|
value={formData.prefix}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="name">Query Template</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="template"
|
||||||
|
id="template"
|
||||||
|
placeholder="https://www.google.com/search?q="
|
||||||
|
required
|
||||||
|
value={formData.template}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(null, { addQuery, updateQuery })(QueriesForm);
|
||||||
159
client/src/components/Settings/SearchSettings/SearchSettings.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// React
|
||||||
|
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
// State
|
||||||
|
import { createNotification, updateConfig } from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
GlobalState,
|
||||||
|
NewNotification,
|
||||||
|
Query,
|
||||||
|
SearchForm,
|
||||||
|
} from '../../../interfaces';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import CustomQueries from './CustomQueries/CustomQueries';
|
||||||
|
|
||||||
|
// UI
|
||||||
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||||
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { inputHandler, searchSettingsTemplate } from '../../../utility';
|
||||||
|
|
||||||
|
// Data
|
||||||
|
import { queries } from '../../../utility/searchQueries.json';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
updateConfig: (formData: SearchForm) => void;
|
||||||
|
loading: boolean;
|
||||||
|
customQueries: Query[];
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchSettings = (props: Props): JSX.Element => {
|
||||||
|
// Initial state
|
||||||
|
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
|
||||||
|
|
||||||
|
// Get config
|
||||||
|
useEffect(() => {
|
||||||
|
setFormData({
|
||||||
|
...props.config,
|
||||||
|
});
|
||||||
|
}, [props.loading]);
|
||||||
|
|
||||||
|
// Form handler
|
||||||
|
const formSubmitHandler = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
await props.updateConfig(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Input handler
|
||||||
|
const inputChangeHandler = (
|
||||||
|
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
options?: { isNumber?: boolean; isBool?: boolean }
|
||||||
|
) => {
|
||||||
|
inputHandler<SearchForm>({
|
||||||
|
e,
|
||||||
|
options,
|
||||||
|
setStateHandler: setFormData,
|
||||||
|
state: formData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{/* GENERAL SETTINGS */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => formSubmitHandler(e)}
|
||||||
|
style={{ marginBottom: '30px' }}
|
||||||
|
>
|
||||||
|
<SettingsHeadline text="General" />
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
|
||||||
|
<select
|
||||||
|
id="defaultSearchProvider"
|
||||||
|
name="defaultSearchProvider"
|
||||||
|
value={formData.defaultSearchProvider}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
>
|
||||||
|
{[...queries, ...props.customQueries].map((query: Query, idx) => {
|
||||||
|
const isCustom = idx >= queries.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<option key={idx} value={query.prefix}>
|
||||||
|
{isCustom && '+'} {query.name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="searchSameTab">
|
||||||
|
Open search results in the same tab
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="searchSameTab"
|
||||||
|
name="searchSameTab"
|
||||||
|
value={formData.searchSameTab ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="hideSearch">Hide search bar</label>
|
||||||
|
<select
|
||||||
|
id="hideSearch"
|
||||||
|
name="hideSearch"
|
||||||
|
value={formData.hideSearch ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
|
||||||
|
<select
|
||||||
|
id="disableAutofocus"
|
||||||
|
name="disableAutofocus"
|
||||||
|
value={formData.disableAutofocus ? 1 : 0}
|
||||||
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
|
>
|
||||||
|
<option value={1}>True</option>
|
||||||
|
<option value={0}>False</option>
|
||||||
|
</select>
|
||||||
|
</InputGroup>
|
||||||
|
<Button>Save changes</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* CUSTOM QUERIES */}
|
||||||
|
<SettingsHeadline text="Custom search providers" />
|
||||||
|
<CustomQueries />
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
loading: state.config.loading,
|
||||||
|
customQueries: state.config.customQueries,
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
createNotification,
|
||||||
|
updateConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, actions)(SearchSettings);
|
||||||
@@ -1,54 +1,61 @@
|
|||||||
import { NavLink, Link, Switch, Route, withRouter } from 'react-router-dom';
|
//
|
||||||
|
import { NavLink, Link, Switch, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import { Route as SettingsRoute } from '../../interfaces';
|
||||||
|
|
||||||
|
// CSS
|
||||||
import classes from './Settings.module.css';
|
import classes from './Settings.module.css';
|
||||||
|
|
||||||
import { Container } from '../UI/Layout/Layout';
|
// Components
|
||||||
import Headline from '../UI/Headlines/Headline/Headline';
|
|
||||||
import Themer from '../Themer/Themer';
|
import Themer from '../Themer/Themer';
|
||||||
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
import WeatherSettings from './WeatherSettings/WeatherSettings';
|
||||||
import OtherSettings from './OtherSettings/OtherSettings';
|
import OtherSettings from './OtherSettings/OtherSettings';
|
||||||
|
import AppDetails from './AppDetails/AppDetails';
|
||||||
|
import StyleSettings from './StyleSettings/StyleSettings';
|
||||||
|
import SearchSettings from './SearchSettings/SearchSettings';
|
||||||
|
|
||||||
|
// UI
|
||||||
|
import { Container } from '../UI/Layout/Layout';
|
||||||
|
import Headline from '../UI/Headlines/Headline/Headline';
|
||||||
|
|
||||||
|
// Data
|
||||||
|
import { routes } from './settings.json';
|
||||||
|
|
||||||
const Settings = (): JSX.Element => {
|
const Settings = (): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Headline
|
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
|
||||||
title='Settings'
|
|
||||||
subtitle={<Link to='/'>Go back</Link>}
|
|
||||||
/>
|
|
||||||
<div className={classes.Settings}>
|
<div className={classes.Settings}>
|
||||||
|
{/* NAVIGATION MENU */}
|
||||||
<nav className={classes.SettingsNav}>
|
<nav className={classes.SettingsNav}>
|
||||||
<NavLink
|
{routes.map(({ name, dest }: SettingsRoute, idx) => (
|
||||||
className={classes.SettingsNavLink}
|
<NavLink
|
||||||
activeClassName={classes.SettingsNavLinkActive}
|
className={classes.SettingsNavLink}
|
||||||
exact
|
activeClassName={classes.SettingsNavLinkActive}
|
||||||
to='/settings'>
|
exact
|
||||||
Theme
|
to={dest}
|
||||||
</NavLink>
|
key={idx}
|
||||||
<NavLink
|
>
|
||||||
className={classes.SettingsNavLink}
|
{name}
|
||||||
activeClassName={classes.SettingsNavLinkActive}
|
</NavLink>
|
||||||
exact
|
))}
|
||||||
to='/settings/weather'>
|
|
||||||
Weather
|
|
||||||
</NavLink>
|
|
||||||
<NavLink
|
|
||||||
className={classes.SettingsNavLink}
|
|
||||||
activeClassName={classes.SettingsNavLinkActive}
|
|
||||||
exact
|
|
||||||
to='/settings/other'>
|
|
||||||
Other
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* ROUTES */}
|
||||||
<section className={classes.SettingsContent}>
|
<section className={classes.SettingsContent}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path='/settings' component={Themer} />
|
<Route exact path="/settings" component={Themer} />
|
||||||
<Route path='/settings/weather' component={WeatherSettings} />
|
<Route path="/settings/weather" component={WeatherSettings} />
|
||||||
<Route path='/settings/other' component={OtherSettings} />
|
<Route path="/settings/search" component={SearchSettings} />
|
||||||
|
<Route path="/settings/other" component={OtherSettings} />
|
||||||
|
<Route path="/settings/css" component={StyleSettings} />
|
||||||
|
<Route path="/settings/app" component={AppDetails} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default withRouter(Settings);
|
export default Settings;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createNotification } from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import { ApiResponse, NewNotification } from '../../../interfaces';
|
||||||
|
|
||||||
|
// UI
|
||||||
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
|
|
||||||
|
interface ComponentProps {
|
||||||
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyleSettings = (props: ComponentProps): JSX.Element => {
|
||||||
|
const [customStyles, setCustomStyles] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
axios.get<ApiResponse<string>>('/api/config/0/css')
|
||||||
|
.then(data => setCustomStyles(data.data.data))
|
||||||
|
.catch(err => console.log(err.response));
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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 })
|
||||||
|
.then(() => {
|
||||||
|
props.createNotification({
|
||||||
|
title: 'Success',
|
||||||
|
message: 'CSS saved. Reload page to see changes'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(err => console.log(err.response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||||
|
<InputGroup>
|
||||||
|
<label htmlFor='customStyles'>Custom CSS</label>
|
||||||
|
<textarea
|
||||||
|
id='customStyles'
|
||||||
|
name='customStyles'
|
||||||
|
value={customStyles}
|
||||||
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
|
spellCheck={false}
|
||||||
|
></textarea>
|
||||||
|
</InputGroup>
|
||||||
|
<Button>Save CSS</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(null, { createNotification })(StyleSettings);
|
||||||
@@ -1,173 +1,173 @@
|
|||||||
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
import { useState, ChangeEvent, useEffect, FormEvent } from 'react';
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { ApiResponse, Config, NewNotification, Weather } from '../../../interfaces';
|
|
||||||
|
|
||||||
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createNotification, updateConfig } from '../../../store/actions';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import {
|
||||||
|
ApiResponse,
|
||||||
|
Config,
|
||||||
|
GlobalState,
|
||||||
|
NewNotification,
|
||||||
|
Weather,
|
||||||
|
WeatherForm,
|
||||||
|
} from '../../../interfaces';
|
||||||
|
|
||||||
|
// UI
|
||||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||||
import Button from '../../UI/Buttons/Button/Button';
|
import Button from '../../UI/Buttons/Button/Button';
|
||||||
import { createNotification } from '../../../store/actions';
|
|
||||||
|
|
||||||
interface FormState {
|
// Utils
|
||||||
WEATHER_API_KEY: string;
|
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
|
||||||
lat: number;
|
|
||||||
long: number;
|
|
||||||
isCelsius: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
createNotification: (notification: NewNotification) => void;
|
createNotification: (notification: NewNotification) => void;
|
||||||
|
updateConfig: (formData: WeatherForm) => void;
|
||||||
|
loading: boolean;
|
||||||
|
config: Config;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
const WeatherSettings = (props: ComponentProps): JSX.Element => {
|
||||||
const [formData, setFormData] = useState<FormState>({
|
// Initial state
|
||||||
WEATHER_API_KEY: '',
|
const [formData, setFormData] = useState<WeatherForm>(
|
||||||
lat: 0,
|
weatherSettingsTemplate
|
||||||
long: 0,
|
);
|
||||||
isCelsius: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Get config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios.get<ApiResponse<Config[]>>('/api/config?keys=WEATHER_API_KEY,lat,long,isCelsius')
|
setFormData({
|
||||||
.then(data => {
|
...props.config,
|
||||||
let tmpFormData = { ...formData };
|
});
|
||||||
|
}, [props.loading]);
|
||||||
|
|
||||||
data.data.data.forEach((config: Config) => {
|
// Form handler
|
||||||
let value: string | number = config.value;
|
const formSubmitHandler = async (e: FormEvent) => {
|
||||||
if (config.valueType === 'number') {
|
|
||||||
value = parseFloat(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpFormData = {
|
|
||||||
...tmpFormData,
|
|
||||||
[config.key]: value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setFormData(tmpFormData);
|
|
||||||
})
|
|
||||||
.catch(err => console.log(err));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formSubmitHandler = (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Check for api key input
|
// Check for api key input
|
||||||
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
|
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
|
||||||
props.createNotification({
|
props.createNotification({
|
||||||
title: 'Warning',
|
title: 'Warning',
|
||||||
message: 'API Key is missing. Weather Module will NOT work'
|
message: 'API key is missing. Weather Module will NOT work',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
axios.put<ApiResponse<{}>>('/api/config', formData)
|
await props.updateConfig(formData);
|
||||||
|
|
||||||
|
// Update weather
|
||||||
|
axios
|
||||||
|
.get<ApiResponse<Weather>>('/api/weather/update')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
props.createNotification({
|
props.createNotification({
|
||||||
title: 'Success',
|
title: 'Success',
|
||||||
message: 'Settings updated'
|
message: 'Weather updated',
|
||||||
})
|
});
|
||||||
|
|
||||||
// Update weather with new settings
|
|
||||||
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
|
|
||||||
})
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch(err => console.log(err));
|
.catch((err) => {
|
||||||
|
props.createNotification({
|
||||||
// set localStorage
|
title: 'Error',
|
||||||
localStorage.setItem('isCelsius', JSON.stringify(parseInt(`${formData.isCelsius}`) === 1))
|
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 (
|
return (
|
||||||
<form onSubmit={(e) => formSubmitHandler(e)}>
|
<form onSubmit={(e) => formSubmitHandler(e)}>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='WEATHER_API_KEY'>API Key</label>
|
<label htmlFor="WEATHER_API_KEY">API key</label>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type="text"
|
||||||
id='WEATHER_API_KEY'
|
id="WEATHER_API_KEY"
|
||||||
name='WEATHER_API_KEY'
|
name="WEATHER_API_KEY"
|
||||||
placeholder='secret'
|
placeholder="secret"
|
||||||
value={formData.WEATHER_API_KEY}
|
value={formData.WEATHER_API_KEY}
|
||||||
onChange={(e) => inputChangeHandler(e)}
|
onChange={(e) => inputChangeHandler(e)}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
Using
|
Using
|
||||||
<a
|
<a href="https://www.weatherapi.com/pricing.aspx" target="blank">
|
||||||
href='https://www.weatherapi.com/pricing.aspx'
|
{' '}
|
||||||
target='blank'>
|
Weather API
|
||||||
{' '}Weather API
|
|
||||||
</a>
|
</a>
|
||||||
. Key is required for weather module to work.
|
. Key is required for weather module to work.
|
||||||
</span>
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='lat'>Location Latitude</label>
|
<label htmlFor="lat">Location latitude</label>
|
||||||
<input
|
<input
|
||||||
type='number'
|
type="number"
|
||||||
id='lat'
|
id="lat"
|
||||||
name='lat'
|
name="lat"
|
||||||
placeholder='52.22'
|
placeholder="52.22"
|
||||||
value={formData.lat}
|
value={formData.lat}
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
|
||||||
|
step="any"
|
||||||
|
lang="en-150"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
You can use
|
You can use
|
||||||
<a
|
<a
|
||||||
href='https://www.latlong.net/convert-address-to-lat-long.html'
|
href="https://www.latlong.net/convert-address-to-lat-long.html"
|
||||||
target='blank'>
|
target="blank"
|
||||||
{' '}latlong.net
|
>
|
||||||
|
{' '}
|
||||||
|
latlong.net
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='long'>Location Longitude</label>
|
<label htmlFor="long">Location longitude</label>
|
||||||
<input
|
<input
|
||||||
type='number'
|
type="number"
|
||||||
id='long'
|
id="long"
|
||||||
name='long'
|
name="long"
|
||||||
placeholder='21.01'
|
placeholder="21.01"
|
||||||
value={formData.long}
|
value={formData.long}
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
|
||||||
|
step="any"
|
||||||
|
lang="en-150"
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<label htmlFor='isCelsius'>Temperature Unit</label>
|
<label htmlFor="isCelsius">Temperature unit</label>
|
||||||
<select
|
<select
|
||||||
id='isCelsius'
|
id="isCelsius"
|
||||||
name='isCelsius'
|
name="isCelsius"
|
||||||
onChange={(e) => inputChangeHandler(e, true)}
|
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||||
value={formData.isCelsius}
|
value={formData.isCelsius ? 1 : 0}
|
||||||
>
|
>
|
||||||
<option value={1}>Celsius</option>
|
<option value={1}>Celsius</option>
|
||||||
<option value={0}>Fahrenheit</option>
|
<option value={0}>Fahrenheit</option>
|
||||||
</select>
|
</select>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
<Button>Save changes</Button>
|
<Button>Save changes</Button>
|
||||||
</form>
|
</form>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(null, { createNotification })(WeatherSettings);
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
loading: state.config.loading,
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, { createNotification, updateConfig })(
|
||||||
|
WeatherSettings
|
||||||
|
);
|
||||||
|
|||||||
28
client/src/components/Settings/settings.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"name": "Theme",
|
||||||
|
"dest": "/settings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weather",
|
||||||
|
"dest": "/settings/weather"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Search",
|
||||||
|
"dest": "/settings/search"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Other",
|
||||||
|
"dest": "/settings/other"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CSS",
|
||||||
|
"dest": "/settings/css"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "App",
|
||||||
|
"dest": "/settings/app"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -95,6 +95,30 @@
|
|||||||
"primary": "#4C432E",
|
"primary": "#4C432E",
|
||||||
"accent": "#AA9A73"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Button:hover,
|
.Button:hover {
|
||||||
.Button:focus {
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--color-accent);
|
background-color: var(--color-accent);
|
||||||
color: var(--color-background);
|
color: var(--color-background);
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import classes from './Button.module.css';
|
|||||||
|
|
||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
children: string;
|
children: string;
|
||||||
|
click?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = (props: ComponentProps): JSX.Element => {
|
const Button = (props: ComponentProps): JSX.Element => {
|
||||||
return <button className={classes.Button}>{props.children}</button>
|
const {
|
||||||
|
children,
|
||||||
|
click
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classes.Button} onClick={click ? click : () => {}} >
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Button;
|
export default Button;
|
||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
.InputGroup label,
|
.InputGroup label,
|
||||||
.InputGroup span,
|
.InputGroup span,
|
||||||
.InputGroup input {
|
.InputGroup input,
|
||||||
|
.InputGroup textarea {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.InputGroup input,
|
.InputGroup input,
|
||||||
.InputGroup select {
|
.InputGroup select,
|
||||||
|
.InputGroup textarea {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -30,4 +32,9 @@
|
|||||||
|
|
||||||
.InputGroup label {
|
.InputGroup label {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.InputGroup textarea {
|
||||||
|
resize: none;
|
||||||
|
height: 50vh;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.SettingsHeadline {
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding-bottom: 3px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 2px solid var(--color-accent);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const classes = require('./SettingsHeadline.module.css');
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsHeadline = (props: Props): JSX.Element => {
|
||||||
|
return <h2 className={classes.SettingsHeadline}>{props.text}</h2>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsHeadline;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MouseEvent, useRef, useEffect } from 'react';
|
import { MouseEvent, useRef } from 'react';
|
||||||
|
|
||||||
import classes from './Modal.module.css';
|
import classes from './Modal.module.css';
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ interface ComponentProps {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
id: number;
|
id: number;
|
||||||
|
url: string | null;
|
||||||
clearNotification: (id: number) => void;
|
clearNotification: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Notification = (props: ComponentProps): JSX.Element => {
|
const Notification = (props: ComponentProps): JSX.Element => {
|
||||||
const [isOpen, setIsOpen] = useState(true);
|
const [isOpen, setIsOpen] = useState(true);
|
||||||
const elementClasses = [classes.Notification, isOpen ? classes.NotificationOpen : classes.NotificationClose].join(' ');
|
const elementClasses = [
|
||||||
|
classes.Notification,
|
||||||
|
isOpen ? classes.NotificationOpen : classes.NotificationClose,
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const closeNotification = setTimeout(() => {
|
const closeNotification = setTimeout(() => {
|
||||||
@@ -22,21 +26,27 @@ const Notification = (props: ComponentProps): JSX.Element => {
|
|||||||
|
|
||||||
const clearNotification = setTimeout(() => {
|
const clearNotification = setTimeout(() => {
|
||||||
props.clearNotification(props.id);
|
props.clearNotification(props.id);
|
||||||
}, 3600)
|
}, 3600);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(closeNotification);
|
window.clearTimeout(closeNotification);
|
||||||
window.clearTimeout(clearNotification);
|
window.clearTimeout(clearNotification);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clickHandler = () => {
|
||||||
|
if (props.url) {
|
||||||
|
window.open(props.url, '_blank');
|
||||||
}
|
}
|
||||||
}, [])
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={elementClasses}>
|
<div className={elementClasses} onClick={clickHandler}>
|
||||||
<h4>{props.title}</h4>
|
<h4>{props.title}</h4>
|
||||||
<p>{props.message}</p>
|
<p>{props.message}</p>
|
||||||
<div className={classes.Pog}></div>
|
<div className={classes.Pog}></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default connect(null, { clearNotification })(Notification);
|
export default connect(null, { clearNotification })(Notification);
|
||||||
|
|||||||
@@ -8,15 +8,17 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.Table th,
|
.Table th,
|
||||||
.Table td {
|
.Table td {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Head */
|
/* Head */
|
||||||
|
|
||||||
.Table th {
|
.Table th {
|
||||||
--header-radius: 4px;
|
--header-radius: 4px;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
@@ -34,8 +36,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Body */
|
/* Body */
|
||||||
|
|
||||||
.Table td {
|
.Table td {
|
||||||
/* opacity: 0.5; */
|
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,12 @@ import classes from './Table.module.css';
|
|||||||
interface ComponentProps {
|
interface ComponentProps {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
headers: string[];
|
headers: string[];
|
||||||
|
innerRef?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = (props: ComponentProps): JSX.Element => {
|
const Table = (props: ComponentProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div className={classes.TableContainer}>
|
<div className={classes.TableContainer} ref={props.innerRef}>
|
||||||
<table className={classes.Table}>
|
<table className={classes.Table}>
|
||||||
<thead className={classes.TableHead}>
|
<thead className={classes.TableHead}>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
import { useState, useEffect, Fragment } from 'react';
|
import { useState, useEffect, Fragment } from 'react';
|
||||||
import { Weather, ApiResponse, Config } from '../../../interfaces';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
|
// Redux
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces';
|
||||||
|
|
||||||
|
// CSS
|
||||||
import classes from './WeatherWidget.module.css';
|
import classes from './WeatherWidget.module.css';
|
||||||
|
|
||||||
const WeatherWidget = (): JSX.Element => {
|
// UI
|
||||||
|
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
|
||||||
|
|
||||||
|
interface ComponentProps {
|
||||||
|
configLoading: boolean;
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WeatherWidget = (props: ComponentProps): JSX.Element => {
|
||||||
const [weather, setWeather] = useState<Weather>({
|
const [weather, setWeather] = useState<Weather>({
|
||||||
externalLastUpdate: '',
|
externalLastUpdate: '',
|
||||||
tempC: 0,
|
tempC: 0,
|
||||||
@@ -17,80 +29,73 @@ const WeatherWidget = (): JSX.Element => {
|
|||||||
conditionCode: 1000,
|
conditionCode: 1000,
|
||||||
id: -1,
|
id: -1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isCelsius, setIsCelsius] = useState(true);
|
|
||||||
|
|
||||||
// Initial request to get data
|
// Initial request to get data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// get weather
|
axios
|
||||||
axios.get<ApiResponse<Weather[]>>('/api/weather')
|
.get<ApiResponse<Weather[]>>('/api/weather')
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
const weatherData = data.data.data[0];
|
const weatherData = data.data.data[0];
|
||||||
if (weatherData) {
|
if (weatherData) {
|
||||||
setWeather(weatherData);
|
setWeather(weatherData);
|
||||||
}
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
.catch(err => console.log(err));
|
.catch((err) => console.log(err));
|
||||||
|
|
||||||
// get config
|
|
||||||
if (!localStorage.isCelsius) {
|
|
||||||
axios.get<ApiResponse<Config>>('/api/config/isCelsius')
|
|
||||||
.then((data) => {
|
|
||||||
setIsCelsius(parseInt(data.data.data.value) === 1);
|
|
||||||
localStorage.setItem('isCelsius', JSON.stringify(isCelsius));
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err));
|
|
||||||
} else {
|
|
||||||
setIsCelsius(JSON.parse(localStorage.isCelsius));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Open socket for data updates
|
// Open socket for data updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const webSocketClient = new WebSocket(`ws://${window.location.host}/socket`);
|
const socketProtocol =
|
||||||
|
document.location.protocol === 'http:' ? 'ws:' : 'wss:';
|
||||||
webSocketClient.onopen = () => {
|
const socketAddress = `${socketProtocol}//${window.location.host}/socket`;
|
||||||
console.log('Socket: listen')
|
const webSocketClient = new WebSocket(socketAddress);
|
||||||
}
|
|
||||||
|
|
||||||
webSocketClient.onmessage = (e) => {
|
webSocketClient.onmessage = (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
setWeather({
|
setWeather({
|
||||||
...weather,
|
...weather,
|
||||||
...data
|
...data,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return () => webSocketClient.close();
|
return () => webSocketClient.close();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.WeatherWidget}>
|
<div className={classes.WeatherWidget}>
|
||||||
{isLoading
|
{isLoading ||
|
||||||
? 'loading'
|
props.configLoading ||
|
||||||
: (weather.id > 0 &&
|
(props.config.WEATHER_API_KEY && weather.id > 0 && (
|
||||||
(<Fragment>
|
<Fragment>
|
||||||
<div className={classes.WeatherIcon}>
|
<div className={classes.WeatherIcon}>
|
||||||
<WeatherIcon
|
<WeatherIcon
|
||||||
weatherStatusCode={weather.conditionCode}
|
weatherStatusCode={weather.conditionCode}
|
||||||
isDay={weather.isDay}
|
isDay={weather.isDay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={classes.WeatherDetails}>
|
<div className={classes.WeatherDetails}>
|
||||||
{isCelsius
|
{props.config.isCelsius ? (
|
||||||
? <span>{weather.tempC}°C</span>
|
<span>{weather.tempC}°C</span>
|
||||||
: <span>{weather.tempF}°F</span>
|
) : (
|
||||||
}
|
<span>{weather.tempF}°F</span>
|
||||||
<span>{weather.cloud}%</span>
|
)}
|
||||||
</div>
|
<span>{weather.cloud}%</span>
|
||||||
</Fragment>)
|
</div>
|
||||||
)
|
</Fragment>
|
||||||
}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default WeatherWidget;
|
const mapStateToProps = (state: GlobalState) => {
|
||||||
|
return {
|
||||||
|
configLoading: state.config.loading,
|
||||||
|
config: state.config.config,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(WeatherWidget);
|
||||||
|
|||||||
@@ -1,3 +1,39 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: local(''),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff2') format('woff2'),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-regular.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
src: local(''),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-500.woff2') format('woff2'),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-500.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
src: local(''),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-900.woff2') format('woff2'),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-900.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: local(''),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-700.woff2') format('woff2'),
|
||||||
|
url('./assets/fonts/Roboto/roboto-v29-latin-700.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -5,18 +41,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
--color-background: #2B2C56;
|
--color-background: #242b33;
|
||||||
--color-primary: #EFF1FC;
|
--color-primary: #effbff;
|
||||||
--color-accent: #6677EB;
|
--color-accent: #6ee2ff;
|
||||||
--spacing-ui: 10px;
|
--spacing-ui: 10px;
|
||||||
|
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
transition: background-color 0.3s;
|
transition: background-color 0.3s;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Roboto, sans-serif;
|
font-family: Roboto, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface App extends Model {
|
|||||||
url: string;
|
url: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
orderId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NewApp {
|
export interface NewApp {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Model, Bookmark } from '.';
|
|||||||
export interface Category extends Model {
|
export interface Category extends Model {
|
||||||
name: string;
|
name: string;
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
orderId: number;
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
import { Model } from './';
|
export interface Config {
|
||||||
|
WEATHER_API_KEY: string;
|
||||||
export interface Config extends Model {
|
lat: number;
|
||||||
key: string;
|
long: number;
|
||||||
value: string;
|
isCelsius: boolean;
|
||||||
valueType: string;
|
customTitle: string;
|
||||||
isLocked: boolean;
|
pinAppsByDefault: boolean;
|
||||||
}
|
pinCategoriesByDefault: boolean;
|
||||||
|
hideHeader: boolean;
|
||||||
|
useOrdering: string;
|
||||||
|
appsSameTab: boolean;
|
||||||
|
bookmarksSameTab: boolean;
|
||||||
|
searchSameTab: boolean;
|
||||||
|
hideApps: boolean;
|
||||||
|
hideCategories: boolean;
|
||||||
|
hideSearch: boolean;
|
||||||
|
defaultSearchProvider: string;
|
||||||
|
dockerApps: boolean;
|
||||||
|
dockerHost: string;
|
||||||
|
kubernetesApps: boolean;
|
||||||
|
unpinStoppedApps: boolean;
|
||||||
|
useAmericanDate: boolean;
|
||||||
|
disableAutofocus: boolean;
|
||||||
|
greetingsSchema: string;
|
||||||
|
daySchema: string;
|
||||||
|
monthSchema: string;
|
||||||
|
}
|
||||||
|
|||||||
33
client/src/interfaces/Forms.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface WeatherForm {
|
||||||
|
WEATHER_API_KEY: string;
|
||||||
|
lat: number;
|
||||||
|
long: number;
|
||||||
|
isCelsius: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchForm {
|
||||||
|
hideSearch: boolean;
|
||||||
|
defaultSearchProvider: string;
|
||||||
|
searchSameTab: boolean;
|
||||||
|
disableAutofocus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OtherSettingsForm {
|
||||||
|
customTitle: string;
|
||||||
|
pinAppsByDefault: boolean;
|
||||||
|
pinCategoriesByDefault: boolean;
|
||||||
|
hideHeader: boolean;
|
||||||
|
hideApps: boolean;
|
||||||
|
hideCategories: boolean;
|
||||||
|
useOrdering: string;
|
||||||
|
appsSameTab: boolean;
|
||||||
|
bookmarksSameTab: boolean;
|
||||||
|
dockerApps: boolean;
|
||||||
|
dockerHost: string;
|
||||||
|
kubernetesApps: boolean;
|
||||||
|
unpinStoppedApps: boolean;
|
||||||
|
useAmericanDate: boolean;
|
||||||
|
greetingsSchema: string;
|
||||||
|
daySchema: string;
|
||||||
|
monthSchema: string;
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ import { State as AppState } from '../store/reducers/app';
|
|||||||
import { State as ThemeState } from '../store/reducers/theme';
|
import { State as ThemeState } from '../store/reducers/theme';
|
||||||
import { State as BookmarkState } from '../store/reducers/bookmark';
|
import { State as BookmarkState } from '../store/reducers/bookmark';
|
||||||
import { State as NotificationState } from '../store/reducers/notification';
|
import { State as NotificationState } from '../store/reducers/notification';
|
||||||
|
import { State as ConfigState } from '../store/reducers/config';
|
||||||
|
|
||||||
export interface GlobalState {
|
export interface GlobalState {
|
||||||
theme: ThemeState;
|
theme: ThemeState;
|
||||||
app: AppState;
|
app: AppState;
|
||||||
bookmark: BookmarkState;
|
bookmark: BookmarkState;
|
||||||
notification: NotificationState;
|
notification: NotificationState;
|
||||||
|
config: ConfigState;
|
||||||
}
|
}
|
||||||