Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08afaece2e | ||
|
|
4f2ba0a96d | ||
|
|
9db46faabe | ||
|
|
567af1c66e | ||
|
|
2485f4ff33 | ||
|
|
bce51bb2c4 | ||
|
|
7febd59ad7 | ||
|
|
1388a1876e | ||
|
|
aca8b0261e | ||
|
|
4e20527834 | ||
|
|
4ed29fe276 | ||
|
|
b45eecada2 | ||
|
|
1d70bd132a | ||
|
|
88694c7e27 | ||
|
|
3dd255f359 | ||
|
|
feb7275cf8 | ||
|
|
da13ca6092 | ||
|
|
3d3e2eed8c | ||
|
|
df6d96f5b6 | ||
|
|
0ec77c33bf | ||
|
|
98924ac006 | ||
|
|
4ef9652ede | ||
|
|
cfb471e578 | ||
|
|
76e50624e7 | ||
|
|
34279c8b8c | ||
|
|
b7de1e3d27 | ||
|
|
85ee5da025 | ||
|
|
e5cba605fa | ||
|
|
6f44200a3c | ||
|
|
7129fe83da | ||
|
|
6f8a017bfb | ||
|
|
55f192f664 | ||
|
|
edb04c375f | ||
|
|
38ffdf1bff | ||
|
|
a885440fef | ||
|
|
16341ca6da | ||
|
|
fc219f704c | ||
|
|
63346f7e38 | ||
|
|
04be0d1316 | ||
|
|
65a33f16fd | ||
|
|
fdec74acc6 | ||
|
|
231dbc4577 | ||
|
|
459523dfd2 | ||
|
|
591824dd0c | ||
|
|
da928f20a2 | ||
|
|
a162450568 | ||
|
|
084218027c | ||
|
|
bf1aa9e85c | ||
|
|
afc0f16470 | ||
|
|
59271d3376 | ||
|
|
84bd641cf2 | ||
|
|
1d8e36b46d | ||
|
|
1625932e52 | ||
|
|
6a6f1750b1 | ||
|
|
4252457871 | ||
|
|
9606978bd7 | ||
|
|
ebae61a688 | ||
|
|
43f38a2f44 | ||
|
|
53d50ca869 | ||
|
|
fac280ff0a | ||
|
|
6ae6c58f4c | ||
|
|
8521995758 | ||
|
|
19f95c433c | ||
|
|
45fb337c87 | ||
|
|
8808f65b47 | ||
|
|
5cef34a467 | ||
|
|
8681f75bab | ||
|
|
c1b61f9cd9 | ||
|
|
78a018f686 | ||
|
|
36c9b7648a | ||
|
|
5c60c7c156 | ||
|
|
683c948f6c | ||
|
|
1699146f79 | ||
|
|
a01661d0d5 | ||
|
|
1962af01e6 | ||
|
|
39349dded1 | ||
|
|
b53509aa69 | ||
|
|
b5ba9856ed | ||
|
|
b94df53267 | ||
|
|
4b42f991f8 | ||
|
|
2ceff6828a | ||
|
|
d39eda49de | ||
|
|
a5d6cf04cf | ||
|
|
1fbe0746a4 | ||
|
|
f93659b661 | ||
|
|
88785aaa32 | ||
|
|
4143ae8198 | ||
|
|
f1c48e8a15 | ||
|
|
6445a5009a | ||
|
|
112a35c08f | ||
|
|
7970ac3031 | ||
|
|
c03f302fa6 | ||
|
|
0c3a27febd |
10
.dev/DEV_GUIDELINES.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## Adding new config key
|
||||
|
||||
1. Edit utils/init/initialConfig.json
|
||||
2. Edit client/src/interfaces/Config.ts
|
||||
3. Edit client/src/utility/templateObjects/configTemplate.ts
|
||||
|
||||
If config value will be used in a form:
|
||||
|
||||
4. Edit client/src/interfaces/Forms.ts
|
||||
5. Edit client/src/utility/templateObjects/settingsTemplate.ts
|
||||
166
.dev/bookmarks_importer.py
Executable file
@@ -0,0 +1,166 @@
|
||||
import sqlite3
|
||||
from bs4 import BeautifulSoup
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from io import BytesIO
|
||||
import re
|
||||
import base64
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
import argparse
|
||||
|
||||
|
||||
"""
|
||||
Imports html bookmarks file into Flame.
|
||||
Tested only on Firefox html exports so far.
|
||||
|
||||
Usage:
|
||||
python3 bookmarks_importer.py --bookmarks <path to bookmarks file> --data <path to flame data dir>
|
||||
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--bookmarks', type=str, required=True)
|
||||
parser.add_argument('--data', type=str, required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
bookmarks_path = args.bookmarks
|
||||
data_path = args.data
|
||||
created = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + datetime.now().astimezone().strftime(" %z")
|
||||
updated = created
|
||||
if data_path[-1] != '/':
|
||||
data_path = data_path + '/'
|
||||
|
||||
|
||||
|
||||
|
||||
def Base64toPNG(codec, name):
|
||||
|
||||
"""
|
||||
Convert base64 encoded image to png file
|
||||
Reference: https://github.com/python-pillow/Pillow/issues/3400#issuecomment-428104239
|
||||
|
||||
Parameters:
|
||||
codec (str): icon in html bookmark format.e.g. 'data:image/png;base64,<image encoding>'
|
||||
name (str): name for export file
|
||||
|
||||
Returns:
|
||||
icon_name(str): name of png output E.g. 1636473849374--mybookmark.png
|
||||
None: if image not produced successfully
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
unix_t = str(int(datetime.now(tz=timezone.utc).timestamp() * 1000))
|
||||
icon_name = unix_t + '--' + re.sub(r'\W+', '', name).lower() + '.png'
|
||||
image_path = data_path + 'uploads/' + icon_name
|
||||
if os.path.exists(image_path):
|
||||
return image_path
|
||||
base64_data = re.sub('^data:image/.+;base64,', '', codec)
|
||||
byte_data = base64.b64decode(base64_data)
|
||||
image_data = BytesIO(byte_data)
|
||||
img = Image.open(image_data)
|
||||
img.save(image_path, "PNG")
|
||||
return icon_name
|
||||
except UnidentifiedImageError:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
|
||||
def FlameBookmarkParser(bookmarks_path):
|
||||
|
||||
"""
|
||||
Parses HTML bookmarks file
|
||||
Reference: https://stackoverflow.com/questions/68621107/extracting-bookmarks-and-folder-hierarchy-from-google-chrome-with-beautifulsoup
|
||||
|
||||
Parameters:
|
||||
bookmarks_path (str): path to bookmarks.html
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
|
||||
soup = BeautifulSoup()
|
||||
with open(bookmarks_path) as f:
|
||||
soup = BeautifulSoup(f.read(), 'lxml')
|
||||
|
||||
dt = soup.find_all('dt')
|
||||
folder_name =''
|
||||
for i in dt:
|
||||
n = i.find_next()
|
||||
if n.name == 'h3':
|
||||
folder_name = n.text
|
||||
continue
|
||||
else:
|
||||
url = n.get("href")
|
||||
website_name = n.text
|
||||
icon = n.get("icon")
|
||||
if icon != None:
|
||||
icon_name = Base64toPNG(icon, website_name)
|
||||
cat_id = AddFlameCategory(folder_name)
|
||||
AddFlameBookmark(website_name, url, cat_id, icon_name)
|
||||
|
||||
|
||||
|
||||
|
||||
def AddFlameCategory(cat_name):
|
||||
"""
|
||||
Parses HTML bookmarks file
|
||||
|
||||
Parameters:
|
||||
cat_name (str): category name
|
||||
|
||||
Returns:
|
||||
cat_id (int): primary key id of cat_name
|
||||
|
||||
"""
|
||||
|
||||
|
||||
|
||||
con = sqlite3.connect(data_path + 'db.sqlite')
|
||||
cur = con.cursor()
|
||||
count_sql = ("SELECT count(*) FROM categories WHERE name = ?;")
|
||||
cur.execute(count_sql, [cat_name])
|
||||
count = int(cur.fetchall()[0][0])
|
||||
if count > 0:
|
||||
getid_sql = ("SELECT id FROM categories WHERE name = ?;")
|
||||
cur.execute(getid_sql, [cat_name])
|
||||
cat_id = int(cur.fetchall()[0][0])
|
||||
return cat_id
|
||||
|
||||
is_pinned = 1
|
||||
|
||||
insert_sql = "INSERT OR IGNORE INTO categories(name, isPinned, createdAt, updatedAt) VALUES (?, ?, ?, ?);"
|
||||
cur.execute(insert_sql, (cat_name, is_pinned, created, updated))
|
||||
con.commit()
|
||||
|
||||
getid_sql = ("SELECT id FROM categories WHERE name = ?;")
|
||||
cur.execute(getid_sql, [cat_name])
|
||||
cat_id = int(cur.fetchall()[0][0])
|
||||
return cat_id
|
||||
|
||||
|
||||
|
||||
|
||||
def AddFlameBookmark(website_name, url, cat_id, icon_name):
|
||||
con = sqlite3.connect(data_path + 'db.sqlite')
|
||||
cur = con.cursor()
|
||||
if icon_name == None:
|
||||
insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?);"
|
||||
cur.execute(insert_sql, (website_name, url, cat_id, created, updated))
|
||||
con.commit()
|
||||
else:
|
||||
insert_sql = "INSERT OR IGNORE INTO bookmarks(name, url, categoryId, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?);"
|
||||
cur.execute(insert_sql, (website_name, url, cat_id, icon_name, created, updated))
|
||||
con.commit()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
FlameBookmarkParser(bookmarks_path)
|
||||
9
.dev/getMdi.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Script to get all icon names from materialdesignicons.com
|
||||
const getMdi = () => {
|
||||
const icons = document.querySelectorAll('#icons div span');
|
||||
const names = [...icons].map((icon) => icon.textContent.replace('mdi-', ''));
|
||||
const output = names.map((name) => ({ name }));
|
||||
output.pop();
|
||||
const json = JSON.stringify(output);
|
||||
console.log(json);
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
node_modules
|
||||
github
|
||||
public
|
||||
public
|
||||
build.sh
|
||||
k8s
|
||||
skaffold.yaml
|
||||
|
||||
0
github/_apps.png → .github/_apps.png
vendored
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
0
github/_home.png → .github/_home.png
vendored
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 75 KiB |
0
github/_themes.png → .github/_themes.png
vendored
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
node_modules
|
||||
data
|
||||
public
|
||||
public
|
||||
!client/public
|
||||
build.sh
|
||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*.md
|
||||
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"arrowParens": "always",
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
110
CHANGELOG.md
Normal file
@@ -0,0 +1,110 @@
|
||||
### v1.7.4 (2021-11-08)
|
||||
- Added option to set custom greetings and date ([#103](https://github.com/pawelmalak/flame/issues/103))
|
||||
- Fallback to web search if local search has zero results ([#129](https://github.com/pawelmalak/flame/issues/129))
|
||||
- Added iOS "Add to homescreen" icon ([#131](https://github.com/pawelmalak/flame/issues/131))
|
||||
- Added experimental script to import bookmarks ([#141](https://github.com/pawelmalak/flame/issues/141))
|
||||
- Added 3 new themes
|
||||
|
||||
### v1.7.3 (2021-10-28)
|
||||
- Fixed bug with custom CSS not updating
|
||||
|
||||
### v1.7.2 (2021-10-28)
|
||||
- Pressing Enter while search bar is focused will now redirect to first result of local search ([#121](https://github.com/pawelmalak/flame/issues/121))
|
||||
- Use search bar shortcuts when it's not focused ([#124](https://github.com/pawelmalak/flame/issues/124))
|
||||
- Fixed bug with Weather API still logging with module being disabled ([#125](https://github.com/pawelmalak/flame/issues/125))
|
||||
- Added option to disable search bar autofocus ([#127](https://github.com/pawelmalak/flame/issues/127))
|
||||
|
||||
### v1.7.1 (2021-10-22)
|
||||
- Fixed search action not being triggered by Numpad Enter
|
||||
- Added option to change date formatting ([#92](https://github.com/pawelmalak/flame/issues/92))
|
||||
- Added shortcuts (Esc and double click) to clear search bar ([#100](https://github.com/pawelmalak/flame/issues/100))
|
||||
- Added Traefik integration ([#102](https://github.com/pawelmalak/flame/issues/102))
|
||||
- Fixed search bar not redirecting to valid URL if it starts with capital letter ([#118](https://github.com/pawelmalak/flame/issues/118))
|
||||
- Performance improvements
|
||||
|
||||
### v1.7.0 (2021-10-11)
|
||||
- Search bar will now redirect if valid URL or IP is provided ([#67](https://github.com/pawelmalak/flame/issues/67))
|
||||
- Users can now add their custom search providers ([#71](https://github.com/pawelmalak/flame/issues/71))
|
||||
- Fixed bug related to creating new apps/bookmarks with custom icon ([#83](https://github.com/pawelmalak/flame/issues/83))
|
||||
- URL can now be assigned to notifications. Clicking on "New version is available" popup will now redirect to changelog ([#86](https://github.com/pawelmalak/flame/issues/86))
|
||||
- Added static fonts ([#94](https://github.com/pawelmalak/flame/issues/94))
|
||||
- Fixed bug with overriding app icon created with docker labels
|
||||
|
||||
### v1.6.9 (2021-10-09)
|
||||
- Added option for remote docker host ([#97](https://github.com/pawelmalak/flame/issues/97))
|
||||
|
||||
### v1.6.8 (2021-10-05)
|
||||
- Implemented migration system for database
|
||||
|
||||
### v1.6.7 (2021-10-04)
|
||||
- Add multiple labels to Docker Compose ([#90](https://github.com/pawelmalak/flame/issues/90))
|
||||
- Custom icons via Docker Compose labels ([#91](https://github.com/pawelmalak/flame/issues/91))
|
||||
|
||||
### v1.6.6 (2021-09-06)
|
||||
- Added local search (filter) for apps and bookmarks ([#47](https://github.com/pawelmalak/flame/issues/47))
|
||||
|
||||
### v1.6.5 (2021-08-28)
|
||||
- Added support for more URL schemes ([#74](https://github.com/pawelmalak/flame/issues/74))
|
||||
|
||||
### v1.6.4 (2021-08-17)
|
||||
- Added Kubernetes integration ([#72 continued](https://github.com/pawelmalak/flame/issues/72))
|
||||
|
||||
### v1.6.3 (2021-08-09)
|
||||
- Added support for custom SVG icons ([#73](https://github.com/pawelmalak/flame/issues/73))
|
||||
- Added Deezer and Tidal to search queries
|
||||
|
||||
### v1.6.2 (2021-08-06)
|
||||
- Fixed changelog link
|
||||
- Added support for Docker API ([#14](https://github.com/pawelmalak/flame/issues/14))
|
||||
|
||||
### v1.6.1 (2021-07-28)
|
||||
- Added option to upload custom icons for bookmarks ([#52](https://github.com/pawelmalak/flame/issues/52))
|
||||
- Fixed custom icons not updating ([#58](https://github.com/pawelmalak/flame/issues/58))
|
||||
- Added changelog file
|
||||
|
||||
### v1.6 (2021-07-17)
|
||||
- Added support for Steam URLs ([#62](https://github.com/pawelmalak/flame/issues/62))
|
||||
- Fixed bug with custom CSS not persisting ([#64](https://github.com/pawelmalak/flame/issues/64))
|
||||
- Added option to set default prefix for search bar ([#65](https://github.com/pawelmalak/flame/issues/65))
|
||||
|
||||
### v1.5 (2021-06-24)
|
||||
- Added ability to set custom CSS from settings ([#8](https://github.com/pawelmalak/flame/issues/8) and [#17](https://github.com/pawelmalak/flame/issues/17)) (experimental)
|
||||
- Added option to upload custom icons ([#12](https://github.com/pawelmalak/flame/issues/12))
|
||||
- Added option to open links in a new or the same tab ([#27](https://github.com/pawelmalak/flame/issues/27))
|
||||
- Added Search bar with support for 3 search engines and 4 services ([#44](https://github.com/pawelmalak/flame/issues/44))
|
||||
- Added option to hide applications and categories ([#48](https://github.com/pawelmalak/flame/issues/48))
|
||||
- Improved Logger
|
||||
|
||||
### v1.4 (2021-06-18)
|
||||
- Added more sorting options. User can now choose to sort apps and categories by name, creation time or to use custom order ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Added reordering functionality. User can now set custom order for apps and categories from their 'edit tables' ([#13](https://github.com/pawelmalak/flame/issues/13))
|
||||
- Changed get all controllers for applications and categories to use case-insensitive ordering ([#36](https://github.com/pawelmalak/flame/issues/36))
|
||||
- New apps will be placed correctly in the array depending on used sorting settings ([#37](https://github.com/pawelmalak/flame/issues/37))
|
||||
- Added app version to settings with option to check for updates manually ([#38](https://github.com/pawelmalak/flame/issues/38))
|
||||
- Added update check on app start ([#38](https://github.com/pawelmalak/flame/issues/38))
|
||||
- Fixed bug with decimal input values in Safari browser ([#40](https://github.com/pawelmalak/flame/issues/40))
|
||||
|
||||
### v1.3 (2021-06-14)
|
||||
- Added reverse proxy support ([#23](https://github.com/pawelmalak/flame/issues/23) and [#24](https://github.com/pawelmalak/flame/issues/24))
|
||||
- Added support for more url formats ([#26](https://github.com/pawelmalak/flame/issues/26))
|
||||
- Added ability to hide main header ([#28](https://github.com/pawelmalak/flame/issues/28))
|
||||
- Fixed settings not being synchronized ([#29](https://github.com/pawelmalak/flame/issues/29))
|
||||
- Added auto-refresh for greeting and date ([#34](https://github.com/pawelmalak/flame/issues/34))
|
||||
|
||||
### v1.2 (2021-06-10)
|
||||
- Added simple check to the weather module settings to inform user if the api key is missing ([#2](https://github.com/pawelmalak/flame/issues/2))
|
||||
- Added ability to set optional icons to the bookmarks ([#7](https://github.com/pawelmalak/flame/issues/7))
|
||||
- Added option to pin new applications and categories to the homescreen by default ([#11](https://github.com/pawelmalak/flame/issues/11))
|
||||
- Added background highlight while hovering over application card ([#15](https://github.com/pawelmalak/flame/issues/15))
|
||||
- Created CRON job to clear old weather data from the database ([#16](https://github.com/pawelmalak/flame/issues/16))
|
||||
- Added proxy for websocket instead of using hard coded host ([#18](https://github.com/pawelmalak/flame/issues/18))
|
||||
- Fixed bug with overwriting opened tabs ([#20](https://github.com/pawelmalak/flame/issues/20))
|
||||
|
||||
### v1.1 (2021-06-09)
|
||||
- Added custom favicon and changed page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Added functionality to set custom page title ([#3](https://github.com/pawelmalak/flame/issues/3))
|
||||
- Changed messages on the homescreen when there are apps/bookmarks created but not pinned to the homescreen ([#4](https://github.com/pawelmalak/flame/issues/4))
|
||||
- Added 'warnings' to apps and bookmarks forms about supported url formats ([#5](https://github.com/pawelmalak/flame/issues/5))
|
||||
|
||||
### v1.0 (2021-06-08)
|
||||
Initial release of Flame - self-hosted startpage using Node.js on backend and React on frontend.
|
||||
10
Dockerfile
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -1,27 +1,26 @@
|
||||
# Flame
|
||||
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
|
||||

|
||||

|
||||
|
||||
## Description
|
||||
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own appliaction hub in no time - no file editing necessary.
|
||||
|
||||
Flame is self-hosted startpage for your server. Its design is inspired (heavily) by [SUI](https://github.com/jeroenpardon/sui). Flame is very easy to setup and use. With built-in editors it allows you to setup your very own application hub in no time - no file editing necessary.
|
||||
|
||||
## Technology
|
||||
|
||||
- Backend
|
||||
- Node.js + Express
|
||||
- Sequelize ORM + SQLite
|
||||
- Frontend
|
||||
- React
|
||||
- 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
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
[](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
@@ -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;
|
||||
|
||||
@@ -1 +1 @@
|
||||
REACT_APP_VERSION=1.5.0
|
||||
REACT_APP_VERSION=1.7.4
|
||||
447
client/package-lock.json
generated
@@ -1806,9 +1806,9 @@
|
||||
}
|
||||
},
|
||||
"@mdi/js": {
|
||||
"version": "5.9.55",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-5.9.55.tgz",
|
||||
"integrity": "sha512-BbeHMgeK2/vjdJIRnx12wvQ6s8xAYfvMmEAVsUx9b+7GiQGQ9Za8jpwp17dMKr9CgKRvemlAM4S7S3QOtEbp4A=="
|
||||
"version": "6.4.95",
|
||||
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-6.4.95.tgz",
|
||||
"integrity": "sha512-b1/P//1D2KOzta8YRGyoSLGsAlWyUHfxzVBhV4e/ppnjM4DfBgay/vWz7Eg5Ee80JZ4zsQz8h54X+KOahtBk5Q=="
|
||||
},
|
||||
"@mdi/react": {
|
||||
"version": "1.5.0",
|
||||
@@ -2047,20 +2047,45 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/dom": {
|
||||
"version": "7.30.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.4.tgz",
|
||||
"integrity": "sha512-GObDVMaI4ARrZEXaRy4moolNAxWPKvEYNV/fa6Uc2eAzR/t4otS6A7EhrntPBIQLeehL9DbVhscvvv7gd6hWqA==",
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.0.tgz",
|
||||
"integrity": "sha512-8Ay4UDiMlB5YWy+ZvCeRyFFofs53ebxrWnOFvCoM1HpMAX4cHyuSrCuIM9l2lVuUWUt+Gr3loz/nCwdrnG6ShQ==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^4.2.0",
|
||||
"aria-query": "^4.2.2",
|
||||
"aria-query": "^5.0.0",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.4",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.4.4",
|
||||
"pretty-format": "^26.6.2"
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "27.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
|
||||
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^16.0.0",
|
||||
"chalk": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
||||
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -2069,10 +2094,15 @@
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"aria-query": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
|
||||
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
|
||||
"integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
@@ -2096,6 +2126,29 @@
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
|
||||
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
|
||||
"requires": {
|
||||
"@jest/types": "^27.2.5",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -2107,9 +2160,9 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/jest-dom": {
|
||||
"version": "5.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.12.0.tgz",
|
||||
"integrity": "sha512-N9Y82b2Z3j6wzIoAqajlKVF1Zt7sOH0pPee0sUHXHc5cv2Fdn23r+vpWm0MBBoGJtPOly5+Bdx1lnc3CD+A+ow==",
|
||||
"version": "5.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.15.0.tgz",
|
||||
"integrity": "sha512-lOMuQidnL1tWHLEWIhL6UvSZC1Qt3OkNe1khvi2h6xFiqpe5O8arYs46OU0qyUGq0cSTbroQyMktYNXu3a7sAA==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.9.2",
|
||||
"@types/testing-library__jest-dom": "^5.9.1",
|
||||
@@ -2117,6 +2170,7 @@
|
||||
"chalk": "^3.0.0",
|
||||
"css": "^3.0.0",
|
||||
"css.escape": "^1.5.1",
|
||||
"dom-accessibility-api": "^0.5.6",
|
||||
"lodash": "^4.17.15",
|
||||
"redent": "^3.0.0"
|
||||
},
|
||||
@@ -2191,18 +2245,18 @@
|
||||
}
|
||||
},
|
||||
"@testing-library/react": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.6.tgz",
|
||||
"integrity": "sha512-TXMCg0jT8xmuU8BkKMtp8l7Z50Ykew5WNX8UoIKTaLFwKkP2+1YDhOLA2Ga3wY4x29jyntk7EWfum0kjlYiSjQ==",
|
||||
"version": "12.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz",
|
||||
"integrity": "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^7.28.1"
|
||||
"@testing-library/dom": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"@testing-library/user-event": {
|
||||
"version": "12.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz",
|
||||
"integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==",
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||
"requires": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
}
|
||||
@@ -2213,9 +2267,9 @@
|
||||
"integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA=="
|
||||
},
|
||||
"@types/aria-query": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.1.tgz",
|
||||
"integrity": "sha512-S6oPal772qJZHoRZLFc/XoZW2gFvwXusYUmXPXkgxJLuEk2vOt7jc4Yo6z/vtI0EBkbPBVrJJ0B+prLIKiWqHg=="
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
|
||||
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="
|
||||
},
|
||||
"@types/babel__core": {
|
||||
"version": "7.1.14",
|
||||
@@ -2286,9 +2340,9 @@
|
||||
}
|
||||
},
|
||||
"@types/history": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz",
|
||||
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA=="
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
|
||||
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ=="
|
||||
},
|
||||
"@types/hoist-non-react-statics": {
|
||||
"version": "3.3.1",
|
||||
@@ -2305,9 +2359,9 @@
|
||||
"integrity": "sha512-giAlZwstKbmvMk1OO7WXSj4OZ0keXAcl2TQq4LWHiiPH2ByaH7WeUzng+Qej8UPxxv+8lRTuouo0iaNDBuzIBA=="
|
||||
},
|
||||
"@types/http-proxy": {
|
||||
"version": "1.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.6.tgz",
|
||||
"integrity": "sha512-+qsjqR75S/ib0ig0R9WN+CDoZeOBU6F2XLewgC4KVgdXiNHiKKHFEMRHOrs5PbYE97D5vataw5wPj4KLYfUkuQ==",
|
||||
"version": "1.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.7.tgz",
|
||||
"integrity": "sha512-9hdj6iXH64tHSLTY+Vt2eYOGzSogC+JQ2H7bdPWkuh7KXP5qLllWx++t+K9Wk556c3dkDdPws/SpMRi0sdCT1w==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -2334,12 +2388,126 @@
|
||||
}
|
||||
},
|
||||
"@types/jest": {
|
||||
"version": "26.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz",
|
||||
"integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==",
|
||||
"version": "27.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.0.2.tgz",
|
||||
"integrity": "sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==",
|
||||
"requires": {
|
||||
"jest-diff": "^26.0.0",
|
||||
"pretty-format": "^26.0.0"
|
||||
"jest-diff": "^27.0.0",
|
||||
"pretty-format": "^27.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/types": {
|
||||
"version": "27.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
|
||||
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
|
||||
"requires": {
|
||||
"@types/istanbul-lib-coverage": "^2.0.0",
|
||||
"@types/istanbul-reports": "^3.0.0",
|
||||
"@types/node": "*",
|
||||
"@types/yargs": "^16.0.0",
|
||||
"chalk": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"@types/yargs": {
|
||||
"version": "16.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
|
||||
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
|
||||
"requires": {
|
||||
"@types/yargs-parser": "*"
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"requires": {
|
||||
"color-name": "~1.1.4"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"diff-sequences": {
|
||||
"version": "27.0.6",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.0.6.tgz",
|
||||
"integrity": "sha512-ag6wfpBFyNXZ0p8pcuIDS//D8H062ZQJ3fzYxjpmeKjnz8W4pekL3AI8VohmyZmsWW2PWaHgjsmqR6L13101VQ=="
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||
},
|
||||
"jest-diff": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.3.1.tgz",
|
||||
"integrity": "sha512-PCeuAH4AWUo2O5+ksW4pL9v5xJAcIKPUPfIhZBcG1RKv/0+dvaWTQK1Nrau8d67dp65fOqbeMdoil+6PedyEPQ==",
|
||||
"requires": {
|
||||
"chalk": "^4.0.0",
|
||||
"diff-sequences": "^27.0.6",
|
||||
"jest-get-type": "^27.3.1",
|
||||
"pretty-format": "^27.3.1"
|
||||
}
|
||||
},
|
||||
"jest-get-type": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.3.1.tgz",
|
||||
"integrity": "sha512-+Ilqi8hgHSAdhlQ3s12CAVNd8H96ZkQBfYoXmArzZnOfAtVAJEiPDBirjByEblvG/4LPJmkL+nBqPO3A1YJAEg=="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "27.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
|
||||
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
|
||||
"requires": {
|
||||
"@jest/types": "^27.2.5",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
"react-is": "^17.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"requires": {
|
||||
"has-flag": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/json-schema": {
|
||||
@@ -2358,9 +2526,9 @@
|
||||
"integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA=="
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "12.20.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.12.tgz",
|
||||
"integrity": "sha512-KQZ1al2hKOONAs2MFv+yTQP1LkDWMrRJ9YCVRalXltOfXsBmH5IownLxQaiq0lnAHwAViLnh2aTYqrPcRGEbgg=="
|
||||
"version": "16.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz",
|
||||
"integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w=="
|
||||
},
|
||||
"@types/normalize-package-data": {
|
||||
"version": "2.4.0",
|
||||
@@ -2378,9 +2546,9 @@
|
||||
"integrity": "sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA=="
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz",
|
||||
"integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw=="
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
|
||||
"integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ=="
|
||||
},
|
||||
"@types/q": {
|
||||
"version": "1.5.4",
|
||||
@@ -2388,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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
client/public/icons/apple-touch-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
client/public/icons/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
client/public/icons/apple-touch-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
client/public/icons/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
client/public/icons/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
client/public/icons/apple-touch-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
client/public/icons/apple-touch-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
client/public/icons/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
client/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -2,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-500.woff2
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-700.woff2
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-900.woff2
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff
Normal file
BIN
client/src/assets/fonts/Roboto/roboto-v29-latin-regular.woff2
Normal file
@@ -33,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.Switch {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.Switch:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
31
client/src/components/Home/Header/Header.module.css
Normal file
@@ -0,0 +1,31 @@
|
||||
.Header h1 {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 4em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Header p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.HeaderMain {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.SettingsLink {
|
||||
visibility: visible;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.SettingsLink {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
49
client/src/components/Home/Header/Header.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Config, GlobalState } from '../../../interfaces';
|
||||
import WeatherWidget from '../../Widgets/WeatherWidget/WeatherWidget';
|
||||
import { getDateTime } from './functions/getDateTime';
|
||||
import { greeter } from './functions/greeter';
|
||||
import classes from './Header.module.css';
|
||||
|
||||
interface Props {
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const Header = (props: Props): JSX.Element => {
|
||||
const [dateTime, setDateTime] = useState<string>(getDateTime());
|
||||
const [greeting, setGreeting] = useState<string>(greeter());
|
||||
|
||||
useEffect(() => {
|
||||
let dateTimeInterval: NodeJS.Timeout;
|
||||
|
||||
dateTimeInterval = setInterval(() => {
|
||||
setDateTime(getDateTime());
|
||||
setGreeting(greeter());
|
||||
}, 1000);
|
||||
|
||||
return () => window.clearInterval(dateTimeInterval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className={classes.Header}>
|
||||
<p>{dateTime}</p>
|
||||
<Link to="/settings" className={classes.SettingsLink}>
|
||||
Go to Settings
|
||||
</Link>
|
||||
<span className={classes.HeaderMain}>
|
||||
<h1>{greeting}</h1>
|
||||
<WeatherWidget />
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(Header);
|
||||
40
client/src/components/Home/Header/functions/getDateTime.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const getDateTime = (): string => {
|
||||
const days = localStorage.getItem('daySchema')?.split(';') || [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
|
||||
const months = localStorage.getItem('monthSchema')?.split(';') || [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const useAmericanDate = localStorage.useAmericanDate === 'true';
|
||||
|
||||
if (!useAmericanDate) {
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${
|
||||
months[now.getMonth()]
|
||||
} ${now.getFullYear()}`;
|
||||
} else {
|
||||
return `${days[now.getDay()]}, ${
|
||||
months[now.getMonth()]
|
||||
} ${now.getDate()} ${now.getFullYear()}`;
|
||||
}
|
||||
};
|
||||
17
client/src/components/Home/Header/functions/greeter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const greeter = (): string => {
|
||||
const now = new Date().getHours();
|
||||
let msg: string;
|
||||
|
||||
const greetingsSchemaRaw =
|
||||
localStorage.getItem('greetingsSchema') ||
|
||||
'Good evening!;Good afternoon!;Good morning!;Good night!';
|
||||
const greetingsSchema = greetingsSchemaRaw.split(';');
|
||||
|
||||
if (now >= 18) msg = greetingsSchema[0];
|
||||
else if (now >= 12) msg = greetingsSchema[1];
|
||||
else if (now >= 6) msg = greetingsSchema[2];
|
||||
else if (now >= 0) msg = greetingsSchema[3];
|
||||
else msg = 'Hello!';
|
||||
|
||||
return msg;
|
||||
};
|
||||
@@ -1,24 +1,3 @@
|
||||
.Header h1 {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 4em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.Header p {
|
||||
color: var(--color-primary);
|
||||
font-weight: 300;
|
||||
text-transform: uppercase;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.HeaderMain {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.SettingsButton {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export const dateTime = (): string => {
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return `${days[now.getDay()]}, ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()}`;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export const greeter = (): string => {
|
||||
const now = new Date().getHours();
|
||||
let msg: string;
|
||||
|
||||
if (now >= 18) msg = 'Good evening!';
|
||||
else if (now >= 12) msg = 'Good afternoon!';
|
||||
else if (now >= 6) msg = 'Good morning!';
|
||||
else if (now >= 0) msg = 'Good night!';
|
||||
else msg = 'Hello!';
|
||||
|
||||
return msg;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
137
client/src/components/SearchBar/SearchBar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useRef, useEffect, KeyboardEvent } from 'react';
|
||||
|
||||
// Redux
|
||||
import { connect } from 'react-redux';
|
||||
import { createNotification } from '../../store/actions';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
App,
|
||||
Category,
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
} from '../../interfaces';
|
||||
|
||||
// CSS
|
||||
import classes from './SearchBar.module.css';
|
||||
|
||||
// Utils
|
||||
import { searchParser, urlParser, redirectUrl } from '../../utility';
|
||||
|
||||
interface ComponentProps {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
setLocalSearch: (query: string) => void;
|
||||
appSearchResult: App[] | null;
|
||||
bookmarkSearchResult: Category[] | null;
|
||||
config: Config;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const SearchBar = (props: ComponentProps): JSX.Element => {
|
||||
const {
|
||||
setLocalSearch,
|
||||
createNotification,
|
||||
config,
|
||||
loading,
|
||||
appSearchResult,
|
||||
bookmarkSearchResult,
|
||||
} = props;
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(document.createElement('input'));
|
||||
|
||||
// Search bar autofocus
|
||||
useEffect(() => {
|
||||
if (!loading && !config.disableAutofocus) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Listen for keyboard events outside of search bar
|
||||
useEffect(() => {
|
||||
const keyOutsideFocus = (e: any) => {
|
||||
const { key } = e as KeyboardEvent;
|
||||
|
||||
if (key === 'Escape') {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', keyOutsideFocus);
|
||||
|
||||
return () => window.removeEventListener('keydown', keyOutsideFocus);
|
||||
}, []);
|
||||
|
||||
const clearSearch = () => {
|
||||
inputRef.current.value = '';
|
||||
setLocalSearch('');
|
||||
};
|
||||
|
||||
const searchHandler = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { isLocal, search, query, isURL, sameTab } = searchParser(
|
||||
inputRef.current.value
|
||||
);
|
||||
|
||||
if (isLocal) {
|
||||
setLocalSearch(search);
|
||||
}
|
||||
|
||||
if (e.code === 'Enter' || e.code === 'NumpadEnter') {
|
||||
if (!query.prefix) {
|
||||
// Prefix not found -> emit notification
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Prefix not found',
|
||||
});
|
||||
} else if (isURL) {
|
||||
// URL or IP passed -> redirect
|
||||
const url = urlParser(inputRef.current.value)[1];
|
||||
redirectUrl(url, sameTab);
|
||||
} else if (isLocal) {
|
||||
// Local query -> redirect if at least 1 result found
|
||||
if (appSearchResult?.length) {
|
||||
redirectUrl(appSearchResult[0].url, sameTab);
|
||||
} else if (bookmarkSearchResult?.[0]?.bookmarks?.length) {
|
||||
redirectUrl(bookmarkSearchResult[0].bookmarks[0].url, sameTab);
|
||||
} else {
|
||||
// no local results -> search the internet with the default search provider
|
||||
let template = query.template;
|
||||
|
||||
if (query.prefix === 'l') {
|
||||
template = 'https://duckduckgo.com/?q=';
|
||||
}
|
||||
|
||||
const url = `${template}${search}`;
|
||||
redirectUrl(url, sameTab);
|
||||
}
|
||||
} else {
|
||||
// Valid query -> redirect to search results
|
||||
const url = `${query.template}${search}`;
|
||||
redirectUrl(url, sameTab);
|
||||
}
|
||||
} else if (e.code === 'Escape') {
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.SearchContainer}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={classes.SearchBar}
|
||||
onKeyUp={(e) => searchHandler(e)}
|
||||
onDoubleClick={clearSearch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
config: state.config.config,
|
||||
loading: state.config.loading,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { createNotification })(SearchBar);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.QueriesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.QueriesGrid span {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.QueriesGrid span:last-child {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ActionIcons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ActionIcons svg {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.ActionIcons svg:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.Separator {
|
||||
grid-column: 1 / 4;
|
||||
border-bottom: 1px solid var(--color-primary);
|
||||
margin: 10px 0;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import classes from './CustomQueries.module.css';
|
||||
|
||||
import Modal from '../../../UI/Modal/Modal';
|
||||
import Icon from '../../../UI/Icons/Icon/Icon';
|
||||
import {
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
Query,
|
||||
} from '../../../../interfaces';
|
||||
import QueriesForm from './QueriesForm';
|
||||
import { deleteQuery, createNotification } from '../../../../store/actions';
|
||||
import Button from '../../../UI/Buttons/Button/Button';
|
||||
|
||||
interface Props {
|
||||
customQueries: Query[];
|
||||
deleteQuery: (prefix: string) => {};
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const CustomQueries = (props: Props): JSX.Element => {
|
||||
const { customQueries, deleteQuery, createNotification } = props;
|
||||
|
||||
const [modalIsOpen, setModalIsOpen] = useState(false);
|
||||
const [editableQuery, setEditableQuery] = useState<Query | null>(null);
|
||||
|
||||
const updateHandler = (query: Query) => {
|
||||
setEditableQuery(query);
|
||||
setModalIsOpen(true);
|
||||
};
|
||||
|
||||
const deleteHandler = (query: Query) => {
|
||||
const currentProvider = props.config.defaultSearchProvider;
|
||||
const isCurrent = currentProvider === query.prefix;
|
||||
|
||||
if (isCurrent) {
|
||||
createNotification({
|
||||
title: 'Error',
|
||||
message: 'Cannot delete active provider',
|
||||
});
|
||||
} else if (
|
||||
window.confirm(`Are you sure you want to delete this provider?`)
|
||||
) {
|
||||
deleteQuery(query.prefix);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Modal
|
||||
isOpen={modalIsOpen}
|
||||
setIsOpen={() => setModalIsOpen(!modalIsOpen)}
|
||||
>
|
||||
{editableQuery ? (
|
||||
<QueriesForm
|
||||
modalHandler={() => setModalIsOpen(!modalIsOpen)}
|
||||
query={editableQuery}
|
||||
/>
|
||||
) : (
|
||||
<QueriesForm modalHandler={() => setModalIsOpen(!modalIsOpen)} />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<div>
|
||||
<div className={classes.QueriesGrid}>
|
||||
{customQueries.length > 0 && (
|
||||
<Fragment>
|
||||
<span>Name</span>
|
||||
<span>Prefix</span>
|
||||
<span>Actions</span>
|
||||
|
||||
<div className={classes.Separator}></div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{customQueries.map((q: Query, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<span>{q.name}</span>
|
||||
<span>{q.prefix}</span>
|
||||
<span className={classes.ActionIcons}>
|
||||
<span onClick={() => updateHandler(q)}>
|
||||
<Icon icon="mdiPencil" />
|
||||
</span>
|
||||
<span onClick={() => deleteHandler(q)}>
|
||||
<Icon icon="mdiDelete" />
|
||||
</span>
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
click={() => {
|
||||
setEditableQuery(null);
|
||||
setModalIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Add new search provider
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
customQueries: state.config.customQueries,
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, { deleteQuery, createNotification })(
|
||||
CustomQueries
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ChangeEvent, FormEvent, useState, useEffect } from 'react';
|
||||
import { Query } from '../../../../interfaces';
|
||||
import Button from '../../../UI/Buttons/Button/Button';
|
||||
import InputGroup from '../../../UI/Forms/InputGroup/InputGroup';
|
||||
import ModalForm from '../../../UI/Forms/ModalForm/ModalForm';
|
||||
import { connect } from 'react-redux';
|
||||
import { addQuery, updateQuery } from '../../../../store/actions';
|
||||
|
||||
interface Props {
|
||||
modalHandler: () => void;
|
||||
addQuery: (query: Query) => {};
|
||||
updateQuery: (query: Query, Oldprefix: string) => {};
|
||||
query?: Query;
|
||||
}
|
||||
|
||||
const QueriesForm = (props: Props): JSX.Element => {
|
||||
const { modalHandler, addQuery, updateQuery, query } = props;
|
||||
|
||||
const [formData, setFormData] = useState<Query>({
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
});
|
||||
|
||||
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const formHandler = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (query) {
|
||||
updateQuery(formData, query.prefix);
|
||||
} else {
|
||||
addQuery(formData);
|
||||
}
|
||||
|
||||
// close modal
|
||||
modalHandler();
|
||||
|
||||
// clear form
|
||||
setFormData({
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (query) {
|
||||
setFormData(query);
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
prefix: '',
|
||||
template: '',
|
||||
});
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<ModalForm modalHandler={modalHandler} formHandler={formHandler}>
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
placeholder="Google"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Prefix</label>
|
||||
<input
|
||||
type="text"
|
||||
name="prefix"
|
||||
id="prefix"
|
||||
placeholder="g"
|
||||
required
|
||||
value={formData.prefix}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="name">Query Template</label>
|
||||
<input
|
||||
type="text"
|
||||
name="template"
|
||||
id="template"
|
||||
placeholder="https://www.google.com/search?q="
|
||||
required
|
||||
value={formData.template}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{query ? <Button>Update provider</Button> : <Button>Add provider</Button>}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect(null, { addQuery, updateQuery })(QueriesForm);
|
||||
159
client/src/components/Settings/SearchSettings/SearchSettings.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
// React
|
||||
import { useState, useEffect, FormEvent, ChangeEvent, Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
// State
|
||||
import { createNotification, updateConfig } from '../../../store/actions';
|
||||
|
||||
// Typescript
|
||||
import {
|
||||
Config,
|
||||
GlobalState,
|
||||
NewNotification,
|
||||
Query,
|
||||
SearchForm,
|
||||
} from '../../../interfaces';
|
||||
|
||||
// Components
|
||||
import CustomQueries from './CustomQueries/CustomQueries';
|
||||
|
||||
// UI
|
||||
import Button from '../../UI/Buttons/Button/Button';
|
||||
import SettingsHeadline from '../../UI/Headlines/SettingsHeadline/SettingsHeadline';
|
||||
import InputGroup from '../../UI/Forms/InputGroup/InputGroup';
|
||||
|
||||
// Utils
|
||||
import { inputHandler, searchSettingsTemplate } from '../../../utility';
|
||||
|
||||
// Data
|
||||
import { queries } from '../../../utility/searchQueries.json';
|
||||
|
||||
interface Props {
|
||||
createNotification: (notification: NewNotification) => void;
|
||||
updateConfig: (formData: SearchForm) => void;
|
||||
loading: boolean;
|
||||
customQueries: Query[];
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const SearchSettings = (props: Props): JSX.Element => {
|
||||
// Initial state
|
||||
const [formData, setFormData] = useState<SearchForm>(searchSettingsTemplate);
|
||||
|
||||
// Get config
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
...props.config,
|
||||
});
|
||||
}, [props.loading]);
|
||||
|
||||
// Form handler
|
||||
const formSubmitHandler = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Save settings
|
||||
await props.updateConfig(formData);
|
||||
};
|
||||
|
||||
// Input handler
|
||||
const inputChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
options?: { isNumber?: boolean; isBool?: boolean }
|
||||
) => {
|
||||
inputHandler<SearchForm>({
|
||||
e,
|
||||
options,
|
||||
setStateHandler: setFormData,
|
||||
state: formData,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* GENERAL SETTINGS */}
|
||||
<form
|
||||
onSubmit={(e) => formSubmitHandler(e)}
|
||||
style={{ marginBottom: '30px' }}
|
||||
>
|
||||
<SettingsHeadline text="General" />
|
||||
<InputGroup>
|
||||
<label htmlFor="defaultSearchProvider">Default Search Provider</label>
|
||||
<select
|
||||
id="defaultSearchProvider"
|
||||
name="defaultSearchProvider"
|
||||
value={formData.defaultSearchProvider}
|
||||
onChange={(e) => inputChangeHandler(e)}
|
||||
>
|
||||
{[...queries, ...props.customQueries].map((query: Query, idx) => {
|
||||
const isCustom = idx >= queries.length;
|
||||
|
||||
return (
|
||||
<option key={idx} value={query.prefix}>
|
||||
{isCustom && '+'} {query.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="searchSameTab">
|
||||
Open search results in the same tab
|
||||
</label>
|
||||
<select
|
||||
id="searchSameTab"
|
||||
name="searchSameTab"
|
||||
value={formData.searchSameTab ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="hideSearch">Hide search bar</label>
|
||||
<select
|
||||
id="hideSearch"
|
||||
name="hideSearch"
|
||||
value={formData.hideSearch ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<label htmlFor="disableAutofocus">Disable search bar autofocus</label>
|
||||
<select
|
||||
id="disableAutofocus"
|
||||
name="disableAutofocus"
|
||||
value={formData.disableAutofocus ? 1 : 0}
|
||||
onChange={(e) => inputChangeHandler(e, { isBool: true })}
|
||||
>
|
||||
<option value={1}>True</option>
|
||||
<option value={0}>False</option>
|
||||
</select>
|
||||
</InputGroup>
|
||||
<Button>Save changes</Button>
|
||||
</form>
|
||||
|
||||
{/* CUSTOM QUERIES */}
|
||||
<SettingsHeadline text="Custom search providers" />
|
||||
<CustomQueries />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: GlobalState) => {
|
||||
return {
|
||||
loading: state.config.loading,
|
||||
customQueries: state.config.customQueries,
|
||||
config: state.config.config,
|
||||
};
|
||||
};
|
||||
|
||||
const actions = {
|
||||
createNotification,
|
||||
updateConfig,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, actions)(SearchSettings);
|
||||
@@ -1,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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
28
client/src/components/Settings/settings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"routes": [
|
||||
{
|
||||
"name": "Theme",
|
||||
"dest": "/settings"
|
||||
},
|
||||
{
|
||||
"name": "Weather",
|
||||
"dest": "/settings/weather"
|
||||
},
|
||||
{
|
||||
"name": "Search",
|
||||
"dest": "/settings/search"
|
||||
},
|
||||
{
|
||||
"name": "Other",
|
||||
"dest": "/settings/other"
|
||||
},
|
||||
{
|
||||
"name": "CSS",
|
||||
"dest": "/settings/css"
|
||||
},
|
||||
{
|
||||
"name": "App",
|
||||
"dest": "/settings/app"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -95,6 +95,30 @@
|
||||
"primary": "#4C432E",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export interface NewNotification {
|
||||
title: string;
|
||||
message: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface Notification extends NewNotification {
|
||||
id: number;
|
||||
}
|
||||
}
|
||||
|
||||
4
client/src/interfaces/Route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Route {
|
||||
name: string;
|
||||
dest: string;
|
||||
}
|
||||
9
client/src/interfaces/SearchResult.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Query } from './Query';
|
||||
|
||||
export interface SearchResult {
|
||||
isLocal: boolean;
|
||||
isURL: boolean;
|
||||
sameTab: boolean;
|
||||
search: string;
|
||||
query: Query;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
39
client/src/utility/inputHandler.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
11
client/src/utility/redirectUrl.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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="
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||