Compare commits

...

93 Commits
v1.5 ... v1.7.4

Author SHA1 Message Date
pawelmalak
08afaece2e Merge pull request #145 from pawelmalak/feature
Version 1.7.4
2021-11-08 15:44:53 +01:00
Paweł Malak
4f2ba0a96d Pushed version 1.7.4 2021-11-08 15:42:32 +01:00
pawelmalak
9db46faabe Merge pull request #152 from oregonpillow/feature
bookmarks_importer.py script added
2021-11-08 15:09:23 +01:00
oregonpillow
567af1c66e bookmarks_importer.py script added 2021-11-08 14:00:57 +01:00
Paweł Malak
2485f4ff33 Fallback to web search if local search has zero results. Updated packages 2021-11-08 13:22:11 +01:00
Paweł Malak
bce51bb2c4 Added option to set custom day and month 2021-11-05 17:16:19 +01:00
Paweł Malak
7febd59ad7 Merge branch 'feature' of https://github.com/pawelmalak/flame into feature 2021-11-05 16:43:31 +01:00
pawelmalak
1388a1876e Merge pull request #134 from ekremparlak/docker-image
Update Dockerfile for smaller image
2021-11-05 16:43:24 +01:00
Paweł Malak
aca8b0261e Added option to set custom greetings. Moved HomeHeader to separate file. Cleaned up README file 2021-11-05 16:39:42 +01:00
Paweł Malak
4e20527834 Added new themes 2021-11-05 15:05:33 +01:00
Paweł Malak
4ed29fe276 Split remaining controllers into separate files. Added iOS homescreen icon. Removed additional logging from weather module. 2021-11-04 23:39:35 +01:00
Ekrem
b45eecada2 Update Dockerfile.multiarch 2021-11-01 19:08:30 +03:00
Ekrem Parlak
1d70bd132a Update Dockerfile for smaller image 2021-11-01 15:13:06 +01:00
Paweł Malak
88694c7e27 Fixed bug with custom css not updating 2021-10-28 16:05:21 +02:00
pawelmalak
3dd255f359 Merge pull request #128 from pawelmalak/feature
Version 1.7.2
2021-10-28 12:43:00 +02:00
Paweł Malak
feb7275cf8 Pushed version 1.7.2 2021-10-28 11:44:36 +02:00
Paweł Malak
da13ca6092 Search bar redirect to local search results 2021-10-27 11:52:57 +02:00
Paweł Malak
3d3e2eed8c Fixed bug with weather logging. Fixed bug with search bar shortcuts 2021-10-26 14:37:01 +02:00
Paweł Malak
df6d96f5b6 Added option to disable search bar autofocus 2021-10-26 13:09:42 +02:00
pawelmalak
0ec77c33bf Merge pull request #123 from pawelmalak/feature
Version 1.7.1
2021-10-22 17:15:31 +02:00
Paweł Malak
98924ac006 Pushed version 1.7.1 2021-10-22 16:10:38 +02:00
Paweł Malak
4ef9652ede Added option to change date formatting. Added shortcuts to clear search bar 2021-10-22 15:51:11 +02:00
Paweł Malak
cfb471e578 Changed config api. Split config controllers into separate files. Split bookmarks controllers into separate files 2021-10-22 14:00:38 +02:00
Paweł Malak
76e50624e7 Client: Implemented new config system 2021-10-22 13:31:02 +02:00
Paweł Malak
34279c8b8c Split apps controllers into separate files 2021-10-22 00:42:27 +02:00
Paweł Malak
b7de1e3d27 Server: Reimplemented config system 2021-10-21 17:14:25 +02:00
pawelmalak
85ee5da025 Merge pull request #112 from pa-sowa/traefik
Support traefik labels for URL configuration
2021-10-21 13:40:10 +02:00
Paweł Malak
e5cba605fa Search bar bug fixes 2021-10-13 13:31:01 +02:00
pawelmalak
6f44200a3c Merge pull request #114 from pawelmalak/v1.7.0
Version 1.7.0
2021-10-11 16:10:35 +02:00
Paweł Malak
7129fe83da Fixed bug with fetching config. Pushed version 1.7.0 2021-10-11 15:15:26 +02:00
pawelmalak
6f8a017bfb Merge pull request #109 from pa-sowa/docker-user-icon-fix
Don't override user selected icon of a docker app
2021-10-11 15:03:47 +02:00
Paweł Malak
55f192f664 Merge branch 'feature' into v1.7.0 2021-10-11 14:31:59 +02:00
unknown
edb04c375f Prevent deleting active search provider 2021-10-11 14:05:53 +02:00
unknown
38ffdf1bff Add and update custom queries 2021-10-11 13:55:53 +02:00
unknown
a885440fef Add and delete custom search provider actions and controllers 2021-10-11 13:03:31 +02:00
Przemysław Adam Sowa
16341ca6da Support traefik labels for URL configuration 2021-10-10 13:06:48 +02:00
pawelmalak
fc219f704c Merge pull request #111 from pawelmalak/remote-host
Version 1.6.9
2021-10-09 23:39:01 +02:00
unknown
63346f7e38 Pushed version 1.6.9 2021-10-09 23:12:26 +02:00
Przemysław Adam Sowa
04be0d1316 Don't override user selected icon of a docker app 2021-10-09 12:57:14 +02:00
pawelmalak
65a33f16fd Merge pull request #101 from pruizlezcano/docker-api
Added remote docker host
2021-10-09 01:36:51 +02:00
Pablo Ruiz
fdec74acc6 Update README 2021-10-06 22:22:18 +02:00
Pablo Ruiz
231dbc4577 Added remote docker host 2021-10-06 22:17:43 +02:00
unknown
459523dfd2 Changed initial files creation process 2021-10-06 14:17:31 +02:00
unknown
591824dd0c Fetch and use custom search queries 2021-10-06 14:15:05 +02:00
unknown
da928f20a2 Added redirect function to search bar 2021-10-06 12:01:07 +02:00
unknown
a162450568 Added static fonts 2021-10-06 11:23:30 +02:00
unknown
084218027c Bugfix for #83 2021-10-05 17:08:37 +02:00
unknown
bf1aa9e85c Clickable notifications with url redirect 2021-10-05 16:31:56 +02:00
pawelmalak
afc0f16470 Merge pull request #98 from pawelmalak/db-migrations
Added database migrations
2021-10-05 13:29:42 +02:00
unknown
59271d3376 Create database backup before migrating 2021-10-05 13:17:09 +02:00
unknown
84bd641cf2 Database migrations 2021-10-05 12:29:17 +02:00
unknown
1d8e36b46d Added search tab to settings 2021-10-04 17:07:02 +02:00
unknown
1625932e52 Fix for #96 2021-10-04 16:15:17 +02:00
unknown
6a6f1750b1 Pushed version 1.6.7 2021-10-04 12:11:41 +02:00
pawelmalak
4252457871 Merge pull request #93 from pruizlezcano/docker-api
Add custom icon and multiple apps support to Docker API
2021-10-04 11:45:43 +02:00
Pablo Ruiz
9606978bd7 Add custom icon & multiple apps support to Docker API 2021-09-29 22:26:12 +02:00
unknown
ebae61a688 Pushed version 1.6.6 2021-09-06 14:46:05 +02:00
pawelmalak
43f38a2f44 Merge pull request #84 from pawelmalak/local-search
Added searching (filtering) of local apps and bookmarks
2021-09-06 14:43:35 +02:00
unknown
53d50ca869 Normal and live search for bookmarks 2021-09-06 13:22:47 +02:00
unknown
fac280ff0a Live search for apps 2021-09-06 12:47:17 +02:00
unknown
6ae6c58f4c Local search for apps 2021-09-06 12:24:01 +02:00
unknown
8521995758 Pushed version 1.6.5 2021-08-28 11:42:54 +02:00
pawelmalak
19f95c433c Merge pull request #81 from Samuel-Martineau/master
Add support for all protocols for urls (fix #74)
2021-08-28 11:36:53 +02:00
Samuel Martineau
45fb337c87 Add support for all protocols for urls (#74) 2021-08-21 19:08:40 +00:00
pawelmalak
8808f65b47 Fixed typo with latest issues 2021-08-17 10:44:12 +02:00
unknown
5cef34a467 Pushed version 1.6.4 2021-08-17 10:38:16 +02:00
Dimitri Pommier
8681f75bab Kubernetes integration (#80)
* chore(): skaffold

* chore(): kubernetes integration

* chore(skaffold): refine shokohsc profile

* chore(): removed docker & kubernetes from database + stoppedApp pin option

* Revert "chore(): removed docker & kubernetes from database + stoppedApp pin option"

This reverts commit 5111c7ad79.
2021-08-17 10:32:15 +02:00
pawelmalak
c1b61f9cd9 Merge pull request #79 from pawelmalak/icon-fix
Bookmark icon fixes
2021-08-09 15:38:41 +02:00
unknown
78a018f686 Bookmark icon fixes 2021-08-09 15:31:20 +02:00
pawelmalak
36c9b7648a Merge pull request #76 from pawelmalak/feature
Added support for custom SVG Icons. Added new search queries
2021-08-09 13:01:46 +02:00
unknown
5c60c7c156 Pushed version 1.6.3. Added Deezer and Tidal to search queries 2021-08-09 12:54:07 +02:00
unknown
683c948f6c Added cli tool for adding new search engines/providers 2021-08-06 16:16:13 +02:00
unknown
1699146f79 Added support for custom SVG icons 2021-08-06 15:15:54 +02:00
unknown
a01661d0d5 Pushed version 1.6.2. Small formatting fixes 2021-08-06 10:36:05 +02:00
pawelmalak
1962af01e6 Merge pull request #72 from pruizlezcano/docker-api
Added Docker API
2021-08-06 10:28:58 +02:00
Pablo Ruiz
39349dded1 fix optional docker.sock mount 2021-08-05 08:56:02 +02:00
Pablo Ruiz
b53509aa69 docker api 2021-08-04 22:19:35 +02:00
pawelmalak
b5ba9856ed Merge pull request #70 from itsbhanusharma/patch-1
Use correct changelog link
2021-08-03 10:23:37 +02:00
unknown
b94df53267 github directory name changed 2021-08-03 10:22:55 +02:00
Bhanu
4b42f991f8 Use correct changelog link
current link in release 1.6.1 points to an incorrect url that returns 404
2021-08-03 11:51:54 +05:30
pawelmalak
2ceff6828a Merge pull request #69 from pawelmalak/fixes
Fixed custom icons not updating and added custom icons for bookmarks
2021-07-28 12:56:19 +02:00
unknown
d39eda49de Added changelog. Added curl to Docker image 2021-07-28 12:52:30 +02:00
unknown
a5d6cf04cf Custom icons for bookmarks 2021-07-28 12:36:03 +02:00
unknown
1fbe0746a4 Fixed custom icons not updating 2021-07-28 11:36:48 +02:00
pawelmalak
f93659b661 Merge pull request #66 from pawelmalak/feature
v1.6 Release
2021-07-17 23:29:02 +02:00
unknown
88785aaa32 Fixed bug with custom css not persisting 2021-07-17 23:11:24 +02:00
unknown
4143ae8198 Added support for Steam URLs. Changed default prefix setting options to be dynamically rendered 2021-07-16 11:55:26 +02:00
pawelmalak
f1c48e8a15 Merge pull request #61 from jjack/master
Adding a default search provider option, with DuckDuckGo as the default
2021-07-16 11:23:58 +02:00
pawelmalak
6445a5009a Merge pull request #56 from strig/patch-1
Add docker-compose instructions and link in readme
2021-07-15 11:57:18 +02:00
Jeremy Jack
112a35c08f Adding a default search provider option, with DuckDuckGo as the default 2021-07-05 23:04:03 -05:00
Neal Striegler-Pettersson
7970ac3031 Add docker-compose instructions and link in readme 2021-06-27 19:33:27 -04:00
unknown
c03f302fa6 Added option to open links in the same tab for apps/bookamrs/search separately 2021-06-25 11:24:29 +02:00
unknown
0c3a27febd Added warning about unsupported prefix. Added support for Spotify search. Edited README file 2021-06-25 10:42:44 +02:00
192 changed files with 6732 additions and 2957 deletions

10
.dev/DEV_GUIDELINES.md Normal file
View 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
View 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
View 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);
};

View File

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

3
.env
View File

@@ -1,2 +1,3 @@
PORT=5005
NODE_ENV=development
NODE_ENV=development
VERSION=1.7.4

View File

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

View File

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

4
.gitignore vendored
View File

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

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
*.md

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"arrowParens": "always",
"printWidth": 80,
"trailingComma": "es5"
}

110
CHANGELOG.md Normal file
View 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.

View File

@@ -1,6 +1,4 @@
FROM node:14-alpine
RUN apk update && apk add --no-cache nano
FROM node:14 as builder
WORKDIR /app
@@ -18,6 +16,12 @@ RUN mkdir -p ./public ./data \
&& mv ./client/build/* ./public \
&& rm -rf ./client
FROM node:14-alpine
COPY --from=builder /app /app
WORKDIR /app
EXPOSE 5005
ENV NODE_ENV=production

16
Dockerfile.dev Normal file
View 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"]

View File

@@ -1,15 +1,12 @@
FROM node:14-alpine
RUN apk update && apk add --no-cache nano
FROM node:14 as builder
WORKDIR /app
COPY package*.json ./
RUN apk --no-cache --virtual build-dependencies add python make g++ \
&& npm install --production
RUN npm install --production
COPY . .
COPY . .
RUN mkdir -p ./public ./data \
&& cd ./client \
@@ -17,11 +14,16 @@ RUN mkdir -p ./public ./data \
&& npm run build \
&& cd .. \
&& mv ./client/build/* ./public \
&& rm -rf ./client \
&& apk del build-dependencies
&& rm -rf ./client
FROM node:14-alpine
COPY --from=builder /app /app
WORKDIR /app
EXPOSE 5005
ENV NODE_ENV=production
CMD ["node", "server.js"]
CMD ["node", "server.js"]

191
README.md
View File

@@ -1,27 +1,26 @@
# Flame
[![JS Badge](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black)](https://shields.io/)
[![TS Badge](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://shields.io/)
[![Node Badge](https://img.shields.io/badge/Node.js-43853D?style=for-the-badge&logo=node.js&logoColor=white)](https://shields.io/)
[![React Badge](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB)](https://shields.io/)
![Homescreen screenshot](./github/_home.png)
![Homescreen screenshot](./.github/_home.png)
## Description
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own appliaction hub in no time - no file editing necessary.
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary.
## Technology
- Backend
- Node.js + Express
- Sequelize ORM + SQLite
- Frontend
- React
- React
- Redux
- TypeScript
- Deployment
- Docker
- Kubernetes
## Development
```sh
# clone repository
git clone https://github.com/pawelmalak/flame
@@ -34,10 +33,22 @@ npm run dev-init
npm run dev
```
## Installation
## Installation
### With Docker (recommended)
[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
```
#### Building images
```sh
# build image for amd64 only
docker build -t flame .
@@ -51,77 +62,161 @@ docker buildx build \
```
#### 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 homescreen
- Pin your favourite apps to the homescreen
![Homescreen screenshot](./github/_apps.png)
![Homescreen screenshot](./.github/_apps.png)
- Bookmarks
- 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)
![Homescreen screenshot](./github/_bookmarks.png)
![Homescreen screenshot](./.github/_bookmarks.png)
- Weather
- Get current temperature, cloud coverage and weather status with animated icons
- Themes
- Customize your page by choosing from 12 color themes
- Customize your page by choosing from 15 color themes
![Homescreen screenshot](./github/_themes.png)
![Homescreen screenshot](./.github/_themes.png)
## Usage
### Search bar
> While opening links, module will follow `Open all links in the same tab` setting
#### Supported search engines
| Name | Prefix | Search URL |
|------------|--------|-------------------------------------|
| Disroot | /ds | http://search.disroot.org/search?q= |
| DuckDuckGo | /d | https://duckduckgo.com/?q= |
| Google | /g | https://www.google.com/search?q= |
#### Supported services
| Name | Prefix | Search URL |
|--------------------|--------|-----------------------------------------------|
| IMDb | /im | https://www.imdb.com/find?q= |
| Reddit | /r | -https://www.reddit.com/search?q= |
| The Movie Database | /mv | https://www.themoviedb.org/search?query= |
| Youtube | /yt | https://www.youtube.com/results?search_query= |
### 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.
### Supported URL formats for applications and bookmarks
#### Rules
- URL starts with `http://`
- Format: `http://www.domain.com`, `http://domain.com`
- Redirect: `{dest}`
- URL starts with `https://`
- Format: `https://www.domain.com`, `https://domain.com`
- Redirect: `https://{dest}`
- URL without protocol
- Format: `www.domain.com`, `domain.com`, `sub.domain.com`, `local`, `ip`, `ip:port`
- Redirect: `http://{dest}`
### Docker integration
### Custom CSS
> This is an experimental feature. Its behaviour might change in the future.
>
Follow instructions from wiki: [Custom CSS](https://github.com/pawelmalak/flame/wiki/Custom-CSS)
In order to use the Docker integration, each container must have the following labels:
## Support
If you want to support development of Flame and my upcoming self-hosted and open source projects you can use the following link:
```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
```
[![PayPal Badge](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.com/paypalme/pawelmalak)
> "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).

6
api.js
View File

@@ -9,8 +9,7 @@ api.use(express.static(join(__dirname, 'public')));
api.use('/uploads', express.static(join(__dirname, 'data/uploads')));
api.get(/^\/(?!api)/, (req, res) => {
res.sendFile(join(__dirname, 'public/index.html'));
})
});
// Body parser
api.use(express.json());
@@ -21,8 +20,9 @@ api.use('/api/config', require('./routes/config'));
api.use('/api/weather', require('./routes/weather'));
api.use('/api/categories', require('./routes/category'));
api.use('/api/bookmarks', require('./routes/bookmark'));
api.use('/api/queries', require('./routes/queries'));
// Custom error handler
api.use(errorHandler);
module.exports = api;
module.exports = api;

View File

@@ -1 +1 @@
REACT_APP_VERSION=1.5.0
REACT_APP_VERSION=1.7.4

447
client/package-lock.json generated
View File

@@ -1806,9 +1806,9 @@
}
},
"@mdi/js": {
"version": "5.9.55",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-5.9.55.tgz",
"integrity": "sha512-BbeHMgeK2/vjdJIRnx12wvQ6s8xAYfvMmEAVsUx9b+7GiQGQ9Za8jpwp17dMKr9CgKRvemlAM4S7S3QOtEbp4A=="
"version": "6.4.95",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-6.4.95.tgz",
"integrity": "sha512-b1/P//1D2KOzta8YRGyoSLGsAlWyUHfxzVBhV4e/ppnjM4DfBgay/vWz7Eg5Ee80JZ4zsQz8h54X+KOahtBk5Q=="
},
"@mdi/react": {
"version": "1.5.0",
@@ -2047,20 +2047,45 @@
}
},
"@testing-library/dom": {
"version": "7.30.4",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.4.tgz",
"integrity": "sha512-GObDVMaI4ARrZEXaRy4moolNAxWPKvEYNV/fa6Uc2eAzR/t4otS6A7EhrntPBIQLeehL9DbVhscvvv7gd6hWqA==",
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.0.tgz",
"integrity": "sha512-8Ay4UDiMlB5YWy+ZvCeRyFFofs53ebxrWnOFvCoM1HpMAX4cHyuSrCuIM9l2lVuUWUt+Gr3loz/nCwdrnG6ShQ==",
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^4.2.0",
"aria-query": "^4.2.2",
"aria-query": "^5.0.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.4",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.4.4",
"pretty-format": "^26.6.2"
"pretty-format": "^27.0.2"
},
"dependencies": {
"@jest/types": {
"version": "27.2.5",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
"@types/node": "*",
"@types/yargs": "^16.0.0",
"chalk": "^4.0.0"
}
},
"@types/yargs": {
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
"requires": {
"@types/yargs-parser": "*"
}
},
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2069,10 +2094,15 @@
"color-convert": "^2.0.1"
}
},
"aria-query": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="
},
"chalk": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -2096,6 +2126,29 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"pretty-format": {
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
"requires": {
"@jest/types": "^27.2.5",
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"dependencies": {
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
}
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2107,9 +2160,9 @@
}
},
"@testing-library/jest-dom": {
"version": "5.12.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.12.0.tgz",
"integrity": "sha512-N9Y82b2Z3j6wzIoAqajlKVF1Zt7sOH0pPee0sUHXHc5cv2Fdn23r+vpWm0MBBoGJtPOly5+Bdx1lnc3CD+A+ow==",
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.15.0.tgz",
"integrity": "sha512-lOMuQidnL1tWHLEWIhL6UvSZC1Qt3OkNe1khvi2h6xFiqpe5O8arYs46OU0qyUGq0cSTbroQyMktYNXu3a7sAA==",
"requires": {
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
@@ -2117,6 +2170,7 @@
"chalk": "^3.0.0",
"css": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.5.6",
"lodash": "^4.17.15",
"redent": "^3.0.0"
},
@@ -2191,18 +2245,18 @@
}
},
"@testing-library/react": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz",
"integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==",
"version": "12.1.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz",
"integrity": "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==",
"requires": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^7.28.1"
"@testing-library/dom": "^8.0.0"
}
},
"@testing-library/user-event": {
"version": "12.8.3",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz",
"integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==",
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
"requires": {
"@babel/runtime": "^7.12.5"
}
@@ -2213,9 +2267,9 @@
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA=="
},
"@types/aria-query": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz",
"integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg=="
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="
},
"@types/babel__core": {
"version": "7.1.14",
@@ -2286,9 +2340,9 @@
}
},
"@types/history": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz",
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA=="
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ=="
},
"@types/hoist-non-react-statics": {
"version": "3.3.1",
@@ -2305,9 +2359,9 @@
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
},
"@types/http-proxy": {
"version": "1.17.6",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz",
"integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==",
"version": "1.17.7",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz",
"integrity": "sha512-9hdj6iXH64tHSLTY+Vt2eYOGzSogC+JQ2H7bdPWkuh7KXP5qLllWx++t+K9Wk556c3dkDdPws/SpMRi0sdCT1w==",
"requires": {
"@types/node": "*"
}
@@ -2334,12 +2388,126 @@
}
},
"@types/jest": {
"version": "26.0.23",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz",
"integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==",
"version": "27.0.2",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz",
"integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==",
"requires": {
"jest-diff": "^26.0.0",
"pretty-format": "^26.0.0"
"jest-diff": "^27.0.0",
"pretty-format": "^27.0.0"
},
"dependencies": {
"@jest/types": {
"version": "27.2.5",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
"@types/node": "*",
"@types/yargs": "^16.0.0",
"chalk": "^4.0.0"
}
},
"@types/yargs": {
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
"requires": {
"@types/yargs-parser": "*"
}
},
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"diff-sequences": {
"version": "27.0.6",
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz",
"integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ=="
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"jest-diff": {
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.3.1.tgz",
"integrity": "sha512-PCeuAH4AWUo2O5+ksW4pL9v5xJAcIKPUPfIhZBcG1RKv/0+dvaWTQK1Nrau8d67dp65fOqbeMdoil+6PedyEPQ==",
"requires": {
"chalk": "^4.0.0",
"diff-sequences": "^27.0.6",
"jest-get-type": "^27.3.1",
"pretty-format": "^27.3.1"
}
},
"jest-get-type": {
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.3.1.tgz",
"integrity": "sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg=="
},
"pretty-format": {
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
"requires": {
"@jest/types": "^27.2.5",
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"dependencies": {
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
}
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
"@types/json-schema": {
@@ -2358,9 +2526,9 @@
"integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA=="
},
"@types/node": {
"version": "12.20.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.12.tgz",
"integrity": "sha512-KQZ1al2hKOONAs2MFv+yTQP1LkDWMrRJ9YCVRalXltOfXsBmH5IownLxQaiq0lnAHwAViLnh2aTYqrPcRGEbgg=="
"version": "16.11.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz",
"integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w=="
},
"@types/normalize-package-data": {
"version": "2.4.0",
@@ -2378,9 +2546,9 @@
"integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA=="
},
"@types/prop-types": {
"version": "15.7.3",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
"integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ=="
},
"@types/q": {
"version": "1.5.4",
@@ -2388,35 +2556,43 @@
"integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug=="
},
"@types/react": {
"version": "17.0.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.5.tgz",
"integrity": "sha512-bj4biDB9ZJmGAYTWSKJly6bMr4BLUiBrx9ujiJEoP9XIDY9CTaPGxE5QWN/1WjpPLzYF7/jRNnV2nNxNe970sw==",
"version": "17.0.34",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.34.tgz",
"integrity": "sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==",
"requires": {
"@types/prop-types": "*",
"@types/scheduler": "*",
"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.0.0",
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz",
"integrity": "sha512-by80tJ8aTTDXT256Gl+RfLRtFjYbUWOnZuEigJgNsJrSEGxvFe5eY6k3g4VIvf0M/6+xoLgfYWoWonlOo6Wqdg==",
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.2.tgz",
"integrity": "sha512-+OvPkB8CdE/bGdXKyIhc/Lm2U7UAYCCJgsqmopFmh9gbAudmslkI8eOrPDjg4JhwSE6wytz4a3/wRjKtovHVJg==",
"requires": {
"@types/react": "*"
}
},
"@types/react-dom": {
"version": "17.0.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz",
"integrity": "sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w==",
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz",
"integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==",
"requires": {
"@types/react": "*"
}
},
"@types/react-redux": {
"version": "7.1.16",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz",
"integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==",
"version": "7.1.20",
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.20.tgz",
"integrity": "sha512-q42es4c8iIeTgcnB+yJgRTTzftv3eYYvCZOh1Ckn2eX/3o5TdsQYKUWpLoLuGlcY/p+VAhV9IOEZJcWk/vfkXw==",
"requires": {
"@types/hoist-non-react-statics": "^3.3.0",
"@types/react": "*",
@@ -2425,9 +2601,9 @@
}
},
"@types/react-router": {
"version": "5.1.14",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.14.tgz",
"integrity": "sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw==",
"version": "5.1.17",
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.17.tgz",
"integrity": "sha512-RNSXOyb3VyRs/EOGmjBhhGKTbnN6fHWvy5FNLzWfOWOGjgVUKqJZXfpKzLmgoU8h6Hj8mpALj/mbXQASOb92wQ==",
"requires": {
"@types/history": "*",
"@types/react": "*"
@@ -2452,9 +2628,9 @@
}
},
"@types/scheduler": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz",
"integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA=="
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
},
"@types/source-list-map": {
"version": "0.1.2",
@@ -2472,9 +2648,9 @@
"integrity": "sha512-0VBprVqfgFD7Ehb2vd8Lh9TG3jP98gvr8rgehQqzztZNI7o8zS8Ad4jyZneKELphpuE212D8J70LnSNQSyO6bQ=="
},
"@types/testing-library__jest-dom": {
"version": "5.9.5",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz",
"integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==",
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.1.tgz",
"integrity": "sha512-Gk9vaXfbzc5zCXI9eYE9BI5BNHEp4D3FWjgqBE/ePGYElLAP+KvxBcsdkwfIVvezs605oiyd/VrpiHe3Oeg+Aw==",
"requires": {
"@types/jest": "*"
}
@@ -3159,11 +3335,18 @@
"integrity": "sha512-1uIESzroqpaTzt9uX48HO+6gfnKu3RwvWdCcWSrX4csMInJfCo1yvKPNXCwXFRpJqRW25tiASb6No0YH57PXqg=="
},
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"requires": {
"follow-redirects": "^1.10.0"
"follow-redirects": "^1.14.4"
},
"dependencies": {
"follow-redirects": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
}
}
},
"axobject-query": {
@@ -4906,9 +5089,9 @@
}
},
"csstype": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
},
"cyclist": {
"version": "1.0.1",
@@ -5227,9 +5410,9 @@
}
},
"dom-accessibility-api": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz",
"integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ=="
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz",
"integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g=="
},
"dom-converter": {
"version": "0.2.0",
@@ -5577,6 +5760,11 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
@@ -6462,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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz",
@@ -7473,9 +7669,9 @@
}
},
"http-proxy-middleware": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.0.tgz",
"integrity": "sha512-S+RN5njuyvYV760aiVKnyuTXqUMcSIvYOsHA891DOVQyrdZOwaXtBHpt9FUVPEDAsOvsPArZp6VXQLs44yvkow==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.1.tgz",
"integrity": "sha512-cfaXRVoZxSed/BmkA7SwBVNI9Kj7HFltaE5rqYOub5kWzWZ+gofV2koVN1j2rMW7pEfSSlCHGJ31xmuyFyfLOg==",
"requires": {
"@types/http-proxy": "^1.17.5",
"http-proxy": "^1.18.1",
@@ -7527,6 +7723,11 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
@@ -12100,6 +12301,12 @@
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
"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": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -12388,6 +12595,18 @@
"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",
@@ -12529,16 +12748,31 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"react-redux": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.4.tgz",
"integrity": "sha512-hOQ5eOSkEJEXdpIKbnRyl04LhaWabkDPV+Ix97wqQX3T3d2NQ8DUblNXXtNMavc7DpswyQM6xfaN4HQDKNY2JA==",
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz",
"integrity": "sha512-10RPdsz0UUrRL1NZE0ejTkucnclYSgXp5q+tB5SWx2qeG2ZJQJyymgAhwKy73yiL/13btfB6fPr+rgbMAaZIAQ==",
"requires": {
"@babel/runtime": "^7.12.1",
"@types/react-redux": "^7.1.16",
"@babel/runtime": "^7.15.4",
"@types/react-redux": "^7.1.20",
"hoist-non-react-statics": "^3.3.2",
"loose-envify": "^1.4.0",
"prop-types": "^15.7.2",
"react-is": "^16.13.1"
"react-is": "^17.0.2"
},
"dependencies": {
"@babel/runtime": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.0.tgz",
"integrity": "sha512-Nht8L0O8YCktmsDV6FqFue7vQLRx3Hb0B37lS5y0jDRqRxlBG4wIJHnf9/bgSE2UyipKFA01YtS+npRdTWBUyw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
}
}
},
"react-refresh": {
@@ -12658,6 +12892,21 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
@@ -12774,9 +13023,9 @@
}
},
"redux": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.0.tgz",
"integrity": "sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz",
"integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==",
"requires": {
"@babel/runtime": "^7.9.2"
}
@@ -12787,9 +13036,9 @@
"integrity": "sha512-cNJ8Q/EtjhQaZ71c8I9+BPySIBVEKssbPpskBfsXqb8HJ002A3KRVHfeRzwRo6mGPqsm7XuHTqNSNeS1Khig0A=="
},
"redux-thunk": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz",
"integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw=="
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.0.tgz",
"integrity": "sha512-/y6ZKQNU/0u8Bm7ROLq9Pt/7lU93cT0IucYMrubo89ENjxPa7i8pqLKu6V4X7/TvYovQ6x01unTeyeZ9lgXiTA=="
},
"regenerate": {
"version": "1.4.2",
@@ -13492,6 +13741,11 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -13666,6 +13920,11 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
@@ -14907,9 +15166,9 @@
}
},
"typescript": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz",
"integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg=="
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz",
"integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA=="
},
"unbox-primitive": {
"version": "1.0.1",
@@ -15506,9 +15765,9 @@
}
},
"web-vitals": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-1.1.2.tgz",
"integrity": "sha512-PFMKIY+bRSXlMxVAQ+m2aw9c/ioUYfDgrYot0YUa+/xa0sakubWhSDyxAKwzymvXVdF4CZI71g06W+mqhzu6ig=="
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.2.tgz",
"integrity": "sha512-nZnEH8dj+vJFqCRYdvYv0a59iLXsb8jJkt+xvXfwgnkyPdsSLtKNlYmtTDiHmTNGXeSXtpjTTUcNvFtrAk6VMQ=="
},
"webidl-conversions": {
"version": "6.1.0",

View File

@@ -3,32 +3,35 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@mdi/js": "^5.9.55",
"@mdi/js": "^6.4.95",
"@mdi/react": "^1.5.0",
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^12.8.3",
"@types/jest": "^26.0.23",
"@types/node": "^12.20.12",
"@types/react": "^17.0.5",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.3",
"@types/react-redux": "^7.1.16",
"@testing-library/jest-dom": "^5.15.0",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.0.2",
"@types/node": "^16.11.6",
"@types/react": "^17.0.34",
"@types/react-autosuggest": "^10.1.5",
"@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.11",
"@types/react-redux": "^7.1.20",
"@types/react-router-dom": "^5.1.7",
"axios": "^0.21.1",
"http-proxy-middleware": "^2.0.0",
"axios": "^0.24.0",
"external-svg-loader": "^1.3.4",
"http-proxy-middleware": "^2.0.1",
"react": "^17.0.2",
"react-autosuggest": "^10.1.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^17.0.2",
"react-redux": "^7.2.4",
"react-redux": "^7.2.6",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
"redux": "^4.1.0",
"redux": "^4.1.2",
"redux-devtools-extension": "^2.13.9",
"redux-thunk": "^2.3.0",
"redux-thunk": "^2.4.0",
"skycons-ts": "^0.2.0",
"typescript": "^4.2.4",
"web-vitals": "^1.1.2"
"typescript": "^4.4.4",
"web-vitals": "^2.1.2"
},
"scripts": {
"start": "react-scripts start",
@@ -53,5 +56,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"prettier": "^2.4.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -2,18 +2,61 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="%PUBLIC_URL%/icons/favicon.ico" />
<link
rel="apple-touch-icon"
href="%PUBLIC_URL%/icons/apple-touch-icon.png"
/>
<link
rel="apple-touch-icon"
sizes="57x57"
href="%PUBLIC_URL%/icons/apple-touch-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="%PUBLIC_URL%/icons/apple-touch-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="%PUBLIC_URL%/icons/apple-touch-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="%PUBLIC_URL%/icons/apple-touch-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="%PUBLIC_URL%/icons/apple-touch-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="%PUBLIC_URL%/icons/apple-touch-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="%PUBLIC_URL%/icons/apple-touch-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/icons/apple-touch-icon-180x180.png"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Flame - self-hosted startpage for your server" />
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css?family=Roboto:400,500,700,900" rel="stylesheet">
<link rel="stylesheet" href="%PUBLIC_URL%/flame.css">
<meta
name="description"
content="Flame - self-hosted startpage for your server"
/>
<link rel="stylesheet" href="%PUBLIC_URL%/flame.css" />
<title>Flame</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
</html>

View File

@@ -1,5 +1,6 @@
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { getConfig, setTheme } from './store/actions';
import { fetchQueries, getConfig, setTheme } from './store/actions';
import 'external-svg-loader';
// Redux
import { store } from './store/store';
@@ -15,7 +16,7 @@ import Settings from './components/Settings/Settings';
import Bookmarks from './components/Bookmarks/Bookmarks';
import NotificationCenter from './components/NotificationCenter/NotificationCenter';
// Get config pairs from database
// Load config
store.dispatch<any>(getConfig());
// Set theme
@@ -26,20 +27,23 @@ if (localStorage.theme) {
// Check for updates
checkVersion();
// fetch queries
store.dispatch<any>(fetchQueries());
const App = (): JSX.Element => {
return (
<Provider store={store}>
<BrowserRouter>
<Switch>
<Route exact path='/' component={Home} />
<Route path='/settings' component={Settings} />
<Route path='/applications' component={Apps} />
<Route path='/bookmarks' component={Bookmarks} />
<Route exact path="/" component={Home} />
<Route path="/settings" component={Settings} />
<Route path="/applications" component={Apps} />
<Route path="/bookmarks" component={Bookmarks} />
</Switch>
</BrowserRouter>
<NotificationCenter />
</Provider>
);
}
};
export default App;
export default App;

View File

@@ -33,11 +33,11 @@
.AppCard {
padding: 2px;
border-radius: 4px;
transition: all 0.10s;
transition: all 0.1s;
}
.AppCard:hover {
background-color: rgba(0,0,0,0.2);
background-color: rgba(0, 0, 0, 0.2);
}
}
@@ -47,4 +47,4 @@
margin-top: 2px;
margin-left: 2px;
object-fit: contain;
}
}

View File

@@ -2,40 +2,63 @@ import classes from './AppCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser } from '../../../utility';
import { App } from '../../../interfaces';
import { searchConfig } from '../../../utility';
import { App, Config, GlobalState } from '../../../interfaces';
import { connect } from 'react-redux';
interface ComponentProps {
app: App;
pinHandler?: Function;
config: Config;
}
const AppCard = (props: ComponentProps): JSX.Element => {
const [displayUrl, redirectUrl] = urlParser(props.app.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 (
<a
href={redirectUrl}
target={searchConfig('openSameTab', false) ? '' : '_blank'}
rel='noreferrer'
target={props.config.appsSameTab ? '' : '_blank'}
rel="noreferrer"
className={classes.AppCard}
>
<div className={classes.AppCardIcon}>
{(/.(jpeg|jpg|png)$/).test(props.app.icon)
? <img
src={`/uploads/${props.app.icon}`}
alt={`${props.app.name} icon`}
className={classes.CustomIcon}
/>
: <Icon icon={iconParser(props.app.icon)} />
}
</div>
<div className={classes.AppCardIcon}>{iconEl}</div>
<div className={classes.AppCardDetails}>
<h5>{props.app.name}</h5>
<span>{displayUrl}</span>
</div>
</a>
)
}
);
};
export default AppCard;
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(AppCard);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, ChangeEvent, SyntheticEvent } from 'react';
import { useState, useEffect, ChangeEvent, SyntheticEvent } from 'react';
import { connect } from 'react-redux';
import { addApp, updateApp } from '../../../store/actions';
import { App, NewApp } from '../../../interfaces';
@@ -8,12 +8,11 @@ import classes from './AppForm.module.css';
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
import axios from 'axios';
interface ComponentProps {
modalHandler: () => void;
addApp: (formData: NewApp | FormData) => any;
updateApp: (id: number, formData: NewApp) => any;
updateApp: (id: number, formData: NewApp | FormData) => any;
app?: App;
}
@@ -23,72 +22,76 @@ const AppForm = (props: ComponentProps): JSX.Element => {
const [formData, setFormData] = useState<NewApp>({
name: '',
url: '',
icon: ''
icon: '',
});
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [inputRef])
useEffect(() => {
if (props.app) {
setFormData({
name: props.app.name,
url: props.app.url,
icon: props.app.icon
})
icon: props.app.icon,
});
} else {
setFormData({
name: '',
url: '',
icon: ''
})
icon: '',
});
}
}, [props.app])
}, [props.app]);
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({
...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 => {
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 (customIcon) {
const data = new FormData();
data.append('icon', customIcon);
data.append('name', formData.name);
data.append('url', formData.url);
const data = createFormData();
props.addApp(data);
} else {
props.addApp(formData);
}
} else {
props.updateApp(props.app.id, formData);
props.modalHandler();
if (customIcon) {
const data = createFormData();
props.updateApp(props.app.id, data);
} else {
props.updateApp(props.app.id, formData);
props.modalHandler();
}
}
setFormData({
name: '',
url: '',
icon: ''
})
}
icon: '',
});
};
return (
<ModalForm
@@ -96,90 +99,96 @@ const AppForm = (props: ComponentProps): JSX.Element => {
formHandler={formSubmitHandler}
>
<InputGroup>
<label htmlFor='name'>App Name</label>
<label htmlFor="name">App Name</label>
<input
type='text'
name='name'
id='name'
placeholder='Bookstack'
type="text"
name="name"
id="name"
placeholder="Bookstack"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
ref={inputRef}
/>
</InputGroup>
<InputGroup>
<label htmlFor='url'>App URL</label>
<label htmlFor="url">App URL</label>
<input
type='text'
name='url'
id='url'
placeholder='bookstack.example.com'
type="text"
name="url"
id="url"
placeholder="bookstack.example.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
target='_blank'
rel='noreferrer'
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
{' '}Check supported URL formats
{' '}
Check supported URL formats
</a>
</span>
</InputGroup>
{!useCustomIcon
{!useCustomIcon ? (
// use mdi icon
? (<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>)
<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'
/>
<span
onClick={() => toggleUseCustomIcon(!useCustomIcon)}
className={classes.Switch}>
Switch to MDI
</span>
</InputGroup>)
}
{!props.app
? <Button>Add new application</Button>
: <Button>Update application</Button>
}
<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>
)
}
);
};
export default connect(null, { addApp, updateApp })(AppForm);
export default connect(null, { addApp, updateApp })(AppForm);

View File

@@ -7,6 +7,7 @@ import AppCard from '../AppCard/AppCard';
interface ComponentProps {
apps: App[];
totalApps?: number;
searching: boolean;
}
const AppGrid = (props: ComponentProps): JSX.Element => {
@@ -16,26 +17,37 @@ const AppGrid = (props: ComponentProps): JSX.Element => {
apps = (
<div className={classes.AppGrid}>
{props.apps.map((app: App): JSX.Element => {
return <AppCard
key={app.id}
app={app}
/>
return <AppCard key={app.id} app={app} />;
})}
</div>
)
);
} else {
if (props.totalApps) {
apps = (
<p className={classes.AppsMessage}>There are no pinned applications. You can pin them from the <Link to='/applications'>/applications</Link> menu</p>
);
if (props.searching) {
apps = (
<p className={classes.AppsMessage}>
No apps match your search criteria
</p>
);
} else {
apps = (
<p className={classes.AppsMessage}>
There are no pinned applications. You can pin them from the{' '}
<Link to="/applications">/applications</Link> menu
</p>
);
}
} else {
apps = (
<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;
}
};
export default AppGrid;
export default AppGrid;

View File

@@ -1,13 +1,24 @@
import { Fragment, KeyboardEvent, useState, useEffect } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import {
DragDropContext,
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';
import {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification,
} from '../../../store/actions';
// Typescript
import { App, GlobalState, NewNotification } from '../../../interfaces';
import { App, Config, GlobalState, NewNotification } from '../../../interfaces';
// CSS
import classes from './AppTable.module.css';
@@ -16,11 +27,9 @@ import classes from './AppTable.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import Table from '../../UI/Table/Table';
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
apps: App[];
config: Config;
pinApp: (app: App) => void;
deleteApp: (id: number) => void;
updateAppHandler: (app: App) => void;
@@ -36,38 +45,44 @@ const AppTable = (props: ComponentProps): JSX.Element => {
// Copy apps array
useEffect(() => {
setLocalApps([...props.apps]);
}, [props.apps])
}, [props.apps]);
// Check ordering
useEffect(() => {
const order = searchConfig('useOrdering', '');
const order = props.config.useOrdering;
if (order === 'orderId') {
setIsCustomOrder(true);
}
}, [])
}, []);
const deleteAppHandler = (app: App): void => {
const proceed = window.confirm(`Are you sure you want to delete ${app.name} at ${app.url} ?`);
const proceed = window.confirm(
`Are you sure you want to delete ${app.name} at ${app.url} ?`
);
if (proceed) {
props.deleteApp(app.id);
}
}
};
// Support keyboard navigation for actions
const keyboardActionHandler = (e: KeyboardEvent, app: App, handler: Function) => {
const keyboardActionHandler = (
e: KeyboardEvent,
app: App,
handler: Function
) => {
if (e.key === 'Enter') {
handler(app);
}
}
};
const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) {
props.createNotification({
title: 'Error',
message: 'Custom order is disabled'
})
message: 'Custom order is disabled',
});
return;
}
@@ -81,32 +96,39 @@ const AppTable = (props: ComponentProps): JSX.Element => {
setLocalApps(tmpApps);
props.reorderApps(tmpApps);
}
};
return (
<Fragment>
<div className={classes.Message}>
{isCustomOrder
? <p>You can drag and drop single rows to reorder application</p>
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
}
{isCustomOrder ? (
<p>You can drag and drop single rows to reorder application</p>
) : (
<p>
Custom order is disabled. You can change it in{' '}
<Link to="/settings/other">settings</Link>
</p>
)}
</div>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId='apps'>
<Droppable droppableId="apps">
{(provided) => (
<Table headers={[
'Name',
'URL',
'Icon',
'Actions'
]}
innerRef={provided.innerRef}>
<Table
headers={['Name', 'URL', 'Icon', 'Actions']}
innerRef={provided.innerRef}
>
{localApps.map((app: App, index): JSX.Element => {
return (
<Draggable key={app.id} draggableId={app.id.toString()} index={index}>
<Draggable
key={app.id}
draggableId={app.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
@@ -118,63 +140,85 @@ const AppTable = (props: ComponentProps): JSX.Element => {
ref={provided.innerRef}
style={style}
>
<td style={{ width:'200px' }}>{app.name}</td>
<td style={{ width:'200px' }}>{app.url}</td>
<td style={{ width:'200px' }}>{app.icon}</td>
<td style={{ width: '200px' }}>{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' />
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' />
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' />
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) => {
return {
apps: state.app.apps
}
}
apps: state.app.apps,
config: state.config.config,
};
};
const actions = {
pinApp,
deleteApp,
reorderApps,
updateConfig,
createNotification
}
createNotification,
};
export default connect(mapStateToProps, actions)(AppTable);
export default connect(mapStateToProps, actions)(AppTable);

View File

@@ -27,14 +27,11 @@ interface ComponentProps {
getApps: Function;
apps: App[];
loading: boolean;
searching: boolean;
}
const Apps = (props: ComponentProps): JSX.Element => {
const {
getApps,
apps,
loading
} = props;
const { getApps, apps, loading, searching = false } = props;
const [modalIsOpen, setModalIsOpen] = useState(false);
const [isInEdit, setIsInEdit] = useState(false);
@@ -47,8 +44,8 @@ const Apps = (props: ComponentProps): JSX.Element => {
orderId: 0,
id: 0,
createdAt: new Date(),
updatedAt: new Date()
})
updatedAt: new Date(),
});
useEffect(() => {
if (apps.length === 0) {
@@ -59,63 +56,57 @@ const Apps = (props: ComponentProps): JSX.Element => {
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
setIsInUpdate(false);
}
};
const toggleEdit = (): void => {
setIsInEdit(!isInEdit);
setIsInUpdate(false);
}
};
const toggleUpdate = (app: App): void => {
setAppInUpdate(app);
setIsInUpdate(true);
setModalIsOpen(true);
}
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={setModalIsOpen}>
{!isInUpdate
? <AppForm modalHandler={toggleModal} />
: <AppForm modalHandler={toggleModal} app={appInUpdate} />
}
{!isInUpdate ? (
<AppForm modalHandler={toggleModal} />
) : (
<AppForm modalHandler={toggleModal} app={appInUpdate} />
)}
</Modal>
<Headline
title='All Applications'
subtitle={(<Link to='/'>Go back</Link>)}
title="All Applications"
subtitle={<Link to="/">Go back</Link>}
/>
<div className={classes.ActionsContainer}>
<ActionButton
name='Add'
icon='mdiPlusBox'
handler={toggleModal}
/>
<ActionButton
name='Edit'
icon='mdiPencil'
handler={toggleEdit}
/>
<ActionButton name="Add" icon="mdiPlusBox" handler={toggleModal} />
<ActionButton name="Edit" icon="mdiPencil" handler={toggleEdit} />
</div>
<div className={classes.Apps}>
{loading
? <Spinner />
: (!isInEdit
? <AppGrid apps={apps} />
: <AppTable updateAppHandler={toggleUpdate} />)
}
{loading ? (
<Spinner />
) : !isInEdit ? (
<AppGrid apps={apps} searching />
) : (
<AppTable updateAppHandler={toggleUpdate} />
)}
</div>
</Container>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
apps: state.app.apps,
loading: state.app.loading
}
}
loading: state.app.loading,
};
};
export default connect(mapStateToProps, { getApps })(Apps);
export default connect(mapStateToProps, { getApps })(Apps);

View File

@@ -32,4 +32,21 @@
display: flex;
margin-top: 3px;
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;
}

View File

@@ -1,11 +1,14 @@
import { Bookmark, Category } from '../../../interfaces';
import { Bookmark, Category, Config, GlobalState } from '../../../interfaces';
import classes from './BookmarkCard.module.css';
import Icon from '../../UI/Icons/Icon/Icon';
import { iconParser, urlParser, searchConfig } from '../../../utility';
import { iconParser, urlParser } from '../../../utility';
import { Fragment } from 'react';
import { connect } from 'react-redux';
interface ComponentProps {
category: Category;
config: Config;
}
const BookmarkCard = (props: ComponentProps): JSX.Element => {
@@ -16,24 +19,61 @@ const BookmarkCard = (props: ComponentProps): JSX.Element => {
{props.category.bookmarks.map((bookmark: Bookmark) => {
const redirectUrl = urlParser(bookmark.url)[1];
let iconEl: JSX.Element = <Fragment></Fragment>;
if (bookmark.icon) {
const { icon, name } = bookmark;
if (/.(jpeg|jpg|png)$/i.test(icon)) {
iconEl = (
<div className={classes.BookmarkIcon}>
<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={searchConfig('openSameTab', false) ? '' : '_blank'}
rel='noreferrer'
key={`bookmark-${bookmark.id}`}>
{bookmark.icon && (
<div className={classes.BookmarkIcon}>
<Icon icon={iconParser(bookmark.icon)} />
</div>
)}
target={props.config.bookmarksSameTab ? '' : '_blank'}
rel="noreferrer"
key={`bookmark-${bookmark.id}`}
>
{bookmark.icon && iconEl}
{bookmark.name}
</a>
)
);
})}
</div>
</div>
)
}
);
};
export default BookmarkCard;
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
export default connect(mapStateToProps)(BookmarkCard);

View File

@@ -0,0 +1,7 @@
.Switch {
text-decoration: underline;
}
.Switch:hover {
cursor: pointer;
}

View File

@@ -1,13 +1,42 @@
import { useState, SyntheticEvent, Fragment, ChangeEvent, useEffect } from 'react';
import { connect } from 'react-redux';
// React
import {
useState,
SyntheticEvent,
Fragment,
ChangeEvent,
useEffect,
} from 'react';
// Redux
import { connect } from 'react-redux';
import {
getCategories,
addCategory,
addBookmark,
updateCategory,
updateBookmark,
createNotification,
} from '../../../store/actions';
// Typescript
import {
Bookmark,
Category,
GlobalState,
NewBookmark,
NewCategory,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// UI
import ModalForm from '../../UI/Forms/ModalForm/ModalForm';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import { 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';
// CSS
import classes from './BookmarkForm.module.css';
interface ComponentProps {
modalHandler: () => void;
contentType: ContentType;
@@ -15,23 +44,32 @@ interface ComponentProps {
category?: Category;
bookmark?: Bookmark;
addCategory: (formData: NewCategory) => void;
addBookmark: (formData: NewBookmark) => void;
addBookmark: (formData: NewBookmark | FormData) => 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;
}
const BookmarkForm = (props: ComponentProps): JSX.Element => {
const [useCustomIcon, toggleUseCustomIcon] = useState<boolean>(false);
const [customIcon, setCustomIcon] = useState<File | null>(null);
const [categoryName, setCategoryName] = useState<NewCategory>({
name: ''
})
name: '',
});
const [formData, setFormData] = useState<NewBookmark>({
name: '',
url: '',
categoryId: -1,
icon: ''
})
icon: '',
});
// Load category data if provided for editing
useEffect(() => {
@@ -40,7 +78,7 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
} else {
setCategoryName({ name: '' });
}
}, [props.category])
}, [props.category]);
// Load bookmark data if provided for editing
useEffect(() => {
@@ -49,21 +87,33 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
name: props.bookmark.name,
url: props.bookmark.url,
categoryId: props.bookmark.categoryId,
icon: props.bookmark.icon
})
icon: props.bookmark.icon,
});
} else {
setFormData({
name: '',
url: '',
categoryId: -1,
icon: ''
})
icon: '',
});
}
}, [props.bookmark])
}, [props.bookmark]);
const formSubmitHandler = (e: SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
const createFormData = (): FormData => {
const data = new FormData();
if (customIcon) {
data.append('icon', customIcon);
}
data.append('name', formData.name);
data.append('url', formData.url);
data.append('categoryId', `${formData.categoryId}`);
return data;
};
if (!props.category && !props.bookmark) {
// Add new
if (props.contentType === ContentType.category) {
@@ -75,18 +125,26 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
if (formData.categoryId === -1) {
props.createNotification({
title: 'Error',
message: 'Please select category'
})
message: 'Please select category',
});
return;
}
props.addBookmark(formData);
if (customIcon) {
const data = createFormData();
props.addBookmark(data);
} else {
props.addBookmark(formData);
}
setFormData({
name: '',
url: '',
categoryId: formData.categoryId,
icon: ''
})
icon: '',
});
// setCustomIcon(null);
}
} else {
// Update
@@ -96,34 +154,54 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
setCategoryName({ name: '' });
} else if (props.contentType === ContentType.bookmark && props.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({
name: '',
url: '',
categoryId: -1,
icon: ''
})
icon: '',
});
setCustomIcon(null);
}
props.modalHandler();
}
}
};
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>): void => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
[e.target.name]: e.target.value,
});
};
const selectChangeHandler = (e: ChangeEvent<HTMLSelectElement>): void => {
setFormData({
...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.contentType === ContentType.category) {
@@ -132,9 +210,9 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
button = <Button>Add new bookmark</Button>;
}
} else if (props.category) {
button = <Button>Update category</Button>
button = <Button>Update category</Button>;
} else if (props.bookmark) {
button = <Button>Update bookmark</Button>
button = <Button>Update bookmark</Button>;
}
return (
@@ -142,112 +220,136 @@ const BookmarkForm = (props: ComponentProps): JSX.Element => {
modalHandler={props.modalHandler}
formHandler={formSubmitHandler}
>
{props.contentType === ContentType.category
? (
<Fragment>
<InputGroup>
<label htmlFor='categoryName'>Category Name</label>
<input
type='text'
name='categoryName'
id='categoryName'
placeholder='Social Media'
required
value={categoryName.name}
onChange={(e) => setCategoryName({ name: e.target.value })}
/>
</InputGroup>
</Fragment>
)
: (
<Fragment>
<InputGroup>
<label htmlFor='name'>Bookmark Name</label>
<input
type='text'
name='name'
id='name'
placeholder='Reddit'
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor='url'>Bookmark URL</label>
<input
type='text'
name='url'
id='url'
placeholder='reddit.com'
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href='https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks'
target='_blank'
rel='noreferrer'
>
{' '}Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor='categoryId'>Bookmark Category</label>
<select
name='categoryId'
id='categoryId'
required
onChange={(e) => selectChangeHandler(e)}
value={formData.categoryId}
{props.contentType === ContentType.category ? (
<Fragment>
<InputGroup>
<label htmlFor="categoryName">Category Name</label>
<input
type="text"
name="categoryName"
id="categoryName"
placeholder="Social Media"
required
value={categoryName.name}
onChange={(e) => setCategoryName({ name: e.target.value })}
/>
</InputGroup>
</Fragment>
) : (
<Fragment>
<InputGroup>
<label htmlFor="name">Bookmark Name</label>
<input
type="text"
name="name"
id="name"
placeholder="Reddit"
required
value={formData.name}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
<InputGroup>
<label htmlFor="url">Bookmark URL</label>
<input
type="text"
name="url"
id="url"
placeholder="reddit.com"
required
value={formData.url}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
<a
href="https://github.com/pawelmalak/flame#supported-url-formats-for-applications-and-bookmarks"
target="_blank"
rel="noreferrer"
>
<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>
{' '}
Check supported URL formats
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor="categoryId">Bookmark Category</label>
<select
name="categoryId"
id="categoryId"
required
onChange={(e) => selectChangeHandler(e)}
value={formData.categoryId}
>
<option value={-1}>Select category</option>
{props.categories.map((category: Category): JSX.Element => {
return (
<option key={category.id} value={category.id}>
{category.name}
</option>
);
})}
</select>
</InputGroup>
{!useCustomIcon ? (
// mdi
<InputGroup>
<label htmlFor='icon'>Bookmark Icon (optional)</label>
<label htmlFor="icon">Bookmark Icon (optional)</label>
<input
type='text'
name='icon'
id='icon'
placeholder='book-open-outline'
type="text"
name="icon"
id="icon"
placeholder="book-open-outline"
value={formData.icon}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Use icon name from MDI.
<a
href='https://materialdesignicons.com/'
target='blank'>
{' '}Click here for reference
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>
</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}
</ModalForm>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
categories: state.bookmark.categories
}
}
categories: state.bookmark.categories,
};
};
const dispatchMap = {
getCategories,
@@ -255,7 +357,7 @@ const dispatchMap = {
addBookmark,
updateCategory,
updateBookmark,
createNotification
}
createNotification,
};
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);
export default connect(mapStateToProps, dispatchMap)(BookmarkForm);

View File

@@ -9,30 +9,49 @@ import BookmarkCard from '../BookmarkCard/BookmarkCard';
interface ComponentProps {
categories: Category[];
totalCategories?: number;
searching: boolean;
}
const BookmarkGrid = (props: ComponentProps): JSX.Element => {
let bookmarks: JSX.Element;
if (props.categories.length > 0) {
bookmarks = (
<div className={classes.BookmarkGrid}>
{props.categories.map((category: Category): JSX.Element => <BookmarkCard category={category} key={category.id} />)}
</div>
);
} else {
if (props.totalCategories) {
if (props.searching && props.categories[0].bookmarks.length === 0) {
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 {
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;
}
};
export default BookmarkGrid;
export default BookmarkGrid;

View File

@@ -1,13 +1,30 @@
import { KeyboardEvent, useState, useEffect, Fragment } from 'react';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
// Redux
import { connect } from 'react-redux';
import { pinCategory, deleteCategory, deleteBookmark, createNotification, reorderCategories } from '../../../store/actions';
import {
pinCategory,
deleteCategory,
deleteBookmark,
createNotification,
reorderCategories,
} from '../../../store/actions';
// Typescript
import { Bookmark, Category, NewNotification } from '../../../interfaces';
import {
Bookmark,
Category,
Config,
GlobalState,
NewNotification,
} from '../../../interfaces';
import { ContentType } from '../Bookmarks';
// CSS
@@ -17,12 +34,10 @@ import classes from './BookmarkTable.module.css';
import Table from '../../UI/Table/Table';
import Icon from '../../UI/Icons/Icon/Icon';
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
contentType: ContentType;
categories: Category[];
config: Config;
pinCategory: (category: Category) => void;
deleteCategory: (id: number) => void;
updateHandler: (data: Category | Bookmark) => void;
@@ -38,45 +53,53 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
// Copy categories array
useEffect(() => {
setLocalCategories([...props.categories]);
}, [props.categories])
}, [props.categories]);
// Check ordering
useEffect(() => {
const order = searchConfig('useOrdering', '');
const order = props.config.useOrdering;
if (order === 'orderId') {
setIsCustomOrder(true);
}
})
});
const deleteCategoryHandler = (category: Category): void => {
const proceed = window.confirm(`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`);
const proceed = window.confirm(
`Are you sure you want to delete ${category.name}? It will delete ALL assigned bookmarks`
);
if (proceed) {
props.deleteCategory(category.id);
}
}
};
const deleteBookmarkHandler = (bookmark: Bookmark): void => {
const proceed = window.confirm(`Are you sure you want to delete ${bookmark.name}?`);
const proceed = window.confirm(
`Are you sure you want to delete ${bookmark.name}?`
);
if (proceed) {
props.deleteBookmark(bookmark.id, bookmark.categoryId);
}
}
};
const keyboardActionHandler = (e: KeyboardEvent, category: Category, handler: Function) => {
const keyboardActionHandler = (
e: KeyboardEvent,
category: Category,
handler: Function
) => {
if (e.key === 'Enter') {
handler(category);
}
}
};
const dragEndHanlder = (result: DropResult): void => {
if (!isCustomOrder) {
props.createNotification({
title: 'Error',
message: 'Custom order is disabled'
})
message: 'Custom order is disabled',
});
return;
}
@@ -90,136 +113,171 @@ const BookmarkTable = (props: ComponentProps): JSX.Element => {
setLocalCategories(tmpCategories);
props.reorderCategories(tmpCategories);
}
};
if (props.contentType === ContentType.category) {
return (
<Fragment>
<div className={classes.Message}>
{isCustomOrder
? <p>You can drag and drop single rows to reorder categories</p>
: <p>Custom order is disabled. You can change it in <Link to='/settings/other'>settings</Link></p>
}
{isCustomOrder ? (
<p>You can drag and drop single rows to reorder categories</p>
) : (
<p>
Custom order is disabled. You can change it in{' '}
<Link to="/settings/other">settings</Link>
</p>
)}
</div>
<DragDropContext onDragEnd={dragEndHanlder}>
<Droppable droppableId='categories'>
<Droppable droppableId="categories">
{(provided) => (
<Table headers={[
'Name',
'Actions'
]}
innerRef={provided.innerRef}>
{localCategories.map((category: Category, index): JSX.Element => {
return (
<Draggable key={category.id} draggableId={category.id.toString()} index={index}>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging ? '1px solid var(--color-accent)' : 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
<Table headers={['Name', 'Actions']} innerRef={provided.innerRef}>
{localCategories.map(
(category: Category, index): JSX.Element => {
return (
<Draggable
key={category.id}
draggableId={category.id.toString()}
index={index}
>
{(provided, snapshot) => {
const style = {
border: snapshot.isDragging
? '1px solid var(--color-accent)'
: 'none',
borderRadius: '4px',
...provided.draggableProps.style,
};
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td>{category.name}</td>
{!snapshot.isDragging && (
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteCategoryHandler(category)}
onKeyDown={(e) => keyboardActionHandler(e, category, deleteCategoryHandler)}
tabIndex={0}>
<Icon icon='mdiDelete' />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(category)}
tabIndex={0}>
<Icon icon='mdiPencil' />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinCategory(category)}
onKeyDown={(e) => keyboardActionHandler(e, category, props.pinCategory)}
tabIndex={0}>
{category.isPinned
? <Icon icon='mdiPinOff' color='var(--color-accent)' />
: <Icon icon='mdiPin' />
}
</div>
</td>
)}
</tr>
)
}}
</Draggable>
)
})}
return (
<tr
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
style={style}
>
<td>{category.name}</td>
{!snapshot.isDragging && (
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() =>
deleteCategoryHandler(category)
}
onKeyDown={(e) =>
keyboardActionHandler(
e,
category,
deleteCategoryHandler
)
}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() =>
props.updateHandler(category)
}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
<div
className={classes.TableAction}
onClick={() => props.pinCategory(category)}
onKeyDown={(e) =>
keyboardActionHandler(
e,
category,
props.pinCategory
)
}
tabIndex={0}
>
{category.isPinned ? (
<Icon
icon="mdiPinOff"
color="var(--color-accent)"
/>
) : (
<Icon icon="mdiPin" />
)}
</div>
</td>
)}
</tr>
);
}}
</Draggable>
);
}
)}
</Table>
)}
</Droppable>
</DragDropContext>
</Fragment>
)
);
} else {
const bookmarks: {bookmark: Bookmark, categoryName: string}[] = [];
const bookmarks: { bookmark: Bookmark; categoryName: string }[] = [];
props.categories.forEach((category: Category) => {
category.bookmarks.forEach((bookmark: Bookmark) => {
bookmarks.push({
bookmark,
categoryName: category.name
categoryName: category.name,
});
})
})
});
});
return (
<Table headers={[
'Name',
'URL',
'Icon',
'Category',
'Actions'
]}>
{bookmarks.map((bookmark: {bookmark: Bookmark, categoryName: string}) => {
return (
<tr key={bookmark.bookmark.id}>
<td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td>
<td>{bookmark.bookmark.icon}</td>
<td>{bookmark.categoryName}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
tabIndex={0}>
<Icon icon='mdiDelete' />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(bookmark.bookmark)}
tabIndex={0}>
<Icon icon='mdiPencil' />
</div>
</td>
</tr>
)
})}
<Table headers={['Name', 'URL', 'Icon', 'Category', 'Actions']}>
{bookmarks.map(
(bookmark: { bookmark: Bookmark; categoryName: string }) => {
return (
<tr key={bookmark.bookmark.id}>
<td>{bookmark.bookmark.name}</td>
<td>{bookmark.bookmark.url}</td>
<td>{bookmark.bookmark.icon}</td>
<td>{bookmark.categoryName}</td>
<td className={classes.TableActions}>
<div
className={classes.TableAction}
onClick={() => deleteBookmarkHandler(bookmark.bookmark)}
tabIndex={0}
>
<Icon icon="mdiDelete" />
</div>
<div
className={classes.TableAction}
onClick={() => props.updateHandler(bookmark.bookmark)}
tabIndex={0}
>
<Icon icon="mdiPencil" />
</div>
</td>
</tr>
);
}
)}
</Table>
)
);
}
}
};
const mapStateToProps = (state: GlobalState) => {
return {
config: state.config.config,
};
};
const actions = {
pinCategory,
deleteCategory,
deleteBookmark,
createNotification,
reorderCategories
}
reorderCategories,
};
export default connect(null, actions)(BookmarkTable);
export default connect(mapStateToProps, actions)(BookmarkTable);

View File

@@ -20,24 +20,23 @@ interface ComponentProps {
loading: boolean;
categories: Category[];
getCategories: () => void;
searching: boolean;
}
export enum ContentType {
category,
bookmark
bookmark,
}
const Bookmarks = (props: ComponentProps): JSX.Element => {
const {
getCategories,
categories,
loading
} = props;
const { getCategories, categories, loading, searching = false } = props;
const [modalIsOpen, setModalIsOpen] = useState(false);
const [formContentType, setFormContentType] = useState(ContentType.category);
const [isInEdit, setIsInEdit] = useState(false);
const [tableContentType, setTableContentType] = useState(ContentType.category);
const [tableContentType, setTableContentType] = useState(
ContentType.category
);
const [isInUpdate, setIsInUpdate] = useState(false);
const [categoryInUpdate, setCategoryInUpdate] = useState<Category>({
name: '',
@@ -46,8 +45,8 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
orderId: 0,
bookmarks: [],
createdAt: new Date(),
updatedAt: new Date()
})
updatedAt: new Date(),
});
const [bookmarkInUpdate, setBookmarkInUpdate] = useState<Bookmark>({
name: '',
url: '',
@@ -55,24 +54,24 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
icon: '',
id: -1,
createdAt: new Date(),
updatedAt: new Date()
})
updatedAt: new Date(),
});
useEffect(() => {
if (categories.length === 0) {
getCategories();
}
}, [getCategories])
}, [getCategories]);
const toggleModal = (): void => {
setModalIsOpen(!modalIsOpen);
}
};
const addActionHandler = (contentType: ContentType) => {
setFormContentType(contentType);
setIsInUpdate(false);
toggleModal();
}
};
const editActionHandler = (contentType: ContentType) => {
// We're in the edit mode and the same button was clicked - go back to list
@@ -82,11 +81,11 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
setIsInEdit(true);
setTableContentType(contentType);
}
}
};
const instanceOfCategory = (object: any): object is Category => {
return 'bookmarks' in object;
}
};
const goToUpdateMode = (data: Category | Bookmark): void => {
setIsInUpdate(true);
@@ -98,67 +97,76 @@ const Bookmarks = (props: ComponentProps): JSX.Element => {
setBookmarkInUpdate(data);
}
toggleModal();
}
};
return (
<Container>
<Modal isOpen={modalIsOpen} setIsOpen={toggleModal}>
{!isInUpdate
? <BookmarkForm modalHandler={toggleModal} contentType={formContentType} />
: formContentType === ContentType.category
? <BookmarkForm modalHandler={toggleModal} contentType={formContentType} category={categoryInUpdate} />
: <BookmarkForm modalHandler={toggleModal} contentType={formContentType} bookmark={bookmarkInUpdate} />
}
{!isInUpdate ? (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
/>
) : formContentType === ContentType.category ? (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
category={categoryInUpdate}
/>
) : (
<BookmarkForm
modalHandler={toggleModal}
contentType={formContentType}
bookmark={bookmarkInUpdate}
/>
)}
</Modal>
<Headline
title='All Bookmarks'
subtitle={(<Link to='/'>Go back</Link>)}
/>
<Headline title="All Bookmarks" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.ActionsContainer}>
<ActionButton
name='Add Category'
icon='mdiPlusBox'
name="Add Category"
icon="mdiPlusBox"
handler={() => addActionHandler(ContentType.category)}
/>
<ActionButton
name='Add Bookmark'
icon='mdiPlusBox'
name="Add Bookmark"
icon="mdiPlusBox"
handler={() => addActionHandler(ContentType.bookmark)}
/>
<ActionButton
name='Edit Categories'
icon='mdiPencil'
name="Edit Categories"
icon="mdiPencil"
handler={() => editActionHandler(ContentType.category)}
/>
<ActionButton
name='Edit Bookmarks'
icon='mdiPencil'
name="Edit Bookmarks"
icon="mdiPencil"
handler={() => editActionHandler(ContentType.bookmark)}
/>
</div>
{loading
? <Spinner />
: (!isInEdit
? <BookmarkGrid categories={categories} />
: <BookmarkTable
contentType={tableContentType}
categories={categories}
updateHandler={goToUpdateMode}
/>
)
}
{loading ? (
<Spinner />
) : !isInEdit ? (
<BookmarkGrid categories={categories} searching />
) : (
<BookmarkTable
contentType={tableContentType}
categories={categories}
updateHandler={goToUpdateMode}
/>
)}
</Container>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.bookmark.loading,
categories: state.bookmark.categories
}
}
categories: state.bookmark.categories,
};
};
export default connect(mapStateToProps, { getCategories })(Bookmarks);
export default connect(mapStateToProps, { getCategories })(Bookmarks);

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

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

View 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()}`;
}
};

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

View File

@@ -1,24 +1,3 @@
.Header h1 {
color: var(--color-primary);
font-weight: 700;
font-size: 4em;
display: inline-block;
}
.Header p {
color: var(--color-primary);
font-weight: 300;
text-transform: uppercase;
height: 30px;
}
.HeaderMain {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2.5rem;
}
.SettingsButton {
width: 35px;
height: 35px;
@@ -40,21 +19,12 @@
opacity: 1;
}
.SettingsLink {
visibility: visible;
color: var(--color-accent);
}
@media (min-width: 769px) {
.SettingsButton {
visibility: visible;
}
.SettingsLink {
visibility: hidden;
}
}
.HomeSpace {
height: 20px;
}
}

View File

@@ -7,7 +7,7 @@ import { getApps, getCategories } from '../../store/actions';
// Typescript
import { GlobalState } from '../../interfaces/GlobalState';
import { App, Category } from '../../interfaces';
import { App, Category, Config } from '../../interfaces';
// UI
import Icon from '../UI/Icons/Icon/Icon';
@@ -21,15 +21,8 @@ import classes from './Home.module.css';
// Components
import AppGrid from '../Apps/AppGrid/AppGrid';
import BookmarkGrid from '../Bookmarks/BookmarkGrid/BookmarkGrid';
import WeatherWidget from '../Widgets/WeatherWidget/WeatherWidget';
import SearchBox from '../SearchBox/SearchBox';
// Functions
import { greeter } from './functions/greeter';
import { dateTime } from './functions/dateTime';
// Utils
import { searchConfig } from '../../utility';
import SearchBar from '../SearchBar/SearchBar';
import Header from './Header/Header';
interface ComponentProps {
getApps: Function;
@@ -38,6 +31,7 @@ interface ComponentProps {
apps: App[];
categoriesLoading: boolean;
categories: Category[];
config: Config;
}
const Home = (props: ComponentProps): JSX.Element => {
@@ -47,109 +41,125 @@ const Home = (props: ComponentProps): JSX.Element => {
appsLoading,
getCategories,
categories,
categoriesLoading
categoriesLoading,
} = props;
const [header, setHeader] = useState({
dateTime: dateTime(),
greeting: greeter()
})
// 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(() => {
if (apps.length === 0) {
if (!apps.length) {
getApps();
}
}, [getApps]);
// Load bookmark categories
useEffect(() => {
if (categories.length === 0) {
if (!categories.length) {
getCategories();
}
}, [getCategories]);
// Refresh greeter and time
useEffect(() => {
let interval: any;
if (localSearch) {
// Search through apps
setAppSearchResult([
...apps.filter(({ name }) => new RegExp(localSearch, 'i').test(name)),
]);
// Start interval only when hideHeader is false
if (searchConfig('hideHeader', 0) !== 1) {
interval = setInterval(() => {
setHeader({
dateTime: dateTime(),
greeting: greeter()
})
}, 1000);
// 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);
}
}, [localSearch]);
return () => clearInterval(interval);
}, [])
return (
<Container>
{searchConfig('hideSearch', 0) !== 1
? <SearchBox />
: <div></div>
}
{!props.config.hideSearch ? (
<SearchBar
setLocalSearch={setLocalSearch}
appSearchResult={appSearchResult}
bookmarkSearchResult={bookmarkSearchResult}
/>
) : (
<div></div>
)}
{searchConfig('hideHeader', 0) !== 1
? (
<header className={classes.Header}>
<p>{header.dateTime}</p>
<Link to='/settings' className={classes.SettingsLink}>Go to Settings</Link>
<span className={classes.HeaderMain}>
<h1>{header.greeting}</h1>
<WeatherWidget />
</span>
</header>
)
: <div></div>
}
{searchConfig('hideApps', 0) !== 1
? (<Fragment>
<SectionHeadline title='Applications' link='/applications' />
{appsLoading
? <Spinner />
: <AppGrid
apps={apps.filter((app: App) => app.isPinned)}
totalApps={apps.length}
/>
}
<div className={classes.HomeSpace}></div>
</Fragment>)
: <div></div>
}
{!props.config.hideHeader ? <Header /> : <div></div>}
{searchConfig('hideCategories', 0) !== 1
? (<Fragment>
<SectionHeadline title='Bookmarks' link='/bookmarks' />
{categoriesLoading
? <Spinner />
: <BookmarkGrid
categories={categories.filter((category: Category) => category.isPinned)}
totalCategories={categories.length}
/>
}
</Fragment>)
: <div></div>
}
{!props.config.hideApps ? (
<Fragment>
<SectionHeadline title="Applications" link="/applications" />
{appsLoading ? (
<Spinner />
) : (
<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}>
<Icon icon='mdiCog' color='var(--color-background)' />
{!props.config.hideCategories ? (
<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>
</Container>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
appsLoading: state.app.loading,
apps: state.app.apps,
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);

View File

@@ -1,8 +0,0 @@
export const dateTime = (): string => {
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const now = new Date();
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
}

View File

@@ -1,12 +0,0 @@
export const greeter = (): string => {
const now = new Date().getHours();
let msg: string;
if (now >= 18) msg = 'Good evening!';
else if (now >= 12) msg = 'Good afternoon!';
else if (now >= 6) msg = 'Good morning!';
else if (now >= 0) msg = 'Good night!';
else msg = 'Hello!';
return msg;
}

View File

@@ -20,19 +20,20 @@ const NotificationCenter = (props: ComponentProps): JSX.Element => {
<Notification
title={notification.title}
message={notification.message}
url={notification.url || null}
id={notification.id}
key={notification.id}
/>
)
);
})}
</div>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
notifications: state.notification.notifications
}
}
notifications: state.notification.notifications,
};
};
export default connect(mapStateToProps)(NotificationCenter);
export default connect(mapStateToProps)(NotificationCenter);

View File

@@ -1,4 +1,4 @@
.SearchBox {
.SearchBar {
width: 100%;
padding: 10px 0;
color: var(--color-primary);
@@ -11,7 +11,7 @@
transition: all 0.2s;
}
.SearchBox:focus {
.SearchBar:focus {
opacity: 1;
outline: none;
}

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

View File

@@ -1,29 +0,0 @@
import { useRef, useEffect, KeyboardEvent } from 'react';
import classes from './SearchBox.module.css';
import { searchParser } from '../../utility';
const SearchBox = (): JSX.Element => {
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
useEffect(() => {
inputRef.current.focus();
}, [])
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.code === 'Enter') {
searchParser(inputRef.current.value);
}
}
return (
<input
ref={inputRef}
type='text'
className={classes.SearchBox}
onKeyDown={(e) => searchHandler(e)}
/>
)
}
export default SearchBox;

View File

@@ -17,9 +17,18 @@ const AppDetails = (): JSX.Element => {
{' '}
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;
export default AppDetails;

View File

@@ -2,56 +2,51 @@ import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
// Redux
import { connect } from 'react-redux';
import { createNotification, updateConfig, sortApps, sortCategories } from '../../../store/actions';
import {
createNotification,
updateConfig,
sortApps,
sortCategories,
} from '../../../store/actions';
// Typescript
import { GlobalState, NewNotification, SettingsForm } from '../../../interfaces';
import {
Config,
GlobalState,
NewNotification,
OtherSettingsForm,
} from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// CSS
import classes from './OtherSettings.module.css';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
// Utils
import { searchConfig } from '../../../utility';
import { otherSettingsTemplate, inputHandler } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: SettingsForm) => void;
updateConfig: (formData: OtherSettingsForm) => void;
sortApps: () => void;
sortCategories: () => void;
loading: boolean;
config: Config;
}
const OtherSettings = (props: ComponentProps): JSX.Element => {
const { config } = props;
// Initial state
const [formData, setFormData] = useState<SettingsForm>({
customTitle: document.title,
pinAppsByDefault: 1,
pinCategoriesByDefault: 1,
hideHeader: 0,
hideApps: 0,
hideCategories: 0,
hideSearch: 0,
useOrdering: 'createdAt',
openSameTab: 0
})
const [formData, setFormData] = useState<OtherSettingsForm>(
otherSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
customTitle: searchConfig('customTitle', 'Flame'),
pinAppsByDefault: searchConfig('pinAppsByDefault', 1),
pinCategoriesByDefault: searchConfig('pinCategoriesByDefault', 1),
hideHeader: searchConfig('hideHeader', 0),
hideApps: searchConfig('hideApps', 0),
hideCategories: searchConfig('hideCategories', 0),
hideSearch: searchConfig('hideSearch', 0),
useOrdering: searchConfig('useOrdering', 'createdAt'),
openSameTab: searchConfig('openSameTab', 0)
})
...config,
});
}, [props.loading]);
// Form handler
@@ -67,84 +62,123 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
// Sort apps and categories with new settings
props.sortApps();
props.sortCategories();
}
};
// Input handler
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
let value: string | number = e.target.value;
if (isNumber) {
value = parseFloat(value);
}
setFormData({
...formData,
[e.target.name]: value
})
}
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<OtherSettingsForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
{/* OTHER OPTIONS */}
<h2 className={classes.SettingsSection}>Miscellaneous</h2>
<SettingsHeadline text="Miscellaneous" />
{/* PAGE TITLE */}
<InputGroup>
<label htmlFor='customTitle'>Custom page title</label>
<label htmlFor="customTitle">Custom page title</label>
<input
type='text'
id='customTitle'
name='customTitle'
placeholder='Flame'
type="text"
id="customTitle"
name="customTitle"
placeholder="Flame"
value={formData.customTitle}
onChange={(e) => inputChangeHandler(e)}
/>
</InputGroup>
{/* DATE FORMAT */}
<InputGroup>
<label htmlFor="useAmericanDate">Date formatting</label>
<select
id="useAmericanDate"
name="useAmericanDate"
value={formData.useAmericanDate ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>Friday, October 22 2021</option>
<option value={0}>Friday, 22 October 2021</option>
</select>
</InputGroup>
{/* BEAHVIOR OPTIONS */}
<h2 className={classes.SettingsSection}>App Behavior</h2>
<SettingsHeadline text="App Behavior" />
{/* PIN APPS */}
<InputGroup>
<label htmlFor='pinAppsByDefault'>Pin new applications by default</label>
<label htmlFor="pinAppsByDefault">
Pin new applications by default
</label>
<select
id='pinAppsByDefault'
name='pinAppsByDefault'
value={formData.pinAppsByDefault}
onChange={(e) => inputChangeHandler(e, true)}
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>
<label htmlFor="pinCategoriesByDefault">
Pin new categories by default
</label>
<select
id='pinCategoriesByDefault'
name='pinCategoriesByDefault'
value={formData.pinCategoriesByDefault}
onChange={(e) => inputChangeHandler(e, true)}
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>
<label htmlFor="useOrdering">Sorting type</label>
<select
id='useOrdering'
name='useOrdering'
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>
<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='openSameTab'>Open all links in the same tab</label>
<label htmlFor="appsSameTab">Open applications in the same tab</label>
<select
id='openSameTab'
name='openSameTab'
value={formData.openSameTab}
onChange={(e) => inputChangeHandler(e, true)}
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>
@@ -152,71 +186,171 @@ const OtherSettings = (props: ComponentProps): JSX.Element => {
</InputGroup>
{/* MODULES OPTIONS */}
<h2 className={classes.SettingsSection}>Modules</h2>
<SettingsHeadline text="Modules" />
{/* HIDE HEADER */}
<InputGroup>
<label htmlFor='hideSearch'>Hide search bar</label>
<label htmlFor="hideHeader">Hide greeting and date</label>
<select
id='hideSearch'
name='hideSearch'
value={formData.hideSearch}
onChange={(e) => inputChangeHandler(e, true)}
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='hideHeader'>Hide greeting and date</label>
<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='hideHeader'
name='hideHeader'
value={formData.hideHeader}
onChange={(e) => inputChangeHandler(e, true)}
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='hideApps'>Hide applications</label>
<label htmlFor="hideCategories">Hide categories</label>
<select
id='hideApps'
name='hideApps'
value={formData.hideApps}
onChange={(e) => inputChangeHandler(e, true)}
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='hideCategories'>Hide categories</label>
<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='hideCategories'
name='hideCategories'
value={formData.hideCategories}
onChange={(e) => inputChangeHandler(e, true)}
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>
<Button>Save changes</Button>
{/* 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
}
}
loading: state.config.loading,
config: state.config.config,
};
};
const actions = {
createNotification,
updateConfig,
sortApps,
sortCategories
}
sortCategories,
};
export default connect(mapStateToProps, actions)(OtherSettings);
export default connect(mapStateToProps, actions)(OtherSettings);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
// React
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
import { connect } from 'react-redux';
// State
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript
import {
Config,
GlobalState,
NewNotification,
Query,
SearchForm,
} from '../../../interfaces';
// Components
import CustomQueries from './CustomQueries/CustomQueries';
// UI
import Button from '../../UI/Buttons/Button/Button';
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
// Utils
import { inputHandler, searchSettingsTemplate } from '../../../utility';
// Data
import { queries } from '../../../utility/searchQueries.json';
interface Props {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: SearchForm) => void;
loading: boolean;
customQueries: Query[];
config: Config;
}
const SearchSettings = (props: Props): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
// Get config
useEffect(() => {
setFormData({
...props.config,
});
}, [props.loading]);
// Form handler
const formSubmitHandler = async (e: FormEvent) => {
e.preventDefault();
// Save settings
await props.updateConfig(formData);
};
// Input handler
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<SearchForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<Fragment>
{/* GENERAL SETTINGS */}
<form
onSubmit={(e) => formSubmitHandler(e)}
style={{ marginBottom: '30px' }}
>
<SettingsHeadline text="General" />
<InputGroup>
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
<select
id="defaultSearchProvider"
name="defaultSearchProvider"
value={formData.defaultSearchProvider}
onChange={(e) => inputChangeHandler(e)}
>
{[...queries, ...props.customQueries].map((query: Query, idx) => {
const isCustom = idx >= queries.length;
return (
<option key={idx} value={query.prefix}>
{isCustom && '+'} {query.name}
</option>
);
})}
</select>
</InputGroup>
<InputGroup>
<label htmlFor="searchSameTab">
Open search results in the same tab
</label>
<select
id="searchSameTab"
name="searchSameTab"
value={formData.searchSameTab ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor="hideSearch">Hide search bar</label>
<select
id="hideSearch"
name="hideSearch"
value={formData.hideSearch ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<InputGroup>
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
<select
id="disableAutofocus"
name="disableAutofocus"
value={formData.disableAutofocus ? 1 : 0}
onChange={(e) => inputChangeHandler(e, { isBool: true })}
>
<option value={1}>True</option>
<option value={0}>False</option>
</select>
</InputGroup>
<Button>Save changes</Button>
</form>
{/* CUSTOM QUERIES */}
<SettingsHeadline text="Custom search providers" />
<CustomQueries />
</Fragment>
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading,
customQueries: state.config.customQueries,
config: state.config.config,
};
};
const actions = {
createNotification,
updateConfig,
};
export default connect(mapStateToProps, actions)(SearchSettings);

View File

@@ -1,73 +1,61 @@
//
import { NavLink, Link, Switch, Route } from 'react-router-dom';
// Typescript
import { Route as SettingsRoute } from '../../interfaces';
// CSS
import classes from './Settings.module.css';
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
// Components
import Themer from '../Themer/Themer';
import WeatherSettings from './WeatherSettings/WeatherSettings';
import OtherSettings from './OtherSettings/OtherSettings';
import AppDetails from './AppDetails/AppDetails';
import StyleSettings from './StyleSettings/StyleSettings';
import SearchSettings from './SearchSettings/SearchSettings';
// UI
import { Container } from '../UI/Layout/Layout';
import Headline from '../UI/Headlines/Headline/Headline';
// Data
import { routes } from './settings.json';
const Settings = (): JSX.Element => {
return (
<Container>
<Headline
title='Settings'
subtitle={<Link to='/'>Go back</Link>}
/>
<Headline title="Settings" subtitle={<Link to="/">Go back</Link>} />
<div className={classes.Settings}>
{/* NAVIGATION MENU */}
<nav className={classes.SettingsNav}>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings'>
Theme
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/weather'>
Weather
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/other'>
Other
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/css'>
CSS
</NavLink>
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to='/settings/app'>
App
</NavLink>
{routes.map(({ name, dest }: SettingsRoute, idx) => (
<NavLink
className={classes.SettingsNavLink}
activeClassName={classes.SettingsNavLinkActive}
exact
to={dest}
key={idx}
>
{name}
</NavLink>
))}
</nav>
{/* ROUTES */}
<section className={classes.SettingsContent}>
<Switch>
<Route exact path='/settings' component={Themer} />
<Route path='/settings/weather' component={WeatherSettings} />
<Route path='/settings/other' component={OtherSettings} />
<Route path='/settings/css' component={StyleSettings} />
<Route path='/settings/app' component={AppDetails} />
<Route exact path="/settings" component={Themer} />
<Route path="/settings/weather" component={WeatherSettings} />
<Route path="/settings/search" component={SearchSettings} />
<Route path="/settings/other" component={OtherSettings} />
<Route path="/settings/css" component={StyleSettings} />
<Route path="/settings/app" component={AppDetails} />
</Switch>
</section>
</div>
</Container>
)
}
);
};
export default Settings;
export default Settings;

View File

@@ -6,38 +6,40 @@ import { connect } from 'react-redux';
import { createNotification, updateConfig } from '../../../store/actions';
// Typescript
import { ApiResponse, GlobalState, NewNotification, Weather, WeatherForm } from '../../../interfaces';
import {
ApiResponse,
Config,
GlobalState,
NewNotification,
Weather,
WeatherForm,
} from '../../../interfaces';
// UI
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
import Button from '../../UI/Buttons/Button/Button';
// Utils
import { searchConfig } from '../../../utility';
import { inputHandler, weatherSettingsTemplate } from '../../../utility';
interface ComponentProps {
createNotification: (notification: NewNotification) => void;
updateConfig: (formData: WeatherForm) => void;
loading: boolean;
config: Config;
}
const WeatherSettings = (props: ComponentProps): JSX.Element => {
// Initial state
const [formData, setFormData] = useState<WeatherForm>({
WEATHER_API_KEY: '',
lat: 0,
long: 0,
isCelsius: 1
})
const [formData, setFormData] = useState<WeatherForm>(
weatherSettingsTemplate
);
// Get config
useEffect(() => {
setFormData({
WEATHER_API_KEY: searchConfig('WEATHER_API_KEY', ''),
lat: searchConfig('lat', 0),
long: searchConfig('long', 0),
isCelsius: searchConfig('isCelsius', 1)
})
...props.config,
});
}, [props.loading]);
// Form handler
@@ -48,120 +50,124 @@ const WeatherSettings = (props: ComponentProps): JSX.Element => {
if ((formData.lat || formData.long) && !formData.WEATHER_API_KEY) {
props.createNotification({
title: 'Warning',
message: 'API key is missing. Weather Module will NOT work'
})
message: 'API key is missing. Weather Module will NOT work',
});
}
// Save settings
await props.updateConfig(formData);
// Update weather
axios.get<ApiResponse<Weather>>('/api/weather/update')
axios
.get<ApiResponse<Weather>>('/api/weather/update')
.then(() => {
props.createNotification({
title: 'Success',
message: 'Weather updated'
})
message: 'Weather updated',
});
})
.catch((err) => {
props.createNotification({
title: 'Error',
message: err.response.data.error
})
message: err.response.data.error,
});
});
}
};
// Input handler
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>, isNumber?: boolean) => {
let value: string | number = e.target.value;
if (isNumber) {
value = parseFloat(value);
}
setFormData({
...formData,
[e.target.name]: value
})
}
const inputChangeHandler = (
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
options?: { isNumber?: boolean; isBool?: boolean }
) => {
inputHandler<WeatherForm>({
e,
options,
setStateHandler: setFormData,
state: formData,
});
};
return (
<form onSubmit={(e) => formSubmitHandler(e)}>
<InputGroup>
<label htmlFor='WEATHER_API_KEY'>API key</label>
<label htmlFor="WEATHER_API_KEY">API key</label>
<input
type='text'
id='WEATHER_API_KEY'
name='WEATHER_API_KEY'
placeholder='secret'
type="text"
id="WEATHER_API_KEY"
name="WEATHER_API_KEY"
placeholder="secret"
value={formData.WEATHER_API_KEY}
onChange={(e) => inputChangeHandler(e)}
/>
<span>
Using
<a
href='https://www.weatherapi.com/pricing.aspx'
target='blank'>
{' '}Weather API
<a href="https://www.weatherapi.com/pricing.aspx" target="blank">
{' '}
Weather API
</a>
. Key is required for weather module to work.
</span>
</InputGroup>
<InputGroup>
<label htmlFor='lat'>Location latitude</label>
<label htmlFor="lat">Location latitude</label>
<input
type='number'
id='lat'
name='lat'
placeholder='52.22'
type="number"
id="lat"
name="lat"
placeholder="52.22"
value={formData.lat}
onChange={(e) => inputChangeHandler(e, true)}
step='any'
lang='en-150'
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
step="any"
lang="en-150"
/>
<span>
You can use
<a
href='https://www.latlong.net/convert-address-to-lat-long.html'
target='blank'>
{' '}latlong.net
href="https://www.latlong.net/convert-address-to-lat-long.html"
target="blank"
>
{' '}
latlong.net
</a>
</span>
</InputGroup>
<InputGroup>
<label htmlFor='long'>Location longitude</label>
<label htmlFor="long">Location longitude</label>
<input
type='number'
id='long'
name='long'
placeholder='21.01'
type="number"
id="long"
name="long"
placeholder="21.01"
value={formData.long}
onChange={(e) => inputChangeHandler(e, true)}
step='any'
lang='en-150'
onChange={(e) => inputChangeHandler(e, { isNumber: true })}
step="any"
lang="en-150"
/>
</InputGroup>
<InputGroup>
<label htmlFor='isCelsius'>Temperature unit</label>
<label htmlFor="isCelsius">Temperature unit</label>
<select
id='isCelsius'
name='isCelsius'
onChange={(e) => inputChangeHandler(e, true)}
value={formData.isCelsius}
id="isCelsius"
name="isCelsius"
onChange={(e) => inputChangeHandler(e, { isBool: true })}
value={formData.isCelsius ? 1 : 0}
>
<option value={1}>Celsius</option>
<option value={0}>Fahrenheit</option>
</select>
</InputGroup>
<Button>Save changes</Button>
<Button>Save changes</Button>
</form>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
loading: state.config.loading
}
}
loading: state.config.loading,
config: state.config.config,
};
};
export default connect(mapStateToProps, { createNotification, updateConfig })(WeatherSettings);
export default connect(mapStateToProps, { createNotification, updateConfig })(
WeatherSettings
);

View 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"
}
]
}

View File

@@ -95,6 +95,30 @@
"primary": "#4C432E",
"accent": "#AA9A73"
}
},
{
"name": "neon",
"colors": {
"background": "#091833",
"primary": "#EFFBFF",
"accent": "#ea00d9"
}
},
{
"name": "pumpkin",
"colors": {
"background": "#2d3436",
"primary": "#EFFBFF",
"accent": "#ffa500"
}
},
{
"name": "onedark",
"colors": {
"background": "#282c34",
"primary": "#dfd9d6",
"accent": "#98c379"
}
}
]
}
}

View File

@@ -1,4 +1,4 @@
.SettingsSection {
.SettingsHeadline {
color: var(--color-primary);
padding-bottom: 3px;
margin-bottom: 10px;
@@ -6,4 +6,4 @@
font-weight: 500;
border-bottom: 2px solid var(--color-accent);
display: inline-block;
}
}

View File

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

View File

@@ -8,12 +8,16 @@ interface ComponentProps {
title: string;
message: string;
id: number;
url: string | null;
clearNotification: (id: number) => void;
}
const Notification = (props: ComponentProps): JSX.Element => {
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(() => {
const closeNotification = setTimeout(() => {
@@ -22,21 +26,27 @@ const Notification = (props: ComponentProps): JSX.Element => {
const clearNotification = setTimeout(() => {
props.clearNotification(props.id);
}, 3600)
}, 3600);
return () => {
window.clearTimeout(closeNotification);
window.clearTimeout(clearNotification);
};
}, []);
const clickHandler = () => {
if (props.url) {
window.open(props.url, '_blank');
}
}, [])
};
return (
<div className={elementClasses}>
<div className={elementClasses} onClick={clickHandler}>
<h4>{props.title}</h4>
<p>{props.message}</p>
<div className={classes.Pog}></div>
</div>
)
}
);
};
export default connect(null, { clearNotification })(Notification);
export default connect(null, { clearNotification })(Notification);

View File

@@ -5,7 +5,7 @@ import axios from 'axios';
import { connect } from 'react-redux';
// Typescript
import { Weather, ApiResponse, Config, GlobalState } from '../../../interfaces';
import { Weather, ApiResponse, GlobalState, Config } from '../../../interfaces';
// CSS
import classes from './WeatherWidget.module.css';
@@ -13,12 +13,9 @@ import classes from './WeatherWidget.module.css';
// UI
import WeatherIcon from '../../UI/Icons/WeatherIcon/WeatherIcon';
// Utils
import { searchConfig } from '../../../utility';
interface ComponentProps {
configLoading: boolean;
config: Config[];
config: Config;
}
const WeatherWidget = (props: ComponentProps): JSX.Element => {
@@ -32,26 +29,28 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
conditionCode: 1000,
id: -1,
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
});
const [isLoading, setIsLoading] = useState(true);
// Initial request to get data
useEffect(() => {
axios.get<ApiResponse<Weather[]>>('/api/weather')
.then(data => {
axios
.get<ApiResponse<Weather[]>>('/api/weather')
.then((data) => {
const weatherData = data.data.data[0];
if (weatherData) {
setWeather(weatherData);
}
setIsLoading(false);
})
.catch(err => console.log(err));
.catch((err) => console.log(err));
}, []);
// Open socket for data updates
useEffect(() => {
const socketProtocol = document.location.protocol === 'http:' ? 'ws:' : 'wss:';
const socketProtocol =
document.location.protocol === 'http:' ? 'ws:' : 'wss:';
const socketAddress = `${socketProtocol}//${window.location.host}/socket`;
const webSocketClient = new WebSocket(socketAddress);
@@ -59,43 +58,44 @@ const WeatherWidget = (props: ComponentProps): JSX.Element => {
const data = JSON.parse(e.data);
setWeather({
...weather,
...data
})
}
...data,
});
};
return () => webSocketClient.close();
}, []);
return (
<div className={classes.WeatherWidget}>
{(isLoading || props.configLoading || searchConfig('WEATHER_API_KEY', '')) &&
(weather.id > 0 &&
(<Fragment>
<div className={classes.WeatherIcon}>
<WeatherIcon
weatherStatusCode={weather.conditionCode}
isDay={weather.isDay}
/>
</div>
<div className={classes.WeatherDetails}>
{searchConfig('isCelsius', true)
? <span>{weather.tempC}°C</span>
: <span>{weather.tempF}°F</span>
}
<span>{weather.cloud}%</span>
</div>
</Fragment>)
)
}
{isLoading ||
props.configLoading ||
(props.config.WEATHER_API_KEY && weather.id > 0 && (
<Fragment>
<div className={classes.WeatherIcon}>
<WeatherIcon
weatherStatusCode={weather.conditionCode}
isDay={weather.isDay}
/>
</div>
<div className={classes.WeatherDetails}>
{props.config.isCelsius ? (
<span>{weather.tempC}°C</span>
) : (
<span>{weather.tempF}°F</span>
)}
<span>{weather.cloud}%</span>
</div>
</Fragment>
))}
</div>
)
}
);
};
const mapStateToProps = (state: GlobalState) => {
return {
configLoading: state.config.loading,
config: state.config.config
}
}
config: state.config.config,
};
};
export default connect(mapStateToProps)(WeatherWidget);
export default connect(mapStateToProps)(WeatherWidget);

View File

@@ -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;
padding: 0;
@@ -5,18 +41,18 @@
}
body {
--color-background: #2B2C56;
--color-primary: #EFF1FC;
--color-accent: #6677EB;
--color-background: #242b33;
--color-primary: #effbff;
--color-accent: #6ee2ff;
--spacing-ui: 10px;
background-color: var(--color-background);
transition: background-color 0.3s;
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Roboto, sans-serif;
font-family: Roboto, sans-serif;
font-size: 14px;
}
a {
color: var(--color-primary);
text-decoration: none;
}
}

View File

@@ -1,8 +1,27 @@
import { Model } from './';
export interface Config extends Model {
key: string;
value: string;
valueType: string;
isLocked: boolean;
}
export interface Config {
WEATHER_API_KEY: string;
lat: number;
long: number;
isCelsius: boolean;
customTitle: string;
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;
}

View File

@@ -2,17 +2,32 @@ export interface WeatherForm {
WEATHER_API_KEY: string;
lat: number;
long: number;
isCelsius: number;
isCelsius: boolean;
}
export interface SettingsForm {
export interface SearchForm {
hideSearch: boolean;
defaultSearchProvider: string;
searchSameTab: boolean;
disableAutofocus: boolean;
}
export interface OtherSettingsForm {
customTitle: string;
pinAppsByDefault: number;
pinCategoriesByDefault: number;
hideHeader: number;
hideApps: number;
hideCategories: number;
hideSearch: number;
pinAppsByDefault: boolean;
pinCategoriesByDefault: boolean;
hideHeader: boolean;
hideApps: boolean;
hideCategories: boolean;
useOrdering: string;
openSameTab: number;
}
appsSameTab: boolean;
bookmarksSameTab: boolean;
dockerApps: boolean;
dockerHost: string;
kubernetesApps: boolean;
unpinStoppedApps: boolean;
useAmericanDate: boolean;
greetingsSchema: string;
daySchema: string;
monthSchema: string;
}

View File

@@ -1,8 +1,9 @@
export interface NewNotification {
title: string;
message: string;
url?: string;
}
export interface Notification extends NewNotification {
id: number;
}
}

View File

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

View File

@@ -0,0 +1,9 @@
import { Query } from './Query';
export interface SearchResult {
isLocal: boolean;
isURL: boolean;
sameTab: boolean;
search: string;
query: Query;
}

View File

@@ -8,4 +8,6 @@ export * from './Category';
export * from './Notification';
export * from './Config';
export * from './Forms';
export * from './Query';
export * from './Query';
export * from './SearchResult';
export * from './Route';

View File

@@ -26,8 +26,14 @@ import {
ClearNotificationAction,
// Config
GetConfigAction,
UpdateConfigAction
UpdateConfigAction,
} from './';
import {
AddQueryAction,
DeleteQueryAction,
FetchQueriesAction,
UpdateQueryAction,
} from './config';
export enum ActionTypes {
// Theme
@@ -62,35 +68,43 @@ export enum ActionTypes {
clearNotification = 'CLEAR_NOTIFICATION',
// Config
getConfig = 'GET_CONFIG',
updateConfig = 'UPDATE_CONFIG'
updateConfig = 'UPDATE_CONFIG',
fetchQueries = 'FETCH_QUERIES',
addQuery = 'ADD_QUERY',
deleteQuery = 'DELETE_QUERY',
updateQuery = 'UPDATE_QUERY',
}
export type Action =
export type Action =
// Theme
SetThemeAction |
| SetThemeAction
// Apps
GetAppsAction<any> |
PinAppAction |
AddAppAction |
DeleteAppAction |
UpdateAppAction |
ReorderAppsAction |
SortAppsAction |
| GetAppsAction<any>
| PinAppAction
| AddAppAction
| DeleteAppAction
| UpdateAppAction
| ReorderAppsAction
| SortAppsAction
// Categories
GetCategoriesAction<any> |
AddCategoryAction |
PinCategoryAction |
DeleteCategoryAction |
UpdateCategoryAction |
SortCategoriesAction |
ReorderCategoriesAction |
| GetCategoriesAction<any>
| AddCategoryAction
| PinCategoryAction
| DeleteCategoryAction
| UpdateCategoryAction
| SortCategoriesAction
| ReorderCategoriesAction
// Bookmarks
AddBookmarkAction |
DeleteBookmarkAction |
UpdateBookmarkAction |
| AddBookmarkAction
| DeleteBookmarkAction
| UpdateBookmarkAction
// Notifications
CreateNotificationAction |
ClearNotificationAction |
| CreateNotificationAction
| ClearNotificationAction
// Config
GetConfigAction |
UpdateConfigAction;
| GetConfigAction
| UpdateConfigAction
| FetchQueriesAction
| AddQueryAction
| DeleteQueryAction
| UpdateQueryAction;

View File

@@ -5,14 +5,17 @@ import { App, ApiResponse, NewApp, Config } from '../../interfaces';
import { CreateNotificationAction } from './notification';
export interface GetAppsAction<T> {
type: ActionTypes.getApps | ActionTypes.getAppsSuccess | ActionTypes.getAppsError;
type:
| ActionTypes.getApps
| ActionTypes.getAppsSuccess
| ActionTypes.getAppsError;
payload: T;
}
export const getApps = () => async (dispatch: Dispatch) => {
dispatch<GetAppsAction<undefined>>({
type: ActionTypes.getApps,
payload: undefined
payload: undefined,
});
try {
@@ -20,12 +23,12 @@ export const getApps = () => async (dispatch: Dispatch) => {
dispatch<GetAppsAction<App[]>>({
type: ActionTypes.getAppsSuccess,
payload: res.data.data
})
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
}
};
export interface PinAppAction {
type: ActionTypes.pinApp;
@@ -35,59 +38,64 @@ export interface PinAppAction {
export const pinApp = (app: App) => async (dispatch: Dispatch) => {
try {
const { id, isPinned, name } = app;
const res = await axios.put<ApiResponse<App>>(`/api/apps/${id}`, { isPinned: !isPinned });
const res = await axios.put<ApiResponse<App>>(`/api/apps/${id}`, {
isPinned: !isPinned,
});
const status = isPinned ? 'unpinned from Homescreen' : 'pinned to Homescreen';
const status = isPinned
? 'unpinned from Homescreen'
: 'pinned to Homescreen';
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `App ${name} ${status}`
}
})
message: `App ${name} ${status}`,
},
});
dispatch<PinAppAction>({
type: ActionTypes.pinApp,
payload: res.data.data
})
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
}
};
export interface AddAppAction {
type: ActionTypes.addAppSuccess;
payload: App;
}
export const addApp = (formData: NewApp | FormData) => async (dispatch: Dispatch) => {
try {
const res = await axios.post<ApiResponse<App>>('/api/apps', formData);
export const addApp =
(formData: NewApp | FormData) => async (dispatch: Dispatch) => {
try {
const res = await axios.post<ApiResponse<App>>('/api/apps', formData);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `App added`
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `App added`,
},
});
await dispatch<AddAppAction>({
type: ActionTypes.addAppSuccess,
payload: res.data.data
})
await dispatch<AddAppAction>({
type: ActionTypes.addAppSuccess,
payload: res.data.data,
});
// Sort apps
dispatch<any>(sortApps())
} catch (err) {
console.log(err);
}
}
// Sort apps
dispatch<any>(sortApps());
} catch (err) {
console.log(err);
}
};
export interface DeleteAppAction {
type: ActionTypes.deleteApp,
payload: number
type: ActionTypes.deleteApp;
payload: number;
}
export const deleteApp = (id: number) => async (dispatch: Dispatch) => {
@@ -98,79 +106,85 @@ export const deleteApp = (id: number) => async (dispatch: Dispatch) => {
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: 'App deleted'
}
})
message: 'App deleted',
},
});
dispatch<DeleteAppAction>({
type: ActionTypes.deleteApp,
payload: id
})
payload: id,
});
} catch (err) {
console.log(err);
}
}
};
export interface UpdateAppAction {
type: ActionTypes.updateApp;
payload: App;
}
export const updateApp = (id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<App>>(`/api/apps/${id}`, formData);
export const updateApp =
(id: number, formData: NewApp | FormData) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<App>>(
`/api/apps/${id}`,
formData
);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `App updated`
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `App updated`,
},
});
await dispatch<UpdateAppAction>({
type: ActionTypes.updateApp,
payload: res.data.data
})
await dispatch<UpdateAppAction>({
type: ActionTypes.updateApp,
payload: res.data.data,
});
// Sort apps
dispatch<any>(sortApps())
} catch (err) {
console.log(err);
}
}
// Sort apps
dispatch<any>(sortApps());
} catch (err) {
console.log(err);
}
};
export interface ReorderAppsAction {
type: ActionTypes.reorderApps;
payload: App[]
payload: App[];
}
interface ReorderQuery {
apps: {
id: number;
orderId: number;
}[]
}[];
}
export const reorderApps = (apps: App[]) => async (dispatch: Dispatch) => {
try {
const updateQuery: ReorderQuery = { apps: [] }
const updateQuery: ReorderQuery = { apps: [] };
apps.forEach((app, index) => updateQuery.apps.push({
id: app.id,
orderId: index + 1
}))
apps.forEach((app, index) =>
updateQuery.apps.push({
id: app.id,
orderId: index + 1,
})
);
await axios.put<ApiResponse<{}>>('/api/apps/0/reorder', updateQuery);
dispatch<ReorderAppsAction>({
type: ActionTypes.reorderApps,
payload: apps
})
payload: apps,
});
} catch (err) {
console.log(err);
}
}
};
export interface SortAppsAction {
type: ActionTypes.sortApps;
@@ -179,13 +193,13 @@ export interface SortAppsAction {
export const sortApps = () => async (dispatch: Dispatch) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering');
const res = await axios.get<ApiResponse<Config>>('/api/config');
dispatch<SortAppsAction>({
type: ActionTypes.sortApps,
payload: res.data.data.value
})
payload: res.data.data.useOrdering,
});
} catch (err) {
console.log(err);
}
}
};

View File

@@ -1,133 +1,157 @@
import axios from 'axios';
import { Dispatch } from 'redux';
import { ActionTypes } from './actionTypes';
import { Category, ApiResponse, NewCategory, Bookmark, NewBookmark, Config } from '../../interfaces';
import {
Category,
ApiResponse,
NewCategory,
Bookmark,
NewBookmark,
Config,
} from '../../interfaces';
import { CreateNotificationAction } from './notification';
/**
* GET CATEGORIES
*/
export interface GetCategoriesAction<T> {
type: ActionTypes.getCategories | ActionTypes.getCategoriesSuccess | ActionTypes.getCategoriesError;
type:
| ActionTypes.getCategories
| ActionTypes.getCategoriesSuccess
| ActionTypes.getCategoriesError;
payload: T;
}
export const getCategories = () => async (dispatch: Dispatch) => {
dispatch<GetCategoriesAction<undefined>>({
type: ActionTypes.getCategories,
payload: undefined
})
payload: undefined,
});
try {
const res = await axios.get<ApiResponse<Category[]>>('/api/categories');
dispatch<GetCategoriesAction<Category[]>>({
type: ActionTypes.getCategoriesSuccess,
payload: res.data.data
})
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
}
};
/**
* ADD CATEGORY
*/
export interface AddCategoryAction {
type: ActionTypes.addCategory,
payload: Category
type: ActionTypes.addCategory;
payload: Category;
}
export const addCategory = (formData: NewCategory) => async (dispatch: Dispatch) => {
try {
const res = await axios.post<ApiResponse<Category>>('/api/categories', formData);
export const addCategory =
(formData: NewCategory) => async (dispatch: Dispatch) => {
try {
const res = await axios.post<ApiResponse<Category>>(
'/api/categories',
formData
);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category ${formData.name} created`
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category ${formData.name} created`,
},
});
dispatch<AddCategoryAction>({
type: ActionTypes.addCategory,
payload: res.data.data
})
dispatch<AddCategoryAction>({
type: ActionTypes.addCategory,
payload: res.data.data,
});
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
}
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
};
/**
* ADD BOOKMARK
*/
export interface AddBookmarkAction {
type: ActionTypes.addBookmark,
payload: Bookmark
type: ActionTypes.addBookmark;
payload: Bookmark;
}
export const addBookmark = (formData: NewBookmark) => async (dispatch: Dispatch) => {
try {
const res = await axios.post<ApiResponse<Bookmark>>('/api/bookmarks', formData);
export const addBookmark =
(formData: NewBookmark | FormData) => async (dispatch: Dispatch) => {
try {
const res = await axios.post<ApiResponse<Bookmark>>(
'/api/bookmarks',
formData
);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Bookmark ${formData.name} created`
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Bookmark created`,
},
});
dispatch<AddBookmarkAction>({
type: ActionTypes.addBookmark,
payload: res.data.data
})
} catch (err) {
console.log(err);
}
}
dispatch<AddBookmarkAction>({
type: ActionTypes.addBookmark,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
/**
* PIN CATEGORY
*/
export interface PinCategoryAction {
type: ActionTypes.pinCategory,
payload: Category
type: ActionTypes.pinCategory;
payload: Category;
}
export const pinCategory = (category: Category) => async (dispatch: Dispatch) => {
try {
const { id, isPinned, name } = category;
const res = await axios.put<ApiResponse<Category>>(`/api/categories/${id}`, { isPinned: !isPinned });
export const pinCategory =
(category: Category) => async (dispatch: Dispatch) => {
try {
const { id, isPinned, name } = category;
const res = await axios.put<ApiResponse<Category>>(
`/api/categories/${id}`,
{ isPinned: !isPinned }
);
const status = isPinned ? 'unpinned from Homescreen' : 'pinned to Homescreen';
const status = isPinned
? 'unpinned from Homescreen'
: 'pinned to Homescreen';
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category ${name} ${status}`
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category ${name} ${status}`,
},
});
dispatch<PinCategoryAction>({
type: ActionTypes.pinCategory,
payload: res.data.data
})
} catch (err) {
console.log(err);
}
}
dispatch<PinCategoryAction>({
type: ActionTypes.pinCategory,
payload: res.data.data,
});
} catch (err) {
console.log(err);
}
};
/**
* DELETE CATEGORY
*/
export interface DeleteCategoryAction {
type: ActionTypes.deleteCategory,
payload: number
type: ActionTypes.deleteCategory;
payload: number;
}
export const deleteCategory = (id: number) => async (dispatch: Dispatch) => {
@@ -138,134 +162,151 @@ export const deleteCategory = (id: number) => async (dispatch: Dispatch) => {
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category deleted`
}
})
message: `Category deleted`,
},
});
dispatch<DeleteCategoryAction>({
type: ActionTypes.deleteCategory,
payload: id
})
payload: id,
});
} catch (err) {
console.log(err);
}
}
};
/**
* UPDATE CATEGORY
*/
export interface UpdateCategoryAction {
type: ActionTypes.updateCategory,
payload: Category
type: ActionTypes.updateCategory;
payload: Category;
}
export const updateCategory = (id: number, formData: NewCategory) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<Category>>(`/api/categories/${id}`, formData);
export const updateCategory =
(id: number, formData: NewCategory) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<Category>>(
`/api/categories/${id}`,
formData
);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category ${formData.name} updated`
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Category ${formData.name} updated`,
},
});
dispatch<UpdateCategoryAction>({
type: ActionTypes.updateCategory,
payload: res.data.data
})
dispatch<UpdateCategoryAction>({
type: ActionTypes.updateCategory,
payload: res.data.data,
});
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
}
dispatch<any>(sortCategories());
} catch (err) {
console.log(err);
}
};
/**
* DELETE BOOKMARK
*/
export interface DeleteBookmarkAction {
type: ActionTypes.deleteBookmark,
type: ActionTypes.deleteBookmark;
payload: {
bookmarkId: number,
categoryId: number
}
bookmarkId: number;
categoryId: number;
};
}
export const deleteBookmark = (bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => {
try {
await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
export const deleteBookmark =
(bookmarkId: number, categoryId: number) => async (dispatch: Dispatch) => {
try {
await axios.delete<ApiResponse<{}>>(`/api/bookmarks/${bookmarkId}`);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: 'Bookmark deleted'
}
})
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: 'Bookmark deleted',
},
});
dispatch<DeleteBookmarkAction>({
type: ActionTypes.deleteBookmark,
payload: {
bookmarkId,
categoryId
}
})
} catch (err) {
console.log(err);
}
}
dispatch<DeleteBookmarkAction>({
type: ActionTypes.deleteBookmark,
payload: {
bookmarkId,
categoryId,
},
});
} catch (err) {
console.log(err);
}
};
/**
* UPDATE BOOKMARK
*/
export interface UpdateBookmarkAction {
type: ActionTypes.updateBookmark,
payload: Bookmark
type: ActionTypes.updateBookmark;
payload: Bookmark;
}
export const updateBookmark = (bookmarkId: number, formData: NewBookmark, previousCategoryId: number) => async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<Bookmark>>(`/api/bookmarks/${bookmarkId}`, formData);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Bookmark ${formData.name} updated`
}
})
// Check if category was changed
const categoryWasChanged = formData.categoryId !== previousCategoryId;
if (categoryWasChanged) {
// Delete bookmark from old category
dispatch<DeleteBookmarkAction>({
type: ActionTypes.deleteBookmark,
payload: {
bookmarkId,
categoryId: previousCategoryId
}
})
// Add bookmark to the new category
dispatch<AddBookmarkAction>({
type: ActionTypes.addBookmark,
payload: res.data.data
})
} else {
// Else update only name/url
dispatch<UpdateBookmarkAction>({
type: ActionTypes.updateBookmark,
payload: res.data.data
})
export const updateBookmark =
(
bookmarkId: number,
formData: NewBookmark | FormData,
category: {
prev: number;
curr: number;
}
} catch (err) {
console.log(err);
}
}
) =>
async (dispatch: Dispatch) => {
try {
const res = await axios.put<ApiResponse<Bookmark>>(
`/api/bookmarks/${bookmarkId}`,
formData
);
dispatch<CreateNotificationAction>({
type: ActionTypes.createNotification,
payload: {
title: 'Success',
message: `Bookmark updated`,
},
});
// Check if category was changed
const categoryWasChanged = category.curr !== category.prev;
if (categoryWasChanged) {
// Delete bookmark from old category
dispatch<DeleteBookmarkAction>({
type: ActionTypes.deleteBookmark,
payload: {
bookmarkId,
categoryId: category.prev,
},
});
// Add bookmark to the new category
dispatch<AddBookmarkAction>({
type: ActionTypes.addBookmark,
payload: res.data.data,
});
} else {
// Else update only name/url/icon
dispatch<UpdateBookmarkAction>({
type: ActionTypes.updateBookmark,
payload: res.data.data,
});
}
} catch (err) {
console.log(err);
}
};
/**
* SORT CATEGORIES
@@ -277,16 +318,16 @@ export interface SortCategoriesAction {
export const sortCategories = () => async (dispatch: Dispatch) => {
try {
const res = await axios.get<ApiResponse<Config>>('/api/config/useOrdering');
const res = await axios.get<ApiResponse<Config>>('/api/config');
dispatch<SortCategoriesAction>({
type: ActionTypes.sortCategories,
payload: res.data.data.value
})
payload: res.data.data.useOrdering,
});
} catch (err) {
console.log(err);
}
}
};
/**
* REORDER CATEGORIES
@@ -300,25 +341,31 @@ interface ReorderQuery {
categories: {
id: number;
orderId: number;
}[]
}[];
}
export const reorderCategories = (categories: Category[]) => async (dispatch: Dispatch) => {
try {
const updateQuery: ReorderQuery = { categories: [] }
export const reorderCategories =
(categories: Category[]) => async (dispatch: Dispatch) => {
try {
const updateQuery: ReorderQuery = { categories: [] };
categories.forEach((category, index) => updateQuery.categories.push({
id: category.id,
orderId: index + 1
}))
categories.forEach((category, index) =>
updateQuery.categories.push({
id: category.id,
orderId: index + 1,
})
);
await axios.put<ApiResponse<{}>>('/api/categories/0/reorder', updateQuery);
await axios.put<ApiResponse<{}>>(
'/api/categories/0/reorder',
updateQuery
);
dispatch<ReorderCategoriesAction>({
type: ActionTypes.reorderCategories,
payload: categories
})
} catch (err) {
console.log(err);
}
}
dispatch<ReorderCategoriesAction>({
type: ActionTypes.reorderCategories,
payload: categories,
});
} catch (err) {
console.log(err);
}
};

View File

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

View File

@@ -1,36 +1,79 @@
import { ActionTypes, Action } from '../actions';
import { Config } from '../../interfaces';
import { Config, Query } from '../../interfaces';
import { configTemplate } from '../../utility';
export interface State {
loading: boolean;
config: Config[];
config: Config;
customQueries: Query[];
}
const initialState: State = {
loading: true,
config: []
}
config: { ...configTemplate },
customQueries: [],
};
const getConfig = (state: State, action: Action): State => {
return {
...state,
loading: false,
config: action.payload
}
}
config: action.payload,
};
};
const updateConfig = (state: State, action: Action): State => {
return {
...state,
config: action.payload
}
}
config: action.payload,
};
};
const fetchQueries = (state: State, action: Action): State => {
return {
...state,
customQueries: action.payload,
};
};
const addQuery = (state: State, action: Action): State => {
return {
...state,
customQueries: [...state.customQueries, action.payload],
};
};
const deleteQuery = (state: State, action: Action): State => {
return {
...state,
customQueries: action.payload,
};
};
const updateQuery = (state: State, action: Action): State => {
return {
...state,
customQueries: action.payload,
};
};
const configReducer = (state: State = initialState, action: Action) => {
switch(action.type) {
case ActionTypes.getConfig: return getConfig(state, action);
case ActionTypes.updateConfig: return updateConfig(state, action);
default: return state;
switch (action.type) {
case ActionTypes.getConfig:
return getConfig(state, action);
case ActionTypes.updateConfig:
return updateConfig(state, action);
case ActionTypes.fetchQueries:
return fetchQueries(state, action);
case ActionTypes.addQuery:
return addQuery(state, action);
case ActionTypes.deleteQuery:
return deleteQuery(state, action);
case ActionTypes.updateQuery:
return updateQuery(state, action);
default:
return state;
}
}
};
export default configReducer;
export default configReducer;

View File

@@ -7,20 +7,22 @@ export interface State {
const initialState: State = {
theme: {
name: 'blues',
name: 'tron',
colors: {
background: '#2B2C56',
primary: '#EFF1FC',
accent: '#6677EB'
}
}
}
background: '#242B33',
primary: '#EFFBFF',
accent: '#6EE2FF',
},
},
};
const themeReducer = (state = initialState, action: Action) => {
switch (action.type) {
case ActionTypes.setTheme: return { theme: action.payload };
default: return state;
case ActionTypes.setTheme:
return { theme: action.payload };
default:
return state;
}
}
};
export default themeReducer;
export default themeReducer;

View File

@@ -4,24 +4,31 @@ import { createNotification } from '../store/actions';
export const checkVersion = async (isForced: boolean = false) => {
try {
const res = await axios.get<string>('https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env');
const res = await axios.get<string>(
'https://raw.githubusercontent.com/pawelmalak/flame/master/client/.env'
);
const githubVersion = res.data
.split('\n')
.map(pair => pair.split('='))[0][1];
.map((pair) => pair.split('='))[0][1];
if (githubVersion !== process.env.REACT_APP_VERSION) {
store.dispatch<any>(createNotification({
title: 'Info',
message: 'New version is available!'
}))
store.dispatch<any>(
createNotification({
title: 'Info',
message: 'New version is available!',
url: 'https://github.com/pawelmalak/flame/blob/master/CHANGELOG.md',
})
);
} else if (isForced) {
store.dispatch<any>(createNotification({
title: 'Info',
message: 'You are using the latest version!'
}))
store.dispatch<any>(
createNotification({
title: 'Info',
message: 'You are using the latest version!',
})
);
}
} catch (err) {
console.log(err);
}
}
};

View File

@@ -1,6 +1,9 @@
export * from './iconParser';
export * from './urlParser';
export * from './searchConfig';
export * from './checkVersion';
export * from './sortData';
export * from './searchParser';
export * from './searchParser';
export * from './redirectUrl';
export * from './templateObjects';
export * from './inputHandler';
export * from './storeUIConfig';

View File

@@ -0,0 +1,39 @@
import { ChangeEvent, SetStateAction } from 'react';
type Event = ChangeEvent<HTMLInputElement | HTMLSelectElement>;
interface Options {
isNumber?: boolean;
isBool?: boolean;
}
interface Params<T> {
e: Event;
options?: Options;
setStateHandler: (v: SetStateAction<T>) => void;
state: T;
}
export const inputHandler = <T>(params: Params<T>): void => {
const { e, options, setStateHandler, state } = params;
const rawValue = e.target.value;
let value: string | number | boolean = e.target.value;
if (options) {
const { isNumber = false, isBool = false } = options;
if (isNumber) {
value = parseFloat(rawValue);
}
if (isBool) {
value = !!parseInt(rawValue);
}
}
setStateHandler({
...state,
[e.target.name]: value,
});
};

View File

@@ -0,0 +1,11 @@
import { urlParser } from '.';
export const redirectUrl = (url: string, sameTab: boolean) => {
const parsedUrl = urlParser(url)[1];
if (sameTab) {
document.location.replace(parsedUrl);
} else {
window.open(parsedUrl);
}
};

View File

@@ -1,24 +0,0 @@
import { store } from '../store/store';
/**
* Search config store with given key
* @param key Config pair key to search
* @param _default Value to return if key is not found
*/
export const searchConfig = (key: string, _default: any) => {
const state = store.getState();
const pair = state.config.config.find(p => p.key === key);
if (pair) {
if (pair.valueType === 'number') {
return parseFloat(pair.value);
} else if (pair.valueType === 'boolean') {
return parseInt(pair.value);
} else {
return pair.value;
}
}
return _default;
}

View File

@@ -1,22 +1,54 @@
import { queries } from './searchQueries.json';
import { Query } from '../interfaces';
import { Query, SearchResult } from '../interfaces';
import { store } from '../store/store';
import { searchConfig } from '.';
export const searchParser = (searchQuery: string): SearchResult => {
const result: SearchResult = {
isLocal: false,
isURL: false,
sameTab: false,
search: '',
query: {
name: '',
prefix: '',
template: '',
},
};
export const searchParser = (searchQuery: string): void => {
const space = searchQuery.indexOf(' ');
const prefix = searchQuery.slice(1, space);
const search = encodeURIComponent(searchQuery.slice(space + 1));
const { customQueries, config } = store.getState().config;
const query = queries.find((q: Query) => q.prefix === prefix);
// Check if url or ip was passed
const urlRegex =
/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/i;
result.isURL = urlRegex.test(searchQuery);
// Match prefix and query
const splitQuery = searchQuery.match(/^\/([a-z]+)[ ](.+)$/i);
const prefix = splitQuery ? splitQuery[1] : config.defaultSearchProvider;
const search = splitQuery
? encodeURIComponent(splitQuery[2])
: encodeURIComponent(searchQuery);
const query = [...queries, ...customQueries].find(
(q: Query) => q.prefix === prefix
);
// If search provider was found
if (query) {
const sameTab = searchConfig('openSameTab', false);
result.query = query;
result.search = search;
if (sameTab) {
document.location.replace(`${query.template}${search}`);
if (prefix === 'l') {
result.isLocal = true;
} else {
window.open(`${query.template}${search}`);
result.sameTab = config.searchSameTab;
}
return result;
}
}
return result;
};

View File

@@ -1,14 +1,9 @@
{
"queries": [
{
"name": "Google",
"prefix": "g",
"template": "https://www.google.com/search?q="
},
{
"name": "DuckDuckGo",
"prefix": "d",
"template": "https://duckduckgo.com/?q="
"name": "Deezer",
"prefix": "dz",
"template": "https://www.deezer.com/search/"
},
{
"name": "Disroot",
@@ -16,24 +11,49 @@
"template": "http://search.disroot.org/search?q="
},
{
"name": "YouTube",
"prefix": "yt",
"template": "https://www.youtube.com/results?search_query="
"name": "DuckDuckGo",
"prefix": "d",
"template": "https://duckduckgo.com/?q="
},
{
"name": "Reddit",
"prefix": "r",
"template": "https://www.reddit.com/search?q="
"name": "Google",
"prefix": "g",
"template": "https://www.google.com/search?q="
},
{
"name": "IMDb",
"prefix": "im",
"template": "https://www.imdb.com/find?q="
},
{
"name": "Local search",
"prefix": "l",
"template": "#"
},
{
"name": "Reddit",
"prefix": "r",
"template": "https://www.reddit.com/search?q="
},
{
"name": "Spotify",
"prefix": "sp",
"template": "https://open.spotify.com/search/"
},
{
"name": "The Movie Database",
"prefix": "mv",
"template": "https://www.themoviedb.org/search?query="
},
{
"name": "Tidal",
"prefix": "td",
"template": "https://listen.tidal.com/search?q="
},
{
"name": "YouTube",
"prefix": "yt",
"template": "https://www.youtube.com/results?search_query="
}
]
}
}

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